Compare commits
3 Commits
main
...
graph-awar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
030e60bce2 | ||
|
|
343055b7dd | ||
|
|
f3db6af958 |
44
.env.example
44
.env.example
@@ -1,44 +0,0 @@
|
|||||||
# TTL path used as a fallback by the owl-imports combiner when COMBINE_ENTRY_LOCATION is not set.
|
|
||||||
TTL_PATH=/data/merged_ontologies_pruned_patched.ttl
|
|
||||||
|
|
||||||
# Backend behavior
|
|
||||||
INCLUDE_BNODES=false
|
|
||||||
|
|
||||||
# Graph snapshot limits (used by Go backend defaults/validation)
|
|
||||||
DEFAULT_NODE_LIMIT=800000
|
|
||||||
DEFAULT_EDGE_LIMIT=2000000
|
|
||||||
MAX_NODE_LIMIT=10000000
|
|
||||||
MAX_EDGE_LIMIT=20000000
|
|
||||||
|
|
||||||
# SPARQL paging: number of triples per batch (LIMIT/OFFSET)
|
|
||||||
EDGE_BATCH_SIZE=100000
|
|
||||||
|
|
||||||
# Owl imports combiner service (docker-compose `owl_imports_combiner`)
|
|
||||||
COMBINE_OWL_IMPORTS_ON_START=true
|
|
||||||
COMBINE_ENTRY_LOCATION=/data/vkg.ttl
|
|
||||||
COMBINE_OUTPUT_LOCATION=/data/vkg_full.ttl
|
|
||||||
# COMBINE_OUTPUT_NAME=combined_ontology.ttl # Only used if COMBINE_OUTPUT_LOCATION is not set.
|
|
||||||
COMBINE_FORCE=true
|
|
||||||
|
|
||||||
# AnzoGraph / SPARQL endpoint settings
|
|
||||||
SPARQL_HOST=http://anzograph:8080
|
|
||||||
# SPARQL_ENDPOINT=http://anzograph:8080/sparql
|
|
||||||
SPARQL_USER=admin
|
|
||||||
SPARQL_PASS=Passw0rd1
|
|
||||||
|
|
||||||
# File URI as seen by the AnzoGraph container (used by SPARQL `LOAD`) # Currently not used.
|
|
||||||
SPARQL_DATA_FILE=file:///opt/shared-files/o3po.ttl # Currently not used.
|
|
||||||
# SPARQL_GRAPH_IRI=http://example.org/graph
|
|
||||||
|
|
||||||
# Startup behavior for AnzoGraph mode
|
|
||||||
SPARQL_LOAD_ON_START=false
|
|
||||||
SPARQL_CLEAR_ON_START=false
|
|
||||||
SPARQL_READY_TIMEOUT_S=10
|
|
||||||
|
|
||||||
# Dev UX
|
|
||||||
CORS_ORIGINS=http://localhost:5173
|
|
||||||
VITE_BACKEND_URL=http://backend:8000
|
|
||||||
|
|
||||||
# Debugging
|
|
||||||
LOG_SNAPSHOT_TIMINGS=false
|
|
||||||
FREE_OS_MEMORY_AFTER_SNAPSHOT=false
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,9 +1,4 @@
|
|||||||
.direnv/
|
.direnv/
|
||||||
.envrc
|
.envrc
|
||||||
.env
|
.env
|
||||||
backend/.env
|
|
||||||
frontend/node_modules/
|
|
||||||
frontend/dist/
|
|
||||||
.npm/
|
|
||||||
.vite/
|
|
||||||
data/
|
data/
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ FROM node:lts-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
EXPOSE 5173
|
|
||||||
|
|
||||||
# Copy dependency definitions
|
# Copy dependency definitions
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
@@ -13,5 +11,8 @@ RUN npm install
|
|||||||
# Copy the rest of the source code
|
# Copy the rest of the source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Start the dev server with --host for external access
|
# Expose the standard Vite port
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "--port", "5173"]
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Compute layout, then start the dev server with --host for external access
|
||||||
|
CMD ["sh", "-c", "npm run dev -- --host"]
|
||||||
155
README.md
155
README.md
@@ -1,108 +1,67 @@
|
|||||||
# Visualizador Instanciados
|
# Large Instanced Ontology Visualizer
|
||||||
|
|
||||||
This repo is a Docker Compose stack for visualizing large RDF/OWL graphs stored in **AnzoGraph**. It includes:
|
An experimental visualizer designed to render and explore massive instanced ontologies (millions of nodes) with interactive performance.
|
||||||
|
|
||||||
- A **Go backend** that queries AnzoGraph via SPARQL and serves a cached graph snapshot + selection queries.
|
## 🚀 The Core Challenge
|
||||||
- A **React/Vite frontend** that renders nodes/edges with WebGL2 and supports “selection query” + “graph query” modes.
|
Ontologies with millions of instances present a significant rendering challenge for traditional graph visualization tools. This project solves this by:
|
||||||
- A **Python one-shot service** to combine `owl:imports` into a single Turtle file.
|
1. **Selective Rendering:** Only rendering up to a set limit of nodes (e.g., 2 million) at any given time.
|
||||||
- An **AnzoGraph** container (SPARQL endpoint).
|
2. **Adaptive Sampling:** When zoomed out, it provides a representative spatial sample of the nodes. When zoomed in, the number of nodes within the viewport naturally falls below the rendering limit, allowing for 100% detail with zero performance degradation.
|
||||||
|
3. **Spatial Indexing:** Using a custom Quadtree to manage millions of points in memory and efficiently determine visibility.
|
||||||
|
|
||||||
## Quick start (Docker Compose)
|
## 🛠 Technical Architecture
|
||||||
|
|
||||||
1) Put your TTL file(s) in `./data/` (this folder is volume-mounted into AnzoGraph as `/opt/shared-files`).
|
### 1. Data Pipeline & AnzoGraph Integration
|
||||||
2) Optionally configure `.env` (see `.env.example`).
|
The project features an automated pipeline to extract and prepare data from an **AnzoGraph** DB:
|
||||||
3) Start the stack:
|
- **SPARQL Extraction:** `scripts/fetch_from_db.ts` connects to AnzoGraph via its SPARQL endpoint. It fetches a seed set of subjects and their related triples, identifying "primary" nodes (objects of `rdf:type`).
|
||||||
|
- **Graph Classification:** Instances are categorized to distinguish between classes and relationships.
|
||||||
|
- **Force-Directed Layout:** `scripts/compute_layout.ts` calculates 2D positions for the nodes using a **Barnes-Hut** optimized force-directed simulation, ensuring scalability for large graphs.
|
||||||
|
|
||||||
|
### 2. Quadtree Spatial Index
|
||||||
|
To handle millions of nodes without per-frame object allocation:
|
||||||
|
- **In-place Sorting:** The Quadtree (`src/quadtree.ts`) spatially sorts the raw `Float32Array` of positions at build-time.
|
||||||
|
- **Index-Based Access:** Leaves store only the index ranges into the sorted array, pointing directly to the data sent to the GPU.
|
||||||
|
- **Fast Lookups:** Used for both frustum culling and efficient "find node under cursor" calculations.
|
||||||
|
|
||||||
|
### 3. WebGL 2 High-Performance Renderer
|
||||||
|
The renderer (`src/renderer.ts`) is built for maximum throughput:
|
||||||
|
- **`WEBGL_multi_draw` Extension:** Batches multiple leaf nodes into single draw calls, minimizing CPU overhead.
|
||||||
|
- **Zero-Allocation Render Loop:** The frame loop uses pre-allocated typed arrays to prevent GC pauses.
|
||||||
|
- **Dynamic Level of Detail (LOD):**
|
||||||
|
- **Points:** Always visible, with adaptive density based on zoom.
|
||||||
|
- **Lines:** Automatically rendered when zoomed in deep enough to see individual relationships (< 20k visible nodes).
|
||||||
|
- **Selection:** Interactive selection of nodes highlights immediate neighbors (incoming/outgoing edges).
|
||||||
|
|
||||||
|
## 🚦 Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- Node.js (for local development)
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
The project includes a `docker-compose.yml` that spins up both the **AnzoGraph** database and the visualizer app.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
# Start the services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Inside the app container, the following will run automatically:
|
||||||
|
# 1. Fetch data from AnzoGraph (fetch_from_db.ts)
|
||||||
|
# 2. Compute the 2D layout (compute_layout.ts)
|
||||||
|
# 3. Start the Vite development server
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the frontend:
|
The app will be available at `http://localhost:5173`.
|
||||||
|
|
||||||
- `http://localhost:5173`
|
## 🖱 Interactions
|
||||||
|
- **Drag:** Pan the view.
|
||||||
|
- **Scroll:** Zoom in/out at the cursor position.
|
||||||
|
- **Click:** Select a node to see its URI/Label and highlight its neighbors.
|
||||||
|
- **HUD:** Real-time stats on FPS, nodes drawn, and current sampling ratio.
|
||||||
|
|
||||||
Stop everything:
|
## TODO
|
||||||
|
- **Positioning:** Use better algorithm to position nodes, trying to avoid as much as possible any edges crossing, but at the same time trying to keep the graph compact.
|
||||||
```bash
|
- **Positioning:** Decide how to handle classes which are both instances and classes.
|
||||||
docker compose down
|
- **Functionality:** Find every equipment with a specific property or that participate in a specific process.
|
||||||
```
|
- **Functionality:** Find every equipment which is connecte to a well.
|
||||||
|
- **Functionality:** Show every connection witin a specified depth.
|
||||||
## Services
|
- **Functionality:** Show every element of a specific class.
|
||||||
|
|
||||||
Defined in `docker-compose.yml`:
|
|
||||||
|
|
||||||
- `anzograph` (image `cambridgesemantics/anzograph:latest`)
|
|
||||||
- Ports: `8080`, `8443`
|
|
||||||
- Shared files: `./data → /opt/shared-files`
|
|
||||||
- `backend` (`./backend_go`)
|
|
||||||
- Port: `8000` (API under `/api/*`)
|
|
||||||
- Talks to AnzoGraph at `SPARQL_HOST` / `SPARQL_ENDPOINT`
|
|
||||||
- `frontend` (`./frontend`)
|
|
||||||
- Port: `5173`
|
|
||||||
- Proxies `/api/*` to `VITE_BACKEND_URL`
|
|
||||||
- `owl_imports_combiner` (`./python_services/owl_imports_combiner`)
|
|
||||||
- One-shot: optionally produces a combined TTL by following `owl:imports`
|
|
||||||
|
|
||||||
Service READMEs:
|
|
||||||
|
|
||||||
- `backend_go/README.md`
|
|
||||||
- `frontend/README.md`
|
|
||||||
- `python_services/owl_imports_combiner/README.md`
|
|
||||||
- `anzograph/README.md`
|
|
||||||
|
|
||||||
## Repo layout
|
|
||||||
|
|
||||||
- `backend_go/` – Go API service (SPARQL → snapshot + selection queries)
|
|
||||||
- `frontend/` – React/Vite WebGL renderer
|
|
||||||
- `python_services/owl_imports_combiner/` – Python one-shot OWL imports combiner
|
|
||||||
- `data/` – local shared volume for TTL inputs/outputs (gitignored)
|
|
||||||
- `docker-compose.yml` – service wiring
|
|
||||||
- `flake.nix` – optional Nix dev shell
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
This repo expects a local `.env` file (not committed). Start from `.env.example`.
|
|
||||||
|
|
||||||
Common knobs:
|
|
||||||
|
|
||||||
- Backend snapshot size: `DEFAULT_NODE_LIMIT`, `DEFAULT_EDGE_LIMIT`, `MAX_NODE_LIMIT`, `MAX_EDGE_LIMIT`
|
|
||||||
- SPARQL connectivity: `SPARQL_HOST` or `SPARQL_ENDPOINT`, plus `SPARQL_USER` / `SPARQL_PASS`
|
|
||||||
- Load data on backend startup: `SPARQL_LOAD_ON_START=true` with `SPARQL_DATA_FILE=file:///opt/shared-files/<file>.ttl`
|
|
||||||
- Frontend → backend proxy: `VITE_BACKEND_URL`
|
|
||||||
|
|
||||||
## API (backend)
|
|
||||||
|
|
||||||
Base URL: `http://localhost:8000`
|
|
||||||
|
|
||||||
- `GET /api/health` – liveness
|
|
||||||
- `GET /api/stats` – snapshot stats (uses default limits)
|
|
||||||
- `GET /api/graph` – graph snapshot
|
|
||||||
- Query params: `node_limit`, `edge_limit`, `graph_query_id`
|
|
||||||
- `GET /api/graph_queries` – available graph snapshot modes (`graph_query_id` values)
|
|
||||||
- `GET /api/selection_queries` – available selection-highlight modes (`query_id` values)
|
|
||||||
- `POST /api/selection_query` – run a selection query for highlighted neighbors
|
|
||||||
- Body: `{"query_id":"neighbors","selected_ids":[...],"node_limit":...,"edge_limit":...,"graph_query_id":"default"}`
|
|
||||||
- `POST /api/sparql` – raw SPARQL passthrough (debug/advanced)
|
|
||||||
- `POST /api/neighbors` – legacy alias (same behavior as `query_id="neighbors"`)
|
|
||||||
|
|
||||||
## Frontend UI
|
|
||||||
|
|
||||||
- Mouse:
|
|
||||||
- Drag: pan
|
|
||||||
- Scroll: zoom
|
|
||||||
- Click: select nodes
|
|
||||||
- **Top-right buttons:** “selection query” mode (how neighbors/highlights are computed for the current selection)
|
|
||||||
- **Bottom-right buttons:** “graph query” mode (which SPARQL edge set is used to build the graph snapshot; switching reloads the graph)
|
|
||||||
|
|
||||||
## Notes on performance/limits
|
|
||||||
|
|
||||||
- The backend caches snapshots in memory; tune `DEFAULT_*_LIMIT` if memory is too high.
|
|
||||||
- The frontend renders a sampled subset when zoomed out, and only draws edges when fewer than ~20k nodes are visible.
|
|
||||||
|
|
||||||
## Nix dev shell (optional)
|
|
||||||
|
|
||||||
If you use Nix, `flake.nix` provides a minimal `devShell`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nix develop
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# AnzoGraph (Docker Compose service)
|
|
||||||
|
|
||||||
This repo runs AnzoGraph as an external container image:
|
|
||||||
|
|
||||||
- Image: `cambridgesemantics/anzograph:latest`
|
|
||||||
- Ports: `8080` (HTTP), `8443` (HTTPS)
|
|
||||||
- Volume: `./data → /opt/shared-files`
|
|
||||||
|
|
||||||
The backend connects to AnzoGraph via:
|
|
||||||
|
|
||||||
- `SPARQL_HOST` (default `http://anzograph:8080`) and the `/sparql` path, or
|
|
||||||
- an explicit `SPARQL_ENDPOINT`
|
|
||||||
|
|
||||||
## Persistence
|
|
||||||
|
|
||||||
The `docker-compose.yml` config mounts named volumes into the AnzoGraph container so its state survives
|
|
||||||
container recreation (e.g. `docker compose up --force-recreate`):
|
|
||||||
|
|
||||||
- `anzograph_app_home → /opt/anzograph/app-home` (machine-id / user home)
|
|
||||||
- `anzograph_persistence → /opt/anzograph/persistence` (database persistence dir)
|
|
||||||
- `anzograph_config → /opt/anzograph/config` (settings + activation markers)
|
|
||||||
- `anzograph_internal → /opt/anzograph/internal` (internal state, including EULA acceptance marker)
|
|
||||||
|
|
||||||
To fully reset AnzoGraph state, remove volumes with `docker compose down -v`.
|
|
||||||
|
|
||||||
## Loading data
|
|
||||||
|
|
||||||
The backend can optionally load a TTL file on startup (after AnzoGraph is ready):
|
|
||||||
|
|
||||||
- `SPARQL_LOAD_ON_START=true`
|
|
||||||
- `SPARQL_DATA_FILE=file:///opt/shared-files/<file>.ttl`
|
|
||||||
|
|
||||||
Because `./data` is mounted at `/opt/shared-files`, anything placed in `./data` is accessible via a `file:///opt/shared-files/...` URI.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Authentication defaults are configured via the backend env (`SPARQL_USER` / `SPARQL_PASS`).
|
|
||||||
- The AnzoGraph container in this repo is not customized; consult the upstream image documentation for persistence, licensing, and advanced configuration.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
ARG GO_VERSION=1.24
|
|
||||||
FROM golang:${GO_VERSION}-alpine AS builder
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod /src/go.mod
|
|
||||||
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . /src
|
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/backend ./
|
|
||||||
|
|
||||||
FROM alpine:3.20
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates curl
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY --from=builder /out/backend /app/backend
|
|
||||||
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
CMD ["/app/backend"]
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# Backend (Go) – Graph + Selection API
|
|
||||||
|
|
||||||
This service exposes a small HTTP API for:
|
|
||||||
|
|
||||||
- Building and caching a “graph snapshot” from AnzoGraph via SPARQL (`/api/graph`)
|
|
||||||
- Returning available “graph query” and “selection query” modes
|
|
||||||
- Running selection queries for the currently selected node IDs
|
|
||||||
- (Optionally) issuing raw SPARQL passthrough for debugging
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
Via Docker Compose (recommended):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up --build backend
|
|
||||||
```
|
|
||||||
|
|
||||||
The backend listens on `:8000` (configurable via `LISTEN_ADDR`).
|
|
||||||
|
|
||||||
## Configuration (env)
|
|
||||||
|
|
||||||
See `backend_go/config.go` for the full set.
|
|
||||||
|
|
||||||
Important variables:
|
|
||||||
|
|
||||||
- Snapshot limits:
|
|
||||||
- `DEFAULT_NODE_LIMIT`, `DEFAULT_EDGE_LIMIT`
|
|
||||||
- `MAX_NODE_LIMIT`, `MAX_EDGE_LIMIT`
|
|
||||||
- SPARQL connectivity:
|
|
||||||
- `SPARQL_HOST` (default `http://anzograph:8080`) or `SPARQL_ENDPOINT`
|
|
||||||
- `SPARQL_USER`, `SPARQL_PASS`
|
|
||||||
- Startup behavior:
|
|
||||||
- `SPARQL_LOAD_ON_START`, `SPARQL_CLEAR_ON_START`
|
|
||||||
- `SPARQL_DATA_FILE` (typically `file:///opt/shared-files/<file>.ttl`)
|
|
||||||
- Other:
|
|
||||||
- `INCLUDE_BNODES` (include blank nodes in snapshots)
|
|
||||||
- `CORS_ORIGINS`
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
- `GET /api/health`
|
|
||||||
- `GET /api/stats`
|
|
||||||
- `GET /api/graph?node_limit=&edge_limit=&graph_query_id=`
|
|
||||||
- `GET /api/graph_queries`
|
|
||||||
- `GET /api/selection_queries`
|
|
||||||
- `POST /api/selection_query`
|
|
||||||
- Body: `{"query_id":"neighbors","selected_ids":[1,2,3],"node_limit":...,"edge_limit":...,"graph_query_id":"default"}`
|
|
||||||
- `POST /api/sparql` (raw passthrough)
|
|
||||||
- `POST /api/neighbors` (legacy alias of `query_id="neighbors"`)
|
|
||||||
|
|
||||||
## Graph snapshots
|
|
||||||
|
|
||||||
Snapshots are built by:
|
|
||||||
|
|
||||||
1) Running a SPARQL edge query (controlled by `graph_query_id`)
|
|
||||||
2) Converting SPARQL bindings into dense integer node IDs + edge list
|
|
||||||
3) Computing a layout and fetching optional `rdfs:label`
|
|
||||||
|
|
||||||
Snapshots are cached in-memory keyed by:
|
|
||||||
|
|
||||||
- `node_limit`, `edge_limit`, `INCLUDE_BNODES`, `graph_query_id`
|
|
||||||
|
|
||||||
## Query registries
|
|
||||||
|
|
||||||
### Graph query modes (`graph_query_id`)
|
|
||||||
|
|
||||||
Stored under `backend_go/graph_queries/` and listed by `GET /api/graph_queries`.
|
|
||||||
|
|
||||||
Built-in modes:
|
|
||||||
|
|
||||||
- `default` – `rdf:type` (to `owl:Class`) + `rdfs:subClassOf`
|
|
||||||
- `hierarchy` – `rdfs:subClassOf` only
|
|
||||||
- `types` – `rdf:type` (to `owl:Class`) only
|
|
||||||
|
|
||||||
To add a new mode:
|
|
||||||
|
|
||||||
1) Add a new file under `backend_go/graph_queries/` that returns a SPARQL query selecting `?s ?p ?o`.
|
|
||||||
2) Register it in `backend_go/graph_queries/registry.go`.
|
|
||||||
|
|
||||||
### Selection query modes (`query_id`)
|
|
||||||
|
|
||||||
Stored under `backend_go/selection_queries/` and listed by `GET /api/selection_queries`.
|
|
||||||
|
|
||||||
Built-in modes:
|
|
||||||
|
|
||||||
- `neighbors` – type + subclass neighbors (both directions)
|
|
||||||
- `superclasses` – `?sel rdfs:subClassOf ?nbr`
|
|
||||||
- `subclasses` – `?nbr rdfs:subClassOf ?sel`
|
|
||||||
|
|
||||||
To add a new mode:
|
|
||||||
|
|
||||||
1) Add a new file under `backend_go/selection_queries/` that returns neighbor node IDs.
|
|
||||||
2) Register it in `backend_go/selection_queries/registry.go`.
|
|
||||||
|
|
||||||
## Performance notes
|
|
||||||
|
|
||||||
- Memory usage is dominated by the cached snapshot (`[]Node`, `[]Edge`) and the temporary SPARQL JSON unmarshalling step.
|
|
||||||
- Tune `DEFAULT_NODE_LIMIT`/`DEFAULT_EDGE_LIMIT` first if memory is too high.
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
IncludeBNodes bool
|
|
||||||
|
|
||||||
CorsOrigins string
|
|
||||||
|
|
||||||
DefaultNodeLimit int
|
|
||||||
DefaultEdgeLimit int
|
|
||||||
MaxNodeLimit int
|
|
||||||
MaxEdgeLimit int
|
|
||||||
|
|
||||||
EdgeBatchSize int
|
|
||||||
|
|
||||||
FreeOSMemoryAfterSnapshot bool
|
|
||||||
LogSnapshotTimings bool
|
|
||||||
|
|
||||||
SparqlHost string
|
|
||||||
SparqlEndpoint string
|
|
||||||
SparqlUser string
|
|
||||||
SparqlPass string
|
|
||||||
SparqlInsecureTLS bool
|
|
||||||
SparqlDataFile string
|
|
||||||
SparqlGraphIRI string
|
|
||||||
SparqlLoadOnStart bool
|
|
||||||
SparqlClearOnStart bool
|
|
||||||
|
|
||||||
SparqlTimeout time.Duration
|
|
||||||
SparqlReadyRetries int
|
|
||||||
SparqlReadyDelay time.Duration
|
|
||||||
SparqlReadyTimeout time.Duration
|
|
||||||
|
|
||||||
ListenAddr string
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadConfig() (Config, error) {
|
|
||||||
cfg := Config{
|
|
||||||
IncludeBNodes: envBool("INCLUDE_BNODES", false),
|
|
||||||
CorsOrigins: envString("CORS_ORIGINS", "*"),
|
|
||||||
|
|
||||||
DefaultNodeLimit: envInt("DEFAULT_NODE_LIMIT", 800_000),
|
|
||||||
DefaultEdgeLimit: envInt("DEFAULT_EDGE_LIMIT", 2_000_000),
|
|
||||||
MaxNodeLimit: envInt("MAX_NODE_LIMIT", 10_000_000),
|
|
||||||
MaxEdgeLimit: envInt("MAX_EDGE_LIMIT", 20_000_000),
|
|
||||||
EdgeBatchSize: envInt("EDGE_BATCH_SIZE", 100_000),
|
|
||||||
FreeOSMemoryAfterSnapshot: envBool("FREE_OS_MEMORY_AFTER_SNAPSHOT", false),
|
|
||||||
LogSnapshotTimings: envBool("LOG_SNAPSHOT_TIMINGS", false),
|
|
||||||
|
|
||||||
SparqlHost: envString("SPARQL_HOST", "http://anzograph:8080"),
|
|
||||||
SparqlEndpoint: envString("SPARQL_ENDPOINT", ""),
|
|
||||||
SparqlUser: envString("SPARQL_USER", ""),
|
|
||||||
SparqlPass: envString("SPARQL_PASS", ""),
|
|
||||||
SparqlInsecureTLS: envBool("SPARQL_INSECURE_TLS", false),
|
|
||||||
SparqlDataFile: envString("SPARQL_DATA_FILE", ""),
|
|
||||||
SparqlGraphIRI: envString("SPARQL_GRAPH_IRI", ""),
|
|
||||||
SparqlLoadOnStart: envBool("SPARQL_LOAD_ON_START", false),
|
|
||||||
SparqlClearOnStart: envBool("SPARQL_CLEAR_ON_START", false),
|
|
||||||
|
|
||||||
SparqlReadyRetries: envInt("SPARQL_READY_RETRIES", 30),
|
|
||||||
ListenAddr: envString("LISTEN_ADDR", ":8000"),
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
cfg.SparqlTimeout, err = envSeconds("SPARQL_TIMEOUT_S", 300)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
cfg.SparqlReadyDelay, err = envSeconds("SPARQL_READY_DELAY_S", 4)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
cfg.SparqlReadyTimeout, err = envSeconds("SPARQL_READY_TIMEOUT_S", 10)
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.SparqlLoadOnStart && strings.TrimSpace(cfg.SparqlDataFile) == "" {
|
|
||||||
return Config{}, fmt.Errorf("SPARQL_LOAD_ON_START=true but SPARQL_DATA_FILE is not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.DefaultNodeLimit < 1 {
|
|
||||||
return Config{}, fmt.Errorf("DEFAULT_NODE_LIMIT must be >= 1")
|
|
||||||
}
|
|
||||||
if cfg.DefaultEdgeLimit < 1 {
|
|
||||||
return Config{}, fmt.Errorf("DEFAULT_EDGE_LIMIT must be >= 1")
|
|
||||||
}
|
|
||||||
if cfg.MaxNodeLimit < 1 {
|
|
||||||
return Config{}, fmt.Errorf("MAX_NODE_LIMIT must be >= 1")
|
|
||||||
}
|
|
||||||
if cfg.MaxEdgeLimit < 1 {
|
|
||||||
return Config{}, fmt.Errorf("MAX_EDGE_LIMIT must be >= 1")
|
|
||||||
}
|
|
||||||
if cfg.DefaultNodeLimit > cfg.MaxNodeLimit {
|
|
||||||
return Config{}, fmt.Errorf("DEFAULT_NODE_LIMIT must be <= MAX_NODE_LIMIT")
|
|
||||||
}
|
|
||||||
if cfg.DefaultEdgeLimit > cfg.MaxEdgeLimit {
|
|
||||||
return Config{}, fmt.Errorf("DEFAULT_EDGE_LIMIT must be <= MAX_EDGE_LIMIT")
|
|
||||||
}
|
|
||||||
if cfg.EdgeBatchSize < 1 {
|
|
||||||
return Config{}, fmt.Errorf("EDGE_BATCH_SIZE must be >= 1")
|
|
||||||
}
|
|
||||||
if cfg.EdgeBatchSize > cfg.MaxEdgeLimit {
|
|
||||||
return Config{}, fmt.Errorf("EDGE_BATCH_SIZE must be <= MAX_EDGE_LIMIT")
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Config) EffectiveSparqlEndpoint() string {
|
|
||||||
if strings.TrimSpace(c.SparqlEndpoint) != "" {
|
|
||||||
return strings.TrimSpace(c.SparqlEndpoint)
|
|
||||||
}
|
|
||||||
return strings.TrimRight(c.SparqlHost, "/") + "/sparql"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Config) corsOriginList() []string {
|
|
||||||
raw := strings.TrimSpace(c.CorsOrigins)
|
|
||||||
if raw == "" || raw == "*" {
|
|
||||||
return []string{"*"}
|
|
||||||
}
|
|
||||||
parts := strings.Split(raw, ",")
|
|
||||||
out := make([]string, 0, len(parts))
|
|
||||||
for _, p := range parts {
|
|
||||||
p = strings.TrimSpace(p)
|
|
||||||
if p == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, p)
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return []string{"*"}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func envString(name, def string) string {
|
|
||||||
v := os.Getenv(name)
|
|
||||||
if strings.TrimSpace(v) == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func envBool(name string, def bool) bool {
|
|
||||||
v := strings.TrimSpace(os.Getenv(name))
|
|
||||||
if v == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
switch strings.ToLower(v) {
|
|
||||||
case "1", "true", "yes", "y", "on":
|
|
||||||
return true
|
|
||||||
case "0", "false", "no", "n", "off":
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func envInt(name string, def int) int {
|
|
||||||
v := strings.TrimSpace(os.Getenv(name))
|
|
||||||
if v == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
v = strings.ReplaceAll(v, "_", "")
|
|
||||||
n, err := strconv.Atoi(v)
|
|
||||||
if err != nil {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func envSeconds(name string, def float64) (time.Duration, error) {
|
|
||||||
v := strings.TrimSpace(os.Getenv(name))
|
|
||||||
if v == "" {
|
|
||||||
return time.Duration(def * float64(time.Second)), nil
|
|
||||||
}
|
|
||||||
f, err := strconv.ParseFloat(v, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("%s must be a number (seconds): %w", name, err)
|
|
||||||
}
|
|
||||||
return time.Duration(f * float64(time.Second)), nil
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
module visualizador_instanciados/backend_go
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
type termKey struct {
|
|
||||||
termType string
|
|
||||||
key string
|
|
||||||
}
|
|
||||||
|
|
||||||
type graphAccumulator struct {
|
|
||||||
includeBNodes bool
|
|
||||||
nodeLimit int
|
|
||||||
nodeIDByKey map[termKey]uint32
|
|
||||||
nodes []Node
|
|
||||||
edges []Edge
|
|
||||||
preds *PredicateDict
|
|
||||||
}
|
|
||||||
|
|
||||||
func newGraphAccumulator(nodeLimit int, includeBNodes bool, edgeCapHint int, preds *PredicateDict) *graphAccumulator {
|
|
||||||
if preds == nil {
|
|
||||||
preds = NewPredicateDict(nil)
|
|
||||||
}
|
|
||||||
return &graphAccumulator{
|
|
||||||
includeBNodes: includeBNodes,
|
|
||||||
nodeLimit: nodeLimit,
|
|
||||||
nodeIDByKey: make(map[termKey]uint32),
|
|
||||||
nodes: make([]Node, 0, min(nodeLimit, 4096)),
|
|
||||||
edges: make([]Edge, 0, min(edgeCapHint, 4096)),
|
|
||||||
preds: preds,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *graphAccumulator) getOrAddNode(term sparqlTerm) (uint32, bool) {
|
|
||||||
if term.Type == "" || term.Value == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if term.Type == "literal" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var key termKey
|
|
||||||
var node Node
|
|
||||||
|
|
||||||
if term.Type == "bnode" {
|
|
||||||
if !g.includeBNodes {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
key = termKey{termType: "bnode", key: term.Value}
|
|
||||||
node = Node{ID: 0, TermType: "bnode", IRI: "_:" + term.Value, Label: nil, X: 0, Y: 0}
|
|
||||||
} else {
|
|
||||||
key = termKey{termType: "uri", key: term.Value}
|
|
||||||
node = Node{ID: 0, TermType: "uri", IRI: term.Value, Label: nil, X: 0, Y: 0}
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing, ok := g.nodeIDByKey[key]; ok {
|
|
||||||
return existing, true
|
|
||||||
}
|
|
||||||
if len(g.nodes) >= g.nodeLimit {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
nid := uint32(len(g.nodes))
|
|
||||||
g.nodeIDByKey[key] = nid
|
|
||||||
node.ID = nid
|
|
||||||
g.nodes = append(g.nodes, node)
|
|
||||||
return nid, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (g *graphAccumulator) addBindings(bindings []map[string]sparqlTerm) {
|
|
||||||
for _, b := range bindings {
|
|
||||||
sTerm := b["s"]
|
|
||||||
oTerm := b["o"]
|
|
||||||
pTerm := b["p"]
|
|
||||||
|
|
||||||
sid, okS := g.getOrAddNode(sTerm)
|
|
||||||
oid, okO := g.getOrAddNode(oTerm)
|
|
||||||
if !okS || !okO {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
predID, ok := g.preds.GetOrAdd(pTerm.Value)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
g.edges = append(g.edges, Edge{
|
|
||||||
Source: sid,
|
|
||||||
Target: oid,
|
|
||||||
PredicateID: predID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func min(a, b int) int {
|
|
||||||
if a < b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package graph_queries
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
func defaultEdgeQuery(limit int, offset int, includeBNodes bool) string {
|
|
||||||
bnodeFilter := ""
|
|
||||||
if !includeBNodes {
|
|
||||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
|
||||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
|
||||||
|
|
||||||
SELECT ?s ?p ?o
|
|
||||||
WHERE {
|
|
||||||
{
|
|
||||||
VALUES ?p { rdf:type }
|
|
||||||
?s ?p ?o .
|
|
||||||
?o rdf:type owl:Class .
|
|
||||||
}
|
|
||||||
UNION
|
|
||||||
{
|
|
||||||
VALUES ?p { rdfs:subClassOf }
|
|
||||||
?s ?p ?o .
|
|
||||||
}
|
|
||||||
FILTER(!isLiteral(?o))
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
ORDER BY ?s ?p ?o
|
|
||||||
LIMIT %d
|
|
||||||
OFFSET %d
|
|
||||||
`, bnodeFilter, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultPredicateQuery(includeBNodes bool) string {
|
|
||||||
bnodeFilter := ""
|
|
||||||
if !includeBNodes {
|
|
||||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
|
||||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
|
||||||
|
|
||||||
SELECT DISTINCT ?p
|
|
||||||
WHERE {
|
|
||||||
{
|
|
||||||
VALUES ?p { rdf:type }
|
|
||||||
?s ?p ?o .
|
|
||||||
?o rdf:type owl:Class .
|
|
||||||
}
|
|
||||||
UNION
|
|
||||||
{
|
|
||||||
VALUES ?p { rdfs:subClassOf }
|
|
||||||
?s ?p ?o .
|
|
||||||
}
|
|
||||||
FILTER(!isLiteral(?o))
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
ORDER BY ?p
|
|
||||||
`, bnodeFilter)
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package graph_queries
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
func hierarchyEdgeQuery(limit int, offset int, includeBNodes bool) string {
|
|
||||||
bnodeFilter := ""
|
|
||||||
if !includeBNodes {
|
|
||||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
|
||||||
|
|
||||||
SELECT ?s ?p ?o
|
|
||||||
WHERE {
|
|
||||||
VALUES ?p { rdfs:subClassOf }
|
|
||||||
?s ?p ?o .
|
|
||||||
FILTER(!isLiteral(?o))
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
ORDER BY ?s ?p ?o
|
|
||||||
LIMIT %d
|
|
||||||
OFFSET %d
|
|
||||||
`, bnodeFilter, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hierarchyPredicateQuery(includeBNodes bool) string {
|
|
||||||
bnodeFilter := ""
|
|
||||||
if !includeBNodes {
|
|
||||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
|
||||||
|
|
||||||
SELECT DISTINCT ?p
|
|
||||||
WHERE {
|
|
||||||
VALUES ?p { rdfs:subClassOf }
|
|
||||||
?s ?p ?o .
|
|
||||||
FILTER(!isLiteral(?o))
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
ORDER BY ?p
|
|
||||||
`, bnodeFilter)
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package graph_queries
|
|
||||||
|
|
||||||
const DefaultID = "default"
|
|
||||||
|
|
||||||
var definitions = []Definition{
|
|
||||||
{Meta: Meta{ID: DefaultID, Label: "Default"}, EdgeQuery: defaultEdgeQuery, PredicateQuery: defaultPredicateQuery},
|
|
||||||
{Meta: Meta{ID: "hierarchy", Label: "Hierarchy"}, EdgeQuery: hierarchyEdgeQuery, PredicateQuery: hierarchyPredicateQuery},
|
|
||||||
{Meta: Meta{ID: "types", Label: "Types"}, EdgeQuery: typesOnlyEdgeQuery, PredicateQuery: typesOnlyPredicateQuery},
|
|
||||||
}
|
|
||||||
|
|
||||||
func List() []Meta {
|
|
||||||
out := make([]Meta, 0, len(definitions))
|
|
||||||
for _, d := range definitions {
|
|
||||||
out = append(out, d.Meta)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func Get(id string) (Definition, bool) {
|
|
||||||
for _, d := range definitions {
|
|
||||||
if d.Meta.ID == id {
|
|
||||||
return d, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Definition{}, false
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package graph_queries
|
|
||||||
|
|
||||||
type Meta struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Definition struct {
|
|
||||||
Meta Meta
|
|
||||||
EdgeQuery func(limit int, offset int, includeBNodes bool) string
|
|
||||||
PredicateQuery func(includeBNodes bool) string
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package graph_queries
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
func typesOnlyEdgeQuery(limit int, offset int, includeBNodes bool) string {
|
|
||||||
bnodeFilter := ""
|
|
||||||
if !includeBNodes {
|
|
||||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
|
||||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
|
||||||
|
|
||||||
SELECT ?s ?p ?o
|
|
||||||
WHERE {
|
|
||||||
VALUES ?p { rdf:type }
|
|
||||||
?s ?p ?o .
|
|
||||||
?o rdf:type owl:Class .
|
|
||||||
FILTER(!isLiteral(?o))
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
ORDER BY ?s ?p ?o
|
|
||||||
LIMIT %d
|
|
||||||
OFFSET %d
|
|
||||||
`, bnodeFilter, limit, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
func typesOnlyPredicateQuery(includeBNodes bool) string {
|
|
||||||
bnodeFilter := ""
|
|
||||||
if !includeBNodes {
|
|
||||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
|
||||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
|
||||||
|
|
||||||
SELECT DISTINCT ?p
|
|
||||||
WHERE {
|
|
||||||
VALUES ?p { rdf:type }
|
|
||||||
?s ?p ?o .
|
|
||||||
?o rdf:type owl:Class .
|
|
||||||
FILTER(!isLiteral(?o))
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
ORDER BY ?p
|
|
||||||
`, bnodeFilter)
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"runtime"
|
|
||||||
"runtime/debug"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
graphqueries "visualizador_instanciados/backend_go/graph_queries"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
rdfsLabelIRI = "http://www.w3.org/2000/01/rdf-schema#label"
|
|
||||||
)
|
|
||||||
|
|
||||||
func fetchGraphSnapshot(
|
|
||||||
ctx context.Context,
|
|
||||||
sparql *AnzoGraphClient,
|
|
||||||
cfg Config,
|
|
||||||
nodeLimit int,
|
|
||||||
edgeLimit int,
|
|
||||||
graphQueryID string,
|
|
||||||
) (GraphResponse, error) {
|
|
||||||
start := time.Now()
|
|
||||||
logStats := func(stage string) {
|
|
||||||
if !cfg.LogSnapshotTimings {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var ms runtime.MemStats
|
|
||||||
runtime.ReadMemStats(&ms)
|
|
||||||
log.Printf(
|
|
||||||
"[snapshot] %s graph_query_id=%s node_limit=%d edge_limit=%d elapsed=%s alloc=%dMB heap_inuse=%dMB sys=%dMB numgc=%d",
|
|
||||||
stage,
|
|
||||||
graphQueryID,
|
|
||||||
nodeLimit,
|
|
||||||
edgeLimit,
|
|
||||||
time.Since(start).Truncate(time.Millisecond),
|
|
||||||
ms.Alloc/1024/1024,
|
|
||||||
ms.HeapInuse/1024/1024,
|
|
||||||
ms.Sys/1024/1024,
|
|
||||||
ms.NumGC,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
def, ok := graphqueries.Get(graphQueryID)
|
|
||||||
if !ok {
|
|
||||||
return GraphResponse{}, fmt.Errorf("unknown graph_query_id: %s", graphQueryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build predicate dictionary (predicate IRI -> uint32 ID) before fetching edges.
|
|
||||||
preds, err := func() (*PredicateDict, error) {
|
|
||||||
logStats("predicates_query_start")
|
|
||||||
predQ := def.PredicateQuery(cfg.IncludeBNodes)
|
|
||||||
t0 := time.Now()
|
|
||||||
rawPred, err := sparql.Query(ctx, predQ)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("predicates query failed: %w", err)
|
|
||||||
}
|
|
||||||
if cfg.LogSnapshotTimings {
|
|
||||||
log.Printf("[snapshot] predicates_query_returned bytes=%d query_time=%s", len(rawPred), time.Since(t0).Truncate(time.Millisecond))
|
|
||||||
}
|
|
||||||
var predRes sparqlResponse
|
|
||||||
t1 := time.Now()
|
|
||||||
if err := json.Unmarshal(rawPred, &predRes); err != nil {
|
|
||||||
return nil, fmt.Errorf("predicates unmarshal failed: %w", err)
|
|
||||||
}
|
|
||||||
if cfg.LogSnapshotTimings {
|
|
||||||
log.Printf("[snapshot] predicates_unmarshal_done bindings=%d unmarshal_time=%s", len(predRes.Results.Bindings), time.Since(t1).Truncate(time.Millisecond))
|
|
||||||
}
|
|
||||||
predicateIRIs := make([]string, 0, len(predRes.Results.Bindings))
|
|
||||||
for _, b := range predRes.Results.Bindings {
|
|
||||||
pTerm, ok := b["p"]
|
|
||||||
if !ok || pTerm.Type != "uri" || pTerm.Value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
predicateIRIs = append(predicateIRIs, pTerm.Value)
|
|
||||||
}
|
|
||||||
logStats("predicates_dict_built")
|
|
||||||
return NewPredicateDict(predicateIRIs), nil
|
|
||||||
}()
|
|
||||||
if err != nil {
|
|
||||||
return GraphResponse{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch edges in batches to avoid decoding a single huge SPARQL JSON response.
|
|
||||||
logStats("edges_batched_start")
|
|
||||||
batchSize := cfg.EdgeBatchSize
|
|
||||||
acc := newGraphAccumulator(nodeLimit, cfg.IncludeBNodes, min(edgeLimit, batchSize), preds)
|
|
||||||
|
|
||||||
totalBindings := 0
|
|
||||||
convAllT0 := time.Now()
|
|
||||||
for batch, offset := 0, 0; offset < edgeLimit; batch, offset = batch+1, offset+batchSize {
|
|
||||||
limit := batchSize
|
|
||||||
remaining := edgeLimit - offset
|
|
||||||
if remaining < limit {
|
|
||||||
limit = remaining
|
|
||||||
}
|
|
||||||
|
|
||||||
logStats(fmt.Sprintf("edges_batch_start batch=%d offset=%d limit=%d", batch, offset, limit))
|
|
||||||
bindings, err := func() ([]map[string]sparqlTerm, error) {
|
|
||||||
edgesQ := def.EdgeQuery(limit, offset, cfg.IncludeBNodes)
|
|
||||||
t0 := time.Now()
|
|
||||||
raw, err := sparql.Query(ctx, edgesQ)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("edges query failed: %w", err)
|
|
||||||
}
|
|
||||||
if cfg.LogSnapshotTimings {
|
|
||||||
log.Printf("[snapshot] edges_batch_query_returned batch=%d offset=%d limit=%d bytes=%d query_time=%s", batch, offset, limit, len(raw), time.Since(t0).Truncate(time.Millisecond))
|
|
||||||
}
|
|
||||||
|
|
||||||
var res sparqlResponse
|
|
||||||
t1 := time.Now()
|
|
||||||
if err := json.Unmarshal(raw, &res); err != nil {
|
|
||||||
return nil, fmt.Errorf("edges unmarshal failed: %w", err)
|
|
||||||
}
|
|
||||||
if cfg.LogSnapshotTimings {
|
|
||||||
log.Printf("[snapshot] edges_batch_unmarshal_done batch=%d bindings=%d unmarshal_time=%s", batch, len(res.Results.Bindings), time.Since(t1).Truncate(time.Millisecond))
|
|
||||||
}
|
|
||||||
return res.Results.Bindings, nil
|
|
||||||
}()
|
|
||||||
if err != nil {
|
|
||||||
return GraphResponse{}, fmt.Errorf("edges batch=%d offset=%d limit=%d: %w", batch, offset, limit, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := len(bindings)
|
|
||||||
totalBindings += got
|
|
||||||
if got == 0 {
|
|
||||||
bindings = nil
|
|
||||||
logStats(fmt.Sprintf("edges_batch_done_empty batch=%d offset=%d", batch, offset))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
convT0 := time.Now()
|
|
||||||
acc.addBindings(bindings)
|
|
||||||
if cfg.LogSnapshotTimings {
|
|
||||||
log.Printf(
|
|
||||||
"[snapshot] edges_batch_convert_done batch=%d got_bindings=%d total_bindings=%d nodes=%d edges=%d convert_time=%s",
|
|
||||||
batch,
|
|
||||||
got,
|
|
||||||
totalBindings,
|
|
||||||
len(acc.nodes),
|
|
||||||
len(acc.edges),
|
|
||||||
time.Since(convT0).Truncate(time.Millisecond),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make the batch eligible for GC.
|
|
||||||
bindings = nil
|
|
||||||
logStats(fmt.Sprintf("edges_batch_done batch=%d offset=%d", batch, offset))
|
|
||||||
if cfg.FreeOSMemoryAfterSnapshot {
|
|
||||||
debug.FreeOSMemory()
|
|
||||||
logStats(fmt.Sprintf("edges_batch_free_os_memory_done batch=%d offset=%d", batch, offset))
|
|
||||||
}
|
|
||||||
|
|
||||||
if got < limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.LogSnapshotTimings {
|
|
||||||
log.Printf("[snapshot] convert_batches_done total_bindings=%d total_time=%s", totalBindings, time.Since(convAllT0).Truncate(time.Millisecond))
|
|
||||||
}
|
|
||||||
logStats("edges_batched_done")
|
|
||||||
|
|
||||||
nodes := acc.nodes
|
|
||||||
edges := acc.edges
|
|
||||||
|
|
||||||
// Layout: invert edges for hierarchy (target -> source).
|
|
||||||
hierEdges := make([][2]int, 0, len(edges))
|
|
||||||
for _, e := range edges {
|
|
||||||
hierEdges = append(hierEdges, [2]int{int(e.Target), int(e.Source)})
|
|
||||||
}
|
|
||||||
|
|
||||||
layers, cycleErr := levelSynchronousKahnLayers(len(nodes), hierEdges)
|
|
||||||
if cycleErr != nil {
|
|
||||||
sample := make([]string, 0, 20)
|
|
||||||
for _, nid := range cycleErr.RemainingNodeIDs {
|
|
||||||
if len(sample) >= 20 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if nid >= 0 && nid < len(nodes) {
|
|
||||||
sample = append(sample, nodes[nid].IRI)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cycleErr.RemainingIRISample = sample
|
|
||||||
return GraphResponse{}, cycleErr
|
|
||||||
}
|
|
||||||
|
|
||||||
idToIRI := make([]string, len(nodes))
|
|
||||||
for i := range nodes {
|
|
||||||
idToIRI[i] = nodes[i].IRI
|
|
||||||
}
|
|
||||||
for _, layer := range layers {
|
|
||||||
sortLayerByIRI(layer, idToIRI)
|
|
||||||
}
|
|
||||||
|
|
||||||
xs, ys := radialPositionsFromLayers(len(nodes), layers, 5000.0)
|
|
||||||
for i := range nodes {
|
|
||||||
nodes[i].X = xs[i]
|
|
||||||
nodes[i].Y = ys[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach labels for URI nodes.
|
|
||||||
iris := make([]string, 0)
|
|
||||||
for _, n := range nodes {
|
|
||||||
if n.TermType == "uri" && n.IRI != "" {
|
|
||||||
iris = append(iris, n.IRI)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(iris) > 0 {
|
|
||||||
labelByIRI, err := fetchRDFSLabels(ctx, sparql, iris, 500)
|
|
||||||
if err != nil {
|
|
||||||
return GraphResponse{}, fmt.Errorf("fetch rdfs:label failed: %w", err)
|
|
||||||
}
|
|
||||||
for i := range nodes {
|
|
||||||
if nodes[i].TermType != "uri" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lbl, ok := labelByIRI[nodes[i].IRI]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val := lbl
|
|
||||||
nodes[i].Label = &val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := &GraphMeta{
|
|
||||||
Backend: "anzograph",
|
|
||||||
TTLPath: nil,
|
|
||||||
SparqlEndpoint: cfg.EffectiveSparqlEndpoint(),
|
|
||||||
IncludeBNodes: cfg.IncludeBNodes,
|
|
||||||
GraphQueryID: graphQueryID,
|
|
||||||
Predicates: preds.IRIs(),
|
|
||||||
NodeLimit: nodeLimit,
|
|
||||||
EdgeLimit: edgeLimit,
|
|
||||||
Nodes: len(nodes),
|
|
||||||
Edges: len(edges),
|
|
||||||
}
|
|
||||||
|
|
||||||
return GraphResponse{Nodes: nodes, Edges: edges, Meta: meta}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type bestLabel struct {
|
|
||||||
score int
|
|
||||||
value string
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchRDFSLabels(
|
|
||||||
ctx context.Context,
|
|
||||||
sparql *AnzoGraphClient,
|
|
||||||
iris []string,
|
|
||||||
batchSize int,
|
|
||||||
) (map[string]string, error) {
|
|
||||||
best := make(map[string]bestLabel)
|
|
||||||
|
|
||||||
for i := 0; i < len(iris); i += batchSize {
|
|
||||||
end := i + batchSize
|
|
||||||
if end > len(iris) {
|
|
||||||
end = len(iris)
|
|
||||||
}
|
|
||||||
batch := iris[i:end]
|
|
||||||
|
|
||||||
values := make([]string, 0, len(batch))
|
|
||||||
for _, u := range batch {
|
|
||||||
values = append(values, "<"+u+">")
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
|
||||||
SELECT ?s ?label
|
|
||||||
WHERE {
|
|
||||||
VALUES ?s { %s }
|
|
||||||
?s <%s> ?label .
|
|
||||||
}
|
|
||||||
`, strings.Join(values, " "), rdfsLabelIRI)
|
|
||||||
|
|
||||||
raw, err := sparql.Query(ctx, q)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var res sparqlResponse
|
|
||||||
if err := json.Unmarshal(raw, &res); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse SPARQL JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, b := range res.Results.Bindings {
|
|
||||||
sTerm, ok := b["s"]
|
|
||||||
if !ok || sTerm.Value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lblTerm, ok := b["label"]
|
|
||||||
if !ok || lblTerm.Type != "literal" || lblTerm.Value == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
score := labelScore(lblTerm.Lang)
|
|
||||||
prev, ok := best[sTerm.Value]
|
|
||||||
if !ok || score > prev.score {
|
|
||||||
best[sTerm.Value] = bestLabel{score: score, value: lblTerm.Value}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out := make(map[string]string, len(best))
|
|
||||||
for iri, v := range best {
|
|
||||||
out[iri] = v.value
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func labelScore(lang string) int {
|
|
||||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
|
||||||
if lang == "en" {
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
if lang == "" {
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortIntsUnique(xs []int) []int {
|
|
||||||
if len(xs) == 0 {
|
|
||||||
return xs
|
|
||||||
}
|
|
||||||
sort.Ints(xs)
|
|
||||||
out := xs[:0]
|
|
||||||
var last int
|
|
||||||
for i, v := range xs {
|
|
||||||
if i == 0 || v != last {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
last = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
enc := json.NewEncoder(w)
|
|
||||||
_ = enc.Encode(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
||||||
writeJSON(w, status, ErrorResponse{Detail: msg})
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeJSON(r io.Reader, dst any) error {
|
|
||||||
dec := json.NewDecoder(r)
|
|
||||||
return dec.Decode(dst)
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CycleError struct {
|
|
||||||
Processed int
|
|
||||||
Total int
|
|
||||||
RemainingNodeIDs []int
|
|
||||||
RemainingIRISample []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *CycleError) Error() string {
|
|
||||||
msg := fmt.Sprintf("Cycle detected in subClassOf graph (processed %d/%d nodes).", e.Processed, e.Total)
|
|
||||||
if len(e.RemainingIRISample) > 0 {
|
|
||||||
msg += " Example nodes: " + stringsJoin(e.RemainingIRISample, ", ")
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
func levelSynchronousKahnLayers(nodeCount int, edges [][2]int) ([][]int, *CycleError) {
|
|
||||||
n := nodeCount
|
|
||||||
if n <= 0 {
|
|
||||||
return [][]int{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
adj := make([][]int, n)
|
|
||||||
indeg := make([]int, n)
|
|
||||||
|
|
||||||
for _, e := range edges {
|
|
||||||
u, v := e[0], e[1]
|
|
||||||
if u == v {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if u < 0 || u >= n || v < 0 || v >= n {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
adj[u] = append(adj[u], v)
|
|
||||||
indeg[v]++
|
|
||||||
}
|
|
||||||
|
|
||||||
q := make([]int, 0, n)
|
|
||||||
for i, d := range indeg {
|
|
||||||
if d == 0 {
|
|
||||||
q = append(q, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
layers := make([][]int, 0)
|
|
||||||
processed := 0
|
|
||||||
for len(q) > 0 {
|
|
||||||
layer := append([]int(nil), q...)
|
|
||||||
q = q[:0]
|
|
||||||
layers = append(layers, layer)
|
|
||||||
|
|
||||||
for _, u := range layer {
|
|
||||||
processed++
|
|
||||||
for _, v := range adj[u] {
|
|
||||||
indeg[v]--
|
|
||||||
if indeg[v] == 0 {
|
|
||||||
q = append(q, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if processed != n {
|
|
||||||
remaining := make([]int, 0)
|
|
||||||
for i, d := range indeg {
|
|
||||||
if d > 0 {
|
|
||||||
remaining = append(remaining, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, &CycleError{Processed: processed, Total: n, RemainingNodeIDs: remaining}
|
|
||||||
}
|
|
||||||
|
|
||||||
return layers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func radialPositionsFromLayers(nodeCount int, layers [][]int, maxR float64) (xs []float64, ys []float64) {
|
|
||||||
n := nodeCount
|
|
||||||
if n <= 0 {
|
|
||||||
return []float64{}, []float64{}
|
|
||||||
}
|
|
||||||
|
|
||||||
xs = make([]float64, n)
|
|
||||||
ys = make([]float64, n)
|
|
||||||
if len(layers) == 0 {
|
|
||||||
return xs, ys
|
|
||||||
}
|
|
||||||
|
|
||||||
twoPi := 2.0 * math.Pi
|
|
||||||
golden := math.Pi * (3.0 - math.Sqrt(5.0))
|
|
||||||
|
|
||||||
layerCount := float64(len(layers))
|
|
||||||
denom := layerCount + 1.0
|
|
||||||
|
|
||||||
for li, layer := range layers {
|
|
||||||
m := len(layer)
|
|
||||||
if m == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
r := (float64(li+1) / denom) * maxR
|
|
||||||
offset := math.Mod(float64(li)*golden, twoPi)
|
|
||||||
|
|
||||||
if m == 1 {
|
|
||||||
nid := layer[0]
|
|
||||||
if nid >= 0 && nid < n {
|
|
||||||
xs[nid] = r * math.Cos(offset)
|
|
||||||
ys[nid] = r * math.Sin(offset)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
step := twoPi / float64(m)
|
|
||||||
for j, nid := range layer {
|
|
||||||
if nid < 0 || nid >= n {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
t := offset + step*float64(j)
|
|
||||||
xs[nid] = r * math.Cos(t)
|
|
||||||
ys[nid] = r * math.Sin(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return xs, ys
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortLayerByIRI(layer []int, idToIRI []string) {
|
|
||||||
sort.Slice(layer, func(i, j int) bool {
|
|
||||||
return idToIRI[layer[i]] < idToIRI[layer[j]]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringsJoin(parts []string, sep string) string {
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
out := parts[0]
|
|
||||||
for i := 1; i < len(parts); i++ {
|
|
||||||
out += sep
|
|
||||||
out += parts[i]
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
cfg, err := LoadConfig()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sparql := NewAnzoGraphClient(cfg)
|
|
||||||
if err := sparql.Startup(context.Background()); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
api := &APIServer{
|
|
||||||
cfg: cfg,
|
|
||||||
sparql: sparql,
|
|
||||||
snapshots: NewGraphSnapshotService(sparql, cfg),
|
|
||||||
}
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: cfg.ListenAddr,
|
|
||||||
Handler: api.handler(),
|
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("backend listening on %s", cfg.ListenAddr)
|
|
||||||
log.Fatal(srv.ListenAndServe())
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
type ErrorResponse struct {
|
|
||||||
Detail string `json:"detail"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HealthResponse struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Node struct {
|
|
||||||
ID uint32 `json:"id"`
|
|
||||||
TermType string `json:"termType"` // "uri" | "bnode"
|
|
||||||
IRI string `json:"iri"`
|
|
||||||
Label *string `json:"label"`
|
|
||||||
X float64 `json:"x"`
|
|
||||||
Y float64 `json:"y"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Edge struct {
|
|
||||||
Source uint32 `json:"source"`
|
|
||||||
Target uint32 `json:"target"`
|
|
||||||
PredicateID uint32 `json:"predicate_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GraphMeta struct {
|
|
||||||
Backend string `json:"backend"`
|
|
||||||
TTLPath *string `json:"ttl_path"`
|
|
||||||
SparqlEndpoint string `json:"sparql_endpoint"`
|
|
||||||
IncludeBNodes bool `json:"include_bnodes"`
|
|
||||||
GraphQueryID string `json:"graph_query_id"`
|
|
||||||
Predicates []string `json:"predicates,omitempty"` // index = predicate_id
|
|
||||||
NodeLimit int `json:"node_limit"`
|
|
||||||
EdgeLimit int `json:"edge_limit"`
|
|
||||||
Nodes int `json:"nodes"`
|
|
||||||
Edges int `json:"edges"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GraphResponse struct {
|
|
||||||
Nodes []Node `json:"nodes"`
|
|
||||||
Edges []Edge `json:"edges"`
|
|
||||||
Meta *GraphMeta `json:"meta"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StatsResponse struct {
|
|
||||||
Backend string `json:"backend"`
|
|
||||||
TTLPath *string `json:"ttl_path"`
|
|
||||||
SparqlEndpoint *string `json:"sparql_endpoint"`
|
|
||||||
ParsedTriples int `json:"parsed_triples"`
|
|
||||||
Nodes int `json:"nodes"`
|
|
||||||
Edges int `json:"edges"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SparqlQueryRequest struct {
|
|
||||||
Query string `json:"query"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NeighborsRequest struct {
|
|
||||||
SelectedIDs []uint32 `json:"selected_ids"`
|
|
||||||
NodeLimit *int `json:"node_limit,omitempty"`
|
|
||||||
EdgeLimit *int `json:"edge_limit,omitempty"`
|
|
||||||
GraphQueryID *string `json:"graph_query_id,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type NeighborsResponse struct {
|
|
||||||
SelectedIDs []uint32 `json:"selected_ids"`
|
|
||||||
NeighborIDs []uint32 `json:"neighbor_ids"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectionQueryRequest struct {
|
|
||||||
QueryID string `json:"query_id"`
|
|
||||||
SelectedIDs []uint32 `json:"selected_ids"`
|
|
||||||
NodeLimit *int `json:"node_limit,omitempty"`
|
|
||||||
EdgeLimit *int `json:"edge_limit,omitempty"`
|
|
||||||
GraphQueryID *string `json:"graph_query_id,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectionQueryResponse struct {
|
|
||||||
QueryID string `json:"query_id"`
|
|
||||||
SelectedIDs []uint32 `json:"selected_ids"`
|
|
||||||
NeighborIDs []uint32 `json:"neighbor_ids"`
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
type PredicateDict struct {
|
|
||||||
idByIRI map[string]uint32
|
|
||||||
iriByID []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewPredicateDict(predicates []string) *PredicateDict {
|
|
||||||
idByIRI := make(map[string]uint32, len(predicates))
|
|
||||||
iriByID := make([]string, 0, len(predicates))
|
|
||||||
for _, iri := range predicates {
|
|
||||||
if iri == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := idByIRI[iri]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := uint32(len(iriByID))
|
|
||||||
idByIRI[iri] = id
|
|
||||||
iriByID = append(iriByID, iri)
|
|
||||||
}
|
|
||||||
return &PredicateDict{idByIRI: idByIRI, iriByID: iriByID}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *PredicateDict) GetOrAdd(iri string) (uint32, bool) {
|
|
||||||
if iri == "" {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if id, ok := d.idByIRI[iri]; ok {
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
id := uint32(len(d.iriByID))
|
|
||||||
d.idByIRI[iri] = id
|
|
||||||
d.iriByID = append(d.iriByID, iri)
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *PredicateDict) IRIs() []string {
|
|
||||||
return d.iriByID
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package selection_queries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func nodeKey(termType, iri string) string {
|
|
||||||
return termType + "\x00" + iri
|
|
||||||
}
|
|
||||||
|
|
||||||
func valuesTerm(n NodeRef) string {
|
|
||||||
if n.TermType == "uri" {
|
|
||||||
if n.IRI == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "<" + n.IRI + ">"
|
|
||||||
}
|
|
||||||
if n.TermType == "bnode" {
|
|
||||||
if n.IRI == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(n.IRI, "_:") {
|
|
||||||
return n.IRI
|
|
||||||
}
|
|
||||||
return "_:" + n.IRI
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func termKeyFromSparqlTerm(term sparqlTerm, includeBNodes bool) (string, bool) {
|
|
||||||
if term.Type == "" || term.Value == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if term.Type == "literal" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if term.Type == "bnode" {
|
|
||||||
if !includeBNodes {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return nodeKey("bnode", "_:"+term.Value), true
|
|
||||||
}
|
|
||||||
if term.Type == "uri" {
|
|
||||||
return nodeKey("uri", term.Value), true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectedNodesFromIDs(idx Index, selectedIDs []uint32, includeBNodes bool) ([]NodeRef, map[uint32]struct{}) {
|
|
||||||
out := make([]NodeRef, 0, len(selectedIDs))
|
|
||||||
set := make(map[uint32]struct{}, len(selectedIDs))
|
|
||||||
for _, nid := range selectedIDs {
|
|
||||||
n, ok := idx.IDToNode[nid]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if n.TermType == "bnode" && !includeBNodes {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, n)
|
|
||||||
set[nid] = struct{}{}
|
|
||||||
}
|
|
||||||
return out, set
|
|
||||||
}
|
|
||||||
|
|
||||||
func idsFromBindings(raw []byte, varName string, idx Index, selectedSet map[uint32]struct{}, includeBNodes bool) ([]uint32, error) {
|
|
||||||
var res sparqlResponse
|
|
||||||
if err := json.Unmarshal(raw, &res); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse SPARQL JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
neighborSet := make(map[uint32]struct{})
|
|
||||||
for _, b := range res.Results.Bindings {
|
|
||||||
term, ok := b[varName]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, ok := termKeyFromSparqlTerm(term, includeBNodes)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nid, ok := idx.KeyToID[key]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, sel := selectedSet[nid]; sel {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
neighborSet[nid] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
ids := make([]uint32, 0, len(neighborSet))
|
|
||||||
for nid := range neighborSet {
|
|
||||||
ids = append(ids, nid)
|
|
||||||
}
|
|
||||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package selection_queries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func neighborsQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
|
||||||
valuesTerms := make([]string, 0, len(selectedNodes))
|
|
||||||
for _, n := range selectedNodes {
|
|
||||||
t := valuesTerm(n)
|
|
||||||
if t == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
valuesTerms = append(valuesTerms, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(valuesTerms) == 0 {
|
|
||||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
|
||||||
}
|
|
||||||
|
|
||||||
bnodeFilter := ""
|
|
||||||
if !includeBNodes {
|
|
||||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
|
||||||
}
|
|
||||||
|
|
||||||
values := strings.Join(valuesTerms, " ")
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
|
||||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
|
||||||
|
|
||||||
SELECT DISTINCT ?nbr
|
|
||||||
WHERE {
|
|
||||||
VALUES ?sel { %s }
|
|
||||||
{
|
|
||||||
?sel rdf:type ?o .
|
|
||||||
?o rdf:type owl:Class .
|
|
||||||
BIND(?o AS ?nbr)
|
|
||||||
}
|
|
||||||
UNION
|
|
||||||
{
|
|
||||||
?s rdf:type ?sel .
|
|
||||||
?sel rdf:type owl:Class .
|
|
||||||
BIND(?s AS ?nbr)
|
|
||||||
}
|
|
||||||
UNION
|
|
||||||
{
|
|
||||||
?sel rdfs:subClassOf ?o .
|
|
||||||
BIND(?o AS ?nbr)
|
|
||||||
}
|
|
||||||
UNION
|
|
||||||
{
|
|
||||||
?s rdfs:subClassOf ?sel .
|
|
||||||
BIND(?s AS ?nbr)
|
|
||||||
}
|
|
||||||
FILTER(!isLiteral(?nbr))
|
|
||||||
FILTER(?nbr != ?sel)
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
`, values, bnodeFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runNeighbors(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error) {
|
|
||||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
|
||||||
if len(selectedNodes) == 0 {
|
|
||||||
return []uint32{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := q.Query(ctx, neighborsQuery(selectedNodes, includeBNodes))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return idsFromBindings(raw, "nbr", idx, selectedSet, includeBNodes)
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package selection_queries
|
|
||||||
|
|
||||||
var definitions = []Definition{
|
|
||||||
{
|
|
||||||
Meta: Meta{ID: "neighbors", Label: "Neighbors"},
|
|
||||||
Run: runNeighbors,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Meta: Meta{ID: "superclasses", Label: "Superclasses"},
|
|
||||||
Run: runSuperclasses,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Meta: Meta{ID: "subclasses", Label: "Subclasses"},
|
|
||||||
Run: runSubclasses,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func List() []Meta {
|
|
||||||
out := make([]Meta, 0, len(definitions))
|
|
||||||
for _, d := range definitions {
|
|
||||||
out = append(out, d.Meta)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func Get(id string) (Definition, bool) {
|
|
||||||
for _, d := range definitions {
|
|
||||||
if d.Meta.ID == id {
|
|
||||||
return d, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Definition{}, false
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package selection_queries
|
|
||||||
|
|
||||||
type sparqlTerm struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Lang string `json:"xml:lang,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type sparqlResponse struct {
|
|
||||||
Results struct {
|
|
||||||
Bindings []map[string]sparqlTerm `json:"bindings"`
|
|
||||||
} `json:"results"`
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package selection_queries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func subclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
|
||||||
valuesTerms := make([]string, 0, len(selectedNodes))
|
|
||||||
for _, n := range selectedNodes {
|
|
||||||
t := valuesTerm(n)
|
|
||||||
if t == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
valuesTerms = append(valuesTerms, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(valuesTerms) == 0 {
|
|
||||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
|
||||||
}
|
|
||||||
|
|
||||||
bnodeFilter := ""
|
|
||||||
if !includeBNodes {
|
|
||||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
|
||||||
}
|
|
||||||
|
|
||||||
values := strings.Join(valuesTerms, " ")
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
|
||||||
|
|
||||||
SELECT DISTINCT ?nbr
|
|
||||||
WHERE {
|
|
||||||
VALUES ?sel { %s }
|
|
||||||
?nbr rdfs:subClassOf ?sel .
|
|
||||||
FILTER(!isLiteral(?nbr))
|
|
||||||
FILTER(?nbr != ?sel)
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
`, values, bnodeFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSubclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error) {
|
|
||||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
|
||||||
if len(selectedNodes) == 0 {
|
|
||||||
return []uint32{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := q.Query(ctx, subclassesQuery(selectedNodes, includeBNodes))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return idsFromBindings(raw, "nbr", idx, selectedSet, includeBNodes)
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
package selection_queries
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func superclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
|
||||||
valuesTerms := make([]string, 0, len(selectedNodes))
|
|
||||||
for _, n := range selectedNodes {
|
|
||||||
t := valuesTerm(n)
|
|
||||||
if t == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
valuesTerms = append(valuesTerms, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(valuesTerms) == 0 {
|
|
||||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
|
||||||
}
|
|
||||||
|
|
||||||
bnodeFilter := ""
|
|
||||||
if !includeBNodes {
|
|
||||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
|
||||||
}
|
|
||||||
|
|
||||||
values := strings.Join(valuesTerms, " ")
|
|
||||||
return fmt.Sprintf(`
|
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
|
||||||
|
|
||||||
SELECT DISTINCT ?nbr
|
|
||||||
WHERE {
|
|
||||||
VALUES ?sel { %s }
|
|
||||||
?sel rdfs:subClassOf ?nbr .
|
|
||||||
FILTER(!isLiteral(?nbr))
|
|
||||||
FILTER(?nbr != ?sel)
|
|
||||||
%s
|
|
||||||
}
|
|
||||||
`, values, bnodeFilter)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runSuperclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error) {
|
|
||||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
|
||||||
if len(selectedNodes) == 0 {
|
|
||||||
return []uint32{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := q.Query(ctx, superclassesQuery(selectedNodes, includeBNodes))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return idsFromBindings(raw, "nbr", idx, selectedSet, includeBNodes)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package selection_queries
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
type Querier interface {
|
|
||||||
Query(ctx context.Context, query string) ([]byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type NodeRef struct {
|
|
||||||
ID uint32
|
|
||||||
TermType string // "uri" | "bnode"
|
|
||||||
IRI string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Index struct {
|
|
||||||
IDToNode map[uint32]NodeRef
|
|
||||||
KeyToID map[string]uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
type Meta struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Definition struct {
|
|
||||||
Meta Meta
|
|
||||||
Run func(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
selectionqueries "visualizador_instanciados/backend_go/selection_queries"
|
|
||||||
)
|
|
||||||
|
|
||||||
func runSelectionQuery(
|
|
||||||
ctx context.Context,
|
|
||||||
sparql *AnzoGraphClient,
|
|
||||||
snapshot GraphResponse,
|
|
||||||
queryID string,
|
|
||||||
selectedIDs []uint32,
|
|
||||||
includeBNodes bool,
|
|
||||||
) ([]uint32, error) {
|
|
||||||
def, ok := selectionqueries.Get(queryID)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("unknown query_id: %s", queryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
idToNode := make(map[uint32]selectionqueries.NodeRef, len(snapshot.Nodes))
|
|
||||||
keyToID := make(map[string]uint32, len(snapshot.Nodes))
|
|
||||||
for _, n := range snapshot.Nodes {
|
|
||||||
nr := selectionqueries.NodeRef{ID: n.ID, TermType: n.TermType, IRI: n.IRI}
|
|
||||||
idToNode[n.ID] = nr
|
|
||||||
keyToID[n.TermType+"\x00"+n.IRI] = n.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
return def.Run(ctx, sparql, selectionqueries.Index{IDToNode: idToNode, KeyToID: keyToID}, selectedIDs, includeBNodes)
|
|
||||||
}
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
graphqueries "visualizador_instanciados/backend_go/graph_queries"
|
|
||||||
selectionqueries "visualizador_instanciados/backend_go/selection_queries"
|
|
||||||
)
|
|
||||||
|
|
||||||
type APIServer struct {
|
|
||||||
cfg Config
|
|
||||||
sparql *AnzoGraphClient
|
|
||||||
snapshots *GraphSnapshotService
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) handler() http.Handler {
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/api/health", s.handleHealth)
|
|
||||||
mux.HandleFunc("/api/stats", s.handleStats)
|
|
||||||
mux.HandleFunc("/api/sparql", s.handleSparql)
|
|
||||||
mux.HandleFunc("/api/graph", s.handleGraph)
|
|
||||||
mux.HandleFunc("/api/graph_queries", s.handleGraphQueries)
|
|
||||||
mux.HandleFunc("/api/selection_queries", s.handleSelectionQueries)
|
|
||||||
mux.HandleFunc("/api/selection_query", s.handleSelectionQuery)
|
|
||||||
mux.HandleFunc("/api/neighbors", s.handleNeighbors)
|
|
||||||
|
|
||||||
return s.corsMiddleware(mux)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) corsMiddleware(next http.Handler) http.Handler {
|
|
||||||
origins := s.cfg.corsOriginList()
|
|
||||||
allowAll := len(origins) == 1 && origins[0] == "*"
|
|
||||||
allowed := make(map[string]struct{}, len(origins))
|
|
||||||
for _, o := range origins {
|
|
||||||
allowed[o] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
origin := r.Header.Get("Origin")
|
|
||||||
if origin != "" {
|
|
||||||
if allowAll {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
} else if _, ok := allowed[origin]; ok {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
||||||
w.Header().Add("Vary", "Origin")
|
|
||||||
}
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "*")
|
|
||||||
}
|
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, HealthResponse{Status: "ok"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) handleStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
snap, err := s.snapshots.Get(ctx, s.cfg.DefaultNodeLimit, s.cfg.DefaultEdgeLimit, graphqueries.DefaultID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("handleStats: snapshot error: %v", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
endpoint := snap.Meta.SparqlEndpoint
|
|
||||||
writeJSON(w, http.StatusOK, StatsResponse{
|
|
||||||
Backend: snap.Meta.Backend,
|
|
||||||
TTLPath: snap.Meta.TTLPath,
|
|
||||||
SparqlEndpoint: &endpoint,
|
|
||||||
ParsedTriples: len(snap.Edges),
|
|
||||||
Nodes: len(snap.Nodes),
|
|
||||||
Edges: len(snap.Edges),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) handleSparql(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req SparqlQueryRequest
|
|
||||||
if err := decodeJSON(r.Body, &req); err != nil || strings.TrimSpace(req.Query) == "" {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := s.sparql.Query(r.Context(), req.Query)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) handleGraph(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeLimit, err := intQuery(r, "node_limit", s.cfg.DefaultNodeLimit)
|
|
||||||
if err != nil || nodeLimit < 1 || nodeLimit > s.cfg.MaxNodeLimit {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, fmt.Sprintf("node_limit must be between 1 and %d", s.cfg.MaxNodeLimit))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
edgeLimit, err := intQuery(r, "edge_limit", s.cfg.DefaultEdgeLimit)
|
|
||||||
if err != nil || edgeLimit < 1 || edgeLimit > s.cfg.MaxEdgeLimit {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, fmt.Sprintf("edge_limit must be between 1 and %d", s.cfg.MaxEdgeLimit))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
graphQueryID := strings.TrimSpace(r.URL.Query().Get("graph_query_id"))
|
|
||||||
if graphQueryID == "" {
|
|
||||||
graphQueryID = graphqueries.DefaultID
|
|
||||||
}
|
|
||||||
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
snap, err := s.snapshots.Get(r.Context(), nodeLimit, edgeLimit, graphQueryID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("handleGraph: snapshot error graph_query_id=%s node_limit=%d edge_limit=%d err=%v", graphQueryID, nodeLimit, edgeLimit, err)
|
|
||||||
if _, ok := err.(*CycleError); ok {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, snap)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) handleGraphQueries(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, graphqueries.List())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) handleSelectionQueries(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, selectionqueries.List())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) handleSelectionQuery(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req SelectionQueryRequest
|
|
||||||
if err := decodeJSON(r.Body, &req); err != nil || strings.TrimSpace(req.QueryID) == "" {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, ok := selectionqueries.Get(req.QueryID); !ok {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, "unknown query_id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.SelectedIDs) == 0 {
|
|
||||||
writeJSON(w, http.StatusOK, SelectionQueryResponse{
|
|
||||||
QueryID: req.QueryID,
|
|
||||||
SelectedIDs: req.SelectedIDs,
|
|
||||||
NeighborIDs: []uint32{},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
graphQueryID := graphqueries.DefaultID
|
|
||||||
if req.GraphQueryID != nil && strings.TrimSpace(*req.GraphQueryID) != "" {
|
|
||||||
graphQueryID = strings.TrimSpace(*req.GraphQueryID)
|
|
||||||
}
|
|
||||||
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeLimit := s.cfg.DefaultNodeLimit
|
|
||||||
edgeLimit := s.cfg.DefaultEdgeLimit
|
|
||||||
if req.NodeLimit != nil {
|
|
||||||
nodeLimit = *req.NodeLimit
|
|
||||||
}
|
|
||||||
if req.EdgeLimit != nil {
|
|
||||||
edgeLimit = *req.EdgeLimit
|
|
||||||
}
|
|
||||||
if nodeLimit < 1 || nodeLimit > s.cfg.MaxNodeLimit || edgeLimit < 1 || edgeLimit > s.cfg.MaxEdgeLimit {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, "invalid node_limit/edge_limit")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
snap, err := s.snapshots.Get(r.Context(), nodeLimit, edgeLimit, graphQueryID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ids, err := runSelectionQuery(r.Context(), s.sparql, snap, req.QueryID, req.SelectedIDs, s.cfg.IncludeBNodes)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, SelectionQueryResponse{
|
|
||||||
QueryID: req.QueryID,
|
|
||||||
SelectedIDs: req.SelectedIDs,
|
|
||||||
NeighborIDs: ids,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *APIServer) handleNeighbors(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req NeighborsRequest
|
|
||||||
if err := decodeJSON(r.Body, &req); err != nil {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, "invalid request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(req.SelectedIDs) == 0 {
|
|
||||||
writeJSON(w, http.StatusOK, NeighborsResponse{SelectedIDs: req.SelectedIDs, NeighborIDs: []uint32{}})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
graphQueryID := graphqueries.DefaultID
|
|
||||||
if req.GraphQueryID != nil && strings.TrimSpace(*req.GraphQueryID) != "" {
|
|
||||||
graphQueryID = strings.TrimSpace(*req.GraphQueryID)
|
|
||||||
}
|
|
||||||
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeLimit := s.cfg.DefaultNodeLimit
|
|
||||||
edgeLimit := s.cfg.DefaultEdgeLimit
|
|
||||||
if req.NodeLimit != nil {
|
|
||||||
nodeLimit = *req.NodeLimit
|
|
||||||
}
|
|
||||||
if req.EdgeLimit != nil {
|
|
||||||
edgeLimit = *req.EdgeLimit
|
|
||||||
}
|
|
||||||
if nodeLimit < 1 || nodeLimit > s.cfg.MaxNodeLimit || edgeLimit < 1 || edgeLimit > s.cfg.MaxEdgeLimit {
|
|
||||||
writeError(w, http.StatusUnprocessableEntity, "invalid node_limit/edge_limit")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
snap, err := s.snapshots.Get(r.Context(), nodeLimit, edgeLimit, graphQueryID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
nbrs, err := runSelectionQuery(r.Context(), s.sparql, snap, "neighbors", req.SelectedIDs, s.cfg.IncludeBNodes)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, NeighborsResponse{SelectedIDs: req.SelectedIDs, NeighborIDs: nbrs})
|
|
||||||
}
|
|
||||||
|
|
||||||
func intQuery(r *http.Request, name string, def int) (int, error) {
|
|
||||||
raw := strings.TrimSpace(r.URL.Query().Get(name))
|
|
||||||
if raw == "" {
|
|
||||||
return def, nil
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(raw)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
type snapshotKey struct {
|
|
||||||
NodeLimit int
|
|
||||||
EdgeLimit int
|
|
||||||
IncludeBNodes bool
|
|
||||||
GraphQueryID string
|
|
||||||
}
|
|
||||||
|
|
||||||
type snapshotInflight struct {
|
|
||||||
ready chan struct{}
|
|
||||||
snapshot GraphResponse
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
type GraphSnapshotService struct {
|
|
||||||
sparql *AnzoGraphClient
|
|
||||||
cfg Config
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
cache map[snapshotKey]GraphResponse
|
|
||||||
inflight map[snapshotKey]*snapshotInflight
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewGraphSnapshotService(sparql *AnzoGraphClient, cfg Config) *GraphSnapshotService {
|
|
||||||
return &GraphSnapshotService{
|
|
||||||
sparql: sparql,
|
|
||||||
cfg: cfg,
|
|
||||||
cache: make(map[snapshotKey]GraphResponse),
|
|
||||||
inflight: make(map[snapshotKey]*snapshotInflight),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *GraphSnapshotService) Get(ctx context.Context, nodeLimit int, edgeLimit int, graphQueryID string) (GraphResponse, error) {
|
|
||||||
key := snapshotKey{NodeLimit: nodeLimit, EdgeLimit: edgeLimit, IncludeBNodes: s.cfg.IncludeBNodes, GraphQueryID: graphQueryID}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
if snap, ok := s.cache[key]; ok {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return snap, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if inf, ok := s.inflight[key]; ok {
|
|
||||||
ready := inf.ready
|
|
||||||
s.mu.Unlock()
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return GraphResponse{}, ctx.Err()
|
|
||||||
case <-ready:
|
|
||||||
return inf.snapshot, inf.err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inf := &snapshotInflight{ready: make(chan struct{})}
|
|
||||||
s.inflight[key] = inf
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
snap, err := fetchGraphSnapshot(ctx, s.sparql, s.cfg, nodeLimit, edgeLimit, graphQueryID)
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
inf.snapshot = snap
|
|
||||||
inf.err = err
|
|
||||||
delete(s.inflight, key)
|
|
||||||
if err == nil {
|
|
||||||
s.cache[key] = snap
|
|
||||||
}
|
|
||||||
close(inf.ready)
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
return snap, err
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AnzoGraphClient struct {
|
|
||||||
cfg Config
|
|
||||||
endpoint string
|
|
||||||
authHeader string
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAnzoGraphClient(cfg Config) *AnzoGraphClient {
|
|
||||||
endpoint := cfg.EffectiveSparqlEndpoint()
|
|
||||||
authHeader := ""
|
|
||||||
user := strings.TrimSpace(cfg.SparqlUser)
|
|
||||||
pass := strings.TrimSpace(cfg.SparqlPass)
|
|
||||||
if user != "" && pass != "" {
|
|
||||||
token := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
|
||||||
authHeader = "Basic " + token
|
|
||||||
}
|
|
||||||
|
|
||||||
return &AnzoGraphClient{
|
|
||||||
cfg: cfg,
|
|
||||||
endpoint: endpoint,
|
|
||||||
authHeader: authHeader,
|
|
||||||
client: &http.Client{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AnzoGraphClient) Startup(ctx context.Context) error {
|
|
||||||
if err := c.waitReady(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.cfg.SparqlClearOnStart {
|
|
||||||
if err := c.update(ctx, "CLEAR ALL"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := c.waitReady(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.cfg.SparqlLoadOnStart {
|
|
||||||
df := strings.TrimSpace(c.cfg.SparqlDataFile)
|
|
||||||
if df == "" {
|
|
||||||
return fmt.Errorf("SPARQL_LOAD_ON_START=true but SPARQL_DATA_FILE is not set")
|
|
||||||
}
|
|
||||||
giri := strings.TrimSpace(c.cfg.SparqlGraphIRI)
|
|
||||||
if giri != "" {
|
|
||||||
if err := c.update(ctx, fmt.Sprintf("LOAD <%s> INTO GRAPH <%s>", df, giri)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := c.update(ctx, fmt.Sprintf("LOAD <%s>", df)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := c.waitReady(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AnzoGraphClient) Shutdown(ctx context.Context) error {
|
|
||||||
_ = ctx
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AnzoGraphClient) Query(ctx context.Context, query string) ([]byte, error) {
|
|
||||||
return c.queryWithTimeout(ctx, query, c.cfg.SparqlTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AnzoGraphClient) queryWithTimeout(ctx context.Context, query string, timeout time.Duration) ([]byte, error) {
|
|
||||||
ctx2, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("query", query)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx2, http.MethodPost, c.endpoint, strings.NewReader(form.Encode()))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.Header.Set("Accept", "application/sparql-results+json")
|
|
||||||
if c.authHeader != "" {
|
|
||||||
req.Header.Set("Authorization", c.authHeader)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return nil, fmt.Errorf("sparql query failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
return body, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AnzoGraphClient) update(ctx context.Context, update string) error {
|
|
||||||
ctx2, cancel := context.WithTimeout(ctx, c.cfg.SparqlTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx2, http.MethodPost, c.endpoint, strings.NewReader(update))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/sparql-update")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
if c.authHeader != "" {
|
|
||||||
req.Header.Set("Authorization", c.authHeader)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return fmt.Errorf("sparql update failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AnzoGraphClient) waitReady(ctx context.Context) error {
|
|
||||||
var lastErr error
|
|
||||||
for i := 0; i < c.cfg.SparqlReadyRetries; i++ {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
if lastErr != nil {
|
|
||||||
return fmt.Errorf("anzograph not ready at %s: %w", c.endpoint, lastErr)
|
|
||||||
}
|
|
||||||
return ctx.Err()
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := c.queryWithTimeout(ctx, "ASK WHERE { ?s ?p ?o }", c.cfg.SparqlReadyTimeout)
|
|
||||||
if err == nil {
|
|
||||||
// Ensure it's JSON, not HTML/text during boot.
|
|
||||||
if strings.HasPrefix(strings.TrimSpace(string(body)), "{") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
err = fmt.Errorf("unexpected readiness response: %s", strings.TrimSpace(string(body)))
|
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
time.Sleep(c.cfg.SparqlReadyDelay)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("anzograph not ready at %s: %w", c.endpoint, lastErr)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
type sparqlTerm struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Lang string `json:"xml:lang,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type sparqlResponse struct {
|
|
||||||
Results struct {
|
|
||||||
Bindings []map[string]sparqlTerm `json:"bindings"`
|
|
||||||
} `json:"results"`
|
|
||||||
}
|
|
||||||
@@ -1,85 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
owl_imports_combiner:
|
app:
|
||||||
build: ./python_services/owl_imports_combiner
|
build: .
|
||||||
environment:
|
|
||||||
- COMBINE_OWL_IMPORTS_ON_START=${COMBINE_OWL_IMPORTS_ON_START:-false}
|
|
||||||
- COMBINE_ENTRY_LOCATION
|
|
||||||
- COMBINE_OUTPUT_LOCATION
|
|
||||||
- COMBINE_OUTPUT_NAME
|
|
||||||
- COMBINE_FORCE=${COMBINE_FORCE:-false}
|
|
||||||
- TTL_PATH=${TTL_PATH:-/data/o3po.ttl}
|
|
||||||
volumes:
|
|
||||||
- ./data:/data:Z
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build: ./backend_go
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
environment:
|
|
||||||
- DEFAULT_NODE_LIMIT=${DEFAULT_NODE_LIMIT:-800000}
|
|
||||||
- DEFAULT_EDGE_LIMIT=${DEFAULT_EDGE_LIMIT:-2000000}
|
|
||||||
- MAX_NODE_LIMIT=${MAX_NODE_LIMIT:-10000000}
|
|
||||||
- MAX_EDGE_LIMIT=${MAX_EDGE_LIMIT:-20000000}
|
|
||||||
- INCLUDE_BNODES=${INCLUDE_BNODES:-false}
|
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173}
|
|
||||||
- SPARQL_HOST=${SPARQL_HOST:-http://anzograph:8080}
|
|
||||||
- SPARQL_ENDPOINT
|
|
||||||
- SPARQL_USER=${SPARQL_USER:-admin}
|
|
||||||
- SPARQL_PASS=${SPARQL_PASS:-Passw0rd1}
|
|
||||||
- SPARQL_DATA_FILE=${SPARQL_DATA_FILE:-file:///opt/shared-files/o3po.ttl}
|
|
||||||
- SPARQL_GRAPH_IRI
|
|
||||||
- SPARQL_LOAD_ON_START=${SPARQL_LOAD_ON_START:-false}
|
|
||||||
- SPARQL_CLEAR_ON_START=${SPARQL_CLEAR_ON_START:-false}
|
|
||||||
- SPARQL_TIMEOUT_S=${SPARQL_TIMEOUT_S:-300}
|
|
||||||
- SPARQL_READY_RETRIES=${SPARQL_READY_RETRIES:-30}
|
|
||||||
- SPARQL_READY_DELAY_S=${SPARQL_READY_DELAY_S:-4}
|
|
||||||
- SPARQL_READY_TIMEOUT_S=${SPARQL_READY_TIMEOUT_S:-10}
|
|
||||||
- EDGE_BATCH_SIZE=${EDGE_BATCH_SIZE:-100000}
|
|
||||||
- FREE_OS_MEMORY_AFTER_SNAPSHOT=${FREE_OS_MEMORY_AFTER_SNAPSHOT:-false}
|
|
||||||
- LOG_SNAPSHOT_TIMINGS=${LOG_SNAPSHOT_TIMINGS:-false}
|
|
||||||
depends_on:
|
depends_on:
|
||||||
owl_imports_combiner:
|
- anzograph
|
||||||
condition: service_completed_successfully
|
|
||||||
anzograph:
|
|
||||||
condition: service_started
|
|
||||||
volumes:
|
|
||||||
- ./data:/data:Z
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/health"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 60
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
environment:
|
env_file:
|
||||||
- VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://backend:8000}
|
- .env
|
||||||
|
command: sh -c "npm run layout && npm run dev -- --host"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- .:/app:Z
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
depends_on:
|
|
||||||
backend:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
anzograph:
|
anzograph:
|
||||||
image: cambridgesemantics/anzograph:latest
|
image: cambridgesemantics/anzograph:latest
|
||||||
container_name: anzograph
|
container_name: anzograph
|
||||||
mem_limit: 20g
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
- "8443:8443"
|
- "8443:8443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/opt/shared-files:Z
|
- ./data:/opt/shared-files:Z
|
||||||
# Persist AnzoGraph state across container recreation (EULA acceptance, machine-id, settings, persistence).
|
|
||||||
- anzograph_app_home:/opt/anzograph/app-home
|
|
||||||
- anzograph_persistence:/opt/anzograph/persistence
|
|
||||||
- anzograph_config:/opt/anzograph/config
|
|
||||||
- anzograph_internal:/opt/anzograph/internal
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
anzograph_app_home:
|
|
||||||
anzograph_persistence:
|
|
||||||
anzograph_config:
|
|
||||||
anzograph_internal:
|
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Frontend (React + Vite) – WebGL Graph Renderer
|
|
||||||
|
|
||||||
The frontend renders the snapshot from `/api/graph` using WebGL2:
|
|
||||||
|
|
||||||
- Nodes are drawn as points
|
|
||||||
- Edges are drawn as lines only when sufficiently zoomed in
|
|
||||||
- Selection + neighbor highlighting is driven by backend “selection queries”
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
Via Docker Compose (recommended):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up --build frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
Open: `http://localhost:5173`
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
- `VITE_BACKEND_URL` controls where `/api/*` is proxied (see `frontend/vite.config.ts`).
|
|
||||||
|
|
||||||
## UI
|
|
||||||
|
|
||||||
- Drag: pan
|
|
||||||
- Scroll: zoom
|
|
||||||
- Click: select/deselect nodes
|
|
||||||
|
|
||||||
Buttons:
|
|
||||||
|
|
||||||
- **Top-right:** selection query mode (controls how the backend expands “neighbors” for the current selection)
|
|
||||||
- **Bottom-right:** graph query mode (controls which SPARQL edge set the backend uses to build the graph snapshot; switching reloads the graph)
|
|
||||||
|
|
||||||
The available modes are discovered from the backend at runtime (`/api/selection_queries` and `/api/graph_queries`).
|
|
||||||
|
|
||||||
## Rendering / limits
|
|
||||||
|
|
||||||
The renderer uses a quadtree spatial index and draws only a subset when zoomed out:
|
|
||||||
|
|
||||||
- Points:
|
|
||||||
- Per-frame cap: `MAX_DRAW = 2_000_000` (sampling over visible leaves)
|
|
||||||
- Lines:
|
|
||||||
- Drawn only when fewer than ~20k nodes are “visible” (leaf AABB overlap with the camera frustum)
|
|
||||||
|
|
||||||
Selected and “neighbor” nodes are drawn on top using index buffers.
|
|
||||||
100000
frontend/public/edges.csv
100000
frontend/public/edges.csv
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,354 +0,0 @@
|
|||||||
#!/usr/bin/env npx tsx
|
|
||||||
/**
|
|
||||||
* Tree-Aware Force Layout
|
|
||||||
*
|
|
||||||
* Generates a random tree (via generate_tree), computes a radial tree layout,
|
|
||||||
* then applies gentle force refinement and writes node_positions.csv.
|
|
||||||
*
|
|
||||||
* Usage: npm run layout
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { writeFileSync } from "fs";
|
|
||||||
import { join, dirname } from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import { generateTree } from "./generate_tree.js";
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const PUBLIC_DIR = join(__dirname, "..", "public");
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Configuration
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const ENABLE_FORCE_SIM = true; // Set to false to skip force simulation
|
|
||||||
const ITERATIONS = 100; // Force iterations (gentle)
|
|
||||||
const REPULSION_K = 80; // Repulsion strength (1% of original 8000)
|
|
||||||
const EDGE_LENGTH = 120; // Desired edge rest length
|
|
||||||
const ATTRACTION_K = 0.0002; // Spring stiffness for edges (1% of original 0.02)
|
|
||||||
const THETA = 0.7; // Barnes-Hut accuracy
|
|
||||||
const INITIAL_MAX_DISP = 15; // Starting max displacement
|
|
||||||
const COOLING = 0.998; // Very slow cooling per iteration
|
|
||||||
const MIN_DIST = 0.5;
|
|
||||||
const PRINT_EVERY = 10; // Print progress every N iterations
|
|
||||||
|
|
||||||
// Scale radius so the tree is nicely spread
|
|
||||||
const RADIUS_PER_DEPTH = EDGE_LENGTH * 1.2;
|
|
||||||
|
|
||||||
// ── Special nodes with longer parent-edges ──
|
|
||||||
// Add vertex IDs here to give them longer edges to their parent.
|
|
||||||
// These nodes (and all their descendants) will be pushed further out.
|
|
||||||
const LONG_EDGE_NODES = new Set<number>([
|
|
||||||
// e.g. 42, 99, 150
|
|
||||||
]);
|
|
||||||
const LONG_EDGE_MULTIPLIER = 3.0; // How many times longer than normal
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Generate tree (in-memory)
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const { root, nodeCount: N, childrenOf, parentOf } = generateTree();
|
|
||||||
|
|
||||||
const nodeIds: number[] = [];
|
|
||||||
for (let i = 0; i < N; i++) nodeIds.push(i);
|
|
||||||
|
|
||||||
// Dense index mapping (identity since IDs are 0..N-1)
|
|
||||||
const idToIdx = new Map<number, number>();
|
|
||||||
for (let i = 0; i < N; i++) idToIdx.set(i, i);
|
|
||||||
|
|
||||||
// Edge list as index pairs (child, parent)
|
|
||||||
const edges: Array<[number, number]> = [];
|
|
||||||
for (const [child, parent] of parentOf) {
|
|
||||||
edges.push([child, parent]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-node neighbor list (for edge traversal)
|
|
||||||
const neighbors: number[][] = Array.from({ length: N }, () => []);
|
|
||||||
for (const [a, b] of edges) {
|
|
||||||
neighbors[a].push(b);
|
|
||||||
neighbors[b].push(a);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Tree: ${N} nodes, ${edges.length} edges, root=${root}`);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Step 1: Radial tree layout (generous spacing, no crossings)
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const x = new Float64Array(N);
|
|
||||||
const y = new Float64Array(N);
|
|
||||||
const depth = new Uint32Array(N);
|
|
||||||
const nodeRadius = new Float64Array(N); // cumulative radius from root
|
|
||||||
|
|
||||||
// Compute subtree sizes
|
|
||||||
const subtreeSize = new Uint32Array(N).fill(1);
|
|
||||||
{
|
|
||||||
const rootIdx = idToIdx.get(root)!;
|
|
||||||
const stack: Array<{ idx: number; phase: "enter" | "exit" }> = [
|
|
||||||
{ idx: rootIdx, phase: "enter" },
|
|
||||||
];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const { idx, phase } = stack.pop()!;
|
|
||||||
if (phase === "enter") {
|
|
||||||
stack.push({ idx, phase: "exit" });
|
|
||||||
const kids = childrenOf.get(nodeIds[idx]);
|
|
||||||
if (kids) {
|
|
||||||
for (const kid of kids) {
|
|
||||||
stack.push({ idx: idToIdx.get(kid)!, phase: "enter" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const kids = childrenOf.get(nodeIds[idx]);
|
|
||||||
if (kids) {
|
|
||||||
for (const kid of kids) {
|
|
||||||
subtreeSize[idx] += subtreeSize[idToIdx.get(kid)!];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute depths & max depth
|
|
||||||
let maxDepth = 0;
|
|
||||||
{
|
|
||||||
const rootIdx = idToIdx.get(root)!;
|
|
||||||
const stack: Array<{ idx: number; d: number }> = [{ idx: rootIdx, d: 0 }];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const { idx, d } = stack.pop()!;
|
|
||||||
depth[idx] = d;
|
|
||||||
if (d > maxDepth) maxDepth = d;
|
|
||||||
const kids = childrenOf.get(nodeIds[idx]);
|
|
||||||
if (kids) {
|
|
||||||
for (const kid of kids) {
|
|
||||||
stack.push({ idx: idToIdx.get(kid)!, d: d + 1 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BFS radial assignment (cumulative radii to support per-edge lengths)
|
|
||||||
{
|
|
||||||
const rootIdx = idToIdx.get(root)!;
|
|
||||||
x[rootIdx] = 0;
|
|
||||||
y[rootIdx] = 0;
|
|
||||||
nodeRadius[rootIdx] = 0;
|
|
||||||
|
|
||||||
interface Entry {
|
|
||||||
idx: number;
|
|
||||||
d: number;
|
|
||||||
aStart: number;
|
|
||||||
aEnd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queue: Entry[] = [{ idx: rootIdx, d: 0, aStart: 0, aEnd: 2 * Math.PI }];
|
|
||||||
let head = 0;
|
|
||||||
|
|
||||||
while (head < queue.length) {
|
|
||||||
const { idx, d, aStart, aEnd } = queue[head++];
|
|
||||||
const kids = childrenOf.get(nodeIds[idx]);
|
|
||||||
if (!kids || kids.length === 0) continue;
|
|
||||||
|
|
||||||
// Sort children by subtree size (largest sectors together for balance)
|
|
||||||
const sortedKids = [...kids].sort(
|
|
||||||
(a, b) => (subtreeSize[idToIdx.get(b)!]) - (subtreeSize[idToIdx.get(a)!])
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalWeight = sortedKids.reduce(
|
|
||||||
(s, k) => s + subtreeSize[idToIdx.get(k)!], 0
|
|
||||||
);
|
|
||||||
|
|
||||||
let angle = aStart;
|
|
||||||
for (const kid of sortedKids) {
|
|
||||||
const kidIdx = idToIdx.get(kid)!;
|
|
||||||
const w = subtreeSize[kidIdx];
|
|
||||||
const sector = (w / totalWeight) * (aEnd - aStart);
|
|
||||||
const mid = angle + sector / 2;
|
|
||||||
|
|
||||||
// Cumulative radius: parent's radius + edge step (longer for special nodes)
|
|
||||||
const step = LONG_EDGE_NODES.has(kid)
|
|
||||||
? RADIUS_PER_DEPTH * LONG_EDGE_MULTIPLIER
|
|
||||||
: RADIUS_PER_DEPTH;
|
|
||||||
const r = nodeRadius[idx] + step;
|
|
||||||
nodeRadius[kidIdx] = r;
|
|
||||||
|
|
||||||
x[kidIdx] = r * Math.cos(mid);
|
|
||||||
y[kidIdx] = r * Math.sin(mid);
|
|
||||||
|
|
||||||
queue.push({ idx: kidIdx, d: d + 1, aStart: angle, aEnd: angle + sector });
|
|
||||||
angle += sector;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Radial layout done (depth=${maxDepth}, radius_step=${RADIUS_PER_DEPTH})`);
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Step 2: Gentle force refinement (preserves non-crossing)
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// Barnes-Hut quadtree for repulsion
|
|
||||||
interface BHNode {
|
|
||||||
cx: number; cy: number;
|
|
||||||
mass: number;
|
|
||||||
size: number;
|
|
||||||
children: (BHNode | null)[];
|
|
||||||
bodyIdx: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBHTree(): BHNode {
|
|
||||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
||||||
for (let i = 0; i < N; i++) {
|
|
||||||
if (x[i] < minX) minX = x[i];
|
|
||||||
if (x[i] > maxX) maxX = x[i];
|
|
||||||
if (y[i] < minY) minY = y[i];
|
|
||||||
if (y[i] > maxY) maxY = y[i];
|
|
||||||
}
|
|
||||||
const size = Math.max(maxX - minX, maxY - minY, 1) * 1.01;
|
|
||||||
const cx = (minX + maxX) / 2;
|
|
||||||
const cy = (minY + maxY) / 2;
|
|
||||||
|
|
||||||
const root: BHNode = {
|
|
||||||
cx: 0, cy: 0, mass: 0, size,
|
|
||||||
children: [null, null, null, null], bodyIdx: -1,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < N; i++) {
|
|
||||||
insert(root, i, cx, cy, size);
|
|
||||||
}
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
function insert(node: BHNode, idx: number, ncx: number, ncy: number, ns: number): void {
|
|
||||||
if (node.mass === 0) {
|
|
||||||
node.bodyIdx = idx;
|
|
||||||
node.cx = x[idx]; node.cy = y[idx];
|
|
||||||
node.mass = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (node.bodyIdx >= 0) {
|
|
||||||
const old = node.bodyIdx;
|
|
||||||
node.bodyIdx = -1;
|
|
||||||
putInQuadrant(node, old, ncx, ncy, ns);
|
|
||||||
}
|
|
||||||
putInQuadrant(node, idx, ncx, ncy, ns);
|
|
||||||
const tm = node.mass + 1;
|
|
||||||
node.cx = (node.cx * node.mass + x[idx]) / tm;
|
|
||||||
node.cy = (node.cy * node.mass + y[idx]) / tm;
|
|
||||||
node.mass = tm;
|
|
||||||
}
|
|
||||||
|
|
||||||
function putInQuadrant(node: BHNode, idx: number, ncx: number, ncy: number, ns: number): void {
|
|
||||||
const hs = ns / 2;
|
|
||||||
const qx = x[idx] >= ncx ? 1 : 0;
|
|
||||||
const qy = y[idx] >= ncy ? 1 : 0;
|
|
||||||
const q = qy * 2 + qx;
|
|
||||||
const ccx = ncx + (qx ? hs / 2 : -hs / 2);
|
|
||||||
const ccy = ncy + (qy ? hs / 2 : -hs / 2);
|
|
||||||
if (!node.children[q]) {
|
|
||||||
node.children[q] = {
|
|
||||||
cx: 0, cy: 0, mass: 0, size: hs,
|
|
||||||
children: [null, null, null, null], bodyIdx: -1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
insert(node.children[q]!, idx, ccx, ccy, hs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function repulse(node: BHNode, idx: number, fx: Float64Array, fy: Float64Array): void {
|
|
||||||
if (node.mass === 0 || node.bodyIdx === idx) return;
|
|
||||||
const dx = x[idx] - node.cx;
|
|
||||||
const dy = y[idx] - node.cy;
|
|
||||||
const d2 = dx * dx + dy * dy;
|
|
||||||
const d = Math.sqrt(d2) || MIN_DIST;
|
|
||||||
|
|
||||||
if (node.bodyIdx >= 0 || (node.size / d) < THETA) {
|
|
||||||
const f = REPULSION_K * node.mass / (d2 + MIN_DIST);
|
|
||||||
fx[idx] += (dx / d) * f;
|
|
||||||
fy[idx] += (dy / d) * f;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const c of node.children) {
|
|
||||||
if (c) repulse(c, idx, fx, fy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Force simulation ──
|
|
||||||
if (ENABLE_FORCE_SIM) {
|
|
||||||
console.log(`Applying gentle forces (${ITERATIONS} steps, 1% strength)...`);
|
|
||||||
const t0 = performance.now();
|
|
||||||
let maxDisp = INITIAL_MAX_DISP;
|
|
||||||
|
|
||||||
for (let iter = 0; iter < ITERATIONS; iter++) {
|
|
||||||
const fx = new Float64Array(N);
|
|
||||||
const fy = new Float64Array(N);
|
|
||||||
|
|
||||||
// 1. Repulsion
|
|
||||||
const tree = buildBHTree();
|
|
||||||
for (let i = 0; i < N; i++) {
|
|
||||||
repulse(tree, i, fx, fy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Edge attraction (spring toward per-edge rest length)
|
|
||||||
for (const [a, b] of edges) {
|
|
||||||
const dx = x[b] - x[a];
|
|
||||||
const dy = y[b] - y[a];
|
|
||||||
const d = Math.sqrt(dx * dx + dy * dy) || MIN_DIST;
|
|
||||||
const aId = nodeIds[a], bId = nodeIds[b];
|
|
||||||
const isLong = LONG_EDGE_NODES.has(aId) || LONG_EDGE_NODES.has(bId);
|
|
||||||
const restLen = isLong ? EDGE_LENGTH * LONG_EDGE_MULTIPLIER : EDGE_LENGTH;
|
|
||||||
const displacement = d - restLen;
|
|
||||||
const f = ATTRACTION_K * displacement;
|
|
||||||
const ux = dx / d, uy = dy / d;
|
|
||||||
fx[a] += ux * f;
|
|
||||||
fy[a] += uy * f;
|
|
||||||
fx[b] -= ux * f;
|
|
||||||
fy[b] -= uy * f;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Apply forces with displacement cap (cooling reduces it over time)
|
|
||||||
for (let i = 0; i < N; i++) {
|
|
||||||
const mag = Math.sqrt(fx[i] * fx[i] + fy[i] * fy[i]);
|
|
||||||
if (mag > 0) {
|
|
||||||
const cap = Math.min(maxDisp, mag) / mag;
|
|
||||||
x[i] += fx[i] * cap;
|
|
||||||
y[i] += fy[i] * cap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Cool down
|
|
||||||
maxDisp *= COOLING;
|
|
||||||
|
|
||||||
if ((iter + 1) % PRINT_EVERY === 0) {
|
|
||||||
let totalForce = 0;
|
|
||||||
for (let i = 0; i < N; i++) totalForce += Math.sqrt(fx[i] * fx[i] + fy[i] * fy[i]);
|
|
||||||
console.log(` iter ${iter + 1}/${ITERATIONS} max_disp=${maxDisp.toFixed(2)} avg_force=${(totalForce / N).toFixed(2)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsed = performance.now() - t0;
|
|
||||||
console.log(`Force simulation done in ${(elapsed / 1000).toFixed(1)}s`);
|
|
||||||
} else {
|
|
||||||
console.log("Force simulation SKIPPED (ENABLE_FORCE_SIM = false)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Write output
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
// Write node positions
|
|
||||||
const outLines: string[] = ["vertex,x,y"];
|
|
||||||
for (let i = 0; i < N; i++) {
|
|
||||||
outLines.push(`${nodeIds[i]},${x[i]},${y[i]}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const outPath = join(PUBLIC_DIR, "node_positions.csv");
|
|
||||||
writeFileSync(outPath, outLines.join("\n") + "\n");
|
|
||||||
console.log(`Wrote ${N} positions to ${outPath}`);
|
|
||||||
|
|
||||||
// Write edges (so the renderer can draw them)
|
|
||||||
const edgeLines: string[] = ["source,target"];
|
|
||||||
for (const [child, parent] of parentOf) {
|
|
||||||
edgeLines.push(`${child},${parent}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const edgesPath = join(PUBLIC_DIR, "edges.csv");
|
|
||||||
writeFileSync(edgesPath, edgeLines.join("\n") + "\n");
|
|
||||||
console.log(`Wrote ${edges.length} edges to ${edgesPath}`);
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Random Tree Generator
|
|
||||||
*
|
|
||||||
* Generates a random tree with 1–MAX_CHILDREN children per node.
|
|
||||||
* Exports a function that returns the tree data in memory.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Configuration
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const TARGET_NODES = 100000; // Approximate number of nodes to generate
|
|
||||||
const MAX_CHILDREN = 3; // Each node gets 1..MAX_CHILDREN children
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Tree data types
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export interface TreeData {
|
|
||||||
root: number;
|
|
||||||
nodeCount: number;
|
|
||||||
childrenOf: Map<number, number[]>;
|
|
||||||
parentOf: Map<number, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
// Generator
|
|
||||||
// ══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export function generateTree(): TreeData {
|
|
||||||
const childrenOf = new Map<number, number[]>();
|
|
||||||
const parentOf = new Map<number, number>();
|
|
||||||
|
|
||||||
const root = 0;
|
|
||||||
let nextId = 1;
|
|
||||||
const queue: number[] = [root];
|
|
||||||
let head = 0;
|
|
||||||
|
|
||||||
while (head < queue.length && nextId < TARGET_NODES) {
|
|
||||||
const parent = queue[head++];
|
|
||||||
const nKids = 1 + Math.floor(Math.random() * MAX_CHILDREN); // 1..MAX_CHILDREN
|
|
||||||
|
|
||||||
const kids: number[] = [];
|
|
||||||
for (let c = 0; c < nKids && nextId < TARGET_NODES; c++) {
|
|
||||||
const child = nextId++;
|
|
||||||
kids.push(child);
|
|
||||||
parentOf.set(child, parent);
|
|
||||||
queue.push(child);
|
|
||||||
}
|
|
||||||
childrenOf.set(parent, kids);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Generated tree: ${nextId} nodes, ${parentOf.size} edges, root=${root}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
root,
|
|
||||||
nodeCount: nextId,
|
|
||||||
childrenOf,
|
|
||||||
parentOf,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,596 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { Renderer } from "./renderer";
|
|
||||||
import { fetchGraphQueries } from "./graph_queries";
|
|
||||||
import type { GraphQueryMeta } from "./graph_queries";
|
|
||||||
import { fetchSelectionQueries, runSelectionQuery } from "./selection_queries";
|
|
||||||
import type { GraphMeta, SelectionQueryMeta } from "./selection_queries";
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
||||||
const rendererRef = useRef<Renderer | null>(null);
|
|
||||||
const [status, setStatus] = useState("Waiting for backend…");
|
|
||||||
const [nodeCount, setNodeCount] = useState(0);
|
|
||||||
const [stats, setStats] = useState({
|
|
||||||
fps: 0,
|
|
||||||
drawn: 0,
|
|
||||||
mode: "",
|
|
||||||
zoom: 0,
|
|
||||||
ptSize: 0,
|
|
||||||
});
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [hoveredNode, setHoveredNode] = useState<{ x: number; y: number; screenX: number; screenY: number; label?: string; iri?: string } | null>(null);
|
|
||||||
const [selectedNodes, setSelectedNodes] = useState<Set<number>>(new Set());
|
|
||||||
const [graphQueries, setGraphQueries] = useState<GraphQueryMeta[]>([]);
|
|
||||||
const [activeGraphQueryId, setActiveGraphQueryId] = useState<string>("default");
|
|
||||||
const [selectionQueries, setSelectionQueries] = useState<SelectionQueryMeta[]>([]);
|
|
||||||
const [activeSelectionQueryId, setActiveSelectionQueryId] = useState<string>("neighbors");
|
|
||||||
const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; backend?: string } | null>(null);
|
|
||||||
const graphMetaRef = useRef<GraphMeta | null>(null);
|
|
||||||
const selectionReqIdRef = useRef(0);
|
|
||||||
const graphInitializedRef = useRef(false);
|
|
||||||
|
|
||||||
// Store mouse position in a ref so it can be accessed in render loop without re-renders
|
|
||||||
const mousePos = useRef({ x: 0, y: 0 });
|
|
||||||
const nodesRef = useRef<any[]>([]);
|
|
||||||
|
|
||||||
async function loadGraph(graphQueryId: string, signal: AbortSignal): Promise<void> {
|
|
||||||
const renderer = rendererRef.current;
|
|
||||||
if (!renderer) return;
|
|
||||||
|
|
||||||
setStatus("Fetching graph…");
|
|
||||||
const graphRes = await fetch(`/api/graph?graph_query_id=${encodeURIComponent(graphQueryId)}`, { signal });
|
|
||||||
if (!graphRes.ok) {
|
|
||||||
let detail = "";
|
|
||||||
try {
|
|
||||||
const err = await graphRes.json();
|
|
||||||
if (err && typeof err === "object" && typeof (err as any).detail === "string") {
|
|
||||||
detail = (err as any).detail;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to fetch graph: ${graphRes.status}${detail ? ` (${detail})` : ""}`);
|
|
||||||
}
|
|
||||||
const graph = await graphRes.json();
|
|
||||||
if (signal.aborted) return;
|
|
||||||
|
|
||||||
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
|
|
||||||
const edges = Array.isArray(graph.edges) ? graph.edges : [];
|
|
||||||
const meta = graph.meta || null;
|
|
||||||
const count = nodes.length;
|
|
||||||
|
|
||||||
nodesRef.current = nodes;
|
|
||||||
graphMetaRef.current = meta && typeof meta === "object" ? (meta as GraphMeta) : null;
|
|
||||||
|
|
||||||
// Build positions from backend-provided node coordinates.
|
|
||||||
setStatus("Preparing buffers…");
|
|
||||||
const xs = new Float32Array(count);
|
|
||||||
const ys = new Float32Array(count);
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const nx = nodes[i]?.x;
|
|
||||||
const ny = nodes[i]?.y;
|
|
||||||
xs[i] = typeof nx === "number" ? nx : 0;
|
|
||||||
ys[i] = typeof ny === "number" ? ny : 0;
|
|
||||||
}
|
|
||||||
const vertexIds = new Uint32Array(count);
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const id = nodes[i]?.id;
|
|
||||||
vertexIds[i] = typeof id === "number" ? id >>> 0 : i;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build edges as vertex-id pairs.
|
|
||||||
const edgeData = new Uint32Array(edges.length * 2);
|
|
||||||
for (let i = 0; i < edges.length; i++) {
|
|
||||||
const s = edges[i]?.source;
|
|
||||||
const t = edges[i]?.target;
|
|
||||||
edgeData[i * 2] = typeof s === "number" ? s >>> 0 : 0;
|
|
||||||
edgeData[i * 2 + 1] = typeof t === "number" ? t >>> 0 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use /api/graph meta; don't do a second expensive backend call.
|
|
||||||
if (meta && typeof meta.nodes === "number" && typeof meta.edges === "number") {
|
|
||||||
setBackendStats({
|
|
||||||
nodes: meta.nodes,
|
|
||||||
edges: meta.edges,
|
|
||||||
backend: typeof meta.backend === "string" ? meta.backend : undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setBackendStats({ nodes: nodes.length, edges: edges.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus("Building spatial index…");
|
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
|
||||||
if (signal.aborted) return;
|
|
||||||
|
|
||||||
const buildMs = renderer.init(xs, ys, vertexIds, edgeData);
|
|
||||||
setNodeCount(renderer.getNodeCount());
|
|
||||||
setSelectedNodes(new Set());
|
|
||||||
setStatus("");
|
|
||||||
console.log(`Init complete: ${count.toLocaleString()} nodes, ${edges.length.toLocaleString()} edges in ${buildMs.toFixed(0)}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
let renderer: Renderer;
|
|
||||||
try {
|
|
||||||
renderer = new Renderer(canvas);
|
|
||||||
rendererRef.current = renderer;
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const initCtrl = new AbortController();
|
|
||||||
graphInitializedRef.current = false;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
// Wait for backend (docker-compose also gates startup via healthcheck, but this
|
|
||||||
// handles running the frontend standalone).
|
|
||||||
const deadline = performance.now() + 180_000;
|
|
||||||
let attempt = 0;
|
|
||||||
while (performance.now() < deadline) {
|
|
||||||
attempt++;
|
|
||||||
setStatus(`Waiting for backend… (attempt ${attempt})`);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/health");
|
|
||||||
if (res.ok) break;
|
|
||||||
} catch {
|
|
||||||
// ignore and retry
|
|
||||||
}
|
|
||||||
await sleep(1000);
|
|
||||||
if (cancelled) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let graphQueryToLoad = activeGraphQueryId;
|
|
||||||
try {
|
|
||||||
setStatus("Fetching graph modes…");
|
|
||||||
const gqs = await fetchGraphQueries(initCtrl.signal);
|
|
||||||
if (cancelled || initCtrl.signal.aborted) return;
|
|
||||||
setGraphQueries(gqs);
|
|
||||||
graphQueryToLoad = gqs.some((q) => q.id === graphQueryToLoad) ? graphQueryToLoad : (gqs[0]?.id ?? "default");
|
|
||||||
setActiveGraphQueryId(graphQueryToLoad);
|
|
||||||
} catch {
|
|
||||||
if (cancelled || initCtrl.signal.aborted) return;
|
|
||||||
setGraphQueries([{ id: "default", label: "Default" }]);
|
|
||||||
graphQueryToLoad = "default";
|
|
||||||
setActiveGraphQueryId("default");
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadGraph(graphQueryToLoad, initCtrl.signal);
|
|
||||||
if (cancelled || initCtrl.signal.aborted) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const qs = await fetchSelectionQueries(initCtrl.signal);
|
|
||||||
if (cancelled) return;
|
|
||||||
setSelectionQueries(qs);
|
|
||||||
setActiveSelectionQueryId((prev) => (qs.length > 0 && !qs.some((q) => q.id === prev) ? qs[0].id : prev));
|
|
||||||
} catch {
|
|
||||||
if (cancelled) return;
|
|
||||||
setSelectionQueries([{ id: "neighbors", label: "Neighbors" }]);
|
|
||||||
setActiveSelectionQueryId((prev) => (prev ? prev : "neighbors"));
|
|
||||||
}
|
|
||||||
|
|
||||||
graphInitializedRef.current = true;
|
|
||||||
} catch (e) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ── Input handling ──
|
|
||||||
let dragging = false;
|
|
||||||
let didDrag = false; // true if mouse moved significantly during drag
|
|
||||||
let downX = 0;
|
|
||||||
let downY = 0;
|
|
||||||
let lastX = 0;
|
|
||||||
let lastY = 0;
|
|
||||||
const DRAG_THRESHOLD = 5; // pixels
|
|
||||||
|
|
||||||
const onDown = (e: MouseEvent) => {
|
|
||||||
dragging = true;
|
|
||||||
didDrag = false;
|
|
||||||
downX = e.clientX;
|
|
||||||
downY = e.clientY;
|
|
||||||
lastX = e.clientX;
|
|
||||||
lastY = e.clientY;
|
|
||||||
};
|
|
||||||
const onMove = (e: MouseEvent) => {
|
|
||||||
mousePos.current = { x: e.clientX, y: e.clientY };
|
|
||||||
if (!dragging) return;
|
|
||||||
|
|
||||||
// Check if we've moved enough to consider it a drag
|
|
||||||
const dx = e.clientX - downX;
|
|
||||||
const dy = e.clientY - downY;
|
|
||||||
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
|
||||||
didDrag = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.pan(e.clientX - lastX, e.clientY - lastY);
|
|
||||||
lastX = e.clientX;
|
|
||||||
lastY = e.clientY;
|
|
||||||
};
|
|
||||||
const onUp = (e: MouseEvent) => {
|
|
||||||
if (dragging && !didDrag) {
|
|
||||||
// This was a click, not a drag - handle selection
|
|
||||||
const node = renderer.findNodeIndexAt(e.clientX, e.clientY);
|
|
||||||
if (node) {
|
|
||||||
setSelectedNodes((prev: Set<number>) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(node.index)) {
|
|
||||||
next.delete(node.index); // Deselect if already selected
|
|
||||||
} else {
|
|
||||||
next.add(node.index); // Select
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dragging = false;
|
|
||||||
didDrag = false;
|
|
||||||
};
|
|
||||||
const onWheel = (e: WheelEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const factor = e.deltaY > 0 ? 0.9 : 1 / 0.9;
|
|
||||||
renderer.zoomAt(factor, e.clientX, e.clientY);
|
|
||||||
};
|
|
||||||
const onMouseLeave = () => {
|
|
||||||
setHoveredNode(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
canvas.addEventListener("mousedown", onDown);
|
|
||||||
window.addEventListener("mousemove", onMove);
|
|
||||||
window.addEventListener("mouseup", onUp);
|
|
||||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
||||||
canvas.addEventListener("mouseleave", onMouseLeave);
|
|
||||||
|
|
||||||
// ── Render loop ──
|
|
||||||
let frameCount = 0;
|
|
||||||
let lastTime = performance.now();
|
|
||||||
let raf = 0;
|
|
||||||
|
|
||||||
const frame = () => {
|
|
||||||
const result = renderer.render();
|
|
||||||
frameCount++;
|
|
||||||
|
|
||||||
// Find hovered node using quadtree
|
|
||||||
const hit = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y);
|
|
||||||
if (hit) {
|
|
||||||
const origIdx = renderer.sortedIndexToOriginalIndex(hit.index);
|
|
||||||
const meta = origIdx === null ? null : nodesRef.current[origIdx];
|
|
||||||
setHoveredNode({
|
|
||||||
x: hit.x,
|
|
||||||
y: hit.y,
|
|
||||||
screenX: mousePos.current.x,
|
|
||||||
screenY: mousePos.current.y,
|
|
||||||
label: meta && typeof meta.label === "string" ? meta.label : undefined,
|
|
||||||
iri: meta && typeof meta.iri === "string" ? meta.iri : undefined,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setHoveredNode(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = performance.now();
|
|
||||||
if (now - lastTime >= 500) {
|
|
||||||
const fps = (frameCount / (now - lastTime)) * 1000;
|
|
||||||
setStats({
|
|
||||||
fps: Math.round(fps),
|
|
||||||
drawn: result.drawnCount,
|
|
||||||
mode: result.mode,
|
|
||||||
zoom: result.zoom,
|
|
||||||
ptSize: result.ptSize,
|
|
||||||
});
|
|
||||||
frameCount = 0;
|
|
||||||
lastTime = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
initCtrl.abort();
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
canvas.removeEventListener("mousedown", onDown);
|
|
||||||
window.removeEventListener("mousemove", onMove);
|
|
||||||
window.removeEventListener("mouseup", onUp);
|
|
||||||
canvas.removeEventListener("wheel", onWheel);
|
|
||||||
canvas.removeEventListener("mouseleave", onMouseLeave);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reload graph when the graph query mode changes (after initial load)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!graphInitializedRef.current) return;
|
|
||||||
const renderer = rendererRef.current;
|
|
||||||
if (!renderer) return;
|
|
||||||
if (!activeGraphQueryId) return;
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await loadGraph(activeGraphQueryId, ctrl.signal);
|
|
||||||
} catch (e) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => ctrl.abort();
|
|
||||||
}, [activeGraphQueryId]);
|
|
||||||
|
|
||||||
// Sync selection state to renderer
|
|
||||||
useEffect(() => {
|
|
||||||
const renderer = rendererRef.current;
|
|
||||||
if (!renderer) return;
|
|
||||||
|
|
||||||
// Optimistically reflect selection immediately; highlights will be filled in by backend.
|
|
||||||
renderer.updateSelection(selectedNodes, new Set());
|
|
||||||
|
|
||||||
// Invalidate any in-flight request for the previous selection/mode.
|
|
||||||
const reqId = ++selectionReqIdRef.current;
|
|
||||||
|
|
||||||
// Convert selected sorted indices to backend node IDs (graph-export dense IDs).
|
|
||||||
const selectedIds: number[] = [];
|
|
||||||
for (const sortedIdx of selectedNodes) {
|
|
||||||
const origIdx = renderer.sortedIndexToOriginalIndex(sortedIdx);
|
|
||||||
if (origIdx === null) continue;
|
|
||||||
const n = nodesRef.current?.[origIdx];
|
|
||||||
const nodeId = n?.id;
|
|
||||||
if (typeof nodeId !== "number") continue;
|
|
||||||
selectedIds.push(nodeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedIds.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const neighborIds = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
if (reqId !== selectionReqIdRef.current) return;
|
|
||||||
|
|
||||||
const neighborSorted = new Set<number>();
|
|
||||||
for (const id of neighborIds) {
|
|
||||||
if (typeof id !== "number") continue;
|
|
||||||
const sorted = renderer.vertexIdToSortedIndexOrNull(id);
|
|
||||||
if (sorted === null) continue;
|
|
||||||
if (!selectedNodes.has(sorted)) neighborSorted.add(sorted);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.updateSelection(selectedNodes, neighborSorted);
|
|
||||||
} catch (e) {
|
|
||||||
if (ctrl.signal.aborted) return;
|
|
||||||
console.warn(e);
|
|
||||||
// Keep the UI usable even if neighbors fail to load.
|
|
||||||
renderer.updateSelection(selectedNodes, new Set());
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => ctrl.abort();
|
|
||||||
}, [selectedNodes, activeSelectionQueryId]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
style={{ display: "block", width: "100%", height: "100%" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Loading overlay */}
|
|
||||||
{status && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: "rgba(0,0,0,0.9)",
|
|
||||||
color: "#0f0",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error overlay */}
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
inset: 0,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
background: "rgba(0,0,0,0.9)",
|
|
||||||
color: "#f44",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Error: {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* HUD */}
|
|
||||||
{!status && !error && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 10,
|
|
||||||
left: 10,
|
|
||||||
background: "rgba(0,0,0,0.75)",
|
|
||||||
color: "#0f0",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
padding: "8px 12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
lineHeight: "1.6",
|
|
||||||
borderRadius: "4px",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>FPS: {stats.fps}</div>
|
|
||||||
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
|
|
||||||
<div>Mode: {stats.mode}</div>
|
|
||||||
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
|
||||||
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
|
||||||
<div style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
|
|
||||||
{backendStats && (
|
|
||||||
<div style={{ color: "#8f8" }}>
|
|
||||||
Backend{backendStats.backend ? ` (${backendStats.backend})` : ""}: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 10,
|
|
||||||
left: 10,
|
|
||||||
background: "rgba(0,0,0,0.75)",
|
|
||||||
color: "#888",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
padding: "6px 10px",
|
|
||||||
fontSize: "11px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Drag to pan · Scroll to zoom · Click to select
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selection query buttons */}
|
|
||||||
{selectionQueries.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 10,
|
|
||||||
right: 10,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "6px",
|
|
||||||
background: "rgba(0,0,0,0.55)",
|
|
||||||
padding: "8px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid rgba(255,255,255,0.08)",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectionQueries.map((q) => {
|
|
||||||
const active = q.id === activeSelectionQueryId;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={q.id}
|
|
||||||
onClick={() => setActiveSelectionQueryId(q.id)}
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "12px",
|
|
||||||
padding: "6px 10px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: active ? "1px solid rgba(0,255,255,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
|
||||||
background: active ? "rgba(0,255,255,0.12)" : "rgba(255,255,255,0.04)",
|
|
||||||
color: active ? "#0ff" : "#bbb",
|
|
||||||
textAlign: "left",
|
|
||||||
}}
|
|
||||||
aria-pressed={active}
|
|
||||||
>
|
|
||||||
{q.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Graph query buttons */}
|
|
||||||
{graphQueries.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
bottom: 10,
|
|
||||||
right: 10,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "6px",
|
|
||||||
background: "rgba(0,0,0,0.55)",
|
|
||||||
padding: "8px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "1px solid rgba(255,255,255,0.08)",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{graphQueries.map((q) => {
|
|
||||||
const active = q.id === activeGraphQueryId;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={q.id}
|
|
||||||
onClick={() => setActiveGraphQueryId(q.id)}
|
|
||||||
style={{
|
|
||||||
cursor: "pointer",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
fontSize: "12px",
|
|
||||||
padding: "6px 10px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: active ? "1px solid rgba(0,255,0,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
|
||||||
background: active ? "rgba(0,255,0,0.12)" : "rgba(255,255,255,0.04)",
|
|
||||||
color: active ? "#8f8" : "#bbb",
|
|
||||||
textAlign: "left",
|
|
||||||
}}
|
|
||||||
aria-pressed={active}
|
|
||||||
>
|
|
||||||
{q.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hover tooltip */}
|
|
||||||
{hoveredNode && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: hoveredNode.screenX + 15,
|
|
||||||
top: hoveredNode.screenY + 15,
|
|
||||||
background: "rgba(0,0,0,0.85)",
|
|
||||||
color: "#0ff",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
padding: "6px 10px",
|
|
||||||
fontSize: "12px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
pointerEvents: "none",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
border: "1px solid rgba(0,255,255,0.3)",
|
|
||||||
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ color: "#0ff" }}>
|
|
||||||
{hoveredNode.label || hoveredNode.iri || "(unknown)"}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#688" }}>
|
|
||||||
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { GraphQueryMeta } from "./types";
|
|
||||||
|
|
||||||
export async function fetchGraphQueries(signal?: AbortSignal): Promise<GraphQueryMeta[]> {
|
|
||||||
const res = await fetch("/api/graph_queries", { signal });
|
|
||||||
if (!res.ok) throw new Error(`GET /api/graph_queries failed: ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return Array.isArray(data) ? (data as GraphQueryMeta[]) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { fetchGraphQueries } from "./api";
|
|
||||||
export type { GraphQueryMeta } from "./types";
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export type GraphQueryMeta = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import type { GraphMeta, SelectionQueryMeta } from "./types";
|
|
||||||
|
|
||||||
export async function fetchSelectionQueries(signal?: AbortSignal): Promise<SelectionQueryMeta[]> {
|
|
||||||
const res = await fetch("/api/selection_queries", { signal });
|
|
||||||
if (!res.ok) throw new Error(`GET /api/selection_queries failed: ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
return Array.isArray(data) ? (data as SelectionQueryMeta[]) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runSelectionQuery(
|
|
||||||
queryId: string,
|
|
||||||
selectedIds: number[],
|
|
||||||
graphMeta: GraphMeta | null,
|
|
||||||
signal: AbortSignal
|
|
||||||
): Promise<number[]> {
|
|
||||||
const body = {
|
|
||||||
query_id: queryId,
|
|
||||||
selected_ids: selectedIds,
|
|
||||||
node_limit: typeof graphMeta?.node_limit === "number" ? graphMeta.node_limit : undefined,
|
|
||||||
edge_limit: typeof graphMeta?.edge_limit === "number" ? graphMeta.edge_limit : undefined,
|
|
||||||
graph_query_id: typeof graphMeta?.graph_query_id === "string" ? graphMeta.graph_query_id : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await fetch("/api/selection_query", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`POST /api/selection_query failed: ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
const ids: unknown = data?.neighbor_ids;
|
|
||||||
if (!Array.isArray(ids)) return [];
|
|
||||||
const out: number[] = [];
|
|
||||||
for (const id of ids) if (typeof id === "number") out.push(id);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { fetchSelectionQueries, runSelectionQuery } from "./api";
|
|
||||||
export type { GraphMeta, SelectionQueryMeta } from "./types";
|
|
||||||
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export type GraphMeta = {
|
|
||||||
backend?: string;
|
|
||||||
ttl_path?: string | null;
|
|
||||||
sparql_endpoint?: string | null;
|
|
||||||
include_bnodes?: boolean;
|
|
||||||
graph_query_id?: string;
|
|
||||||
node_limit?: number;
|
|
||||||
edge_limit?: number;
|
|
||||||
nodes?: number;
|
|
||||||
edges?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SelectionQueryMeta = {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import path from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
viteSingleFile(),
|
|
||||||
{
|
|
||||||
name: "long-timeouts",
|
|
||||||
configureServer(server) {
|
|
||||||
// Large graph snapshots can take minutes; keep the dev server from killing the request.
|
|
||||||
const httpServer = server.httpServer;
|
|
||||||
if (!httpServer) return;
|
|
||||||
const ms30m = 30 * 60 * 1000;
|
|
||||||
httpServer.headersTimeout = ms30m;
|
|
||||||
httpServer.requestTimeout = ms30m;
|
|
||||||
httpServer.keepAliveTimeout = ms30m;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
// Backend is reachable as http://backend:8000 inside docker-compose; localhost outside.
|
|
||||||
"/api": {
|
|
||||||
target: process.env.VITE_BACKEND_URL || "http://localhost:8000",
|
|
||||||
changeOrigin: true,
|
|
||||||
configure: (proxy) => {
|
|
||||||
proxy.on("error", (err) => {
|
|
||||||
// Surface upstream timeouts/socket errors in `docker compose logs frontend`.
|
|
||||||
console.error("[vite-proxy] /api error:", err);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// The initial graph snapshot can take minutes with large limits (SPARQL + layout + labels).
|
|
||||||
// Prevent the dev proxy from timing out and returning a 500 to the browser.
|
|
||||||
timeout: 30 * 60 * 1000,
|
|
||||||
proxyTimeout: 30 * 60 * 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"layout": "tsx scripts/compute_layout.ts"
|
"layout": "npx tsx scripts/fetch_from_db.ts && npx tsx scripts/compute_layout.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@webgpu/types": "^0.1.69",
|
"@webgpu/types": "^0.1.69",
|
||||||
1
public/node_positions.csv
Normal file
1
public/node_positions.csv
Normal file
@@ -0,0 +1 @@
|
|||||||
|
vertex,x,y
|
||||||
|
1
public/primary_edges.csv
Normal file
1
public/primary_edges.csv
Normal file
@@ -0,0 +1 @@
|
|||||||
|
source,target
|
||||||
|
1
public/secondary_edges.csv
Normal file
1
public/secondary_edges.csv
Normal file
@@ -0,0 +1 @@
|
|||||||
|
source,target
|
||||||
|
1
public/uri_map.csv
Normal file
1
public/uri_map.csv
Normal file
@@ -0,0 +1 @@
|
|||||||
|
id,uri,label,isPrimary
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
FROM python:3.12-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1
|
|
||||||
|
|
||||||
COPY requirements.txt /app/requirements.txt
|
|
||||||
RUN pip install --no-cache-dir -r /app/requirements.txt
|
|
||||||
|
|
||||||
COPY owl_imports_combiner.py /app/owl_imports_combiner.py
|
|
||||||
COPY main.py /app/main.py
|
|
||||||
|
|
||||||
CMD ["python", "/app/main.py"]
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# owl_imports_combiner (Python service)
|
|
||||||
|
|
||||||
One-shot utility container that loads an ontology and recursively follows `owl:imports`, then writes a single combined Turtle file.
|
|
||||||
|
|
||||||
This is useful to precompute a single TTL for AnzoGraph loading.
|
|
||||||
|
|
||||||
## Run
|
|
||||||
|
|
||||||
Via Docker Compose:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose run --rm owl_imports_combiner
|
|
||||||
```
|
|
||||||
|
|
||||||
The service mounts `./data → /data`, so use output paths under `/data/...`.
|
|
||||||
|
|
||||||
## Environment variables
|
|
||||||
|
|
||||||
- `COMBINE_OWL_IMPORTS_ON_START` (default `false`)
|
|
||||||
- If `false`, the container exits without doing anything.
|
|
||||||
- `COMBINE_ENTRY_LOCATION`
|
|
||||||
- Entry ontology: local path, `file://` URI, or `http(s)` URL.
|
|
||||||
- If unset, falls back to `TTL_PATH`.
|
|
||||||
- `COMBINE_OUTPUT_LOCATION`
|
|
||||||
- Output location (local file path or `file://` URI). Required if entry is an `http(s)` URL.
|
|
||||||
- `COMBINE_OUTPUT_NAME` (default `combined_ontology.ttl`)
|
|
||||||
- Used only when `COMBINE_OUTPUT_LOCATION` is unset and entry is a local file.
|
|
||||||
- `COMBINE_FORCE` (default `false`)
|
|
||||||
- Overwrite output if it already exists.
|
|
||||||
- `LOG_LEVEL` (default `INFO`)
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
- If the output exists and `COMBINE_FORCE=false`, it skips the combine step.
|
|
||||||
- Output is written atomically via a temporary file + rename.
|
|
||||||
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
from owl_imports_combiner import (
|
|
||||||
build_combined_graph,
|
|
||||||
output_location_to_path,
|
|
||||||
resolve_output_location,
|
|
||||||
serialize_graph_to_ttl,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, *, default: bool = False) -> bool:
|
|
||||||
val = os.getenv(name)
|
|
||||||
if val is None:
|
|
||||||
return default
|
|
||||||
return val.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper())
|
|
||||||
|
|
||||||
if not _env_bool("COMBINE_OWL_IMPORTS_ON_START", default=False):
|
|
||||||
logger.info("Skipping combine step (COMBINE_OWL_IMPORTS_ON_START=false)")
|
|
||||||
return
|
|
||||||
|
|
||||||
entry_location = os.getenv("COMBINE_ENTRY_LOCATION") or os.getenv("TTL_PATH")
|
|
||||||
if not entry_location:
|
|
||||||
raise SystemExit("Set COMBINE_ENTRY_LOCATION (or TTL_PATH) to the ontology file/URL to load.")
|
|
||||||
|
|
||||||
output_name = os.getenv("COMBINE_OUTPUT_NAME", "combined_ontology.ttl")
|
|
||||||
output_location = resolve_output_location(
|
|
||||||
entry_location,
|
|
||||||
output_location=os.getenv("COMBINE_OUTPUT_LOCATION"),
|
|
||||||
output_name=output_name,
|
|
||||||
)
|
|
||||||
output_path = output_location_to_path(output_location)
|
|
||||||
|
|
||||||
force = _env_bool("COMBINE_FORCE", default=False)
|
|
||||||
if output_path.exists() and not force:
|
|
||||||
logger.info("Skipping combine step (output exists): %s", output_location)
|
|
||||||
return
|
|
||||||
|
|
||||||
graph = build_combined_graph(entry_location)
|
|
||||||
logger.info("Finished combining imports; serializing to: %s", output_location)
|
|
||||||
serialize_graph_to_ttl(graph, output_location)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import unquote, urlparse
|
|
||||||
|
|
||||||
from rdflib import Graph
|
|
||||||
from rdflib.namespace import OWL
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_http_url(location: str) -> bool:
|
|
||||||
scheme = urlparse(location).scheme.lower()
|
|
||||||
return scheme in {"http", "https"}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_file_uri(location: str) -> bool:
|
|
||||||
return urlparse(location).scheme.lower() == "file"
|
|
||||||
|
|
||||||
|
|
||||||
def _file_uri_to_path(location: str) -> Path:
|
|
||||||
u = urlparse(location)
|
|
||||||
if u.scheme.lower() != "file":
|
|
||||||
raise ValueError(f"Not a file:// URI: {location!r}")
|
|
||||||
return Path(unquote(u.path))
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_output_location(
|
|
||||||
entry_location: str,
|
|
||||||
*,
|
|
||||||
output_location: str | None,
|
|
||||||
output_name: str,
|
|
||||||
) -> str:
|
|
||||||
if output_location:
|
|
||||||
return output_location
|
|
||||||
|
|
||||||
if _is_http_url(entry_location):
|
|
||||||
raise ValueError(
|
|
||||||
"COMBINE_ENTRY_LOCATION points to an http(s) URL; set COMBINE_OUTPUT_LOCATION to a writable file path."
|
|
||||||
)
|
|
||||||
|
|
||||||
entry_path = _file_uri_to_path(entry_location) if _is_file_uri(entry_location) else Path(entry_location)
|
|
||||||
return str(entry_path.parent / output_name)
|
|
||||||
|
|
||||||
|
|
||||||
def _output_destination_to_path(output_location: str) -> Path:
|
|
||||||
if _is_file_uri(output_location):
|
|
||||||
return _file_uri_to_path(output_location)
|
|
||||||
if _is_http_url(output_location):
|
|
||||||
raise ValueError("Output location must be a local file path (or file:// URI), not http(s).")
|
|
||||||
return Path(output_location)
|
|
||||||
|
|
||||||
|
|
||||||
def output_location_to_path(output_location: str) -> Path:
|
|
||||||
return _output_destination_to_path(output_location)
|
|
||||||
|
|
||||||
|
|
||||||
def build_combined_graph(entry_location: str) -> Graph:
|
|
||||||
"""
|
|
||||||
Recursively loads an RDF document (file path, file:// URI, or http(s) URL) and its
|
|
||||||
owl:imports into a single in-memory graph.
|
|
||||||
"""
|
|
||||||
combined_graph = Graph()
|
|
||||||
visited_locations: set[str] = set()
|
|
||||||
|
|
||||||
def resolve_imports(location: str) -> None:
|
|
||||||
if location in visited_locations:
|
|
||||||
return
|
|
||||||
visited_locations.add(location)
|
|
||||||
|
|
||||||
logger.info("Loading ontology: %s", location)
|
|
||||||
try:
|
|
||||||
combined_graph.parse(location=location)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to load %s (%s)", location, e)
|
|
||||||
return
|
|
||||||
|
|
||||||
imports = [str(o) for _, _, o in combined_graph.triples((None, OWL.imports, None))]
|
|
||||||
for imported_location in imports:
|
|
||||||
if imported_location not in visited_locations:
|
|
||||||
resolve_imports(imported_location)
|
|
||||||
|
|
||||||
resolve_imports(entry_location)
|
|
||||||
return combined_graph
|
|
||||||
|
|
||||||
|
|
||||||
def serialize_graph_to_ttl(graph: Graph, output_location: str) -> None:
|
|
||||||
output_path = _output_destination_to_path(output_location)
|
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
tmp_path = output_path.with_suffix(output_path.suffix + ".tmp")
|
|
||||||
graph.serialize(destination=str(tmp_path), format="turtle")
|
|
||||||
os.replace(str(tmp_path), str(output_path))
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
rdflib
|
|
||||||
376
scripts/compute_layout.ts
Normal file
376
scripts/compute_layout.ts
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* Graph Layout
|
||||||
|
*
|
||||||
|
* Computes a 2D layout for a general graph (not necessarily a tree).
|
||||||
|
*
|
||||||
|
* - Primary nodes (from primary_edges.csv) are placed first in a radial layout
|
||||||
|
* - Remaining nodes are placed near their connected primary neighbors
|
||||||
|
* - Barnes-Hut force simulation relaxes the layout
|
||||||
|
*
|
||||||
|
* Reads: primary_edges.csv, secondary_edges.csv
|
||||||
|
* Writes: node_positions.csv
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/compute_layout.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync, readFileSync, existsSync } from "fs";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PUBLIC_DIR = join(__dirname, "..", "public");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Configuration
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const ITERATIONS = 200; // Force iterations
|
||||||
|
const REPULSION_K = 200; // Repulsion strength
|
||||||
|
const EDGE_LENGTH = 80; // Desired edge rest length
|
||||||
|
const ATTRACTION_K = 0.005; // Spring stiffness for edges
|
||||||
|
const INITIAL_MAX_DISP = 20; // Starting max displacement
|
||||||
|
const COOLING = 0.995; // Cooling per iteration
|
||||||
|
const MIN_DIST = 0.5;
|
||||||
|
const PRINT_EVERY = 20; // Print progress every N iterations
|
||||||
|
const BH_THETA = 0.8; // Barnes-Hut opening angle
|
||||||
|
|
||||||
|
// Primary node radial placement
|
||||||
|
const PRIMARY_RADIUS = 300; // Radius for primary node ring
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Read edge data from CSVs
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const primaryPath = join(PUBLIC_DIR, "primary_edges.csv");
|
||||||
|
const secondaryPath = join(PUBLIC_DIR, "secondary_edges.csv");
|
||||||
|
|
||||||
|
if (!existsSync(primaryPath) || !existsSync(secondaryPath)) {
|
||||||
|
console.error(`Error: Missing input files!`);
|
||||||
|
console.error(` Expected: ${primaryPath}`);
|
||||||
|
console.error(` Expected: ${secondaryPath}`);
|
||||||
|
console.error(` Run 'npx tsx scripts/fetch_from_db.ts' first.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEdges(path: string): Array<[number, number]> {
|
||||||
|
const content = readFileSync(path, "utf-8");
|
||||||
|
const lines = content.trim().split("\n");
|
||||||
|
const edges: Array<[number, number]> = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
const [src, tgt] = line.split(",").map(Number);
|
||||||
|
if (!isNaN(src) && !isNaN(tgt)) {
|
||||||
|
edges.push([src, tgt]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return edges;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryEdges = parseEdges(primaryPath);
|
||||||
|
const secondaryEdges = parseEdges(secondaryPath);
|
||||||
|
const allEdges = [...primaryEdges, ...secondaryEdges];
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Build adjacency
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const allNodes = new Set<number>();
|
||||||
|
const primaryNodes = new Set<number>();
|
||||||
|
const neighbors = new Map<number, Set<number>>();
|
||||||
|
|
||||||
|
function addNeighbor(a: number, b: number) {
|
||||||
|
if (!neighbors.has(a)) neighbors.set(a, new Set());
|
||||||
|
neighbors.get(a)!.add(b);
|
||||||
|
if (!neighbors.has(b)) neighbors.set(b, new Set());
|
||||||
|
neighbors.get(b)!.add(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [src, dst] of primaryEdges) {
|
||||||
|
allNodes.add(src);
|
||||||
|
allNodes.add(dst);
|
||||||
|
primaryNodes.add(src);
|
||||||
|
primaryNodes.add(dst);
|
||||||
|
addNeighbor(src, dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [src, dst] of secondaryEdges) {
|
||||||
|
allNodes.add(src);
|
||||||
|
allNodes.add(dst);
|
||||||
|
addNeighbor(src, dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
const N = allNodes.size;
|
||||||
|
const nodeIds = Array.from(allNodes).sort((a, b) => a - b);
|
||||||
|
const idToIdx = new Map<number, number>();
|
||||||
|
nodeIds.forEach((id, idx) => idToIdx.set(id, idx));
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Read graph: ${N} nodes, ${allEdges.length} edges (P=${primaryEdges.length}, S=${secondaryEdges.length})`
|
||||||
|
);
|
||||||
|
console.log(`Primary nodes: ${primaryNodes.size}`);
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Initial placement
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const x = new Float64Array(N);
|
||||||
|
const y = new Float64Array(N);
|
||||||
|
|
||||||
|
// Step 1: Place primary nodes in a radial layout
|
||||||
|
const primaryArr = Array.from(primaryNodes).sort((a, b) => a - b);
|
||||||
|
const angleStep = (2 * Math.PI) / Math.max(1, primaryArr.length);
|
||||||
|
const radius = PRIMARY_RADIUS * Math.max(1, Math.sqrt(primaryArr.length / 10));
|
||||||
|
|
||||||
|
for (let i = 0; i < primaryArr.length; i++) {
|
||||||
|
const idx = idToIdx.get(primaryArr[i])!;
|
||||||
|
const angle = i * angleStep;
|
||||||
|
x[idx] = radius * Math.cos(angle);
|
||||||
|
y[idx] = radius * Math.sin(angle);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Placed ${primaryArr.length} primary nodes in radial layout (r=${radius.toFixed(0)})`);
|
||||||
|
|
||||||
|
// Step 2: Place remaining nodes near their connected neighbors
|
||||||
|
// BFS from already-placed nodes
|
||||||
|
const placed = new Set<number>(primaryNodes);
|
||||||
|
const queue: number[] = [...primaryArr];
|
||||||
|
let head = 0;
|
||||||
|
|
||||||
|
while (head < queue.length) {
|
||||||
|
const nodeId = queue[head++];
|
||||||
|
const nodeNeighbors = neighbors.get(nodeId);
|
||||||
|
if (!nodeNeighbors) continue;
|
||||||
|
|
||||||
|
for (const nbId of nodeNeighbors) {
|
||||||
|
if (placed.has(nbId)) continue;
|
||||||
|
placed.add(nbId);
|
||||||
|
|
||||||
|
// Place near this neighbor with some jitter
|
||||||
|
const parentIdx = idToIdx.get(nodeId)!;
|
||||||
|
const childIdx = idToIdx.get(nbId)!;
|
||||||
|
const jitterAngle = Math.random() * 2 * Math.PI;
|
||||||
|
const jitterDist = EDGE_LENGTH * (0.5 + Math.random() * 0.5);
|
||||||
|
x[childIdx] = x[parentIdx] + jitterDist * Math.cos(jitterAngle);
|
||||||
|
y[childIdx] = y[parentIdx] + jitterDist * Math.sin(jitterAngle);
|
||||||
|
|
||||||
|
queue.push(nbId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle disconnected nodes (place randomly)
|
||||||
|
for (const id of nodeIds) {
|
||||||
|
if (!placed.has(id)) {
|
||||||
|
const idx = idToIdx.get(id)!;
|
||||||
|
const angle = Math.random() * 2 * Math.PI;
|
||||||
|
const r = radius * (1 + Math.random());
|
||||||
|
x[idx] = r * Math.cos(angle);
|
||||||
|
y[idx] = r * Math.sin(angle);
|
||||||
|
placed.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Initial placement complete: ${placed.size} nodes`);
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Force-directed layout with Barnes-Hut
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
console.log(`Running force simulation (${ITERATIONS} iterations, ${N} nodes, ${allEdges.length} edges)...`);
|
||||||
|
|
||||||
|
const t0 = performance.now();
|
||||||
|
let maxDisp = INITIAL_MAX_DISP;
|
||||||
|
|
||||||
|
for (let iter = 0; iter < ITERATIONS; iter++) {
|
||||||
|
const bhRoot = buildBHTree(x, y, N);
|
||||||
|
const fx = new Float64Array(N);
|
||||||
|
const fy = new Float64Array(N);
|
||||||
|
|
||||||
|
// 1. Repulsion via Barnes-Hut
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
calcBHForce(bhRoot, x[i], y[i], fx, fy, i, BH_THETA, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Edge attraction (spring force)
|
||||||
|
for (const [aId, bId] of allEdges) {
|
||||||
|
const a = idToIdx.get(aId)!;
|
||||||
|
const b = idToIdx.get(bId)!;
|
||||||
|
const dx = x[b] - x[a];
|
||||||
|
const dy = y[b] - y[a];
|
||||||
|
const d = Math.sqrt(dx * dx + dy * dy) || MIN_DIST;
|
||||||
|
const displacement = d - EDGE_LENGTH;
|
||||||
|
const f = ATTRACTION_K * displacement;
|
||||||
|
const ux = dx / d;
|
||||||
|
const uy = dy / d;
|
||||||
|
fx[a] += ux * f;
|
||||||
|
fy[a] += uy * f;
|
||||||
|
fx[b] -= ux * f;
|
||||||
|
fy[b] -= uy * f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Apply forces with displacement capping
|
||||||
|
let totalForce = 0;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const mag = Math.sqrt(fx[i] * fx[i] + fy[i] * fy[i]);
|
||||||
|
totalForce += mag;
|
||||||
|
if (mag > 0) {
|
||||||
|
const cap = Math.min(maxDisp, mag) / mag;
|
||||||
|
x[i] += fx[i] * cap;
|
||||||
|
y[i] += fy[i] * cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
maxDisp *= COOLING;
|
||||||
|
|
||||||
|
if ((iter + 1) % PRINT_EVERY === 0 || iter === 0) {
|
||||||
|
console.log(
|
||||||
|
` iter ${iter + 1}/${ITERATIONS} max_disp=${maxDisp.toFixed(2)} avg_force=${(totalForce / N).toFixed(2)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = performance.now() - t0;
|
||||||
|
console.log(`Force simulation done in ${(elapsed / 1000).toFixed(1)}s`);
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Write output
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const outLines: string[] = ["vertex,x,y"];
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
outLines.push(`${nodeIds[i]},${x[i]},${y[i]}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = join(PUBLIC_DIR, "node_positions.csv");
|
||||||
|
writeFileSync(outPath, outLines.join("\n") + "\n");
|
||||||
|
console.log(`Wrote ${N} positions to ${outPath}`);
|
||||||
|
console.log(`Layout complete.`);
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Barnes-Hut Helpers
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface BHNode {
|
||||||
|
mass: number;
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minY: number;
|
||||||
|
maxY: number;
|
||||||
|
children?: BHNode[];
|
||||||
|
pointIdx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBHTree(x: Float64Array, y: Float64Array, n: number): BHNode {
|
||||||
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (x[i] < minX) minX = x[i];
|
||||||
|
if (x[i] > maxX) maxX = x[i];
|
||||||
|
if (y[i] < minY) minY = y[i];
|
||||||
|
if (y[i] > maxY) maxY = y[i];
|
||||||
|
}
|
||||||
|
const cx = (minX + maxX) / 2;
|
||||||
|
const cy = (minY + maxY) / 2;
|
||||||
|
const halfDim = Math.max(maxX - minX, maxY - minY) / 2 + 0.01;
|
||||||
|
|
||||||
|
const root: BHNode = {
|
||||||
|
mass: 0, cx: 0, cy: 0,
|
||||||
|
minX: cx - halfDim, maxX: cx + halfDim,
|
||||||
|
minY: cy - halfDim, maxY: cy + halfDim,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
insertBH(root, i, x[i], y[i], x, y);
|
||||||
|
}
|
||||||
|
calcBHMass(root, x, y);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertBH(node: BHNode, idx: number, px: number, py: number, x: Float64Array, y: Float64Array) {
|
||||||
|
if (!node.children && node.pointIdx === undefined) {
|
||||||
|
node.pointIdx = idx;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node.children && node.pointIdx !== undefined) {
|
||||||
|
const oldIdx = node.pointIdx;
|
||||||
|
node.pointIdx = undefined;
|
||||||
|
subdivideBH(node);
|
||||||
|
insertBH(node, oldIdx, x[oldIdx], y[oldIdx], x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const mx = (node.minX + node.maxX) / 2;
|
||||||
|
const my = (node.minY + node.maxY) / 2;
|
||||||
|
let q = 0;
|
||||||
|
if (px > mx) q += 1;
|
||||||
|
if (py > my) q += 2;
|
||||||
|
insertBH(node.children[q], idx, px, py, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function subdivideBH(node: BHNode) {
|
||||||
|
const mx = (node.minX + node.maxX) / 2;
|
||||||
|
const my = (node.minY + node.maxY) / 2;
|
||||||
|
node.children = [
|
||||||
|
{ mass: 0, cx: 0, cy: 0, minX: node.minX, maxX: mx, minY: node.minY, maxY: my },
|
||||||
|
{ mass: 0, cx: 0, cy: 0, minX: mx, maxX: node.maxX, minY: node.minY, maxY: my },
|
||||||
|
{ mass: 0, cx: 0, cy: 0, minX: node.minX, maxX: mx, minY: my, maxY: node.maxY },
|
||||||
|
{ mass: 0, cx: 0, cy: 0, minX: mx, maxX: node.maxX, minY: my, maxY: node.maxY },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcBHMass(node: BHNode, x: Float64Array, y: Float64Array) {
|
||||||
|
if (node.pointIdx !== undefined) {
|
||||||
|
node.mass = 1;
|
||||||
|
node.cx = x[node.pointIdx];
|
||||||
|
node.cy = y[node.pointIdx];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
let m = 0, sx = 0, sy = 0;
|
||||||
|
for (const c of node.children) {
|
||||||
|
calcBHMass(c, x, y);
|
||||||
|
m += c.mass;
|
||||||
|
sx += c.cx * c.mass;
|
||||||
|
sy += c.cy * c.mass;
|
||||||
|
}
|
||||||
|
node.mass = m;
|
||||||
|
if (m > 0) {
|
||||||
|
node.cx = sx / m;
|
||||||
|
node.cy = sy / m;
|
||||||
|
} else {
|
||||||
|
node.cx = (node.minX + node.maxX) / 2;
|
||||||
|
node.cy = (node.minY + node.maxY) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcBHForce(
|
||||||
|
node: BHNode,
|
||||||
|
px: number, py: number,
|
||||||
|
fx: Float64Array, fy: Float64Array,
|
||||||
|
idx: number, theta: number,
|
||||||
|
x: Float64Array, y: Float64Array,
|
||||||
|
) {
|
||||||
|
const dx = px - node.cx;
|
||||||
|
const dy = py - node.cy;
|
||||||
|
const d2 = dx * dx + dy * dy;
|
||||||
|
const dist = Math.sqrt(d2);
|
||||||
|
const width = node.maxX - node.minX;
|
||||||
|
|
||||||
|
if (width / dist < theta || !node.children) {
|
||||||
|
if (node.mass > 0 && node.pointIdx !== idx) {
|
||||||
|
const dEff = Math.max(dist, MIN_DIST);
|
||||||
|
const f = (REPULSION_K * node.mass) / (dEff * dEff);
|
||||||
|
fx[idx] += (dx / dEff) * f;
|
||||||
|
fy[idx] += (dy / dEff) * f;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const c of node.children) {
|
||||||
|
calcBHForce(c, px, py, fx, fy, idx, theta, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
390
scripts/fetch_from_db.ts
Normal file
390
scripts/fetch_from_db.ts
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* Fetch RDF Data from AnzoGraph DB
|
||||||
|
*
|
||||||
|
* 1. Query the first 1000 distinct subject URIs
|
||||||
|
* 2. Fetch all triples where those URIs appear as subject or object
|
||||||
|
* 3. Identify primary nodes (objects of rdf:type)
|
||||||
|
* 4. Write primary_edges.csv, secondary_edges.csv, and uri_map.csv
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/fetch_from_db.ts [--host http://localhost:8080]
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PUBLIC_DIR = join(__dirname, "..", "public");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Configuration
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
|
||||||
|
const BATCH_SIZE = 100; // URIs per VALUES batch query
|
||||||
|
const MAX_RETRIES = 30; // Wait up to ~120s for AnzoGraph to start
|
||||||
|
const RETRY_DELAY_MS = 4000;
|
||||||
|
|
||||||
|
// Path to TTL file inside the AnzoGraph container (mapped via docker-compose volume)
|
||||||
|
const DATA_FILE = process.env.SPARQL_DATA_FILE || "file:///opt/shared-files/vkg-materialized.ttl";
|
||||||
|
|
||||||
|
// Parse --host flag, default to http://localhost:8080
|
||||||
|
function getEndpoint(): string {
|
||||||
|
const hostIdx = process.argv.indexOf("--host");
|
||||||
|
if (hostIdx !== -1 && process.argv[hostIdx + 1]) {
|
||||||
|
return process.argv[hostIdx + 1];
|
||||||
|
}
|
||||||
|
// Inside Docker, use service name; otherwise localhost
|
||||||
|
return process.env.SPARQL_HOST || "http://localhost:8080";
|
||||||
|
}
|
||||||
|
|
||||||
|
const SPARQL_ENDPOINT = `${getEndpoint()}/sparql`;
|
||||||
|
|
||||||
|
// Auth credentials (AnzoGraph defaults)
|
||||||
|
const SPARQL_USER = process.env.SPARQL_USER || "admin";
|
||||||
|
const SPARQL_PASS = process.env.SPARQL_PASS || "Passw0rd1";
|
||||||
|
const AUTH_HEADER = "Basic " + Buffer.from(`${SPARQL_USER}:${SPARQL_PASS}`).toString("base64");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// SPARQL helpers
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface SparqlBinding {
|
||||||
|
[key: string]: { type: string; value: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sparqlQuery(query: string, retries = 5): Promise<SparqlBinding[]> {
|
||||||
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 300_000); // 5 min timeout
|
||||||
|
|
||||||
|
try {
|
||||||
|
const t0 = performance.now();
|
||||||
|
const response = await fetch(SPARQL_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/sparql-results+json",
|
||||||
|
"Authorization": AUTH_HEADER,
|
||||||
|
},
|
||||||
|
body: "query=" + encodeURIComponent(query),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const t1 = performance.now();
|
||||||
|
console.log(` [sparql] response status=${response.status} in ${((t1 - t0) / 1000).toFixed(1)}s`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
throw new Error(`SPARQL query failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const t2 = performance.now();
|
||||||
|
console.log(` [sparql] body read (${(text.length / 1024).toFixed(0)} KB) in ${((t2 - t1) / 1000).toFixed(1)}s`);
|
||||||
|
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
return json.results.bindings;
|
||||||
|
} catch (err: any) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
const isTransient = msg.includes("fetch failed") || msg.includes("Timeout") || msg.includes("ABORT") || msg.includes("abort");
|
||||||
|
if (isTransient && attempt < retries) {
|
||||||
|
console.log(` [sparql] transient error (attempt ${attempt}/${retries}): ${msg.substring(0, 100)}`);
|
||||||
|
console.log(` [sparql] retrying in 10s (AnzoGraph may still be indexing after LOAD)...`);
|
||||||
|
await sleep(10_000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("sparqlQuery: should not reach here");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForAnzoGraph(): Promise<void> {
|
||||||
|
console.log(`Waiting for AnzoGraph at ${SPARQL_ENDPOINT}...`);
|
||||||
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(SPARQL_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"Accept": "application/sparql-results+json",
|
||||||
|
"Authorization": AUTH_HEADER,
|
||||||
|
},
|
||||||
|
body: "query=" + encodeURIComponent("ASK WHERE { ?s ?p ?o }"),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
// Verify it's actual JSON (not a plain-text error from a half-started engine)
|
||||||
|
JSON.parse(text);
|
||||||
|
console.log(` AnzoGraph is ready (attempt ${attempt})`);
|
||||||
|
return;
|
||||||
|
} catch (err: any) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
console.log(` Attempt ${attempt}/${MAX_RETRIES}: ${msg.substring(0, 100)}`);
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
await sleep(RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`AnzoGraph not available after ${MAX_RETRIES} attempts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sparqlUpdate(update: string): Promise<string> {
|
||||||
|
const response = await fetch(SPARQL_ENDPOINT, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/sparql-update",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Authorization": AUTH_HEADER,
|
||||||
|
},
|
||||||
|
body: update,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`SPARQL update failed (${response.status}): ${text}`);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(): Promise<void> {
|
||||||
|
console.log(`Loading data from ${DATA_FILE}...`);
|
||||||
|
const t0 = performance.now();
|
||||||
|
const result = await sparqlUpdate(`LOAD <${DATA_FILE}>`);
|
||||||
|
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
console.log(` Load complete in ${elapsed}s: ${result.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Step 1: Fetch seed URIs
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function fetchSeedURIs(): Promise<string[]> {
|
||||||
|
console.log("Querying first 1000 distinct subject URIs...");
|
||||||
|
const t0 = performance.now();
|
||||||
|
const query = `
|
||||||
|
SELECT DISTINCT ?s
|
||||||
|
WHERE { ?s ?p ?o }
|
||||||
|
LIMIT 1000
|
||||||
|
`;
|
||||||
|
const bindings = await sparqlQuery(query);
|
||||||
|
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
const uris = bindings.map((b) => b.s.value);
|
||||||
|
console.log(` Got ${uris.length} seed URIs in ${elapsed}s`);
|
||||||
|
return uris;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Step 2: Fetch all triples involving seed URIs
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface Triple {
|
||||||
|
s: string;
|
||||||
|
p: string;
|
||||||
|
o: string;
|
||||||
|
oType: string; // "uri" or "literal"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTriples(seedURIs: string[]): Promise<Triple[]> {
|
||||||
|
console.log(`Fetching triples for ${seedURIs.length} seed URIs (batch size: ${BATCH_SIZE})...`);
|
||||||
|
const allTriples: Triple[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < seedURIs.length; i += BATCH_SIZE) {
|
||||||
|
const batch = seedURIs.slice(i, i + BATCH_SIZE);
|
||||||
|
const valuesClause = batch.map((u) => `<${u}>`).join(" ");
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
SELECT ?s ?p ?o
|
||||||
|
WHERE {
|
||||||
|
VALUES ?uri { ${valuesClause} }
|
||||||
|
{
|
||||||
|
?uri ?p ?o .
|
||||||
|
BIND(?uri AS ?s)
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
?s ?p ?uri .
|
||||||
|
BIND(?uri AS ?o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const bindings = await sparqlQuery(query);
|
||||||
|
for (const b of bindings) {
|
||||||
|
allTriples.push({
|
||||||
|
s: b.s.value,
|
||||||
|
p: b.p.value,
|
||||||
|
o: b.o.value,
|
||||||
|
oType: b.o.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.min(i + BATCH_SIZE, seedURIs.length);
|
||||||
|
process.stdout.write(`\r Fetched triples: batch ${Math.ceil(progress / BATCH_SIZE)}/${Math.ceil(seedURIs.length / BATCH_SIZE)} (${allTriples.length} triples so far)`);
|
||||||
|
}
|
||||||
|
console.log(`\n Total triples: ${allTriples.length}`);
|
||||||
|
return allTriples;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Step 3: Build graph data
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
interface GraphData {
|
||||||
|
nodeURIs: string[]; // All unique URIs (subjects & objects that are URIs)
|
||||||
|
uriToId: Map<string, number>;
|
||||||
|
primaryNodeIds: Set<number>; // Nodes that are objects of rdf:type
|
||||||
|
edges: Array<[number, number]>; // [source, target] as numeric IDs
|
||||||
|
primaryEdges: Array<[number, number]>;
|
||||||
|
secondaryEdges: Array<[number, number]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGraphData(triples: Triple[]): GraphData {
|
||||||
|
console.log("Building graph data...");
|
||||||
|
|
||||||
|
// Collect all unique URI nodes (skip literal objects)
|
||||||
|
const uriSet = new Set<string>();
|
||||||
|
for (const t of triples) {
|
||||||
|
uriSet.add(t.s);
|
||||||
|
if (t.oType === "uri") {
|
||||||
|
uriSet.add(t.o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign numeric IDs
|
||||||
|
const nodeURIs = Array.from(uriSet).sort();
|
||||||
|
const uriToId = new Map<string, number>();
|
||||||
|
nodeURIs.forEach((uri, idx) => uriToId.set(uri, idx));
|
||||||
|
|
||||||
|
// Identify primary nodes: objects of rdf:type triples
|
||||||
|
const primaryNodeIds = new Set<number>();
|
||||||
|
for (const t of triples) {
|
||||||
|
if (t.p === RDF_TYPE && t.oType === "uri") {
|
||||||
|
const objId = uriToId.get(t.o);
|
||||||
|
if (objId !== undefined) {
|
||||||
|
primaryNodeIds.add(objId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build edges (only between URI nodes, skip literal objects)
|
||||||
|
const edgeSet = new Set<string>();
|
||||||
|
const edges: Array<[number, number]> = [];
|
||||||
|
for (const t of triples) {
|
||||||
|
if (t.oType !== "uri") continue;
|
||||||
|
const srcId = uriToId.get(t.s);
|
||||||
|
const dstId = uriToId.get(t.o);
|
||||||
|
if (srcId === undefined || dstId === undefined) continue;
|
||||||
|
if (srcId === dstId) continue; // Skip self-loops
|
||||||
|
|
||||||
|
const key = `${srcId},${dstId}`;
|
||||||
|
if (edgeSet.has(key)) continue; // Deduplicate
|
||||||
|
edgeSet.add(key);
|
||||||
|
edges.push([srcId, dstId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify edges into primary (touches a primary node) and secondary
|
||||||
|
const primaryEdges: Array<[number, number]> = [];
|
||||||
|
const secondaryEdges: Array<[number, number]> = [];
|
||||||
|
for (const [src, dst] of edges) {
|
||||||
|
if (primaryNodeIds.has(src) || primaryNodeIds.has(dst)) {
|
||||||
|
primaryEdges.push([src, dst]);
|
||||||
|
} else {
|
||||||
|
secondaryEdges.push([src, dst]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Nodes: ${nodeURIs.length}`);
|
||||||
|
console.log(` Primary nodes (rdf:type objects): ${primaryNodeIds.size}`);
|
||||||
|
console.log(` Edges: ${edges.length} (primary: ${primaryEdges.length}, secondary: ${secondaryEdges.length})`);
|
||||||
|
|
||||||
|
return { nodeURIs, uriToId, primaryNodeIds, edges, primaryEdges, secondaryEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Step 4: Write CSV files
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
function extractLabel(uri: string): string {
|
||||||
|
// Extract local name: after # or last /
|
||||||
|
const hashIdx = uri.lastIndexOf("#");
|
||||||
|
if (hashIdx !== -1) return uri.substring(hashIdx + 1);
|
||||||
|
const slashIdx = uri.lastIndexOf("/");
|
||||||
|
if (slashIdx !== -1) return uri.substring(slashIdx + 1);
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCSVs(data: GraphData): void {
|
||||||
|
// Write primary_edges.csv
|
||||||
|
const pLines = ["source,target"];
|
||||||
|
for (const [src, dst] of data.primaryEdges) {
|
||||||
|
pLines.push(`${src},${dst}`);
|
||||||
|
}
|
||||||
|
const pPath = join(PUBLIC_DIR, "primary_edges.csv");
|
||||||
|
writeFileSync(pPath, pLines.join("\n") + "\n");
|
||||||
|
console.log(`Wrote ${data.primaryEdges.length} primary edges to ${pPath}`);
|
||||||
|
|
||||||
|
// Write secondary_edges.csv
|
||||||
|
const sLines = ["source,target"];
|
||||||
|
for (const [src, dst] of data.secondaryEdges) {
|
||||||
|
sLines.push(`${src},${dst}`);
|
||||||
|
}
|
||||||
|
const sPath = join(PUBLIC_DIR, "secondary_edges.csv");
|
||||||
|
writeFileSync(sPath, sLines.join("\n") + "\n");
|
||||||
|
console.log(`Wrote ${data.secondaryEdges.length} secondary edges to ${sPath}`);
|
||||||
|
|
||||||
|
// Write uri_map.csv (id,uri,label,isPrimary)
|
||||||
|
const uLines = ["id,uri,label,isPrimary"];
|
||||||
|
for (let i = 0; i < data.nodeURIs.length; i++) {
|
||||||
|
const uri = data.nodeURIs[i];
|
||||||
|
const label = extractLabel(uri);
|
||||||
|
const isPrimary = data.primaryNodeIds.has(i) ? "1" : "0";
|
||||||
|
// Escape commas in URIs by quoting
|
||||||
|
const safeUri = uri.includes(",") ? `"${uri}"` : uri;
|
||||||
|
const safeLabel = label.includes(",") ? `"${label}"` : label;
|
||||||
|
uLines.push(`${i},${safeUri},${safeLabel},${isPrimary}`);
|
||||||
|
}
|
||||||
|
const uPath = join(PUBLIC_DIR, "uri_map.csv");
|
||||||
|
writeFileSync(uPath, uLines.join("\n") + "\n");
|
||||||
|
console.log(`Wrote ${data.nodeURIs.length} URI mappings to ${uPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Main
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`SPARQL endpoint: ${SPARQL_ENDPOINT}`);
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
|
await waitForAnzoGraph();
|
||||||
|
await loadData();
|
||||||
|
|
||||||
|
// Smoke test: simplest possible query to verify connectivity
|
||||||
|
console.log("Smoke test: SELECT ?s ?p ?o LIMIT 3...");
|
||||||
|
const smokeT0 = performance.now();
|
||||||
|
const smokeResult = await sparqlQuery("SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 3");
|
||||||
|
const smokeElapsed = ((performance.now() - smokeT0) / 1000).toFixed(1);
|
||||||
|
console.log(` Smoke test OK: ${smokeResult.length} results in ${smokeElapsed}s`);
|
||||||
|
if (smokeResult.length > 0) {
|
||||||
|
console.log(` First triple: ${smokeResult[0].s.value} ${smokeResult[0].p.value} ${smokeResult[0].o.value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedURIs = await fetchSeedURIs();
|
||||||
|
const triples = await fetchTriples(seedURIs);
|
||||||
|
const graphData = buildGraphData(triples);
|
||||||
|
writeCSVs(graphData);
|
||||||
|
|
||||||
|
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
console.log(`\nDone in ${elapsed}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("Fatal error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
132
scripts/generate_tree.ts
Normal file
132
scripts/generate_tree.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Random Tree Generator
|
||||||
|
*
|
||||||
|
* Generates a random tree with 1–MAX_CHILDREN children per node.
|
||||||
|
* Splits edges into primary (depth ≤ PRIMARY_DEPTH) and secondary.
|
||||||
|
*
|
||||||
|
* Usage: npx tsx scripts/generate_tree.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { writeFileSync } from "fs";
|
||||||
|
import { join, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PUBLIC_DIR = join(__dirname, "..", "public");
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Configuration
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const TARGET_NODES = 10000; // Approximate number of nodes to generate
|
||||||
|
const MAX_CHILDREN = 4; // Each node gets 1..MAX_CHILDREN children
|
||||||
|
const PRIMARY_DEPTH = 4; // Nodes at depth ≤ this form the primary skeleton
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Tree data types
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export interface TreeData {
|
||||||
|
root: number;
|
||||||
|
nodeCount: number;
|
||||||
|
childrenOf: Map<number, number[]>;
|
||||||
|
parentOf: Map<number, number>;
|
||||||
|
depthOf: Map<number, number>;
|
||||||
|
primaryNodes: Set<number>; // all nodes at depth ≤ PRIMARY_DEPTH
|
||||||
|
primaryEdges: Array<[number, number]>; // [child, parent] edges within primary
|
||||||
|
secondaryEdges: Array<[number, number]>;// remaining edges
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Generator
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
export function generateTree(): TreeData {
|
||||||
|
const childrenOf = new Map<number, number[]>();
|
||||||
|
const parentOf = new Map<number, number>();
|
||||||
|
const depthOf = new Map<number, number>();
|
||||||
|
|
||||||
|
const root = 0;
|
||||||
|
depthOf.set(root, 0);
|
||||||
|
let nextId = 1;
|
||||||
|
const queue: number[] = [root];
|
||||||
|
let head = 0;
|
||||||
|
|
||||||
|
while (head < queue.length && nextId < TARGET_NODES) {
|
||||||
|
const parent = queue[head++];
|
||||||
|
const parentDepth = depthOf.get(parent)!;
|
||||||
|
const nKids = 1 + Math.floor(Math.random() * MAX_CHILDREN); // 1..MAX_CHILDREN
|
||||||
|
|
||||||
|
const kids: number[] = [];
|
||||||
|
for (let c = 0; c < nKids && nextId < TARGET_NODES; c++) {
|
||||||
|
const child = nextId++;
|
||||||
|
kids.push(child);
|
||||||
|
parentOf.set(child, parent);
|
||||||
|
depthOf.set(child, parentDepth + 1);
|
||||||
|
queue.push(child);
|
||||||
|
}
|
||||||
|
childrenOf.set(parent, kids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Classify edges and nodes by depth
|
||||||
|
const primaryNodes = new Set<number>();
|
||||||
|
const primaryEdges: Array<[number, number]> = [];
|
||||||
|
const secondaryEdges: Array<[number, number]> = [];
|
||||||
|
|
||||||
|
// Root is always primary
|
||||||
|
primaryNodes.add(root);
|
||||||
|
|
||||||
|
for (const [child, parent] of parentOf) {
|
||||||
|
const childDepth = depthOf.get(child)!;
|
||||||
|
if (childDepth <= PRIMARY_DEPTH) {
|
||||||
|
primaryNodes.add(child);
|
||||||
|
primaryNodes.add(parent);
|
||||||
|
primaryEdges.push([child, parent]);
|
||||||
|
} else {
|
||||||
|
secondaryEdges.push([child, parent]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Generated tree: ${nextId} nodes, ` +
|
||||||
|
`${primaryEdges.length} primary edges (depth ≤ ${PRIMARY_DEPTH}), ` +
|
||||||
|
`${secondaryEdges.length} secondary edges`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
nodeCount: nextId,
|
||||||
|
childrenOf,
|
||||||
|
parentOf,
|
||||||
|
depthOf,
|
||||||
|
primaryNodes,
|
||||||
|
primaryEdges,
|
||||||
|
secondaryEdges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
// Run if executed directly
|
||||||
|
// ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
const data = generateTree();
|
||||||
|
|
||||||
|
// Write primary_edges.csv
|
||||||
|
const pLines: string[] = ["source,target"];
|
||||||
|
for (const [child, parent] of data.primaryEdges) {
|
||||||
|
pLines.push(`${child},${parent}`);
|
||||||
|
}
|
||||||
|
const pPath = join(PUBLIC_DIR, "primary_edges.csv");
|
||||||
|
writeFileSync(pPath, pLines.join("\n") + "\n");
|
||||||
|
console.log(`Wrote ${data.primaryEdges.length} edges to ${pPath}`);
|
||||||
|
|
||||||
|
// Write secondary_edges.csv
|
||||||
|
const sLines: string[] = ["source,target"];
|
||||||
|
for (const [child, parent] of data.secondaryEdges) {
|
||||||
|
sLines.push(`${child},${parent}`);
|
||||||
|
}
|
||||||
|
const sPath = join(PUBLIC_DIR, "secondary_edges.csv");
|
||||||
|
writeFileSync(sPath, sLines.join("\n") + "\n");
|
||||||
|
console.log(`Wrote ${data.secondaryEdges.length} edges to ${sPath}`);
|
||||||
|
}
|
||||||
374
src/App.tsx
Normal file
374
src/App.tsx
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Renderer } from "./renderer";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const rendererRef = useRef<Renderer | null>(null);
|
||||||
|
const [status, setStatus] = useState("Loading node positions…");
|
||||||
|
const [nodeCount, setNodeCount] = useState(0);
|
||||||
|
const uriMapRef = useRef<Map<number, { uri: string; label: string; isPrimary: boolean }>>(new Map());
|
||||||
|
const [stats, setStats] = useState({
|
||||||
|
fps: 0,
|
||||||
|
drawn: 0,
|
||||||
|
mode: "",
|
||||||
|
zoom: 0,
|
||||||
|
ptSize: 0,
|
||||||
|
});
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [hoveredNode, setHoveredNode] = useState<{ x: number; y: number; screenX: number; screenY: number; index?: number } | null>(null);
|
||||||
|
const [selectedNodes, setSelectedNodes] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Store mouse position in a ref so it can be accessed in render loop without re-renders
|
||||||
|
const mousePos = useRef({ x: 0, y: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
let renderer: Renderer;
|
||||||
|
try {
|
||||||
|
renderer = new Renderer(canvas);
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
// Fetch CSVs, parse, and init renderer
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setStatus("Fetching data files…");
|
||||||
|
const [nodesResponse, primaryEdgesResponse, secondaryEdgesResponse, uriMapResponse] = await Promise.all([
|
||||||
|
fetch("/node_positions.csv"),
|
||||||
|
fetch("/primary_edges.csv"),
|
||||||
|
fetch("/secondary_edges.csv"),
|
||||||
|
fetch("/uri_map.csv"),
|
||||||
|
]);
|
||||||
|
if (!nodesResponse.ok) throw new Error(`Failed to fetch nodes: ${nodesResponse.status}`);
|
||||||
|
if (!primaryEdgesResponse.ok) throw new Error(`Failed to fetch primary edges: ${primaryEdgesResponse.status}`);
|
||||||
|
if (!secondaryEdgesResponse.ok) throw new Error(`Failed to fetch secondary edges: ${secondaryEdgesResponse.status}`);
|
||||||
|
|
||||||
|
const [nodesText, primaryEdgesText, secondaryEdgesText, uriMapText] = await Promise.all([
|
||||||
|
nodesResponse.text(),
|
||||||
|
primaryEdgesResponse.text(),
|
||||||
|
secondaryEdgesResponse.text(),
|
||||||
|
uriMapResponse.ok ? uriMapResponse.text() : Promise.resolve(""),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setStatus("Parsing positions…");
|
||||||
|
const nodeLines = nodesText.split("\n").slice(1).filter(l => l.trim().length > 0);
|
||||||
|
const count = nodeLines.length;
|
||||||
|
|
||||||
|
const xs = new Float32Array(count);
|
||||||
|
const ys = new Float32Array(count);
|
||||||
|
const vertexIds = new Uint32Array(count);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const parts = nodeLines[i].split(",");
|
||||||
|
vertexIds[i] = parseInt(parts[0], 10);
|
||||||
|
xs[i] = parseFloat(parts[1]);
|
||||||
|
ys[i] = parseFloat(parts[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus("Parsing edges…");
|
||||||
|
const pLines = primaryEdgesText.split("\n").slice(1).filter(l => l.trim().length > 0);
|
||||||
|
const sLines = secondaryEdgesText.split("\n").slice(1).filter(l => l.trim().length > 0);
|
||||||
|
|
||||||
|
const totalEdges = pLines.length + sLines.length;
|
||||||
|
const edgeData = new Uint32Array(totalEdges * 2);
|
||||||
|
|
||||||
|
let idx = 0;
|
||||||
|
// Parse primary
|
||||||
|
for (let i = 0; i < pLines.length; i++) {
|
||||||
|
const parts = pLines[i].split(",");
|
||||||
|
edgeData[idx++] = parseInt(parts[0], 10);
|
||||||
|
edgeData[idx++] = parseInt(parts[1], 10);
|
||||||
|
}
|
||||||
|
// Parse secondary
|
||||||
|
for (let i = 0; i < sLines.length; i++) {
|
||||||
|
const parts = sLines[i].split(",");
|
||||||
|
edgeData[idx++] = parseInt(parts[0], 10);
|
||||||
|
edgeData[idx++] = parseInt(parts[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse URI map if available
|
||||||
|
if (uriMapText) {
|
||||||
|
const uriLines = uriMapText.split("\n").slice(1).filter(l => l.trim().length > 0);
|
||||||
|
for (const line of uriLines) {
|
||||||
|
const parts = line.split(",");
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
const id = parseInt(parts[0], 10);
|
||||||
|
const uri = parts[1];
|
||||||
|
const label = parts[2];
|
||||||
|
const isPrimary = parts[3].trim() === "1";
|
||||||
|
uriMapRef.current.set(id, { uri, label, isPrimary });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setStatus("Building spatial index…");
|
||||||
|
await new Promise(r => setTimeout(r, 0));
|
||||||
|
|
||||||
|
const buildMs = renderer.init(xs, ys, vertexIds, edgeData);
|
||||||
|
setNodeCount(renderer.getNodeCount());
|
||||||
|
setStatus("");
|
||||||
|
console.log(`Init complete: ${count.toLocaleString()} nodes, ${totalEdges.toLocaleString()} edges in ${buildMs.toFixed(0)}ms`);
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// ── Input handling ──
|
||||||
|
let dragging = false;
|
||||||
|
let didDrag = false; // true if mouse moved significantly during drag
|
||||||
|
let downX = 0;
|
||||||
|
let downY = 0;
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
const DRAG_THRESHOLD = 5; // pixels
|
||||||
|
|
||||||
|
const onDown = (e: MouseEvent) => {
|
||||||
|
dragging = true;
|
||||||
|
didDrag = false;
|
||||||
|
downX = e.clientX;
|
||||||
|
downY = e.clientY;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
};
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
mousePos.current = { x: e.clientX, y: e.clientY };
|
||||||
|
if (!dragging) return;
|
||||||
|
|
||||||
|
// Check if we've moved enough to consider it a drag
|
||||||
|
const dx = e.clientX - downX;
|
||||||
|
const dy = e.clientY - downY;
|
||||||
|
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
||||||
|
didDrag = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.pan(e.clientX - lastX, e.clientY - lastY);
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
};
|
||||||
|
const onUp = (e: MouseEvent) => {
|
||||||
|
if (dragging && !didDrag) {
|
||||||
|
// This was a click, not a drag - handle selection
|
||||||
|
const node = renderer.findNodeIndexAt(e.clientX, e.clientY);
|
||||||
|
if (node) {
|
||||||
|
setSelectedNodes((prev: Set<number>) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(node.index)) {
|
||||||
|
next.delete(node.index); // Deselect if already selected
|
||||||
|
} else {
|
||||||
|
next.add(node.index); // Select
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragging = false;
|
||||||
|
didDrag = false;
|
||||||
|
};
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY > 0 ? 0.9 : 1 / 0.9;
|
||||||
|
renderer.zoomAt(factor, e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
const onMouseLeave = () => {
|
||||||
|
setHoveredNode(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener("mousedown", onDown);
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
canvas.addEventListener("mouseleave", onMouseLeave);
|
||||||
|
|
||||||
|
// ── Render loop ──
|
||||||
|
let frameCount = 0;
|
||||||
|
let lastTime = performance.now();
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
const frame = () => {
|
||||||
|
const result = renderer.render();
|
||||||
|
frameCount++;
|
||||||
|
|
||||||
|
// Find hovered node using quadtree
|
||||||
|
const nodeResult = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y);
|
||||||
|
if (nodeResult) {
|
||||||
|
setHoveredNode({ x: nodeResult.x, y: nodeResult.y, screenX: mousePos.current.x, screenY: mousePos.current.y, index: nodeResult.index });
|
||||||
|
} else {
|
||||||
|
setHoveredNode(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = performance.now();
|
||||||
|
if (now - lastTime >= 500) {
|
||||||
|
const fps = (frameCount / (now - lastTime)) * 1000;
|
||||||
|
setStats({
|
||||||
|
fps: Math.round(fps),
|
||||||
|
drawn: result.drawnCount,
|
||||||
|
mode: result.mode,
|
||||||
|
zoom: result.zoom,
|
||||||
|
ptSize: result.ptSize,
|
||||||
|
});
|
||||||
|
frameCount = 0;
|
||||||
|
lastTime = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(frame);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
canvas.removeEventListener("mousedown", onDown);
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
canvas.removeEventListener("wheel", onWheel);
|
||||||
|
canvas.removeEventListener("mouseleave", onMouseLeave);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync selection state to renderer
|
||||||
|
useEffect(() => {
|
||||||
|
if (rendererRef.current) {
|
||||||
|
rendererRef.current.updateSelection(selectedNodes);
|
||||||
|
}
|
||||||
|
}, [selectedNodes]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
style={{ display: "block", width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading overlay */}
|
||||||
|
{status && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "rgba(0,0,0,0.9)",
|
||||||
|
color: "#0f0",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error overlay */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "rgba(0,0,0,0.9)",
|
||||||
|
color: "#f44",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HUD */}
|
||||||
|
{!status && !error && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 10,
|
||||||
|
left: 10,
|
||||||
|
background: "rgba(0,0,0,0.75)",
|
||||||
|
color: "#0f0",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: "1.6",
|
||||||
|
borderRadius: "4px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>FPS: {stats.fps}</div>
|
||||||
|
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
|
||||||
|
<div>Mode: {stats.mode}</div>
|
||||||
|
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
||||||
|
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
||||||
|
<div style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 10,
|
||||||
|
left: 10,
|
||||||
|
background: "rgba(0,0,0,0.75)",
|
||||||
|
color: "#888",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: "11px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Drag to pan · Scroll to zoom · Click to select
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover tooltip */}
|
||||||
|
{hoveredNode && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: hoveredNode.screenX + 15,
|
||||||
|
top: hoveredNode.screenY + 15,
|
||||||
|
background: "rgba(0,0,0,0.85)",
|
||||||
|
color: "#0ff",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
padding: "6px 10px",
|
||||||
|
fontSize: "12px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
pointerEvents: "none",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
border: "1px solid rgba(0,255,255,0.3)",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
if (hoveredNode.index !== undefined && rendererRef.current) {
|
||||||
|
const vertexId = rendererRef.current.getVertexId(hoveredNode.index);
|
||||||
|
const info = vertexId !== undefined ? uriMapRef.current.get(vertexId) : undefined;
|
||||||
|
if (info) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ fontWeight: "bold", marginBottom: 2 }}>{info.label}</div>
|
||||||
|
<div style={{ fontSize: "10px", color: "#8cf", wordBreak: "break-all", maxWidth: 400 }}>{info.uri}</div>
|
||||||
|
{info.isPrimary && <div style={{ color: "#ff0", fontSize: "10px", marginTop: 2 }}>⭐ Primary (rdf:type)</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <>({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})</>;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -80,11 +80,10 @@ export class Renderer {
|
|||||||
// Data
|
// Data
|
||||||
private leaves: Leaf[] = [];
|
private leaves: Leaf[] = [];
|
||||||
private sorted: Float32Array = new Float32Array(0);
|
private sorted: Float32Array = new Float32Array(0);
|
||||||
// order[sortedIdx] = originalIdx (original ordering matches input arrays)
|
|
||||||
private sortedToOriginal: Uint32Array = new Uint32Array(0);
|
|
||||||
private vertexIdToSortedIndex: Map<number, number> = new Map();
|
|
||||||
private nodeCount = 0;
|
private nodeCount = 0;
|
||||||
private edgeCount = 0;
|
private edgeCount = 0;
|
||||||
|
private neighborMap: Map<number, number[]> = new Map();
|
||||||
|
private sortedToVertexId: Uint32Array = new Uint32Array(0);
|
||||||
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
|
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
|
||||||
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
|
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
|
||||||
private maxPtSize = 256;
|
private maxPtSize = 256;
|
||||||
@@ -204,7 +203,6 @@ export class Renderer {
|
|||||||
const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
|
const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
|
||||||
this.leaves = leaves;
|
this.leaves = leaves;
|
||||||
this.sorted = sorted;
|
this.sorted = sorted;
|
||||||
this.sortedToOriginal = order;
|
|
||||||
|
|
||||||
// Pre-allocate arrays for render loop (zero-allocation rendering)
|
// Pre-allocate arrays for render loop (zero-allocation rendering)
|
||||||
this.visibleLeafIndices = new Uint32Array(leaves.length);
|
this.visibleLeafIndices = new Uint32Array(leaves.length);
|
||||||
@@ -216,6 +214,12 @@ export class Renderer {
|
|||||||
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
||||||
gl.bindVertexArray(null);
|
gl.bindVertexArray(null);
|
||||||
|
|
||||||
|
// Build sorted index → vertex ID mapping for hover lookups
|
||||||
|
this.sortedToVertexId = new Uint32Array(count);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
this.sortedToVertexId[i] = vertexIds[order[i]];
|
||||||
|
}
|
||||||
|
|
||||||
// Build vertex ID → original input index mapping
|
// Build vertex ID → original input index mapping
|
||||||
const vertexIdToOriginal = new Map<number, number>();
|
const vertexIdToOriginal = new Map<number, number>();
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -229,13 +233,6 @@ export class Renderer {
|
|||||||
originalToSorted[order[i]] = i;
|
originalToSorted[order[i]] = i;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build vertex ID → sorted index mapping (used by backend-driven neighbor highlighting)
|
|
||||||
const vertexIdToSortedIndex = new Map<number, number>();
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
vertexIdToSortedIndex.set(vertexIds[i], originalToSorted[i]);
|
|
||||||
}
|
|
||||||
this.vertexIdToSortedIndex = vertexIdToSortedIndex;
|
|
||||||
|
|
||||||
// Remap edges from vertex IDs to sorted indices
|
// Remap edges from vertex IDs to sorted indices
|
||||||
const lineIndices = new Uint32Array(edgeCount * 2);
|
const lineIndices = new Uint32Array(edgeCount * 2);
|
||||||
let validEdges = 0;
|
let validEdges = 0;
|
||||||
@@ -251,6 +248,18 @@ export class Renderer {
|
|||||||
}
|
}
|
||||||
this.edgeCount = validEdges;
|
this.edgeCount = validEdges;
|
||||||
|
|
||||||
|
// Build per-node neighbor list from edges for selection queries
|
||||||
|
const neighborMap = new Map<number, number[]>();
|
||||||
|
for (let i = 0; i < validEdges; i++) {
|
||||||
|
const src = lineIndices[i * 2];
|
||||||
|
const dst = lineIndices[i * 2 + 1];
|
||||||
|
if (!neighborMap.has(src)) neighborMap.set(src, []);
|
||||||
|
neighborMap.get(src)!.push(dst);
|
||||||
|
if (!neighborMap.has(dst)) neighborMap.set(dst, []);
|
||||||
|
neighborMap.get(dst)!.push(src);
|
||||||
|
}
|
||||||
|
this.neighborMap = neighborMap;
|
||||||
|
|
||||||
// Build per-leaf edge index for efficient visible-only edge drawing
|
// Build per-leaf edge index for efficient visible-only edge drawing
|
||||||
// Find which leaf each sorted index belongs to
|
// Find which leaf each sorted index belongs to
|
||||||
const nodeToLeaf = new Uint32Array(count);
|
const nodeToLeaf = new Uint32Array(count);
|
||||||
@@ -330,25 +339,12 @@ export class Renderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map a sorted buffer index (what findNodeIndexAt returns) back to the original
|
* Get the original vertex ID for a given sorted index.
|
||||||
* index in the input arrays used to initialize the renderer.
|
* Useful for looking up URI labels from the URI map.
|
||||||
*/
|
*/
|
||||||
sortedIndexToOriginalIndex(sortedIndex: number): number | null {
|
getVertexId(sortedIndex: number): number | undefined {
|
||||||
if (
|
if (sortedIndex < 0 || sortedIndex >= this.sortedToVertexId.length) return undefined;
|
||||||
sortedIndex < 0 ||
|
return this.sortedToVertexId[sortedIndex];
|
||||||
sortedIndex >= this.sortedToOriginal.length
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return this.sortedToOriginal[sortedIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a backend node ID (node.id from /api/graph) to a sorted index used by the renderer.
|
|
||||||
*/
|
|
||||||
vertexIdToSortedIndexOrNull(vertexId: number): number | null {
|
|
||||||
const idx = this.vertexIdToSortedIndex.get(vertexId);
|
|
||||||
return typeof idx === "number" ? idx : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -432,10 +428,10 @@ export class Renderer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the selection buffer with the given set of node indices.
|
* Update the selection buffer with the given set of node indices.
|
||||||
* Neighbor indices are provided by the backend (SPARQL query) and uploaded separately.
|
* Also computes neighbors of selected nodes.
|
||||||
* Call this whenever selection or backend neighbor results change.
|
* Call this whenever React's selection state changes.
|
||||||
*/
|
*/
|
||||||
updateSelection(selectedIndices: Set<number>, neighborIndices: Set<number> = new Set()): void {
|
updateSelection(selectedIndices: Set<number>): void {
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
|
|
||||||
// Upload selected indices
|
// Upload selected indices
|
||||||
@@ -445,11 +441,23 @@ export class Renderer {
|
|||||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW);
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW);
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
|
|
||||||
|
// Compute neighbors of selected nodes (excluding already selected)
|
||||||
|
const neighborSet = new Set<number>();
|
||||||
|
for (const nodeIdx of selectedIndices) {
|
||||||
|
const nodeNeighbors = this.neighborMap.get(nodeIdx);
|
||||||
|
if (!nodeNeighbors) continue;
|
||||||
|
for (const n of nodeNeighbors) {
|
||||||
|
if (!selectedIndices.has(n)) {
|
||||||
|
neighborSet.add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Upload neighbor indices
|
// Upload neighbor indices
|
||||||
const neighborIndexArray = new Uint32Array(neighborIndices);
|
const neighborIndices = new Uint32Array(neighborSet);
|
||||||
this.neighborCount = neighborIndexArray.length;
|
this.neighborCount = neighborIndices.length;
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
|
||||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, neighborIndexArray, gl.DYNAMIC_DRAW);
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, neighborIndices, gl.DYNAMIC_DRAW);
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
19
vite.config.ts
Normal file
19
vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss(), viteSingleFile()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user