Compare commits

8 Commits

Author SHA1 Message Date
Oxy8
5badcd8d6f Visualizando todo grafo com anzograph 2026-03-10 17:21:47 -03:00
Oxy8
a0c5bec19f 32bit Node ID 2026-03-06 16:10:52 -03:00
Oxy8
3c487d088b Add filter, add READMES 2026-03-06 15:35:04 -03:00
Oxy8
b44867abfa midpoint - go 2026-03-05 15:39:47 -03:00
Oxy8
a75b5b93da Import Solver + neighbors via sparql query 2026-03-04 13:49:14 -03:00
Oxy8
d4bfa5f064 Reorganiza backend 2026-03-02 17:33:45 -03:00
Oxy8
bba0ae887d Graph access via SPARQL 2026-03-02 16:27:28 -03:00
Oxy8
bf03d333f9 backend 2026-03-02 14:32:42 -03:00
71 changed files with 203911 additions and 1409 deletions

44
.env.example Normal file
View File

@@ -0,0 +1,44 @@
# 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
View File

@@ -1,4 +1,9 @@
.direnv/
.envrc
.env
backend/.env
frontend/node_modules/
frontend/dist/
.npm/
.vite/
data/

155
README.md
View File

@@ -1,67 +1,108 @@
# Large Instanced Ontology Visualizer
# Visualizador Instanciados
An experimental visualizer designed to render and explore massive instanced ontologies (millions of nodes) with interactive performance.
This repo is a Docker Compose stack for visualizing large RDF/OWL graphs stored in **AnzoGraph**. It includes:
## 🚀 The Core Challenge
Ontologies with millions of instances present a significant rendering challenge for traditional graph visualization tools. This project solves this by:
1. **Selective Rendering:** Only rendering up to a set limit of nodes (e.g., 2 million) at any given time.
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.
- A **Go backend** that queries AnzoGraph via SPARQL and serves a cached graph snapshot + selection queries.
- A **React/Vite frontend** that renders nodes/edges with WebGL2 and supports “selection query” + “graph query” modes.
- A **Python one-shot service** to combine `owl:imports` into a single Turtle file.
- An **AnzoGraph** container (SPARQL endpoint).
## 🛠 Technical Architecture
## Quick start (Docker Compose)
### 1. Data Pipeline & AnzoGraph Integration
The project features an automated pipeline to extract and prepare data from an **AnzoGraph** DB:
- **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.
1) Put your TTL file(s) in `./data/` (this folder is volume-mounted into AnzoGraph as `/opt/shared-files`).
2) Optionally configure `.env` (see `.env.example`).
3) Start the stack:
```bash
# 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
docker compose up --build
```
The app will be available at `http://localhost:5173`.
Then open the frontend:
## 🖱 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.
- `http://localhost:5173`
## 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.
- **Positioning:** Decide how to handle classes which are both instances and classes.
- **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.
- **Functionality:** Show every element of a specific class.
Stop everything:
```bash
docker compose down
```
## Services
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
```

0
Requisitos.md Normal file
View File

38
anzograph/README.md Normal file
View File

@@ -0,0 +1,38 @@
# 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.

24
backend_go/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
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"]

98
backend_go/README.md Normal file
View File

@@ -0,0 +1,98 @@
# 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.

190
backend_go/config.go Normal file
View File

@@ -0,0 +1,190 @@
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
}

3
backend_go/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module visualizador_instanciados/backend_go
go 1.22

View File

@@ -0,0 +1,96 @@
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
}

View File

@@ -0,0 +1,65 @@
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)
}

View File

@@ -0,0 +1,45 @@
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)
}

View File

@@ -0,0 +1,26 @@
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
}

View File

@@ -0,0 +1,12 @@
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
}

View File

@@ -0,0 +1,49 @@
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)
}

View File

@@ -0,0 +1,341 @@
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
}

View File

@@ -0,0 +1,23 @@
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)
}

148
backend_go/layout.go Normal file
View File

@@ -0,0 +1,148 @@
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
}

35
backend_go/main.go Normal file
View File

@@ -0,0 +1,35 @@
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())
}

82
backend_go/models.go Normal file
View File

@@ -0,0 +1,82 @@
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"`
}

View File

@@ -0,0 +1,40 @@
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
}

View File

@@ -0,0 +1,101 @@
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
}

View File

@@ -0,0 +1,76 @@
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)
}

View File

@@ -0,0 +1,33 @@
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
}

View File

@@ -0,0 +1,14 @@
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"`
}

View File

@@ -0,0 +1,54 @@
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)
}

View File

@@ -0,0 +1,54 @@
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)
}

View File

@@ -0,0 +1,28 @@
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)
}

View File

@@ -0,0 +1,32 @@
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)
}

304
backend_go/server.go Normal file
View File

@@ -0,0 +1,304 @@
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
}

View File

@@ -0,0 +1,76 @@
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
}

169
backend_go/sparql.go Normal file
View File

@@ -0,0 +1,169 @@
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)
}

View File

@@ -0,0 +1,13 @@
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"`
}

View File

@@ -1,23 +1,85 @@
services:
app:
build: .
owl_imports_combiner:
build: ./python_services/owl_imports_combiner
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:
- anzograph
owl_imports_combiner:
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:
- "5173:5173"
env_file:
- .env
command: sh -c "npm run layout && npm run dev -- --host"
environment:
- VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://backend:8000}
volumes:
- .:/app:Z
- ./frontend:/app
- /app/node_modules
depends_on:
backend:
condition: service_healthy
anzograph:
image: cambridgesemantics/anzograph:latest
container_name: anzograph
mem_limit: 20g
ports:
- "8080:8080"
- "8443:8443"
volumes:
- ./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:

View File

@@ -2,6 +2,8 @@ FROM node:lts-alpine
WORKDIR /app
EXPOSE 5173
# Copy dependency definitions
COPY package*.json ./
@@ -11,8 +13,5 @@ RUN npm install
# Copy the rest of the source code
COPY . .
# Expose the standard Vite port
EXPOSE 5173
# Compute layout, then start the dev server with --host for external access
CMD ["sh", "-c", "npm run dev -- --host"]
# Start the dev server with --host for external access
CMD ["npm", "run", "dev", "--", "--host", "--port", "5173"]

45
frontend/README.md Normal file
View File

@@ -0,0 +1,45 @@
# 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.

View File

@@ -7,7 +7,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"layout": "npx tsx scripts/fetch_from_db.ts && npx tsx scripts/compute_layout.ts"
"layout": "tsx scripts/compute_layout.ts"
},
"dependencies": {
"@webgpu/types": "^0.1.69",

100000
frontend/public/edges.csv Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,354 @@
#!/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}`);

View File

@@ -0,0 +1,61 @@
/**
* Random Tree Generator
*
* Generates a random tree with 1MAX_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,
};
}

596
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,596 @@
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>
);
}

View File

@@ -0,0 +1,9 @@
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[]) : [];
}

View File

@@ -0,0 +1,3 @@
export { fetchGraphQueries } from "./api";
export type { GraphQueryMeta } from "./types";

View File

@@ -0,0 +1,5 @@
export type GraphQueryMeta = {
id: string;
label: string;
};

View File

@@ -80,10 +80,11 @@ export class Renderer {
// Data
private leaves: Leaf[] = [];
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 edgeCount = 0;
private neighborMap: Map<number, number[]> = new Map();
private sortedToVertexId: Uint32Array = new Uint32Array(0);
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
private maxPtSize = 256;
@@ -203,6 +204,7 @@ export class Renderer {
const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
this.leaves = leaves;
this.sorted = sorted;
this.sortedToOriginal = order;
// Pre-allocate arrays for render loop (zero-allocation rendering)
this.visibleLeafIndices = new Uint32Array(leaves.length);
@@ -214,12 +216,6 @@ export class Renderer {
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
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
const vertexIdToOriginal = new Map<number, number>();
for (let i = 0; i < count; i++) {
@@ -233,6 +229,13 @@ export class Renderer {
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
const lineIndices = new Uint32Array(edgeCount * 2);
let validEdges = 0;
@@ -248,18 +251,6 @@ export class Renderer {
}
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
// Find which leaf each sorted index belongs to
const nodeToLeaf = new Uint32Array(count);
@@ -339,12 +330,25 @@ export class Renderer {
}
/**
* Get the original vertex ID for a given sorted index.
* Useful for looking up URI labels from the URI map.
* Map a sorted buffer index (what findNodeIndexAt returns) back to the original
* index in the input arrays used to initialize the renderer.
*/
getVertexId(sortedIndex: number): number | undefined {
if (sortedIndex < 0 || sortedIndex >= this.sortedToVertexId.length) return undefined;
return this.sortedToVertexId[sortedIndex];
sortedIndexToOriginalIndex(sortedIndex: number): number | null {
if (
sortedIndex < 0 ||
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;
}
/**
@@ -428,10 +432,10 @@ export class Renderer {
/**
* Update the selection buffer with the given set of node indices.
* Also computes neighbors of selected nodes.
* Call this whenever React's selection state changes.
* Neighbor indices are provided by the backend (SPARQL query) and uploaded separately.
* Call this whenever selection or backend neighbor results change.
*/
updateSelection(selectedIndices: Set<number>): void {
updateSelection(selectedIndices: Set<number>, neighborIndices: Set<number> = new Set()): void {
const gl = this.gl;
// Upload selected indices
@@ -441,23 +445,11 @@ export class Renderer {
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW);
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
const neighborIndices = new Uint32Array(neighborSet);
this.neighborCount = neighborIndices.length;
const neighborIndexArray = new Uint32Array(neighborIndices);
this.neighborCount = neighborIndexArray.length;
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.neighborIbo);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, neighborIndices, gl.DYNAMIC_DRAW);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, neighborIndexArray, gl.DYNAMIC_DRAW);
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
}

View File

@@ -0,0 +1,37 @@
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;
}

View File

@@ -0,0 +1,3 @@
export { fetchSelectionQueries, runSelectionQuery } from "./api";
export type { GraphMeta, SelectionQueryMeta } from "./types";

View File

@@ -0,0 +1,16 @@
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;
};

54
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,54 @@
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,
},
},
},
});

View File

@@ -1 +0,0 @@
vertex,x,y
1 vertex x y

View File

@@ -1 +0,0 @@
source,target
1 source target

View File

@@ -1 +0,0 @@
source,target
1 source target

View File

@@ -1 +0,0 @@
id,uri,label,isPrimary
1 id uri label isPrimary

View File

@@ -0,0 +1,14 @@
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"]

View File

@@ -0,0 +1,36 @@
# 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.

View File

@@ -0,0 +1,54 @@
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()

View File

@@ -0,0 +1,96 @@
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))

View File

@@ -0,0 +1 @@
rdflib

View File

@@ -1,376 +0,0 @@
#!/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);
}
}
}

View File

@@ -1,390 +0,0 @@
#!/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);
});

View File

@@ -1,132 +0,0 @@
/**
* Random Tree Generator
*
* Generates a random tree with 1MAX_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}`);
}

View File

@@ -1,374 +0,0 @@
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>
);
}

View File

@@ -1,19 +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()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});