Compare commits
8 Commits
3c487d088b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97a30ab769 | ||
|
|
48ce99aac5 | ||
|
|
ca715d7c3c | ||
|
|
44c1d3eaa6 | ||
|
|
696844f341 | ||
|
|
6b9115e43b | ||
|
|
5badcd8d6f | ||
|
|
a0c5bec19f |
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
data
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
radial_sugiyama/target
|
||||||
118
.env.example
Normal file
118
.env.example
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# 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
|
||||||
|
# Use `local` for the Docker Compose AnzoGraph container, or `external` for the
|
||||||
|
# remote endpoint below using a runtime-generated bearer token.
|
||||||
|
SPARQL_SOURCE_MODE=local
|
||||||
|
SPARQL_HOST=http://anzograph:8080
|
||||||
|
# SPARQL_ENDPOINT=http://anzograph:8080/sparql
|
||||||
|
EXTERNAL_SPARQL_ENDPOINT=https://anzograph.k8s.inf.ufrgs.br/sparql
|
||||||
|
KEYCLOAK_TOKEN_ENDPOINT=https://keycloak.k8s.inf.ufrgs.br/realms/INF/protocol/openid-connect/token
|
||||||
|
KEYCLOAK_CLIENT_ID=anzograph
|
||||||
|
KEYCLOAK_USERNAME=
|
||||||
|
KEYCLOAK_PASSWORD=
|
||||||
|
KEYCLOAK_SCOPE=openid
|
||||||
|
ACCESS_TOKEN=
|
||||||
|
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_READY_TIMEOUT_S=10
|
||||||
|
|
||||||
|
# Dev UX
|
||||||
|
CORS_ORIGINS=http://localhost:5173
|
||||||
|
VITE_BACKEND_URL=http://backend:8000
|
||||||
|
|
||||||
|
# Frontend right-pane cosmos.gl layout
|
||||||
|
# Colors accept CSS strings or RGBA tuples like [53,214,255,255].
|
||||||
|
# Ranges accept comma-separated values like 50,150.
|
||||||
|
VITE_COSMOS_ENABLE_SIMULATION=true
|
||||||
|
VITE_COSMOS_DEBUG_LAYOUT=false
|
||||||
|
VITE_COSMOS_BACKGROUND_COLOR="#05070a"
|
||||||
|
VITE_COSMOS_SPACE_SIZE=4096
|
||||||
|
VITE_COSMOS_POINT_DEFAULT_COLOR="#b3b3b3"
|
||||||
|
VITE_COSMOS_POINT_GREYOUT_COLOR=
|
||||||
|
VITE_COSMOS_POINT_GREYOUT_OPACITY=
|
||||||
|
VITE_COSMOS_POINT_DEFAULT_SIZE=4
|
||||||
|
VITE_COSMOS_POINT_OPACITY=1
|
||||||
|
VITE_COSMOS_POINT_SIZE_SCALE=1
|
||||||
|
VITE_COSMOS_HOVERED_POINT_CURSOR=pointer
|
||||||
|
VITE_COSMOS_HOVERED_LINK_CURSOR=pointer
|
||||||
|
VITE_COSMOS_RENDER_HOVERED_POINT_RING=true
|
||||||
|
VITE_COSMOS_HOVERED_POINT_RING_COLOR="#35d6ff"
|
||||||
|
VITE_COSMOS_FOCUSED_POINT_RING_COLOR=white
|
||||||
|
VITE_COSMOS_RENDER_LINKS=true
|
||||||
|
VITE_COSMOS_LINK_DEFAULT_COLOR="#666666"
|
||||||
|
VITE_COSMOS_LINK_OPACITY=1
|
||||||
|
VITE_COSMOS_LINK_GREYOUT_OPACITY=0.1
|
||||||
|
VITE_COSMOS_LINK_DEFAULT_WIDTH=1
|
||||||
|
VITE_COSMOS_HOVERED_LINK_COLOR="#ffd166"
|
||||||
|
VITE_COSMOS_HOVERED_LINK_WIDTH_INCREASE=2.5
|
||||||
|
VITE_COSMOS_LINK_WIDTH_SCALE=1
|
||||||
|
VITE_COSMOS_SCALE_LINKS_ON_ZOOM=false
|
||||||
|
VITE_COSMOS_CURVED_LINKS=true
|
||||||
|
VITE_COSMOS_CURVED_LINK_SEGMENTS=19
|
||||||
|
VITE_COSMOS_CURVED_LINK_WEIGHT=0.8
|
||||||
|
VITE_COSMOS_CURVED_LINK_CONTROL_POINT_DISTANCE=0.5
|
||||||
|
VITE_COSMOS_LINK_DEFAULT_ARROWS=false
|
||||||
|
VITE_COSMOS_LINK_ARROWS_SIZE_SCALE=1
|
||||||
|
VITE_COSMOS_LINK_VISIBILITY_DISTANCE_RANGE=50,150
|
||||||
|
VITE_COSMOS_LINK_VISIBILITY_MIN_TRANSPARENCY=0.25
|
||||||
|
VITE_COSMOS_USE_CLASSIC_QUADTREE=false
|
||||||
|
VITE_COSMOS_FIT_VIEW_PADDING=0.12
|
||||||
|
VITE_COSMOS_FIT_VIEW_ON_INIT=false
|
||||||
|
VITE_COSMOS_FIT_VIEW_DELAY=250
|
||||||
|
VITE_COSMOS_FIT_VIEW_DURATION=250
|
||||||
|
VITE_COSMOS_SIMULATION_DECAY=5000
|
||||||
|
VITE_COSMOS_SIMULATION_GRAVITY=0
|
||||||
|
VITE_COSMOS_SIMULATION_CENTER=0.05
|
||||||
|
VITE_COSMOS_SIMULATION_REPULSION=0.5
|
||||||
|
VITE_COSMOS_SIMULATION_REPULSION_THETA=1.15
|
||||||
|
VITE_COSMOS_SIMULATION_REPULSION_QUADTREE_LEVELS=12
|
||||||
|
VITE_COSMOS_SIMULATION_LINK_SPRING=1
|
||||||
|
VITE_COSMOS_SIMULATION_LINK_DISTANCE=10
|
||||||
|
VITE_COSMOS_SIMULATION_LINK_DISTANCE_RANDOM_VARIATION_RANGE=1,1.2
|
||||||
|
VITE_COSMOS_SIMULATION_REPULSION_FROM_MOUSE=2
|
||||||
|
VITE_COSMOS_ENABLE_RIGHT_CLICK_REPULSION=false
|
||||||
|
VITE_COSMOS_SIMULATION_FRICTION=0.1
|
||||||
|
VITE_COSMOS_SIMULATION_CLUSTER=0.1
|
||||||
|
VITE_COSMOS_SHOW_FPS_MONITOR=false
|
||||||
|
VITE_COSMOS_PIXEL_RATIO=2
|
||||||
|
VITE_COSMOS_SCALE_POINTS_ON_ZOOM=false
|
||||||
|
VITE_COSMOS_INITIAL_ZOOM_LEVEL=
|
||||||
|
VITE_COSMOS_ENABLE_ZOOM=true
|
||||||
|
VITE_COSMOS_ENABLE_SIMULATION_DURING_ZOOM=false
|
||||||
|
VITE_COSMOS_ENABLE_DRAG=true
|
||||||
|
VITE_COSMOS_RANDOM_SEED=
|
||||||
|
VITE_COSMOS_POINT_SAMPLING_DISTANCE=150
|
||||||
|
VITE_COSMOS_RESCALE_POSITIONS=false
|
||||||
|
VITE_COSMOS_ATTRIBUTION=
|
||||||
|
|
||||||
|
# Debugging
|
||||||
|
LOG_SNAPSHOT_TIMINGS=false
|
||||||
|
FREE_OS_MEMORY_AFTER_SNAPSHOT=false
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ frontend/dist/
|
|||||||
.npm/
|
.npm/
|
||||||
.vite/
|
.vite/
|
||||||
data/
|
data/
|
||||||
|
target/
|
||||||
243
CURRENT_HIERARCHY_PIPELINE.md
Normal file
243
CURRENT_HIERARCHY_PIPELINE.md
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
# Current `subClassOf` / `BFO:entity` Pipeline
|
||||||
|
|
||||||
|
This document summarizes how the repository currently builds the hierarchy that ends up in the radial Sugiyama layout, with special attention to the fact that "start from `bfo:entity`" is **not** implemented in the initial `subClassOf` query.
|
||||||
|
|
||||||
|
`bfo:entity` here means:
|
||||||
|
|
||||||
|
- `http://purl.obolibrary.org/obo/BFO_0000001`
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
- The current code does **not** query "all `rdfs:subClassOf` relationships rooted at `bfo:entity`" directly.
|
||||||
|
- It first queries the **entire** `rdfs:subClassOf` graph.
|
||||||
|
- It builds an in-memory graph from those triples.
|
||||||
|
- Only later, in the Rust hierarchy layout bridge, it filters that graph to the descendant closure of the configured root IRI.
|
||||||
|
- Because of that, the "rooted at `bfo:entity`" behavior is currently coupled to the layout pipeline instead of existing as a reusable graph-extraction stage.
|
||||||
|
|
||||||
|
## Where The Request Starts
|
||||||
|
|
||||||
|
The frontend loads the hierarchy through the normal graph endpoint:
|
||||||
|
|
||||||
|
1. `frontend/src/App.tsx`
|
||||||
|
2. `GET /api/graph?graph_query_id=hierarchy`
|
||||||
|
3. `backend_go/server.go` -> `handleGraph`
|
||||||
|
4. `backend_go/snapshot_service.go` -> `Get`
|
||||||
|
5. `backend_go/graph_snapshot.go` -> `fetchGraphSnapshot`
|
||||||
|
|
||||||
|
Important consequence:
|
||||||
|
|
||||||
|
- The hierarchy is treated as a graph snapshot mode, not as a dedicated "query descendants of this root" pipeline.
|
||||||
|
|
||||||
|
## The Actual SPARQL Query Used For `hierarchy`
|
||||||
|
|
||||||
|
The `hierarchy` graph query is defined in:
|
||||||
|
|
||||||
|
- `backend_go/graph_queries/hierarchy.go`
|
||||||
|
|
||||||
|
It effectively does:
|
||||||
|
|
||||||
|
```sparql
|
||||||
|
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))
|
||||||
|
# optionally also FILTER(!isBlank(?s) && !isBlank(?o))
|
||||||
|
}
|
||||||
|
ORDER BY ?s ?p ?o
|
||||||
|
LIMIT ...
|
||||||
|
OFFSET ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Important facts:
|
||||||
|
|
||||||
|
- It queries **all** `rdfs:subClassOf` triples.
|
||||||
|
- There is **no root restriction** here.
|
||||||
|
- There is **no `bfo:entity` filter** here.
|
||||||
|
- Blank nodes are excluded unless `INCLUDE_BNODES=true`.
|
||||||
|
- Objects that are literals are excluded.
|
||||||
|
|
||||||
|
## How The In-Memory Graph Is Built
|
||||||
|
|
||||||
|
Graph construction is handled by:
|
||||||
|
|
||||||
|
- `backend_go/graph_export.go`
|
||||||
|
|
||||||
|
The accumulator logic works like this:
|
||||||
|
|
||||||
|
- Every returned `?s` and `?o` becomes a node if it has not been seen before.
|
||||||
|
- There is no separate node query.
|
||||||
|
- A class only enters the graph if it appears in at least one fetched edge.
|
||||||
|
- Isolated classes with no fetched `subClassOf` edge never appear.
|
||||||
|
- If `node_limit` is reached, new nodes stop being added, and edges that depend on them are skipped.
|
||||||
|
|
||||||
|
Edge direction at this stage is:
|
||||||
|
|
||||||
|
- `Source = subclass (?s)`
|
||||||
|
- `Target = superclass (?o)`
|
||||||
|
|
||||||
|
So the raw in-memory graph is stored as:
|
||||||
|
|
||||||
|
- `subclass -> superclass`
|
||||||
|
|
||||||
|
## Where `BFO:entity` Is Actually Applied
|
||||||
|
|
||||||
|
The root restriction happens only when the backend chooses the Rust hierarchy layout path.
|
||||||
|
|
||||||
|
Relevant files:
|
||||||
|
|
||||||
|
- `backend_go/config.go`
|
||||||
|
- `.env`
|
||||||
|
- `backend_go/graph_snapshot.go`
|
||||||
|
- `backend_go/hierarchy_layout_bridge.go`
|
||||||
|
- `radial_sugiyama/src/bridge.rs`
|
||||||
|
|
||||||
|
Current behavior:
|
||||||
|
|
||||||
|
- `.env` sets `HIERARCHY_LAYOUT_ENGINE=rust`.
|
||||||
|
- If `graph_query_id == "hierarchy"` and the engine is `rust`, the backend calls the Rust bridge.
|
||||||
|
- The root IRI comes from `HIERARCHY_LAYOUT_ROOT_IRI`.
|
||||||
|
- If that env var is not set, the checked-in default is `http://purl.obolibrary.org/obo/BFO_0000001`.
|
||||||
|
|
||||||
|
This means the current repository behavior is effectively:
|
||||||
|
|
||||||
|
- query all `subClassOf`
|
||||||
|
- then filter to descendants of `BFO:entity`
|
||||||
|
- then lay out the filtered graph
|
||||||
|
|
||||||
|
## What Go Sends To Rust
|
||||||
|
|
||||||
|
Before calling Rust, Go rewrites the edge orientation in:
|
||||||
|
|
||||||
|
- `backend_go/hierarchy_layout_bridge.go`
|
||||||
|
|
||||||
|
It converts each stored edge from:
|
||||||
|
|
||||||
|
- `subclass -> superclass`
|
||||||
|
|
||||||
|
into:
|
||||||
|
|
||||||
|
- `parentID = superclass`
|
||||||
|
- `childID = subclass`
|
||||||
|
|
||||||
|
So the Rust side receives:
|
||||||
|
|
||||||
|
- `superclass -> subclass`
|
||||||
|
|
||||||
|
Go also:
|
||||||
|
|
||||||
|
- de-duplicates repeated parent/child edges
|
||||||
|
- sends the configured `root_iri`
|
||||||
|
- sends all nodes that were present in the fetched hierarchy graph
|
||||||
|
|
||||||
|
## How Rust Filters To Descendants Of The Root
|
||||||
|
|
||||||
|
Filtering happens in:
|
||||||
|
|
||||||
|
- `radial_sugiyama/src/bridge.rs`
|
||||||
|
|
||||||
|
The bridge logic does this:
|
||||||
|
|
||||||
|
1. Build an internal graph from the request.
|
||||||
|
2. Find the node whose label/IRI matches `root_iri`.
|
||||||
|
3. Build adjacency lists in the `parent -> child` direction.
|
||||||
|
4. Run a BFS/queue traversal starting at the root.
|
||||||
|
5. Keep only the visited nodes.
|
||||||
|
6. Keep only edges whose endpoints are both visited.
|
||||||
|
7. Run radial Sugiyama layout on that filtered subgraph.
|
||||||
|
|
||||||
|
Important consequences:
|
||||||
|
|
||||||
|
- Nodes outside the descendant closure of the root are dropped.
|
||||||
|
- Disconnected components are dropped.
|
||||||
|
- Ancestors of the root are not kept unless they are also reachable as descendants, which normally they are not.
|
||||||
|
- If the root is missing, the pipeline errors.
|
||||||
|
- If the root has no descendants, the pipeline errors.
|
||||||
|
|
||||||
|
So the actual "select only those starting from `bfo:entity`" logic is:
|
||||||
|
|
||||||
|
- **graph traversal after fetching the full hierarchy**
|
||||||
|
|
||||||
|
not:
|
||||||
|
|
||||||
|
- **root-constrained SPARQL**
|
||||||
|
|
||||||
|
## What Comes Back From Rust
|
||||||
|
|
||||||
|
After Rust finishes:
|
||||||
|
|
||||||
|
- only the filtered nodes are returned
|
||||||
|
- only edges between retained nodes are returned
|
||||||
|
- routed edge segments are returned for drawing
|
||||||
|
|
||||||
|
That filtering is applied back onto the original Go snapshot response, so the final `/api/graph?graph_query_id=hierarchy` response only contains the root-descendant subgraph when the Rust path is active.
|
||||||
|
|
||||||
|
## Why This Feels Like A Separate Pipeline
|
||||||
|
|
||||||
|
The main reason it feels split is that the current behavior crosses multiple stages:
|
||||||
|
|
||||||
|
1. SPARQL query stage fetches the whole `subClassOf` graph.
|
||||||
|
2. Graph materialization stage builds a generic snapshot graph.
|
||||||
|
3. Layout bridge stage applies the root restriction.
|
||||||
|
4. Layout stage computes coordinates.
|
||||||
|
|
||||||
|
This means the "hierarchy rooted at `BFO:entity`" concept is currently embedded in layout preparation instead of existing as a first-class reusable data pipeline.
|
||||||
|
|
||||||
|
In practice, the root filtering is:
|
||||||
|
|
||||||
|
- not reusable by itself through a dedicated backend API
|
||||||
|
- not expressed in the initial SPARQL query
|
||||||
|
- not controlled per request
|
||||||
|
- tied to the hierarchy layout engine choice
|
||||||
|
|
||||||
|
## Selection Queries Are A Different Mechanism
|
||||||
|
|
||||||
|
The repository also has separate selection-query endpoints:
|
||||||
|
|
||||||
|
- `backend_go/selection_queries/subclasses.go`
|
||||||
|
- `backend_go/selection_queries/superclasses.go`
|
||||||
|
- `backend_go/selection_queries/neighbors.go`
|
||||||
|
|
||||||
|
Those are used after nodes are already present in a graph snapshot and the user selects node IDs.
|
||||||
|
|
||||||
|
They are **not** the mechanism that initially builds the `BFO:entity` hierarchy used by the radial layout.
|
||||||
|
|
||||||
|
Their role is more like:
|
||||||
|
|
||||||
|
- "given selected node IDs in the current snapshot, query related triples"
|
||||||
|
|
||||||
|
not:
|
||||||
|
|
||||||
|
- "materialize the hierarchy rooted at `BFO:entity`"
|
||||||
|
|
||||||
|
## Current End-To-End Behavior In One Sentence
|
||||||
|
|
||||||
|
The current system gets all `rdfs:subClassOf` triples first, constructs a general hierarchy graph, and only then filters it to the descendants of `http://purl.obolibrary.org/obo/BFO_0000001` inside the Rust radial Sugiyama bridge.
|
||||||
|
|
||||||
|
## Files To Read When Rewriting
|
||||||
|
|
||||||
|
If you want to rewrite this from zero, these are the main files that define the current behavior:
|
||||||
|
|
||||||
|
- `backend_go/server.go`
|
||||||
|
- `backend_go/snapshot_service.go`
|
||||||
|
- `backend_go/graph_snapshot.go`
|
||||||
|
- `backend_go/graph_queries/hierarchy.go`
|
||||||
|
- `backend_go/graph_export.go`
|
||||||
|
- `backend_go/hierarchy_layout_bridge.go`
|
||||||
|
- `backend_go/config.go`
|
||||||
|
- `.env`
|
||||||
|
- `radial_sugiyama/src/bridge.rs`
|
||||||
|
- `backend_go/selection_queries/subclasses.go`
|
||||||
|
- `backend_go/selection_queries/superclasses.go`
|
||||||
|
|
||||||
|
## Rewrite-Oriented Takeaway
|
||||||
|
|
||||||
|
If your goal is a cleaner standalone pipeline for:
|
||||||
|
|
||||||
|
- query `rdfs:subClassOf`
|
||||||
|
- start from `bfo:entity`
|
||||||
|
- materialize only the rooted descendant hierarchy
|
||||||
|
|
||||||
|
then the current codebase is doing the root restriction too late. Right now, that concern lives in the layout bridge rather than in the query/materialization layer.
|
||||||
655
GRAPH_TRANSPORT_ALTERNATIVES.md
Normal file
655
GRAPH_TRANSPORT_ALTERNATIVES.md
Normal file
@@ -0,0 +1,655 @@
|
|||||||
|
# Graph Transport Alternatives
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document compares alternatives to the current `/api/graph` transport format with two goals:
|
||||||
|
|
||||||
|
1. reduce the cost of building, transferring, and decoding very large graph payloads
|
||||||
|
2. move the frontend transport shape closer to the renderer/GPU input shape while preserving all data the current frontend and backend pipeline still need
|
||||||
|
|
||||||
|
This analysis is based on the current repo state plus official documentation for browser fetch/streaming and candidate transport formats.
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The current bottleneck is not the renderer's typed-array path. It is the browser's need to fully materialize a huge JSON object graph before the renderer ever runs.
|
||||||
|
|
||||||
|
The best candidates for this repo are:
|
||||||
|
|
||||||
|
1. **Custom binary columnar payload**
|
||||||
|
- Best fit for the current renderer.
|
||||||
|
- Lowest decode overhead.
|
||||||
|
- Most direct path from backend memory to frontend typed arrays.
|
||||||
|
- Requires custom protocol/versioning work.
|
||||||
|
|
||||||
|
2. **Apache Arrow IPC**
|
||||||
|
- Best off-the-shelf columnar binary format.
|
||||||
|
- Very good fit for typed-array-heavy rendering.
|
||||||
|
- Strong option if you want a standard format instead of inventing one.
|
||||||
|
- Heavier conceptual/tooling footprint than a custom binary envelope.
|
||||||
|
|
||||||
|
3. **Columnar JSON**
|
||||||
|
- Easiest migration.
|
||||||
|
- Better than today's row-oriented JSON.
|
||||||
|
- Still fundamentally JSON, so it does not remove the browser's JSON parse/object-materialization cost.
|
||||||
|
|
||||||
|
4. **NDJSON / streamed chunked JSON**
|
||||||
|
- Good if progressiveness matters.
|
||||||
|
- Better than one giant monolithic JSON document.
|
||||||
|
- Still weaker than a binary/columnar format for this renderer.
|
||||||
|
|
||||||
|
The strongest overall recommendation is:
|
||||||
|
|
||||||
|
- **Long-term**: custom binary columnar payload or Arrow IPC
|
||||||
|
- **Low-risk interim**: columnar JSON, possibly with chunking/streaming
|
||||||
|
|
||||||
|
Not recommended as the primary solution for this repo:
|
||||||
|
|
||||||
|
- row-oriented MessagePack
|
||||||
|
- Protocol Buffers as one giant message
|
||||||
|
|
||||||
|
## Verified Current Pipeline
|
||||||
|
|
||||||
|
### Backend side
|
||||||
|
|
||||||
|
The backend builds a `GraphResponse` and caches it in memory:
|
||||||
|
|
||||||
|
- `backend_go/models.go`
|
||||||
|
- `backend_go/snapshot_service.go`
|
||||||
|
- `backend_go/graph_snapshot.go`
|
||||||
|
|
||||||
|
The response shape is:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type GraphResponse struct {
|
||||||
|
Nodes []Node
|
||||||
|
Edges []Edge
|
||||||
|
RouteSegments []RouteSegment
|
||||||
|
Meta *GraphMeta
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
and it is currently written as one JSON document with:
|
||||||
|
|
||||||
|
```go
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
```
|
||||||
|
|
||||||
|
in `backend_go/http_helpers.go`.
|
||||||
|
|
||||||
|
### Frontend side
|
||||||
|
|
||||||
|
The frontend currently does:
|
||||||
|
|
||||||
|
1. `fetch("/api/graph?...")`
|
||||||
|
2. `await graphRes.json()`
|
||||||
|
3. read `graph.nodes`, `graph.edges`, `graph.route_segments`, `graph.meta`
|
||||||
|
4. build:
|
||||||
|
- `Float32Array xs`
|
||||||
|
- `Float32Array ys`
|
||||||
|
- `Uint32Array vertexIds`
|
||||||
|
- `Uint32Array edgeData`
|
||||||
|
- `Float32Array routeLineVertices`
|
||||||
|
5. call `renderer.init(xs, ys, vertexIds, edgeData, routeLineVertices)`
|
||||||
|
|
||||||
|
Relevant files:
|
||||||
|
|
||||||
|
- `frontend/src/App.tsx`
|
||||||
|
- `frontend/src/renderer.ts`
|
||||||
|
|
||||||
|
This means the current browser path is:
|
||||||
|
|
||||||
|
- wire bytes
|
||||||
|
- JSON text/body handling
|
||||||
|
- JS arrays of node/edge objects
|
||||||
|
- typed arrays
|
||||||
|
- renderer-side typed arrays/maps/GPU buffers
|
||||||
|
|
||||||
|
The expensive part happens before step 4.
|
||||||
|
|
||||||
|
## Verified Data Access Audit
|
||||||
|
|
||||||
|
This section verifies every field currently produced by the backend and whether it is actually needed by the frontend transport.
|
||||||
|
|
||||||
|
### Main graph response fields
|
||||||
|
|
||||||
|
| Field | Produced in backend | Used by frontend? | Where used | Required on wire for current UX? | Notes |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `nodes[].id` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes | Used to build `vertexIds`, and to map selected renderer indices back to backend IDs for selection queries. |
|
||||||
|
| `nodes[].x` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes | Used to build `xs`. |
|
||||||
|
| `nodes[].y` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes | Used to build `ys`. |
|
||||||
|
| `nodes[].iri` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes, if keeping current hover UX | Used for hover tooltip text. |
|
||||||
|
| `nodes[].label` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes, if keeping current hover UX | Used for hover tooltip text. |
|
||||||
|
| `nodes[].termType` | `backend_go/models.go` | No frontend use | none in `frontend/src` | No | Still needed internally by backend snapshot/selection index. |
|
||||||
|
| `edges[].source` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes | Used to build `edgeData`. |
|
||||||
|
| `edges[].target` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes | Used to build `edgeData`. |
|
||||||
|
| `edges[].predicate_id` | `backend_go/models.go` | No main-graph frontend use | none in `frontend/src/App.tsx` | No | Still needed internally by backend snapshot and hierarchy layout preparation. |
|
||||||
|
| `route_segments[].points` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes when route segments are present | Used to build `routeLineVertices`. |
|
||||||
|
| `route_segments[].edge_index` | `backend_go/models.go` | Not used after parsing | `graphRouteSegmentArray` validation only | No | Could be dropped from frontend transport if route lines are pre-flattened. |
|
||||||
|
| `route_segments[].kind` | `backend_go/models.go` | Not used after parsing | `graphRouteSegmentArray` validation only | No | Could be dropped from frontend transport if route lines are pre-flattened. |
|
||||||
|
| `meta.backend` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes | Displayed in overlay. |
|
||||||
|
| `meta.nodes` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes | Displayed in overlay. |
|
||||||
|
| `meta.edges` | `backend_go/models.go` | Yes | `frontend/src/App.tsx` | Yes | Displayed in overlay. |
|
||||||
|
| `meta.graph_query_id` | `backend_go/models.go` | Yes | `frontend/src/selection_queries/api.ts` | Yes | Sent back on selection endpoints. |
|
||||||
|
| `meta.node_limit` | `backend_go/models.go` | Yes | `frontend/src/selection_queries/api.ts` | Yes | Sent back on selection endpoints. |
|
||||||
|
| `meta.edge_limit` | `backend_go/models.go` | Yes | `frontend/src/selection_queries/api.ts` | Yes | Sent back on selection endpoints. |
|
||||||
|
| `meta.ttl_path` | `backend_go/models.go` | No | none in `frontend/src` | No | Frontend type declares it, but current UI does not use it. |
|
||||||
|
| `meta.sparql_endpoint` | `backend_go/models.go` | No | none in `frontend/src` | No | Not used by current UI. |
|
||||||
|
| `meta.include_bnodes` | `backend_go/models.go` | No | none in `frontend/src` | No | Not used by current UI. |
|
||||||
|
| `meta.layout_engine` | `backend_go/models.go` | No | none in `frontend/src` | No | Not used by current UI. |
|
||||||
|
| `meta.layout_root_iri` | `backend_go/models.go` | No | none in `frontend/src` | No | Not used by current UI. |
|
||||||
|
| `meta.predicates` | `backend_go/models.go` | No frontend use | none in `frontend/src` | No | Still used internally by backend selection/hierarchy logic. |
|
||||||
|
|
||||||
|
### Backend-internal fields that do not need to stay in the frontend transport
|
||||||
|
|
||||||
|
This is the most important audit result.
|
||||||
|
|
||||||
|
The backend currently reuses one struct for:
|
||||||
|
|
||||||
|
- internal cached snapshot
|
||||||
|
- HTTP response payload
|
||||||
|
|
||||||
|
That is convenient, but it means the frontend receives fields that only the backend needs.
|
||||||
|
|
||||||
|
Verified internal-only dependencies:
|
||||||
|
|
||||||
|
- `snapshot.Nodes[].TermType` is used in `backend_go/selection_query.go` to build the selection index.
|
||||||
|
- `snapshot.Meta.Predicates` is used in `backend_go/selection_query.go`.
|
||||||
|
- `Edge.PredicateID` is used internally for hierarchy layout preparation in `backend_go/hierarchy_layout_bridge.go`.
|
||||||
|
|
||||||
|
The frontend does **not** need those fields for current behavior.
|
||||||
|
|
||||||
|
### What the frontend actually needs
|
||||||
|
|
||||||
|
For the current graph view, the hot path can be reduced to:
|
||||||
|
|
||||||
|
- `vertexIds[]`
|
||||||
|
- `xs[]`
|
||||||
|
- `ys[]`
|
||||||
|
- `edgeSources[]`
|
||||||
|
- `edgeTargets[]`
|
||||||
|
- `routeLineVertices[]` or route geometry equivalent
|
||||||
|
- `label[]` and `iri[]` by node index
|
||||||
|
- `meta.backend`
|
||||||
|
- `meta.nodes`
|
||||||
|
- `meta.edges`
|
||||||
|
- `meta.graph_query_id`
|
||||||
|
- `meta.node_limit`
|
||||||
|
- `meta.edge_limit`
|
||||||
|
|
||||||
|
That is much closer to a columnar or binary payload than to the current array-of-objects JSON.
|
||||||
|
|
||||||
|
## Why the Current JSON Path Hurts
|
||||||
|
|
||||||
|
`Response.json()` is not just a lightweight decode helper. MDN states that `Response.json()` reads the stream to completion and resolves with the result of parsing the body text as JSON into a JavaScript object.
|
||||||
|
|
||||||
|
That matters here because the current payload is row-oriented:
|
||||||
|
|
||||||
|
- millions of node objects
|
||||||
|
- millions of edge objects
|
||||||
|
|
||||||
|
Even though the renderer later wants typed arrays, the browser must first create those JS objects.
|
||||||
|
|
||||||
|
This is exactly the part that can stall or run out of memory before `renderer.init(...)` starts.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
### 1. Columnar JSON
|
||||||
|
|
||||||
|
#### Idea
|
||||||
|
|
||||||
|
Keep JSON, but change the schema from row-oriented objects:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": [{ "id": 1, "x": 0.1, "y": 0.2, ... }],
|
||||||
|
"edges": [{ "source": 1, "target": 2, ... }]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
to column-oriented arrays:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vertex_ids": [...],
|
||||||
|
"xs": [...],
|
||||||
|
"ys": [...],
|
||||||
|
"edge_sources": [...],
|
||||||
|
"edge_targets": [...],
|
||||||
|
"node_labels": [...],
|
||||||
|
"node_iris": [...],
|
||||||
|
"route_line_vertices": [...],
|
||||||
|
"meta": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pros
|
||||||
|
|
||||||
|
- easiest migration from the current API contract
|
||||||
|
- no schema compiler
|
||||||
|
- easy to debug with ordinary tooling
|
||||||
|
- much closer to what the renderer already consumes
|
||||||
|
- avoids creating per-edge objects in frontend application code
|
||||||
|
|
||||||
|
#### Cons
|
||||||
|
|
||||||
|
- still goes through JSON parsing
|
||||||
|
- still materializes JS arrays before typed arrays are built
|
||||||
|
- huge numeric arrays in JSON are still text, not binary
|
||||||
|
- string columns are still ordinary JS strings
|
||||||
|
|
||||||
|
#### Fit for current pipeline
|
||||||
|
|
||||||
|
Good.
|
||||||
|
|
||||||
|
No current frontend feature would be lost if the payload includes:
|
||||||
|
|
||||||
|
- ids/xs/ys/edge sources/targets
|
||||||
|
- labels/iris
|
||||||
|
- route line vertices or equivalent
|
||||||
|
- the small subset of meta fields currently used
|
||||||
|
|
||||||
|
#### Overall assessment
|
||||||
|
|
||||||
|
Best low-risk intermediate step.
|
||||||
|
|
||||||
|
It is clearly better than today's row-oriented JSON, but it is not the endgame if the goal is to remove the parse bottleneck for 1 GB+ payloads.
|
||||||
|
|
||||||
|
### 2. NDJSON / Chunked JSON
|
||||||
|
|
||||||
|
#### Idea
|
||||||
|
|
||||||
|
Change the backend to stream multiple JSON records instead of one giant JSON object.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- one line per chunk of nodes/edges
|
||||||
|
- one line for metadata
|
||||||
|
- one line per route segment chunk
|
||||||
|
|
||||||
|
NDJSON is explicitly designed for transporting multiple JSON texts in a stream protocol.
|
||||||
|
|
||||||
|
#### Pros
|
||||||
|
|
||||||
|
- can start processing before the whole payload arrives
|
||||||
|
- better observability and progress reporting
|
||||||
|
- easier cancellation/retry semantics
|
||||||
|
- avoids one monolithic `Response.json()` boundary
|
||||||
|
|
||||||
|
#### Cons
|
||||||
|
|
||||||
|
- record-per-edge NDJSON would still create far too many JS objects
|
||||||
|
- to be worth it here, it should be **chunked columnar NDJSON**, not row NDJSON
|
||||||
|
- frontend load path must become stream-based
|
||||||
|
- renderer still currently expects all arrays at once
|
||||||
|
|
||||||
|
#### Fit for current pipeline
|
||||||
|
|
||||||
|
Moderate.
|
||||||
|
|
||||||
|
It can preserve all current information, but it does not by itself solve the "final representation should look like GPU inputs" goal unless each chunk is already columnar.
|
||||||
|
|
||||||
|
#### Best shape if chosen
|
||||||
|
|
||||||
|
Not:
|
||||||
|
|
||||||
|
- one JSON object per edge
|
||||||
|
- one JSON object per node
|
||||||
|
|
||||||
|
Better:
|
||||||
|
|
||||||
|
- one NDJSON record for metadata
|
||||||
|
- then NDJSON records where each record contains columnar chunks:
|
||||||
|
- `vertex_ids_chunk`
|
||||||
|
- `xs_chunk`
|
||||||
|
- `ys_chunk`
|
||||||
|
- `edge_sources_chunk`
|
||||||
|
- `edge_targets_chunk`
|
||||||
|
|
||||||
|
#### Overall assessment
|
||||||
|
|
||||||
|
Viable, but only attractive if progressiveness is a major goal. On its own, it is weaker than columnar binary formats for this renderer.
|
||||||
|
|
||||||
|
### 3. MessagePack
|
||||||
|
|
||||||
|
#### Idea
|
||||||
|
|
||||||
|
Use a compact binary encoding instead of JSON.
|
||||||
|
|
||||||
|
The official JavaScript implementation supports:
|
||||||
|
|
||||||
|
- `encode`
|
||||||
|
- `decode`
|
||||||
|
- `decodeAsync(stream)`
|
||||||
|
- `decodeArrayStream(stream)`
|
||||||
|
- `decodeMultiStream(stream)`
|
||||||
|
|
||||||
|
and even custom extension types for faster handling of large `Float32Array` payloads.
|
||||||
|
|
||||||
|
#### Pros
|
||||||
|
|
||||||
|
- smaller payload than JSON
|
||||||
|
- binary transport
|
||||||
|
- async and stream-capable decoding APIs exist
|
||||||
|
- mature JS library
|
||||||
|
|
||||||
|
#### Cons
|
||||||
|
|
||||||
|
- if you keep the current row-oriented schema, you still get one huge object graph after decode
|
||||||
|
- therefore MessagePack alone does not remove the fundamental object-allocation problem
|
||||||
|
- custom extension types improve typed-array cases, but then you are already halfway to designing a custom binary protocol
|
||||||
|
|
||||||
|
#### Fit for current pipeline
|
||||||
|
|
||||||
|
Moderate.
|
||||||
|
|
||||||
|
It can preserve all current information easily.
|
||||||
|
|
||||||
|
But if the schema remains object-heavy, the browser still ends up with millions of JS objects.
|
||||||
|
|
||||||
|
#### Overall assessment
|
||||||
|
|
||||||
|
Useful if paired with a **columnar** schema. Not compelling as a first move if the schema stays row-oriented.
|
||||||
|
|
||||||
|
### 4. Apache Arrow IPC
|
||||||
|
|
||||||
|
#### Idea
|
||||||
|
|
||||||
|
Use Arrow's columnar binary format and Arrow JS support.
|
||||||
|
|
||||||
|
Arrow JS provides:
|
||||||
|
|
||||||
|
- `tableFromIPC(...)`
|
||||||
|
- support for `fetch(...)`
|
||||||
|
- typed-array-backed vectors
|
||||||
|
- dictionary-encoded strings
|
||||||
|
- a columnar memory model explicitly meant for efficient processing and movement of large in-memory data
|
||||||
|
|
||||||
|
#### Pros
|
||||||
|
|
||||||
|
- strongest off-the-shelf fit for typed-array-oriented rendering
|
||||||
|
- columnar by design
|
||||||
|
- binary rather than textual
|
||||||
|
- supports large numeric columns very naturally
|
||||||
|
- supports dictionary encoding for repeated strings like labels or IRIs
|
||||||
|
- much closer to the renderer/GPU input shape than JSON objects
|
||||||
|
|
||||||
|
#### Cons
|
||||||
|
|
||||||
|
- larger conceptual/tooling jump than columnar JSON
|
||||||
|
- route segments are nested/variable-length; representing them cleanly needs design
|
||||||
|
- frontend code becomes Arrow-aware unless the decode is hidden behind an adapter
|
||||||
|
- backend must serialize Arrow on the Go side or produce Arrow-compatible IPC
|
||||||
|
|
||||||
|
#### Fit for current pipeline
|
||||||
|
|
||||||
|
Very good.
|
||||||
|
|
||||||
|
Current frontend needs can be represented as columns:
|
||||||
|
|
||||||
|
- `vertex_ids: uint32`
|
||||||
|
- `xs: float32`
|
||||||
|
- `ys: float32`
|
||||||
|
- `edge_sources: uint32`
|
||||||
|
- `edge_targets: uint32`
|
||||||
|
- `labels: utf8` or dictionary-encoded utf8
|
||||||
|
- `iris: utf8` or dictionary-encoded utf8
|
||||||
|
|
||||||
|
Route geometry should probably not stay as nested route-segment objects. It would fit better as:
|
||||||
|
|
||||||
|
- pre-flattened `route_line_vertices` float column/buffer
|
||||||
|
- or a second Arrow table dedicated to line segments
|
||||||
|
|
||||||
|
#### Overall assessment
|
||||||
|
|
||||||
|
One of the two best solutions for this repo.
|
||||||
|
|
||||||
|
If you want a standard format instead of inventing one, Arrow is the most attractive candidate.
|
||||||
|
|
||||||
|
### 5. FlatBuffers
|
||||||
|
|
||||||
|
#### Idea
|
||||||
|
|
||||||
|
Use a schema-defined binary format designed for direct access without unpacking/parsing.
|
||||||
|
|
||||||
|
FlatBuffers explicitly advertises:
|
||||||
|
|
||||||
|
- access to serialized data without parsing/unpacking
|
||||||
|
- memory efficiency and speed
|
||||||
|
- forwards/backwards compatibility
|
||||||
|
|
||||||
|
#### Pros
|
||||||
|
|
||||||
|
- very strong memory-efficiency story
|
||||||
|
- schema evolution support
|
||||||
|
- no full parse/unpack step in the same way as JSON
|
||||||
|
- can model both scalars and more complex structures
|
||||||
|
|
||||||
|
#### Cons
|
||||||
|
|
||||||
|
- requires schema/compiler/generated bindings
|
||||||
|
- JavaScript integration is more manual than JSON or Arrow
|
||||||
|
- ergonomics in app code are not as simple as arrays/objects
|
||||||
|
- strings and nested route structures are supported, but the developer experience is more specialized
|
||||||
|
|
||||||
|
#### Fit for current pipeline
|
||||||
|
|
||||||
|
Good, technically.
|
||||||
|
|
||||||
|
It can preserve all current information and remove the giant object-graph parse step.
|
||||||
|
|
||||||
|
However, compared with Arrow or a custom binary envelope, it is a less natural conceptual fit for a renderer whose hot path is already columnar/typed-array-based.
|
||||||
|
|
||||||
|
#### Overall assessment
|
||||||
|
|
||||||
|
A strong technical option, but probably not the most ergonomic option for this specific frontend.
|
||||||
|
|
||||||
|
### 6. Protocol Buffers
|
||||||
|
|
||||||
|
#### Idea
|
||||||
|
|
||||||
|
Use a schema-defined binary format with generated bindings.
|
||||||
|
|
||||||
|
#### Pros
|
||||||
|
|
||||||
|
- compact binary encoding
|
||||||
|
- schema/versioning
|
||||||
|
- mature ecosystem
|
||||||
|
|
||||||
|
#### Cons
|
||||||
|
|
||||||
|
- official docs describe protobuf as a good fit for typed structured messages up to a few megabytes
|
||||||
|
- the same docs warn that large data can require loading entire messages into memory and can cause multiple copies
|
||||||
|
- large repeated numeric arrays are not protobuf's sweet spot
|
||||||
|
- still not especially close to the renderer's typed-array model
|
||||||
|
|
||||||
|
#### Fit for current pipeline
|
||||||
|
|
||||||
|
Poor for this specific payload size and shape.
|
||||||
|
|
||||||
|
#### Overall assessment
|
||||||
|
|
||||||
|
Not recommended for this main graph transport.
|
||||||
|
|
||||||
|
### 7. Custom Binary Typed-Array Envelope
|
||||||
|
|
||||||
|
#### Idea
|
||||||
|
|
||||||
|
Define a transport specifically around what the renderer and hover/selection pipeline need.
|
||||||
|
|
||||||
|
Example structure:
|
||||||
|
|
||||||
|
- small fixed header or small JSON header:
|
||||||
|
- version
|
||||||
|
- counts
|
||||||
|
- offsets/lengths
|
||||||
|
- meta subset
|
||||||
|
- then raw binary buffers:
|
||||||
|
- `vertex_ids`
|
||||||
|
- `xs`
|
||||||
|
- `ys`
|
||||||
|
- `edge_sources`
|
||||||
|
- `edge_targets`
|
||||||
|
- `route_line_vertices`
|
||||||
|
- string dictionary / offsets for `label` and `iri`
|
||||||
|
|
||||||
|
#### Pros
|
||||||
|
|
||||||
|
- closest possible fit to current renderer
|
||||||
|
- no schema compiler required
|
||||||
|
- no row-object materialization
|
||||||
|
- easiest path to zero-copy or near-zero-copy arrays on the frontend
|
||||||
|
- easiest path to worker transfer via `ArrayBuffer`
|
||||||
|
- can separate hot render data from cold metadata cleanly
|
||||||
|
|
||||||
|
#### Cons
|
||||||
|
|
||||||
|
- custom protocol to design, version, validate, and document
|
||||||
|
- less tooling/interoperability than Arrow
|
||||||
|
- backend and frontend both need careful binary codecs
|
||||||
|
|
||||||
|
#### Fit for current pipeline
|
||||||
|
|
||||||
|
Excellent.
|
||||||
|
|
||||||
|
You can preserve all current behavior while only sending the data the frontend actually uses.
|
||||||
|
|
||||||
|
#### Overall assessment
|
||||||
|
|
||||||
|
The best performance-oriented fit if you are comfortable owning a custom format.
|
||||||
|
|
||||||
|
## Comparison Table
|
||||||
|
|
||||||
|
| Option | Closeness to GPU shape | Avoids giant object graph | Supports all current frontend data | Streaming-friendly | Implementation cost | Recommendation |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| Current row JSON | Poor | No | Yes | Poor | Already done | Replace |
|
||||||
|
| Columnar JSON | Medium | No | Yes | Medium | Low | Good interim |
|
||||||
|
| NDJSON chunked columnar JSON | Medium | Partially | Yes | Good | Medium | Situational |
|
||||||
|
| MessagePack row-oriented | Poor | No | Yes | Good | Medium | Not enough alone |
|
||||||
|
| MessagePack columnar | Medium | Partially | Yes | Good | Medium | Viable but secondary |
|
||||||
|
| Arrow IPC | Very high | Yes or mostly yes | Yes | Good | Medium-high | Strong candidate |
|
||||||
|
| FlatBuffers | High | Yes | Yes | Medium | High | Good but specialized |
|
||||||
|
| Protobuf | Low-medium | No practical win here | Yes | Medium | Medium-high | Not recommended |
|
||||||
|
| Custom binary typed-array envelope | Very high | Yes | Yes | Good | High | Strongest fit |
|
||||||
|
|
||||||
|
## Recommended Data Contract Shapes
|
||||||
|
|
||||||
|
### Recommended shape for any non-row-oriented solution
|
||||||
|
|
||||||
|
The frontend does not need node/edge objects as its primary graph transport.
|
||||||
|
|
||||||
|
The main graph payload should be modeled as:
|
||||||
|
|
||||||
|
- `vertex_ids`
|
||||||
|
- `xs`
|
||||||
|
- `ys`
|
||||||
|
- `edge_sources`
|
||||||
|
- `edge_targets`
|
||||||
|
- `route_line_vertices`
|
||||||
|
- `node_labels`
|
||||||
|
- `node_iris`
|
||||||
|
- `meta`
|
||||||
|
|
||||||
|
This can be represented as:
|
||||||
|
|
||||||
|
- columnar JSON
|
||||||
|
- Arrow columns
|
||||||
|
- FlatBuffers vectors
|
||||||
|
- custom binary sections
|
||||||
|
|
||||||
|
### Fields that can be removed from the frontend transport immediately
|
||||||
|
|
||||||
|
Without changing current visible behavior, the main graph transport does not need to include:
|
||||||
|
|
||||||
|
- `nodes[].termType`
|
||||||
|
- `edges[].predicate_id`
|
||||||
|
- `meta.predicates`
|
||||||
|
- `meta.ttl_path`
|
||||||
|
- `meta.sparql_endpoint`
|
||||||
|
- `meta.include_bnodes`
|
||||||
|
- `meta.layout_engine`
|
||||||
|
- `meta.layout_root_iri`
|
||||||
|
- `route_segments[].edge_index`
|
||||||
|
- `route_segments[].kind`
|
||||||
|
|
||||||
|
Important:
|
||||||
|
|
||||||
|
Some of those fields are still needed by the backend's **internal snapshot**, especially for selection queries and hierarchy layout. That argues for splitting:
|
||||||
|
|
||||||
|
- internal snapshot model
|
||||||
|
- frontend transport DTO
|
||||||
|
|
||||||
|
instead of continuing to reuse one struct for both.
|
||||||
|
|
||||||
|
## Additional Architectural Notes
|
||||||
|
|
||||||
|
### A worker is complementary, not a transport format
|
||||||
|
|
||||||
|
Web Workers can move parsing/build work off the main thread, and `ArrayBuffer` is transferable. That is useful, but it does not by itself solve the current over-allocation problem if the payload is still a giant row-oriented JSON document.
|
||||||
|
|
||||||
|
Workers are most valuable when paired with:
|
||||||
|
|
||||||
|
- binary columnar payloads
|
||||||
|
- streamed columnar chunks
|
||||||
|
- transfer of `ArrayBuffer`s rather than giant JS object graphs
|
||||||
|
|
||||||
|
### The backend can keep a richer internal snapshot than it sends
|
||||||
|
|
||||||
|
This repo already caches snapshots server-side. Selection and triple queries are built from the backend snapshot and the small `graphMeta` values sent back by the client.
|
||||||
|
|
||||||
|
That means the frontend transport can be much slimmer than the backend snapshot representation, as long as the backend retains its richer internal data.
|
||||||
|
|
||||||
|
This is the cleanest way to avoid losing information while optimizing the frontend transport.
|
||||||
|
|
||||||
|
## Final Recommendation
|
||||||
|
|
||||||
|
### Best long-term option
|
||||||
|
|
||||||
|
Pick one of:
|
||||||
|
|
||||||
|
1. **Custom binary typed-array envelope**
|
||||||
|
2. **Apache Arrow IPC**
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
|
||||||
|
- both map naturally to the renderer's actual input model
|
||||||
|
- both avoid the giant row-object parse path
|
||||||
|
- both can preserve all current frontend-visible information
|
||||||
|
|
||||||
|
### Best low-risk migration path
|
||||||
|
|
||||||
|
If you want an incremental step before going binary:
|
||||||
|
|
||||||
|
1. split backend internal snapshot from frontend transport DTO
|
||||||
|
2. move `/api/graph` to **columnar JSON**
|
||||||
|
3. keep only the metadata fields the frontend actually uses
|
||||||
|
4. later replace the same columnar DTO with Arrow or custom binary
|
||||||
|
|
||||||
|
That path reduces waste immediately and keeps the eventual binary migration straightforward.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
Official documentation and primary sources used for the comparison:
|
||||||
|
|
||||||
|
- MDN `Response.json()`
|
||||||
|
- https://developer.mozilla.org/en-US/docs/Web/API/Response/json
|
||||||
|
- MDN `TextDecoderStream`
|
||||||
|
- https://developer.mozilla.org/en-US/docs/Web/API/TextDecoderStream
|
||||||
|
- MDN Web Workers
|
||||||
|
- https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers
|
||||||
|
- MDN Transferable Objects
|
||||||
|
- https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects
|
||||||
|
- Apache Arrow JavaScript
|
||||||
|
- https://arrow.apache.org/js/current/
|
||||||
|
- https://arrow.apache.org/js/main/functions/Arrow.dom.tableFromIPC.html
|
||||||
|
- NDJSON specification
|
||||||
|
- https://github.com/ndjson/ndjson-spec
|
||||||
|
- MessagePack for JavaScript
|
||||||
|
- https://github.com/msgpack/msgpack-javascript
|
||||||
|
- FlatBuffers overview and JavaScript docs
|
||||||
|
- https://flatbuffers.dev/
|
||||||
|
- https://flatbuffers.dev/languages/javascript/
|
||||||
|
- Protocol Buffers overview
|
||||||
|
- https://protobuf.dev/overview/
|
||||||
|
- Streaming JSON parser references
|
||||||
|
- https://github.com/juanjoDiaz/streamparser-json
|
||||||
|
- https://rictic.github.io/jsonriver/
|
||||||
150
README.md
150
README.md
@@ -1,108 +1,96 @@
|
|||||||
# Visualizador Instanciados
|
# Visualizador Instanciados
|
||||||
|
|
||||||
This repo is a Docker Compose stack for visualizing large RDF/OWL graphs stored in **AnzoGraph**. It includes:
|
Docker Compose stack for exploring large RDF/OWL graphs stored in AnzoGraph.
|
||||||
|
|
||||||
- A **Go backend** that queries AnzoGraph via SPARQL and serves a cached graph snapshot + selection queries.
|
## What Runs Here
|
||||||
- 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).
|
|
||||||
|
|
||||||
## Quick start (Docker Compose)
|
- `anzograph`: SPARQL store
|
||||||
|
- `backend`: Go API that queries AnzoGraph and serves cached graph snapshots
|
||||||
|
- `frontend`: React/Vite app with a WebGL left graph and a right-side `cosmos.gl` selection graph
|
||||||
|
- `owl_imports_combiner`: one-shot Python service that can merge `owl:imports`
|
||||||
|
- `radial_sugiyama`: Rust hierarchy layout pipeline used in two ways:
|
||||||
|
- standalone SVG generator through the `radial` Compose profile
|
||||||
|
- optional hierarchy layout engine for the Go backend
|
||||||
|
|
||||||
1) Put your TTL file(s) in `./data/` (this folder is volume-mounted into AnzoGraph as `/opt/shared-files`).
|
## Current Flow
|
||||||
2) Optionally configure `.env` (see `.env.example`).
|
|
||||||
3) Start the stack:
|
- The backend always builds graph snapshots from SPARQL queries against AnzoGraph.
|
||||||
|
- `graph_query_id=default` and `graph_query_id=types` use the Go layout path.
|
||||||
|
- `graph_query_id=hierarchy` can use either:
|
||||||
|
- the Go layout path
|
||||||
|
- the Rust radial Sugiyama path when `HIERARCHY_LAYOUT_ENGINE=rust`
|
||||||
|
- When the Rust hierarchy path is enabled, the backend sends the hierarchy graph to Rust over JSON, Rust lays it out, returns node positions + routed edge segments, and also rewrites:
|
||||||
|
|
||||||
|
```text
|
||||||
|
radial_sugiyama/out/layout.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
That SVG is a debug artifact for the exact Rust layout run used by the backend.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Put your TTL files under `./data/`.
|
||||||
|
2. Copy or edit `.env` as needed.
|
||||||
|
3. Start the stack:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open the frontend:
|
Open:
|
||||||
|
|
||||||
- `http://localhost:5173`
|
- Frontend: `http://localhost:5173`
|
||||||
|
- Backend health: `http://localhost:8000/api/health`
|
||||||
|
|
||||||
Stop everything:
|
Stop:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
## Services
|
## Rust Hierarchy Layout
|
||||||
|
|
||||||
Defined in `docker-compose.yml`:
|
To use Rust for the `hierarchy` graph mode, set this in the repo root `.env`:
|
||||||
|
|
||||||
- `anzograph` (image `cambridgesemantics/anzograph:latest`)
|
```env
|
||||||
- Ports: `8080`, `8443`
|
HIERARCHY_LAYOUT_ENGINE=rust
|
||||||
- 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:
|
The backend also reads `radial_sugiyama/.env` for the Rust layout settings such as:
|
||||||
|
|
||||||
- `backend_go/README.md`
|
- `RADIAL_ROOT_CLASS_IRI`
|
||||||
- `frontend/README.md`
|
- `RADIAL_OUTPUT_DIR`
|
||||||
- `python_services/owl_imports_combiner/README.md`
|
- `RADIAL_OUTPUT_FILE`
|
||||||
- `anzograph/README.md`
|
- `RADIAL_RING_DISTRIBUTION`
|
||||||
|
|
||||||
## Repo layout
|
The debug SVG for backend-driven hierarchy requests is written to:
|
||||||
|
|
||||||
- `backend_go/` – Go API service (SPARQL → snapshot + selection queries)
|
```text
|
||||||
- `frontend/` – React/Vite WebGL renderer
|
radial_sugiyama/out/layout.svg
|
||||||
- `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
|
You can still run the standalone Rust SVG pipeline directly with:
|
||||||
|
|
||||||
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
|
```bash
|
||||||
nix develop
|
docker compose --profile radial up --build radial_sugiyama
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Main API
|
||||||
|
|
||||||
|
- `GET /api/health`
|
||||||
|
- `GET /api/stats`
|
||||||
|
- `GET /api/graph`
|
||||||
|
- `GET /api/graph_queries`
|
||||||
|
- `GET /api/selection_queries`
|
||||||
|
- `POST /api/selection_query`
|
||||||
|
- `POST /api/selection_triples`
|
||||||
|
- `POST /api/sparql`
|
||||||
|
|
||||||
|
## Repo Layout
|
||||||
|
|
||||||
|
- `backend_go/` Go API and SPARQL snapshot logic
|
||||||
|
- `frontend/` React/Vite UI
|
||||||
|
- `radial_sugiyama/` Rust hierarchy layout and SVG export
|
||||||
|
- `python_services/owl_imports_combiner/` import-flattening helper
|
||||||
|
- `data/` local shared data mounted into containers
|
||||||
|
- `docker-compose.yml` service wiring
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
## Objetivos Gerais:
|
||||||
|
|
||||||
|
|
||||||
|
#### Visualizar caracteristicas estruturais (todas valvulas que participam de um processo x)
|
||||||
|
|
||||||
|
#### Visualizar todos equipamentos que conectam a um poço y
|
||||||
|
|
||||||
|
#### Visualizar todos elementos de uma classe.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Como Requisitos (query para cada nodo selecionado):
|
||||||
|
|
||||||
|
|
||||||
|
#### Encontrar Subclasses
|
||||||
|
|
||||||
|
#### Encontrar Superclasses
|
||||||
|
|
||||||
|
#### Encontrar Vizinhos
|
||||||
|
|
||||||
|
#### Encontrar n-hop Vizinhos
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ The backend connects to AnzoGraph via:
|
|||||||
- `SPARQL_HOST` (default `http://anzograph:8080`) and the `/sparql` path, or
|
- `SPARQL_HOST` (default `http://anzograph:8080`) and the `/sparql` path, or
|
||||||
- an explicit `SPARQL_ENDPOINT`
|
- 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
|
## Loading data
|
||||||
|
|
||||||
The backend can optionally load a TTL file on startup (after AnzoGraph is ready):
|
The backend can optionally load a TTL file on startup (after AnzoGraph is ready):
|
||||||
@@ -24,4 +36,3 @@ Because `./data` is mounted at `/opt/shared-files`, anything placed in `./data`
|
|||||||
|
|
||||||
- Authentication defaults are configured via the backend env (`SPARQL_USER` / `SPARQL_PASS`).
|
- 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.
|
- The AnzoGraph container in this repo is not customized; consult the upstream image documentation for persistence, licensing, and advanced configuration.
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
FROM golang:1.22-alpine AS builder
|
ARG GO_VERSION=1.24
|
||||||
|
FROM rust:bookworm AS rust-builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src/radial_sugiyama
|
||||||
|
|
||||||
COPY go.mod /src/go.mod
|
COPY radial_sugiyama /src/radial_sugiyama
|
||||||
|
|
||||||
RUN go mod download
|
RUN cargo build --release --bin radial_sugiyama_go_bridge
|
||||||
|
|
||||||
COPY . /src
|
FROM golang:${GO_VERSION}-alpine AS go-builder
|
||||||
|
|
||||||
|
WORKDIR /src/backend_go
|
||||||
|
|
||||||
|
COPY backend_go /src/backend_go
|
||||||
|
|
||||||
|
RUN go mod tidy
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/backend ./
|
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/backend ./
|
||||||
|
|
||||||
FROM alpine:3.20
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates curl
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /out/backend /app/backend
|
COPY --from=go-builder /out/backend /app/backend
|
||||||
|
COPY --from=rust-builder /src/radial_sugiyama/target/release/radial_sugiyama_go_bridge /app/radial_sugiyama_go_bridge
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ Important variables:
|
|||||||
- `DEFAULT_NODE_LIMIT`, `DEFAULT_EDGE_LIMIT`
|
- `DEFAULT_NODE_LIMIT`, `DEFAULT_EDGE_LIMIT`
|
||||||
- `MAX_NODE_LIMIT`, `MAX_EDGE_LIMIT`
|
- `MAX_NODE_LIMIT`, `MAX_EDGE_LIMIT`
|
||||||
- SPARQL connectivity:
|
- SPARQL connectivity:
|
||||||
|
- `SPARQL_SOURCE_MODE` (`local` or `external`)
|
||||||
- `SPARQL_HOST` (default `http://anzograph:8080`) or `SPARQL_ENDPOINT`
|
- `SPARQL_HOST` (default `http://anzograph:8080`) or `SPARQL_ENDPOINT`
|
||||||
|
- `EXTERNAL_SPARQL_ENDPOINT` for external AnzoGraph access
|
||||||
|
- `KEYCLOAK_TOKEN_ENDPOINT`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_USERNAME`, `KEYCLOAK_PASSWORD`, `KEYCLOAK_SCOPE`
|
||||||
- `SPARQL_USER`, `SPARQL_PASS`
|
- `SPARQL_USER`, `SPARQL_PASS`
|
||||||
|
- External mode fetches a bearer token from Keycloak at startup, sends `Authorization: Bearer ...` to `EXTERNAL_SPARQL_ENDPOINT`, and refreshes once on `401 Unauthorized: Jwt is expired`
|
||||||
- Startup behavior:
|
- Startup behavior:
|
||||||
- `SPARQL_LOAD_ON_START`, `SPARQL_CLEAR_ON_START`
|
- `SPARQL_LOAD_ON_START`
|
||||||
- `SPARQL_DATA_FILE` (typically `file:///opt/shared-files/<file>.ttl`)
|
- `SPARQL_DATA_FILE` (typically `file:///opt/shared-files/<file>.ttl`)
|
||||||
- Other:
|
- Other:
|
||||||
- `INCLUDE_BNODES` (include blank nodes in snapshots)
|
- `INCLUDE_BNODES` (include blank nodes in snapshots)
|
||||||
@@ -68,9 +72,9 @@ Stored under `backend_go/graph_queries/` and listed by `GET /api/graph_queries`.
|
|||||||
|
|
||||||
Built-in modes:
|
Built-in modes:
|
||||||
|
|
||||||
- `default` – `rdf:type` (to `owl:Class`) + `rdfs:subClassOf`
|
- `default` – `rdf:type` + `rdfs:subClassOf`
|
||||||
- `hierarchy` – `rdfs:subClassOf` only
|
- `hierarchy` – `rdfs:subClassOf` + `rdf:type`
|
||||||
- `types` – `rdf:type` (to `owl:Class`) only
|
- `types` – `rdf:type` only
|
||||||
|
|
||||||
To add a new mode:
|
To add a new mode:
|
||||||
|
|
||||||
@@ -94,5 +98,6 @@ To add a new mode:
|
|||||||
|
|
||||||
## Performance notes
|
## Performance notes
|
||||||
|
|
||||||
- Memory usage is dominated by the cached snapshot (`[]Node`, `[]Edge`) and the temporary SPARQL JSON unmarshalling step.
|
- Memory usage is dominated by the cached snapshot (`[]Node`, `[]Edge`) and large SPARQL result sets.
|
||||||
|
- The backend streams SPARQL JSON bindings for snapshot edge batches to reduce decode overhead.
|
||||||
- Tune `DEFAULT_NODE_LIMIT`/`DEFAULT_EDGE_LIMIT` first if memory is too high.
|
- Tune `DEFAULT_NODE_LIMIT`/`DEFAULT_EDGE_LIMIT` first if memory is too high.
|
||||||
|
|||||||
@@ -18,21 +18,39 @@ type Config struct {
|
|||||||
MaxNodeLimit int
|
MaxNodeLimit int
|
||||||
MaxEdgeLimit int
|
MaxEdgeLimit int
|
||||||
|
|
||||||
|
EdgeBatchSize int
|
||||||
|
|
||||||
|
FreeOSMemoryAfterSnapshot bool
|
||||||
|
LogSnapshotTimings bool
|
||||||
|
|
||||||
|
SparqlSourceMode string
|
||||||
SparqlHost string
|
SparqlHost string
|
||||||
SparqlEndpoint string
|
SparqlEndpoint string
|
||||||
|
ExternalSparqlEndpoint string
|
||||||
|
AccessToken string
|
||||||
|
KeycloakTokenEndpoint string
|
||||||
|
KeycloakClientID string
|
||||||
|
KeycloakUsername string
|
||||||
|
KeycloakPassword string
|
||||||
|
KeycloakScope string
|
||||||
SparqlUser string
|
SparqlUser string
|
||||||
SparqlPass string
|
SparqlPass string
|
||||||
SparqlInsecureTLS bool
|
SparqlInsecureTLS bool
|
||||||
SparqlDataFile string
|
SparqlDataFile string
|
||||||
SparqlGraphIRI string
|
SparqlGraphIRI string
|
||||||
SparqlLoadOnStart bool
|
SparqlLoadOnStart bool
|
||||||
SparqlClearOnStart bool
|
|
||||||
|
|
||||||
SparqlTimeout time.Duration
|
SparqlTimeout time.Duration
|
||||||
SparqlReadyRetries int
|
SparqlReadyRetries int
|
||||||
SparqlReadyDelay time.Duration
|
SparqlReadyDelay time.Duration
|
||||||
SparqlReadyTimeout time.Duration
|
SparqlReadyTimeout time.Duration
|
||||||
|
|
||||||
|
HierarchyLayoutEngine string
|
||||||
|
HierarchyLayoutBridgeBin string
|
||||||
|
HierarchyLayoutBridgeWorkdir string
|
||||||
|
HierarchyLayoutTimeout time.Duration
|
||||||
|
HierarchyLayoutRootIRI string
|
||||||
|
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,16 +63,31 @@ func LoadConfig() (Config, error) {
|
|||||||
DefaultEdgeLimit: envInt("DEFAULT_EDGE_LIMIT", 2_000_000),
|
DefaultEdgeLimit: envInt("DEFAULT_EDGE_LIMIT", 2_000_000),
|
||||||
MaxNodeLimit: envInt("MAX_NODE_LIMIT", 10_000_000),
|
MaxNodeLimit: envInt("MAX_NODE_LIMIT", 10_000_000),
|
||||||
MaxEdgeLimit: envInt("MAX_EDGE_LIMIT", 20_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),
|
||||||
|
|
||||||
|
SparqlSourceMode: envString("SPARQL_SOURCE_MODE", "local"),
|
||||||
SparqlHost: envString("SPARQL_HOST", "http://anzograph:8080"),
|
SparqlHost: envString("SPARQL_HOST", "http://anzograph:8080"),
|
||||||
SparqlEndpoint: envString("SPARQL_ENDPOINT", ""),
|
SparqlEndpoint: envString("SPARQL_ENDPOINT", ""),
|
||||||
|
ExternalSparqlEndpoint: envString("EXTERNAL_SPARQL_ENDPOINT", ""),
|
||||||
|
AccessToken: envString("ACCESS_TOKEN", ""),
|
||||||
|
KeycloakTokenEndpoint: envString("KEYCLOAK_TOKEN_ENDPOINT", ""),
|
||||||
|
KeycloakClientID: envString("KEYCLOAK_CLIENT_ID", ""),
|
||||||
|
KeycloakUsername: envString("KEYCLOAK_USERNAME", ""),
|
||||||
|
KeycloakPassword: envString("KEYCLOAK_PASSWORD", ""),
|
||||||
|
KeycloakScope: envString("KEYCLOAK_SCOPE", "openid"),
|
||||||
SparqlUser: envString("SPARQL_USER", ""),
|
SparqlUser: envString("SPARQL_USER", ""),
|
||||||
SparqlPass: envString("SPARQL_PASS", ""),
|
SparqlPass: envString("SPARQL_PASS", ""),
|
||||||
SparqlInsecureTLS: envBool("SPARQL_INSECURE_TLS", false),
|
SparqlInsecureTLS: envBool("SPARQL_INSECURE_TLS", false),
|
||||||
SparqlDataFile: envString("SPARQL_DATA_FILE", ""),
|
SparqlDataFile: envString("SPARQL_DATA_FILE", ""),
|
||||||
SparqlGraphIRI: envString("SPARQL_GRAPH_IRI", ""),
|
SparqlGraphIRI: envString("SPARQL_GRAPH_IRI", ""),
|
||||||
SparqlLoadOnStart: envBool("SPARQL_LOAD_ON_START", false),
|
SparqlLoadOnStart: envBool("SPARQL_LOAD_ON_START", false),
|
||||||
SparqlClearOnStart: envBool("SPARQL_CLEAR_ON_START", false),
|
|
||||||
|
HierarchyLayoutEngine: envString("HIERARCHY_LAYOUT_ENGINE", "go"),
|
||||||
|
HierarchyLayoutBridgeBin: envString("HIERARCHY_LAYOUT_BRIDGE_BIN", "/app/radial_sugiyama_go_bridge"),
|
||||||
|
HierarchyLayoutBridgeWorkdir: envString("HIERARCHY_LAYOUT_BRIDGE_WORKDIR", "/workspace/radial_sugiyama"),
|
||||||
|
HierarchyLayoutRootIRI: envString("HIERARCHY_LAYOUT_ROOT_IRI", "http://purl.obolibrary.org/obo/BFO_0000001"),
|
||||||
|
|
||||||
SparqlReadyRetries: envInt("SPARQL_READY_RETRIES", 30),
|
SparqlReadyRetries: envInt("SPARQL_READY_RETRIES", 30),
|
||||||
ListenAddr: envString("LISTEN_ADDR", ":8000"),
|
ListenAddr: envString("LISTEN_ADDR", ":8000"),
|
||||||
@@ -73,10 +106,43 @@ func LoadConfig() (Config, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
|
cfg.HierarchyLayoutTimeout, err = envSeconds("HIERARCHY_LAYOUT_TIMEOUT_S", 60)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.SparqlLoadOnStart && strings.TrimSpace(cfg.SparqlDataFile) == "" {
|
if cfg.SparqlLoadOnStart && strings.TrimSpace(cfg.SparqlDataFile) == "" {
|
||||||
return Config{}, fmt.Errorf("SPARQL_LOAD_ON_START=true but SPARQL_DATA_FILE is not set")
|
return Config{}, fmt.Errorf("SPARQL_LOAD_ON_START=true but SPARQL_DATA_FILE is not set")
|
||||||
}
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(cfg.SparqlSourceMode)) {
|
||||||
|
case "local", "external":
|
||||||
|
cfg.SparqlSourceMode = strings.ToLower(strings.TrimSpace(cfg.SparqlSourceMode))
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("SPARQL_SOURCE_MODE must be 'local' or 'external'")
|
||||||
|
}
|
||||||
|
if cfg.UsesExternalSparql() {
|
||||||
|
if strings.TrimSpace(cfg.ExternalSparqlEndpoint) == "" {
|
||||||
|
return Config{}, fmt.Errorf("EXTERNAL_SPARQL_ENDPOINT must be set when SPARQL_SOURCE_MODE=external")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.KeycloakTokenEndpoint) == "" {
|
||||||
|
return Config{}, fmt.Errorf("KEYCLOAK_TOKEN_ENDPOINT must be set when SPARQL_SOURCE_MODE=external")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.KeycloakClientID) == "" {
|
||||||
|
return Config{}, fmt.Errorf("KEYCLOAK_CLIENT_ID must be set when SPARQL_SOURCE_MODE=external")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.KeycloakUsername) == "" {
|
||||||
|
return Config{}, fmt.Errorf("KEYCLOAK_USERNAME must be set when SPARQL_SOURCE_MODE=external")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.KeycloakPassword) == "" {
|
||||||
|
return Config{}, fmt.Errorf("KEYCLOAK_PASSWORD must be set when SPARQL_SOURCE_MODE=external")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.KeycloakScope) == "" {
|
||||||
|
cfg.KeycloakScope = "openid"
|
||||||
|
}
|
||||||
|
if cfg.SparqlLoadOnStart {
|
||||||
|
return Config{}, fmt.Errorf("SPARQL_LOAD_ON_START is not supported when SPARQL_SOURCE_MODE=external")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.DefaultNodeLimit < 1 {
|
if cfg.DefaultNodeLimit < 1 {
|
||||||
return Config{}, fmt.Errorf("DEFAULT_NODE_LIMIT must be >= 1")
|
return Config{}, fmt.Errorf("DEFAULT_NODE_LIMIT must be >= 1")
|
||||||
@@ -96,17 +162,48 @@ func LoadConfig() (Config, error) {
|
|||||||
if cfg.DefaultEdgeLimit > cfg.MaxEdgeLimit {
|
if cfg.DefaultEdgeLimit > cfg.MaxEdgeLimit {
|
||||||
return Config{}, fmt.Errorf("DEFAULT_EDGE_LIMIT must be <= MAX_EDGE_LIMIT")
|
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")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(cfg.HierarchyLayoutEngine)) {
|
||||||
|
case "go", "rust":
|
||||||
|
cfg.HierarchyLayoutEngine = strings.ToLower(strings.TrimSpace(cfg.HierarchyLayoutEngine))
|
||||||
|
default:
|
||||||
|
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_ENGINE must be 'go' or 'rust'")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.HierarchyLayoutBridgeBin) == "" {
|
||||||
|
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_BRIDGE_BIN must not be empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.HierarchyLayoutBridgeWorkdir) == "" {
|
||||||
|
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_BRIDGE_WORKDIR must not be empty")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.HierarchyLayoutRootIRI) == "" {
|
||||||
|
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_ROOT_IRI must not be empty")
|
||||||
|
}
|
||||||
|
if cfg.HierarchyLayoutTimeout <= 0 {
|
||||||
|
return Config{}, fmt.Errorf("HIERARCHY_LAYOUT_TIMEOUT_S must be > 0")
|
||||||
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) EffectiveSparqlEndpoint() string {
|
func (c Config) EffectiveSparqlEndpoint() string {
|
||||||
|
if c.UsesExternalSparql() {
|
||||||
|
return strings.TrimSpace(c.ExternalSparqlEndpoint)
|
||||||
|
}
|
||||||
if strings.TrimSpace(c.SparqlEndpoint) != "" {
|
if strings.TrimSpace(c.SparqlEndpoint) != "" {
|
||||||
return strings.TrimSpace(c.SparqlEndpoint)
|
return strings.TrimSpace(c.SparqlEndpoint)
|
||||||
}
|
}
|
||||||
return strings.TrimRight(c.SparqlHost, "/") + "/sparql"
|
return strings.TrimRight(c.SparqlHost, "/") + "/sparql"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c Config) UsesExternalSparql() bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(c.SparqlSourceMode), "external")
|
||||||
|
}
|
||||||
|
|
||||||
func (c Config) corsOriginList() []string {
|
func (c Config) corsOriginList() []string {
|
||||||
raw := strings.TrimSpace(c.CorsOrigins)
|
raw := strings.TrimSpace(c.CorsOrigins)
|
||||||
if raw == "" || raw == "*" {
|
if raw == "" || raw == "*" {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
module visualizador_instanciados/backend_go
|
module visualizador_instanciados/backend_go
|
||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
|
require github.com/apache/arrow/go/v17 v17.0.0
|
||||||
|
|||||||
359
backend_go/graph_arrow.go
Normal file
359
backend_go/graph_arrow.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/apache/arrow/go/v17/arrow"
|
||||||
|
"github.com/apache/arrow/go/v17/arrow/array"
|
||||||
|
"github.com/apache/arrow/go/v17/arrow/ipc"
|
||||||
|
"github.com/apache/arrow/go/v17/arrow/memory"
|
||||||
|
)
|
||||||
|
|
||||||
|
const graphTransportVersion = "1"
|
||||||
|
|
||||||
|
const (
|
||||||
|
graphArrowMetaBackendField = iota
|
||||||
|
graphArrowMetaGraphQueryIDField
|
||||||
|
graphArrowMetaNodeLimitField
|
||||||
|
graphArrowMetaEdgeLimitField
|
||||||
|
graphArrowMetaNodesField
|
||||||
|
graphArrowMetaEdgesField
|
||||||
|
graphArrowMetaRouteLineSegmentsField
|
||||||
|
graphArrowNodeIDField
|
||||||
|
graphArrowNodeXField
|
||||||
|
graphArrowNodeYField
|
||||||
|
graphArrowNodeIRIField
|
||||||
|
graphArrowNodeLabelField
|
||||||
|
graphArrowEdgeSourceField
|
||||||
|
graphArrowEdgeTargetField
|
||||||
|
graphArrowRouteX1Field
|
||||||
|
graphArrowRouteY1Field
|
||||||
|
graphArrowRouteX2Field
|
||||||
|
graphArrowRouteY2Field
|
||||||
|
)
|
||||||
|
|
||||||
|
var graphArrowSchema = arrow.NewSchema([]arrow.Field{
|
||||||
|
{Name: "meta_backend", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||||
|
{Name: "meta_graph_query_id", Type: arrow.BinaryTypes.String, Nullable: true},
|
||||||
|
{Name: "meta_node_limit", Type: arrow.PrimitiveTypes.Int32, Nullable: true},
|
||||||
|
{Name: "meta_edge_limit", Type: arrow.PrimitiveTypes.Int32, Nullable: true},
|
||||||
|
{Name: "meta_nodes", Type: arrow.PrimitiveTypes.Int32, Nullable: true},
|
||||||
|
{Name: "meta_edges", Type: arrow.PrimitiveTypes.Int32, Nullable: true},
|
||||||
|
{Name: "meta_route_line_segments", Type: arrow.PrimitiveTypes.Int32, Nullable: true},
|
||||||
|
{Name: "node_id", Type: arrow.ListOf(arrow.PrimitiveTypes.Uint32), Nullable: true},
|
||||||
|
{Name: "node_x", Type: arrow.ListOf(arrow.PrimitiveTypes.Float32), Nullable: true},
|
||||||
|
{Name: "node_y", Type: arrow.ListOf(arrow.PrimitiveTypes.Float32), Nullable: true},
|
||||||
|
{Name: "node_iri", Type: arrow.ListOf(arrow.BinaryTypes.String), Nullable: true},
|
||||||
|
{Name: "node_label", Type: arrow.ListOf(arrow.BinaryTypes.String), Nullable: true},
|
||||||
|
{Name: "edge_source", Type: arrow.ListOf(arrow.PrimitiveTypes.Uint32), Nullable: true},
|
||||||
|
{Name: "edge_target", Type: arrow.ListOf(arrow.PrimitiveTypes.Uint32), Nullable: true},
|
||||||
|
{Name: "route_x1", Type: arrow.ListOf(arrow.PrimitiveTypes.Float32), Nullable: true},
|
||||||
|
{Name: "route_y1", Type: arrow.ListOf(arrow.PrimitiveTypes.Float32), Nullable: true},
|
||||||
|
{Name: "route_x2", Type: arrow.ListOf(arrow.PrimitiveTypes.Float32), Nullable: true},
|
||||||
|
{Name: "route_y2", Type: arrow.ListOf(arrow.PrimitiveTypes.Float32), Nullable: true},
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
type graphArrowRouteLines struct {
|
||||||
|
X1 []float32
|
||||||
|
Y1 []float32
|
||||||
|
X2 []float32
|
||||||
|
Y2 []float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGraphArrow(w http.ResponseWriter, snap GraphResponse) error {
|
||||||
|
start := time.Now()
|
||||||
|
routes := flattenGraphRouteLines(snap.RouteSegments)
|
||||||
|
meta := snap.Meta
|
||||||
|
if meta == nil {
|
||||||
|
meta = &GraphMeta{
|
||||||
|
GraphQueryID: "default",
|
||||||
|
NodeLimit: len(snap.Nodes),
|
||||||
|
EdgeLimit: len(snap.Edges),
|
||||||
|
Nodes: len(snap.Nodes),
|
||||||
|
Edges: len(snap.Edges),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alloc := memory.NewGoAllocator()
|
||||||
|
w.Header().Set("Content-Type", "application/vnd.apache.arrow.stream")
|
||||||
|
w.Header().Set("X-Graph-Transport-Version", graphTransportVersion)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
writer := ipc.NewWriter(w, ipc.WithSchema(graphArrowSchema))
|
||||||
|
closed := false
|
||||||
|
defer func() {
|
||||||
|
if !closed {
|
||||||
|
_ = writer.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := writeGraphArrowMetaBatch(writer, alloc, meta, len(routes.X1)); err != nil {
|
||||||
|
return fmt.Errorf("write meta batch failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeGraphArrowNodeBatch(writer, alloc, snap.Nodes); err != nil {
|
||||||
|
return fmt.Errorf("write node batch failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeGraphArrowEdgeBatch(writer, alloc, snap.Edges); err != nil {
|
||||||
|
return fmt.Errorf("write edge batch failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := writeGraphArrowRouteBatch(writer, alloc, routes); err != nil {
|
||||||
|
return fmt.Errorf("write route batch failed: %w", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close arrow writer failed: %w", err)
|
||||||
|
}
|
||||||
|
closed = true
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"[graph-arrow] encode_done graph_query_id=%s nodes=%d edges=%d route_line_segments=%d encode_time=%s",
|
||||||
|
meta.GraphQueryID,
|
||||||
|
len(snap.Nodes),
|
||||||
|
len(snap.Edges),
|
||||||
|
len(routes.X1),
|
||||||
|
time.Since(start).Truncate(time.Millisecond),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGraphArrowMetaBatch(writer *ipc.Writer, alloc memory.Allocator, meta *GraphMeta, routeLineSegments int) error {
|
||||||
|
builder := array.NewRecordBuilder(alloc, graphArrowSchema)
|
||||||
|
defer builder.Release()
|
||||||
|
|
||||||
|
appendStringBuilderValue(builder.Field(graphArrowMetaBackendField), meta.Backend)
|
||||||
|
appendStringBuilderValue(builder.Field(graphArrowMetaGraphQueryIDField), meta.GraphQueryID)
|
||||||
|
appendInt32BuilderValue(builder.Field(graphArrowMetaNodeLimitField), int32(meta.NodeLimit))
|
||||||
|
appendInt32BuilderValue(builder.Field(graphArrowMetaEdgeLimitField), int32(meta.EdgeLimit))
|
||||||
|
appendInt32BuilderValue(builder.Field(graphArrowMetaNodesField), int32(meta.Nodes))
|
||||||
|
appendInt32BuilderValue(builder.Field(graphArrowMetaEdgesField), int32(meta.Edges))
|
||||||
|
appendInt32BuilderValue(builder.Field(graphArrowMetaRouteLineSegmentsField), int32(routeLineSegments))
|
||||||
|
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeIDField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeXField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeYField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeIRIField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeLabelField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowEdgeSourceField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowEdgeTargetField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteX1Field))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteY1Field))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteX2Field))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteY2Field))
|
||||||
|
|
||||||
|
return writeGraphArrowRecord(writer, builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGraphArrowNodeBatch(writer *ipc.Writer, alloc memory.Allocator, nodes []Node) error {
|
||||||
|
builder := array.NewRecordBuilder(alloc, graphArrowSchema)
|
||||||
|
defer builder.Release()
|
||||||
|
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaBackendField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaGraphQueryIDField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaNodeLimitField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaEdgeLimitField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaNodesField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaEdgesField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaRouteLineSegmentsField))
|
||||||
|
|
||||||
|
appendUint32List(builder.Field(graphArrowNodeIDField), func(valueBuilder *array.Uint32Builder) {
|
||||||
|
for _, node := range nodes {
|
||||||
|
valueBuilder.Append(node.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appendFloat32List(builder.Field(graphArrowNodeXField), func(valueBuilder *array.Float32Builder) {
|
||||||
|
for _, node := range nodes {
|
||||||
|
valueBuilder.Append(float32(node.X))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appendFloat32List(builder.Field(graphArrowNodeYField), func(valueBuilder *array.Float32Builder) {
|
||||||
|
for _, node := range nodes {
|
||||||
|
valueBuilder.Append(float32(node.Y))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appendStringList(builder.Field(graphArrowNodeIRIField), func(valueBuilder *array.StringBuilder) {
|
||||||
|
for _, node := range nodes {
|
||||||
|
valueBuilder.Append(node.IRI)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appendNullableStringList(builder.Field(graphArrowNodeLabelField), func(valueBuilder *array.StringBuilder) {
|
||||||
|
for _, node := range nodes {
|
||||||
|
if node.Label == nil {
|
||||||
|
valueBuilder.AppendNull()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
valueBuilder.Append(*node.Label)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowEdgeSourceField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowEdgeTargetField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteX1Field))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteY1Field))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteX2Field))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteY2Field))
|
||||||
|
|
||||||
|
return writeGraphArrowRecord(writer, builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGraphArrowEdgeBatch(writer *ipc.Writer, alloc memory.Allocator, edges []Edge) error {
|
||||||
|
builder := array.NewRecordBuilder(alloc, graphArrowSchema)
|
||||||
|
defer builder.Release()
|
||||||
|
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaBackendField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaGraphQueryIDField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaNodeLimitField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaEdgeLimitField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaNodesField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaEdgesField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaRouteLineSegmentsField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeIDField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeXField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeYField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeIRIField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeLabelField))
|
||||||
|
|
||||||
|
appendUint32List(builder.Field(graphArrowEdgeSourceField), func(valueBuilder *array.Uint32Builder) {
|
||||||
|
for _, edge := range edges {
|
||||||
|
valueBuilder.Append(edge.Source)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appendUint32List(builder.Field(graphArrowEdgeTargetField), func(valueBuilder *array.Uint32Builder) {
|
||||||
|
for _, edge := range edges {
|
||||||
|
valueBuilder.Append(edge.Target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteX1Field))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteY1Field))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteX2Field))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowRouteY2Field))
|
||||||
|
|
||||||
|
return writeGraphArrowRecord(writer, builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGraphArrowRouteBatch(writer *ipc.Writer, alloc memory.Allocator, routes graphArrowRouteLines) error {
|
||||||
|
builder := array.NewRecordBuilder(alloc, graphArrowSchema)
|
||||||
|
defer builder.Release()
|
||||||
|
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaBackendField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaGraphQueryIDField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaNodeLimitField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaEdgeLimitField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaNodesField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaEdgesField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowMetaRouteLineSegmentsField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeIDField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeXField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeYField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeIRIField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowNodeLabelField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowEdgeSourceField))
|
||||||
|
appendNullTopLevel(builder.Field(graphArrowEdgeTargetField))
|
||||||
|
|
||||||
|
appendFloat32List(builder.Field(graphArrowRouteX1Field), func(valueBuilder *array.Float32Builder) {
|
||||||
|
for _, value := range routes.X1 {
|
||||||
|
valueBuilder.Append(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appendFloat32List(builder.Field(graphArrowRouteY1Field), func(valueBuilder *array.Float32Builder) {
|
||||||
|
for _, value := range routes.Y1 {
|
||||||
|
valueBuilder.Append(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appendFloat32List(builder.Field(graphArrowRouteX2Field), func(valueBuilder *array.Float32Builder) {
|
||||||
|
for _, value := range routes.X2 {
|
||||||
|
valueBuilder.Append(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
appendFloat32List(builder.Field(graphArrowRouteY2Field), func(valueBuilder *array.Float32Builder) {
|
||||||
|
for _, value := range routes.Y2 {
|
||||||
|
valueBuilder.Append(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return writeGraphArrowRecord(writer, builder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeGraphArrowRecord(writer *ipc.Writer, builder *array.RecordBuilder) error {
|
||||||
|
record := builder.NewRecord()
|
||||||
|
defer record.Release()
|
||||||
|
return writer.Write(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendStringBuilderValue(builder array.Builder, value string) {
|
||||||
|
builder.(*array.StringBuilder).Append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendInt32BuilderValue(builder array.Builder, value int32) {
|
||||||
|
builder.(*array.Int32Builder).Append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendUint32List(builder array.Builder, appendValues func(*array.Uint32Builder)) {
|
||||||
|
listBuilder := builder.(*array.ListBuilder)
|
||||||
|
listBuilder.Append(true)
|
||||||
|
valueBuilder := listBuilder.ValueBuilder().(*array.Uint32Builder)
|
||||||
|
appendValues(valueBuilder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendFloat32List(builder array.Builder, appendValues func(*array.Float32Builder)) {
|
||||||
|
listBuilder := builder.(*array.ListBuilder)
|
||||||
|
listBuilder.Append(true)
|
||||||
|
valueBuilder := listBuilder.ValueBuilder().(*array.Float32Builder)
|
||||||
|
appendValues(valueBuilder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendStringList(builder array.Builder, appendValues func(*array.StringBuilder)) {
|
||||||
|
listBuilder := builder.(*array.ListBuilder)
|
||||||
|
listBuilder.Append(true)
|
||||||
|
valueBuilder := listBuilder.ValueBuilder().(*array.StringBuilder)
|
||||||
|
appendValues(valueBuilder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendNullableStringList(builder array.Builder, appendValues func(*array.StringBuilder)) {
|
||||||
|
listBuilder := builder.(*array.ListBuilder)
|
||||||
|
listBuilder.Append(true)
|
||||||
|
valueBuilder := listBuilder.ValueBuilder().(*array.StringBuilder)
|
||||||
|
appendValues(valueBuilder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendNullTopLevel(builder array.Builder) {
|
||||||
|
switch typed := builder.(type) {
|
||||||
|
case *array.StringBuilder:
|
||||||
|
typed.AppendNull()
|
||||||
|
case *array.Int32Builder:
|
||||||
|
typed.AppendNull()
|
||||||
|
case *array.ListBuilder:
|
||||||
|
typed.AppendNull()
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("unsupported top-level builder type %T", builder))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenGraphRouteLines(routeSegments []RouteSegment) graphArrowRouteLines {
|
||||||
|
lineCount := 0
|
||||||
|
for _, route := range routeSegments {
|
||||||
|
if len(route.Points) > 1 {
|
||||||
|
lineCount += len(route.Points) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := graphArrowRouteLines{
|
||||||
|
X1: make([]float32, 0, lineCount),
|
||||||
|
Y1: make([]float32, 0, lineCount),
|
||||||
|
X2: make([]float32, 0, lineCount),
|
||||||
|
Y2: make([]float32, 0, lineCount),
|
||||||
|
}
|
||||||
|
for _, route := range routeSegments {
|
||||||
|
for i := 1; i < len(route.Points); i++ {
|
||||||
|
prev := route.Points[i-1]
|
||||||
|
curr := route.Points[i]
|
||||||
|
out.X1 = append(out.X1, float32(prev.X))
|
||||||
|
out.Y1 = append(out.Y1, float32(prev.Y))
|
||||||
|
out.X2 = append(out.X2, float32(curr.X))
|
||||||
|
out.Y2 = append(out.Y2, float32(curr.Y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -5,89 +5,95 @@ type termKey struct {
|
|||||||
key string
|
key string
|
||||||
}
|
}
|
||||||
|
|
||||||
type termMeta struct {
|
type edgeKey struct {
|
||||||
termType string
|
source uint32
|
||||||
iri string
|
target uint32
|
||||||
|
predicateID uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
func graphFromSparqlBindings(
|
type graphAccumulator struct {
|
||||||
bindings []map[string]sparqlTerm,
|
includeBNodes bool
|
||||||
nodeLimit int,
|
nodeLimit int
|
||||||
includeBNodes bool,
|
nodeIDByKey map[termKey]uint32
|
||||||
) (nodes []Node, edges []Edge) {
|
seenEdges map[edgeKey]struct{}
|
||||||
nodeIDByKey := map[termKey]int{}
|
nodes []Node
|
||||||
nodeMeta := make([]termMeta, 0, min(nodeLimit, 4096))
|
edges []Edge
|
||||||
|
preds *PredicateDict
|
||||||
|
}
|
||||||
|
|
||||||
getOrAdd := func(term sparqlTerm) (int, bool) {
|
func newGraphAccumulator(nodeLimit int, includeBNodes bool, edgeCapHint int, preds *PredicateDict) *graphAccumulator {
|
||||||
if term.Type == "" || term.Value == "" {
|
if preds == nil {
|
||||||
return 0, false
|
preds = NewPredicateDict(nil)
|
||||||
}
|
}
|
||||||
if term.Type == "literal" {
|
return &graphAccumulator{
|
||||||
return 0, false
|
includeBNodes: includeBNodes,
|
||||||
}
|
nodeLimit: nodeLimit,
|
||||||
|
nodeIDByKey: make(map[termKey]uint32),
|
||||||
|
seenEdges: make(map[edgeKey]struct{}, min(edgeCapHint, 4096)),
|
||||||
|
nodes: make([]Node, 0, min(nodeLimit, 4096)),
|
||||||
|
edges: make([]Edge, 0, min(edgeCapHint, 4096)),
|
||||||
|
preds: preds,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var key termKey
|
func (g *graphAccumulator) getOrAddNode(term sparqlTerm) (uint32, bool) {
|
||||||
var meta termMeta
|
if term.Type == "" || term.Value == "" {
|
||||||
|
return 0, false
|
||||||
if term.Type == "bnode" {
|
}
|
||||||
if !includeBNodes {
|
if term.Type == "literal" {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
|
||||||
key = termKey{termType: "bnode", key: term.Value}
|
|
||||||
meta = termMeta{termType: "bnode", iri: "_:" + term.Value}
|
|
||||||
} else {
|
|
||||||
key = termKey{termType: "uri", key: term.Value}
|
|
||||||
meta = termMeta{termType: "uri", iri: term.Value}
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing, ok := nodeIDByKey[key]; ok {
|
|
||||||
return existing, true
|
|
||||||
}
|
|
||||||
if len(nodeMeta) >= nodeLimit {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
nid := len(nodeMeta)
|
|
||||||
nodeIDByKey[key] = nid
|
|
||||||
nodeMeta = append(nodeMeta, meta)
|
|
||||||
return nid, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, b := range bindings {
|
var key termKey
|
||||||
sTerm := b["s"]
|
var node Node
|
||||||
oTerm := b["o"]
|
|
||||||
pTerm := b["p"]
|
|
||||||
|
|
||||||
sid, okS := getOrAdd(sTerm)
|
if term.Type == "bnode" {
|
||||||
oid, okO := getOrAdd(oTerm)
|
if !g.includeBNodes {
|
||||||
if !okS || !okO {
|
return 0, false
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
key = termKey{termType: "bnode", key: term.Value}
|
||||||
pred := pTerm.Value
|
node = Node{ID: 0, TermType: "bnode", IRI: "_:" + term.Value, Label: nil, X: 0, Y: 0}
|
||||||
if pred == "" {
|
} else {
|
||||||
continue
|
key = termKey{termType: "uri", key: term.Value}
|
||||||
}
|
node = Node{ID: 0, TermType: "uri", IRI: term.Value, Label: nil, X: 0, Y: 0}
|
||||||
|
|
||||||
edges = append(edges, Edge{
|
|
||||||
Source: sid,
|
|
||||||
Target: oid,
|
|
||||||
Predicate: pred,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes = make([]Node, len(nodeMeta))
|
if existing, ok := g.nodeIDByKey[key]; ok {
|
||||||
for i, m := range nodeMeta {
|
return existing, true
|
||||||
nodes[i] = Node{
|
}
|
||||||
ID: i,
|
if len(g.nodes) >= g.nodeLimit {
|
||||||
TermType: m.termType,
|
return 0, false
|
||||||
IRI: m.iri,
|
}
|
||||||
Label: nil,
|
nid := uint32(len(g.nodes))
|
||||||
X: 0,
|
g.nodeIDByKey[key] = nid
|
||||||
Y: 0,
|
node.ID = nid
|
||||||
}
|
g.nodes = append(g.nodes, node)
|
||||||
|
return nid, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *graphAccumulator) addTripleBinding(binding sparqlTripleBinding) {
|
||||||
|
sid, okS := g.getOrAddNode(binding.S)
|
||||||
|
oid, okO := g.getOrAddNode(binding.O)
|
||||||
|
if !okS || !okO {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes, edges
|
predID, ok := g.preds.GetOrAdd(binding.P.Value)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := edgeKey{source: sid, target: oid, predicateID: predID}
|
||||||
|
if _, seen := g.seenEdges[key]; seen {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.seenEdges[key] = struct{}{}
|
||||||
|
|
||||||
|
g.edges = append(g.edges, Edge{
|
||||||
|
Source: sid,
|
||||||
|
Target: oid,
|
||||||
|
PredicateID: predID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func min(a, b int) int {
|
func min(a, b int) int {
|
||||||
|
|||||||
24
backend_go/graph_export_test.go
Normal file
24
backend_go/graph_export_test.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGraphAccumulatorDeduplicatesEdges(t *testing.T) {
|
||||||
|
preds := NewPredicateDict([]string{"http://example.com/p"})
|
||||||
|
acc := newGraphAccumulator(16, false, 16, preds)
|
||||||
|
|
||||||
|
binding := sparqlTripleBinding{
|
||||||
|
S: sparqlTerm{Type: "uri", Value: "http://example.com/s"},
|
||||||
|
P: sparqlTerm{Type: "uri", Value: "http://example.com/p"},
|
||||||
|
O: sparqlTerm{Type: "uri", Value: "http://example.com/o"},
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.addTripleBinding(binding)
|
||||||
|
acc.addTripleBinding(binding)
|
||||||
|
|
||||||
|
if len(acc.nodes) != 2 {
|
||||||
|
t.Fatalf("expected 2 nodes after duplicate bindings, got %d", len(acc.nodes))
|
||||||
|
}
|
||||||
|
if len(acc.edges) != 1 {
|
||||||
|
t.Fatalf("expected 1 deduplicated edge, got %d", len(acc.edges))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,75 @@
|
|||||||
package graph_queries
|
package graph_queries
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
func defaultEdgeQuery(edgeLimit int, includeBNodes bool) string {
|
"visualizador_instanciados/backend_go/queryscope"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultEdgeQuery(limit int, offset int, includeBNodes bool) string {
|
||||||
bnodeFilter := ""
|
bnodeFilter := ""
|
||||||
if !includeBNodes {
|
if !includeBNodes {
|
||||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pattern := queryscope.NamedGraph(`
|
||||||
|
{
|
||||||
|
VALUES ?p { rdf:type }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
VALUES ?p { rdfs:subClassOf }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
return fmt.Sprintf(`
|
||||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
||||||
|
|
||||||
SELECT ?s ?p ?o
|
SELECT DISTINCT ?s ?p ?o
|
||||||
WHERE {
|
WHERE {
|
||||||
{
|
%s
|
||||||
VALUES ?p { rdf:type }
|
|
||||||
?s ?p ?o .
|
|
||||||
?o rdf:type owl:Class .
|
|
||||||
}
|
|
||||||
UNION
|
|
||||||
{
|
|
||||||
VALUES ?p { rdfs:subClassOf }
|
|
||||||
?s ?p ?o .
|
|
||||||
}
|
|
||||||
FILTER(!isLiteral(?o))
|
FILTER(!isLiteral(?o))
|
||||||
%s
|
%s
|
||||||
}
|
}
|
||||||
|
ORDER BY ?s ?p ?o
|
||||||
LIMIT %d
|
LIMIT %d
|
||||||
`, bnodeFilter, edgeLimit)
|
OFFSET %d
|
||||||
|
`, pattern, bnodeFilter, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultPredicateQuery(includeBNodes bool) string {
|
||||||
|
bnodeFilter := ""
|
||||||
|
if !includeBNodes {
|
||||||
|
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := queryscope.NamedGraph(`
|
||||||
|
{
|
||||||
|
VALUES ?p { rdf:type }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
VALUES ?p { rdfs:subClassOf }
|
||||||
|
?s ?p ?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 {
|
||||||
|
%s
|
||||||
|
FILTER(!isLiteral(?o))
|
||||||
|
%s
|
||||||
|
}
|
||||||
|
ORDER BY ?p
|
||||||
|
`, pattern, bnodeFilter)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,24 +1,71 @@
|
|||||||
package graph_queries
|
package graph_queries
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
func hierarchyEdgeQuery(edgeLimit int, includeBNodes bool) string {
|
"visualizador_instanciados/backend_go/queryscope"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hierarchyEdgeQuery(limit int, offset int, includeBNodes bool) string {
|
||||||
bnodeFilter := ""
|
bnodeFilter := ""
|
||||||
if !includeBNodes {
|
if !includeBNodes {
|
||||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pattern := queryscope.NamedGraph(`
|
||||||
|
{
|
||||||
|
VALUES ?p { rdfs:subClassOf }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
VALUES ?p { rdf:type }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
return fmt.Sprintf(`
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||||
|
|
||||||
SELECT ?s ?p ?o
|
SELECT DISTINCT ?s ?p ?o
|
||||||
WHERE {
|
WHERE {
|
||||||
VALUES ?p { rdfs:subClassOf }
|
%s
|
||||||
?s ?p ?o .
|
|
||||||
FILTER(!isLiteral(?o))
|
FILTER(!isLiteral(?o))
|
||||||
%s
|
%s
|
||||||
}
|
}
|
||||||
|
ORDER BY ?s ?p ?o
|
||||||
LIMIT %d
|
LIMIT %d
|
||||||
`, bnodeFilter, edgeLimit)
|
OFFSET %d
|
||||||
|
`, pattern, bnodeFilter, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hierarchyPredicateQuery(includeBNodes bool) string {
|
||||||
|
bnodeFilter := ""
|
||||||
|
if !includeBNodes {
|
||||||
|
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := queryscope.NamedGraph(`
|
||||||
|
{
|
||||||
|
VALUES ?p { rdfs:subClassOf }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
VALUES ?p { rdf:type }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
`)
|
||||||
|
|
||||||
|
return fmt.Sprintf(`
|
||||||
|
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||||
|
|
||||||
|
SELECT DISTINCT ?p
|
||||||
|
WHERE {
|
||||||
|
%s
|
||||||
|
FILTER(!isLiteral(?o))
|
||||||
|
%s
|
||||||
|
}
|
||||||
|
ORDER BY ?p
|
||||||
|
`, pattern, bnodeFilter)
|
||||||
|
}
|
||||||
|
|||||||
49
backend_go/graph_queries/named_graph_test.go
Normal file
49
backend_go/graph_queries/named_graph_test.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package graph_queries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEdgeQueriesUseNamedGraphsAndDistinct(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{name: "default", query: defaultEdgeQuery(100, 25, false)},
|
||||||
|
{name: "hierarchy", query: hierarchyEdgeQuery(100, 25, false)},
|
||||||
|
{name: "types_only", query: typesOnlyEdgeQuery(100, 25, false)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if !strings.Contains(tt.query, "SELECT DISTINCT ?s ?p ?o") {
|
||||||
|
t.Fatalf("%s edge query should de-duplicate triples across named graphs:\n%s", tt.name, tt.query)
|
||||||
|
}
|
||||||
|
if !strings.Contains(tt.query, "GRAPH ?g") {
|
||||||
|
t.Fatalf("%s edge query should read from named graphs:\n%s", tt.name, tt.query)
|
||||||
|
}
|
||||||
|
if strings.Contains(tt.query, "owl:Class") {
|
||||||
|
t.Fatalf("%s edge query should no longer require owl:Class declarations:\n%s", tt.name, tt.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPredicateQueriesUseNamedGraphs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{name: "default", query: defaultPredicateQuery(false)},
|
||||||
|
{name: "hierarchy", query: hierarchyPredicateQuery(false)},
|
||||||
|
{name: "types_only", query: typesOnlyPredicateQuery(false)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if !strings.Contains(tt.query, "SELECT DISTINCT ?p") {
|
||||||
|
t.Fatalf("%s predicate query should remain distinct:\n%s", tt.name, tt.query)
|
||||||
|
}
|
||||||
|
if !strings.Contains(tt.query, "GRAPH ?g") {
|
||||||
|
t.Fatalf("%s predicate query should read from named graphs:\n%s", tt.name, tt.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,9 @@ package graph_queries
|
|||||||
const DefaultID = "default"
|
const DefaultID = "default"
|
||||||
|
|
||||||
var definitions = []Definition{
|
var definitions = []Definition{
|
||||||
{Meta: Meta{ID: DefaultID, Label: "Default"}, EdgeQuery: defaultEdgeQuery},
|
{Meta: Meta{ID: DefaultID, Label: "Default"}, EdgeQuery: defaultEdgeQuery, PredicateQuery: defaultPredicateQuery},
|
||||||
{Meta: Meta{ID: "hierarchy", Label: "Hierarchy"}, EdgeQuery: hierarchyEdgeQuery},
|
{Meta: Meta{ID: "hierarchy", Label: "Hierarchy"}, EdgeQuery: hierarchyEdgeQuery, PredicateQuery: hierarchyPredicateQuery},
|
||||||
{Meta: Meta{ID: "types", Label: "Types"}, EdgeQuery: typesOnlyEdgeQuery},
|
{Meta: Meta{ID: "types", Label: "Types"}, EdgeQuery: typesOnlyEdgeQuery, PredicateQuery: typesOnlyPredicateQuery},
|
||||||
}
|
}
|
||||||
|
|
||||||
func List() []Meta {
|
func List() []Meta {
|
||||||
@@ -24,4 +24,3 @@ func Get(id string) (Definition, bool) {
|
|||||||
}
|
}
|
||||||
return Definition{}, false
|
return Definition{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ type Meta struct {
|
|||||||
|
|
||||||
type Definition struct {
|
type Definition struct {
|
||||||
Meta Meta
|
Meta Meta
|
||||||
EdgeQuery func(edgeLimit int, includeBNodes bool) string
|
EdgeQuery func(limit int, offset int, includeBNodes bool) string
|
||||||
|
PredicateQuery func(includeBNodes bool) string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,59 @@
|
|||||||
package graph_queries
|
package graph_queries
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
func typesOnlyEdgeQuery(edgeLimit int, includeBNodes bool) string {
|
"visualizador_instanciados/backend_go/queryscope"
|
||||||
|
)
|
||||||
|
|
||||||
|
func typesOnlyEdgeQuery(limit int, offset int, includeBNodes bool) string {
|
||||||
bnodeFilter := ""
|
bnodeFilter := ""
|
||||||
if !includeBNodes {
|
if !includeBNodes {
|
||||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pattern := queryscope.NamedGraph(`
|
||||||
|
VALUES ?p { rdf:type }
|
||||||
|
?s ?p ?o .
|
||||||
|
`)
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
return fmt.Sprintf(`
|
||||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
||||||
|
|
||||||
SELECT ?s ?p ?o
|
SELECT DISTINCT ?s ?p ?o
|
||||||
WHERE {
|
WHERE {
|
||||||
VALUES ?p { rdf:type }
|
%s
|
||||||
?s ?p ?o .
|
|
||||||
?o rdf:type owl:Class .
|
|
||||||
FILTER(!isLiteral(?o))
|
FILTER(!isLiteral(?o))
|
||||||
%s
|
%s
|
||||||
}
|
}
|
||||||
|
ORDER BY ?s ?p ?o
|
||||||
LIMIT %d
|
LIMIT %d
|
||||||
`, bnodeFilter, edgeLimit)
|
OFFSET %d
|
||||||
|
`, pattern, bnodeFilter, limit, offset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func typesOnlyPredicateQuery(includeBNodes bool) string {
|
||||||
|
bnodeFilter := ""
|
||||||
|
if !includeBNodes {
|
||||||
|
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := queryscope.NamedGraph(`
|
||||||
|
VALUES ?p { rdf:type }
|
||||||
|
?s ?p ?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 {
|
||||||
|
%s
|
||||||
|
FILTER(!isLiteral(?o))
|
||||||
|
%s
|
||||||
|
}
|
||||||
|
ORDER BY ?p
|
||||||
|
`, pattern, bnodeFilter)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
graphqueries "visualizador_instanciados/backend_go/graph_queries"
|
graphqueries "visualizador_instanciados/backend_go/graph_queries"
|
||||||
|
"visualizador_instanciados/backend_go/queryscope"
|
||||||
)
|
)
|
||||||
|
|
||||||
const rdfsLabelIRI = "http://www.w3.org/2000/01/rdf-schema#label"
|
const (
|
||||||
|
rdfsLabelIRI = "http://www.w3.org/2000/01/rdf-schema#label"
|
||||||
|
)
|
||||||
|
|
||||||
func fetchGraphSnapshot(
|
func fetchGraphSnapshot(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -20,56 +26,195 @@ func fetchGraphSnapshot(
|
|||||||
edgeLimit int,
|
edgeLimit int,
|
||||||
graphQueryID string,
|
graphQueryID string,
|
||||||
) (GraphResponse, error) {
|
) (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)
|
def, ok := graphqueries.Get(graphQueryID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return GraphResponse{}, fmt.Errorf("unknown graph_query_id: %s", graphQueryID)
|
return GraphResponse{}, fmt.Errorf("unknown graph_query_id: %s", graphQueryID)
|
||||||
}
|
}
|
||||||
edgesQ := def.EdgeQuery(edgeLimit, cfg.IncludeBNodes)
|
|
||||||
raw, err := sparql.Query(ctx, edgesQ)
|
// Build predicate dictionary (predicate IRI -> uint32 ID) before fetching edges.
|
||||||
|
preds, err := func() (*PredicateDict, error) {
|
||||||
|
logStats("predicates_query_start")
|
||||||
|
predQ := def.PredicateQuery(cfg.IncludeBNodes)
|
||||||
|
var predRes sparqlBindingsResponse[sparqlPredicateBinding]
|
||||||
|
metrics, err := sparql.QueryJSON(ctx, predQ, &predRes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("predicates query failed: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.LogSnapshotTimings {
|
||||||
|
log.Printf(
|
||||||
|
"[snapshot] predicates_query_done bytes=%d bindings=%d round_trip_time=%s decode_time=%s",
|
||||||
|
metrics.ResponseBytes,
|
||||||
|
len(predRes.Results.Bindings),
|
||||||
|
metrics.RoundTripTime.Truncate(time.Millisecond),
|
||||||
|
metrics.BodyDecodeTime.Truncate(time.Millisecond),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
predicateIRIs := make([]string, 0, len(predRes.Results.Bindings))
|
||||||
|
for _, b := range predRes.Results.Bindings {
|
||||||
|
if b.P.Type != "uri" || b.P.Value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
predicateIRIs = append(predicateIRIs, b.P.Value)
|
||||||
|
}
|
||||||
|
logStats("predicates_dict_built")
|
||||||
|
return NewPredicateDict(predicateIRIs), nil
|
||||||
|
}()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return GraphResponse{}, err
|
return GraphResponse{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var res sparqlResponse
|
// Fetch edges in batches to avoid decoding a single huge SPARQL JSON response.
|
||||||
if err := json.Unmarshal(raw, &res); err != nil {
|
logStats("edges_batched_start")
|
||||||
return GraphResponse{}, fmt.Errorf("failed to parse SPARQL JSON: %w", err)
|
batchSize := cfg.EdgeBatchSize
|
||||||
}
|
acc := newGraphAccumulator(nodeLimit, cfg.IncludeBNodes, min(edgeLimit, batchSize), preds)
|
||||||
|
|
||||||
nodes, edges := graphFromSparqlBindings(res.Results.Bindings, nodeLimit, cfg.IncludeBNodes)
|
totalBindings := 0
|
||||||
|
convAllT0 := time.Now()
|
||||||
// Layout: invert edges for hierarchy (target -> source).
|
for batch, offset := 0, 0; offset < edgeLimit; batch, offset = batch+1, offset+batchSize {
|
||||||
hierEdges := make([][2]int, 0, len(edges))
|
limit := batchSize
|
||||||
for _, e := range edges {
|
remaining := edgeLimit - offset
|
||||||
hierEdges = append(hierEdges, [2]int{e.Target, e.Source})
|
if remaining < limit {
|
||||||
}
|
limit = remaining
|
||||||
|
|
||||||
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
|
logStats(fmt.Sprintf("edges_batch_start batch=%d offset=%d limit=%d", batch, offset, limit))
|
||||||
|
edgesQ := def.EdgeQuery(limit, offset, cfg.IncludeBNodes)
|
||||||
|
var batchConvertTime time.Duration
|
||||||
|
metrics, err := sparql.QueryTripleBindingsStream(ctx, edgesQ, func(binding sparqlTripleBinding) error {
|
||||||
|
if !cfg.LogSnapshotTimings {
|
||||||
|
acc.addTripleBinding(binding)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
convertStart := time.Now()
|
||||||
|
acc.addTripleBinding(binding)
|
||||||
|
batchConvertTime += time.Since(convertStart)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return GraphResponse{}, fmt.Errorf("edges batch=%d offset=%d limit=%d: %w", batch, offset, limit, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := metrics.BindingCount
|
||||||
|
totalBindings += got
|
||||||
|
if got == 0 {
|
||||||
|
logStats(fmt.Sprintf("edges_batch_done_empty batch=%d offset=%d", batch, offset))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.LogSnapshotTimings {
|
||||||
|
log.Printf(
|
||||||
|
"[snapshot] edges_batch_stream_done batch=%d offset=%d limit=%d bytes=%d got_bindings=%d total_bindings=%d round_trip_time=%s stream_time=%s decode_overhead_time=%s convert_time=%s nodes=%d edges=%d",
|
||||||
|
batch,
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
metrics.ResponseBytes,
|
||||||
|
got,
|
||||||
|
totalBindings,
|
||||||
|
metrics.RoundTripTime.Truncate(time.Millisecond),
|
||||||
|
metrics.BodyDecodeTime.Truncate(time.Millisecond),
|
||||||
|
maxDuration(metrics.BodyDecodeTime-batchConvertTime, 0).Truncate(time.Millisecond),
|
||||||
|
batchConvertTime.Truncate(time.Millisecond),
|
||||||
|
len(acc.nodes),
|
||||||
|
len(acc.edges),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
if totalBindings == 0 {
|
||||||
|
log.Printf(
|
||||||
|
"[snapshot] empty_graph_result graph_query_id=%s endpoint=%s hint=app-generated reads now query named graphs only with GRAPH ?g; verify expected triples are present in named graphs and match the graph query shape",
|
||||||
|
graphQueryID,
|
||||||
|
cfg.EffectiveSparqlEndpoint(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
idToIRI := make([]string, len(nodes))
|
nodes := acc.nodes
|
||||||
for i := range nodes {
|
edges := acc.edges
|
||||||
idToIRI[i] = nodes[i].IRI
|
routeSegments := []RouteSegment(nil)
|
||||||
}
|
layoutEngine := "go"
|
||||||
for _, layer := range layers {
|
var layoutRootIRI *string
|
||||||
sortLayerByIRI(layer, idToIRI)
|
|
||||||
}
|
|
||||||
|
|
||||||
xs, ys := radialPositionsFromLayers(len(nodes), layers, 5000.0)
|
if shouldUseRustHierarchyLayout(cfg, graphQueryID) {
|
||||||
for i := range nodes {
|
layoutResult, err := layoutHierarchyWithRust(ctx, cfg, nodes, edges, preds)
|
||||||
nodes[i].X = xs[i]
|
if err != nil {
|
||||||
nodes[i].Y = ys[i]
|
return GraphResponse{}, err
|
||||||
|
}
|
||||||
|
nodes = layoutResult.Nodes
|
||||||
|
edges = layoutResult.Edges
|
||||||
|
routeSegments = layoutResult.RouteSegments
|
||||||
|
layoutEngine = rustHierarchyLayoutEngineID
|
||||||
|
rootIRI := cfg.HierarchyLayoutRootIRI
|
||||||
|
layoutRootIRI = &rootIRI
|
||||||
|
} else {
|
||||||
|
// 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.
|
// Attach labels for URI nodes.
|
||||||
@@ -82,7 +227,7 @@ func fetchGraphSnapshot(
|
|||||||
if len(iris) > 0 {
|
if len(iris) > 0 {
|
||||||
labelByIRI, err := fetchRDFSLabels(ctx, sparql, iris, 500)
|
labelByIRI, err := fetchRDFSLabels(ctx, sparql, iris, 500)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return GraphResponse{}, err
|
return GraphResponse{}, fmt.Errorf("fetch rdfs:label failed: %w", err)
|
||||||
}
|
}
|
||||||
for i := range nodes {
|
for i := range nodes {
|
||||||
if nodes[i].TermType != "uri" {
|
if nodes[i].TermType != "uri" {
|
||||||
@@ -103,13 +248,16 @@ func fetchGraphSnapshot(
|
|||||||
SparqlEndpoint: cfg.EffectiveSparqlEndpoint(),
|
SparqlEndpoint: cfg.EffectiveSparqlEndpoint(),
|
||||||
IncludeBNodes: cfg.IncludeBNodes,
|
IncludeBNodes: cfg.IncludeBNodes,
|
||||||
GraphQueryID: graphQueryID,
|
GraphQueryID: graphQueryID,
|
||||||
|
Predicates: preds.IRIs(),
|
||||||
NodeLimit: nodeLimit,
|
NodeLimit: nodeLimit,
|
||||||
EdgeLimit: edgeLimit,
|
EdgeLimit: edgeLimit,
|
||||||
Nodes: len(nodes),
|
Nodes: len(nodes),
|
||||||
Edges: len(edges),
|
Edges: len(edges),
|
||||||
|
LayoutEngine: layoutEngine,
|
||||||
|
LayoutRootIRI: layoutRootIRI,
|
||||||
}
|
}
|
||||||
|
|
||||||
return GraphResponse{Nodes: nodes, Edges: edges, Meta: meta}, nil
|
return GraphResponse{Nodes: nodes, Edges: edges, RouteSegments: routeSegments, Meta: meta}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type bestLabel struct {
|
type bestLabel struct {
|
||||||
@@ -132,43 +280,26 @@ func fetchRDFSLabels(
|
|||||||
}
|
}
|
||||||
batch := iris[i:end]
|
batch := iris[i:end]
|
||||||
|
|
||||||
values := make([]string, 0, len(batch))
|
q := rdfsLabelQuery(batch)
|
||||||
for _, u := range batch {
|
|
||||||
values = append(values, "<"+u+">")
|
|
||||||
}
|
|
||||||
|
|
||||||
q := fmt.Sprintf(`
|
var res sparqlBindingsResponse[sparqlLabelBinding]
|
||||||
SELECT ?s ?label
|
_, err := sparql.QueryJSON(ctx, q, &res)
|
||||||
WHERE {
|
|
||||||
VALUES ?s { %s }
|
|
||||||
?s <%s> ?label .
|
|
||||||
}
|
|
||||||
`, strings.Join(values, " "), rdfsLabelIRI)
|
|
||||||
|
|
||||||
raw, err := sparql.Query(ctx, q)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
for _, b := range res.Results.Bindings {
|
||||||
sTerm, ok := b["s"]
|
if b.S.Value == "" {
|
||||||
if !ok || sTerm.Value == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lblTerm, ok := b["label"]
|
if b.Label.Type != "literal" || b.Label.Value == "" {
|
||||||
if !ok || lblTerm.Type != "literal" || lblTerm.Value == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
score := labelScore(lblTerm.Lang)
|
score := labelScore(b.Label.Lang)
|
||||||
prev, ok := best[sTerm.Value]
|
prev, ok := best[b.S.Value]
|
||||||
if !ok || score > prev.score {
|
if !ok || score > prev.score {
|
||||||
best[sTerm.Value] = bestLabel{score: score, value: lblTerm.Value}
|
best[b.S.Value] = bestLabel{score: score, value: b.Label.Value}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +311,35 @@ WHERE {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rdfsLabelQuery(iris []string) string {
|
||||||
|
if len(iris) == 0 {
|
||||||
|
return "SELECT ?s ?label WHERE { FILTER(false) }"
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]string, 0, len(iris))
|
||||||
|
for _, u := range iris {
|
||||||
|
if strings.TrimSpace(u) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values = append(values, "<"+u+">")
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "SELECT ?s ?label WHERE { FILTER(false) }"
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := queryscope.NamedGraph(fmt.Sprintf(`
|
||||||
|
VALUES ?s { %s }
|
||||||
|
?s <%s> ?label .
|
||||||
|
`, strings.Join(values, " "), rdfsLabelIRI))
|
||||||
|
|
||||||
|
return fmt.Sprintf(`
|
||||||
|
SELECT DISTINCT ?s ?label
|
||||||
|
WHERE {
|
||||||
|
%s
|
||||||
|
}
|
||||||
|
`, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
func labelScore(lang string) int {
|
func labelScore(lang string) int {
|
||||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||||
if lang == "en" {
|
if lang == "en" {
|
||||||
@@ -206,3 +366,10 @@ func sortIntsUnique(xs []int) []int {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maxDuration(a time.Duration, b time.Duration) time.Duration {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|||||||
23
backend_go/graph_snapshot_named_graph_test.go
Normal file
23
backend_go/graph_snapshot_named_graph_test.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRDFSLabelQueryUsesNamedGraphs(t *testing.T) {
|
||||||
|
query := rdfsLabelQuery([]string{
|
||||||
|
"http://example.com/A",
|
||||||
|
"http://example.com/B",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !strings.Contains(query, "SELECT DISTINCT ?s ?label") {
|
||||||
|
t.Fatalf("label query should de-duplicate rows across named graphs:\n%s", query)
|
||||||
|
}
|
||||||
|
if !strings.Contains(query, "GRAPH ?g") {
|
||||||
|
t.Fatalf("label query should read from named graphs:\n%s", query)
|
||||||
|
}
|
||||||
|
if !strings.Contains(query, "<"+rdfsLabelIRI+">") {
|
||||||
|
t.Fatalf("label query should still fetch rdfs:label:\n%s", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
268
backend_go/hierarchy_layout_bridge.go
Normal file
268
backend_go/hierarchy_layout_bridge.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
hierarchyGraphQueryID = "hierarchy"
|
||||||
|
rustHierarchyLayoutEngineID = "rust_radial_sugiyama"
|
||||||
|
)
|
||||||
|
|
||||||
|
type hierarchyLayoutResult struct {
|
||||||
|
Nodes []Node
|
||||||
|
Edges []Edge
|
||||||
|
RouteSegments []RouteSegment
|
||||||
|
}
|
||||||
|
|
||||||
|
type hierarchyLayoutPrepared struct {
|
||||||
|
Request hierarchyLayoutRequest
|
||||||
|
NormalizedEdges []Edge
|
||||||
|
}
|
||||||
|
|
||||||
|
type hierarchyLayoutRequest struct {
|
||||||
|
RootIRI string `json:"root_iri"`
|
||||||
|
Nodes []hierarchyLayoutRequestNode `json:"nodes"`
|
||||||
|
Edges []hierarchyLayoutRequestEdge `json:"edges"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hierarchyLayoutRequestNode struct {
|
||||||
|
NodeID uint32 `json:"node_id"`
|
||||||
|
IRI string `json:"iri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hierarchyLayoutRequestEdge struct {
|
||||||
|
EdgeIndex int `json:"edge_index"`
|
||||||
|
ParentID uint32 `json:"parent_id"`
|
||||||
|
ChildID uint32 `json:"child_id"`
|
||||||
|
PredicateIRI *string `json:"predicate_iri,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hierarchyLayoutResponse struct {
|
||||||
|
Nodes []hierarchyLayoutResponseNode `json:"nodes"`
|
||||||
|
RouteSegments []hierarchyLayoutResponseRouteSegment `json:"route_segments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hierarchyLayoutResponseNode struct {
|
||||||
|
NodeID uint32 `json:"node_id"`
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
Level int `json:"level"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hierarchyLayoutResponseRouteSegment struct {
|
||||||
|
EdgeIndex int `json:"edge_index"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Points []hierarchyLayoutResponseRoutePoint `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hierarchyLayoutResponseRoutePoint struct {
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type hierarchyEdgeKey struct {
|
||||||
|
ParentID uint32
|
||||||
|
ChildID uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldUseRustHierarchyLayout(cfg Config, graphQueryID string) bool {
|
||||||
|
return cfg.HierarchyLayoutEngine == "rust" && graphQueryID == hierarchyGraphQueryID
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareHierarchyLayoutRequest(
|
||||||
|
rootIRI string,
|
||||||
|
nodes []Node,
|
||||||
|
edges []Edge,
|
||||||
|
preds *PredicateDict,
|
||||||
|
) hierarchyLayoutPrepared {
|
||||||
|
requestNodes := make([]hierarchyLayoutRequestNode, 0, len(nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
requestNodes = append(requestNodes, hierarchyLayoutRequestNode{
|
||||||
|
NodeID: node.ID,
|
||||||
|
IRI: node.IRI,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
predicateIRIs := []string(nil)
|
||||||
|
if preds != nil {
|
||||||
|
predicateIRIs = preds.IRIs()
|
||||||
|
}
|
||||||
|
|
||||||
|
seenEdges := make(map[hierarchyEdgeKey]struct{}, len(edges))
|
||||||
|
normalizedEdges := make([]Edge, 0, len(edges))
|
||||||
|
requestEdges := make([]hierarchyLayoutRequestEdge, 0, len(edges))
|
||||||
|
for _, edge := range edges {
|
||||||
|
parentID := edge.Target
|
||||||
|
childID := edge.Source
|
||||||
|
if parentID == childID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
key := hierarchyEdgeKey{ParentID: parentID, ChildID: childID}
|
||||||
|
if _, ok := seenEdges[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenEdges[key] = struct{}{}
|
||||||
|
|
||||||
|
normalizedEdges = append(normalizedEdges, edge)
|
||||||
|
|
||||||
|
var predicateIRI *string
|
||||||
|
if int(edge.PredicateID) >= 0 && int(edge.PredicateID) < len(predicateIRIs) {
|
||||||
|
value := predicateIRIs[edge.PredicateID]
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
predicateIRI = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestEdges = append(requestEdges, hierarchyLayoutRequestEdge{
|
||||||
|
EdgeIndex: len(normalizedEdges) - 1,
|
||||||
|
ParentID: parentID,
|
||||||
|
ChildID: childID,
|
||||||
|
PredicateIRI: predicateIRI,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return hierarchyLayoutPrepared{
|
||||||
|
Request: hierarchyLayoutRequest{
|
||||||
|
RootIRI: rootIRI,
|
||||||
|
Nodes: requestNodes,
|
||||||
|
Edges: requestEdges,
|
||||||
|
},
|
||||||
|
NormalizedEdges: normalizedEdges,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyHierarchyLayoutResponse(
|
||||||
|
nodes []Node,
|
||||||
|
normalizedEdges []Edge,
|
||||||
|
response hierarchyLayoutResponse,
|
||||||
|
) (hierarchyLayoutResult, error) {
|
||||||
|
positionByID := make(map[uint32]hierarchyLayoutResponseNode, len(response.Nodes))
|
||||||
|
for _, node := range response.Nodes {
|
||||||
|
if _, ok := positionByID[node.NodeID]; ok {
|
||||||
|
return hierarchyLayoutResult{}, fmt.Errorf("hierarchy layout bridge returned duplicate node_id %d", node.NodeID)
|
||||||
|
}
|
||||||
|
positionByID[node.NodeID] = node
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredNodes := make([]Node, 0, len(response.Nodes))
|
||||||
|
keptNodeIDs := make(map[uint32]struct{}, len(response.Nodes))
|
||||||
|
for _, node := range nodes {
|
||||||
|
position, ok := positionByID[node.ID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node.X = position.X
|
||||||
|
node.Y = position.Y
|
||||||
|
filteredNodes = append(filteredNodes, node)
|
||||||
|
keptNodeIDs[node.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(filteredNodes) != len(response.Nodes) {
|
||||||
|
return hierarchyLayoutResult{}, fmt.Errorf("hierarchy layout bridge returned unknown node ids")
|
||||||
|
}
|
||||||
|
|
||||||
|
filteredEdges := make([]Edge, 0, len(normalizedEdges))
|
||||||
|
normalizedToFilteredEdge := make(map[int]int, len(normalizedEdges))
|
||||||
|
for normalizedIndex, edge := range normalizedEdges {
|
||||||
|
if _, ok := keptNodeIDs[edge.Source]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := keptNodeIDs[edge.Target]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalizedToFilteredEdge[normalizedIndex] = len(filteredEdges)
|
||||||
|
filteredEdges = append(filteredEdges, edge)
|
||||||
|
}
|
||||||
|
|
||||||
|
routeSegments := make([]RouteSegment, 0, len(response.RouteSegments))
|
||||||
|
for _, segment := range response.RouteSegments {
|
||||||
|
filteredEdgeIndex, ok := normalizedToFilteredEdge[segment.EdgeIndex]
|
||||||
|
if !ok {
|
||||||
|
return hierarchyLayoutResult{}, fmt.Errorf("hierarchy layout bridge returned route for unknown edge_index %d", segment.EdgeIndex)
|
||||||
|
}
|
||||||
|
points := make([]RoutePoint, 0, len(segment.Points))
|
||||||
|
for _, point := range segment.Points {
|
||||||
|
points = append(points, RoutePoint{
|
||||||
|
X: point.X,
|
||||||
|
Y: point.Y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
routeSegments = append(routeSegments, RouteSegment{
|
||||||
|
EdgeIndex: filteredEdgeIndex,
|
||||||
|
Kind: segment.Kind,
|
||||||
|
Points: points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return hierarchyLayoutResult{
|
||||||
|
Nodes: filteredNodes,
|
||||||
|
Edges: filteredEdges,
|
||||||
|
RouteSegments: routeSegments,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runHierarchyLayoutBridge(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg Config,
|
||||||
|
request hierarchyLayoutRequest,
|
||||||
|
) (hierarchyLayoutResponse, error) {
|
||||||
|
input, err := json.Marshal(request)
|
||||||
|
if err != nil {
|
||||||
|
return hierarchyLayoutResponse{}, fmt.Errorf("marshal hierarchy layout request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bridgeCtx, cancel := context.WithTimeout(ctx, cfg.HierarchyLayoutTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(bridgeCtx, cfg.HierarchyLayoutBridgeBin)
|
||||||
|
cmd.Dir = cfg.HierarchyLayoutBridgeWorkdir
|
||||||
|
cmd.Stdin = bytes.NewReader(input)
|
||||||
|
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
if bridgeCtx.Err() != nil {
|
||||||
|
return hierarchyLayoutResponse{}, fmt.Errorf("hierarchy layout bridge timed out after %s", cfg.HierarchyLayoutTimeout)
|
||||||
|
}
|
||||||
|
detail := strings.TrimSpace(stderr.String())
|
||||||
|
if detail == "" {
|
||||||
|
detail = err.Error()
|
||||||
|
}
|
||||||
|
return hierarchyLayoutResponse{}, fmt.Errorf("hierarchy layout bridge failed: %s", detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response hierarchyLayoutResponse
|
||||||
|
if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
|
||||||
|
detail := strings.TrimSpace(stderr.String())
|
||||||
|
if detail != "" {
|
||||||
|
return hierarchyLayoutResponse{}, fmt.Errorf("parse hierarchy layout bridge response failed: %v (stderr: %s)", err, detail)
|
||||||
|
}
|
||||||
|
return hierarchyLayoutResponse{}, fmt.Errorf("parse hierarchy layout bridge response failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func layoutHierarchyWithRust(
|
||||||
|
ctx context.Context,
|
||||||
|
cfg Config,
|
||||||
|
nodes []Node,
|
||||||
|
edges []Edge,
|
||||||
|
preds *PredicateDict,
|
||||||
|
) (hierarchyLayoutResult, error) {
|
||||||
|
prepared := prepareHierarchyLayoutRequest(cfg.HierarchyLayoutRootIRI, nodes, edges, preds)
|
||||||
|
response, err := runHierarchyLayoutBridge(ctx, cfg, prepared.Request)
|
||||||
|
if err != nil {
|
||||||
|
return hierarchyLayoutResult{}, err
|
||||||
|
}
|
||||||
|
return applyHierarchyLayoutResponse(nodes, prepared.NormalizedEdges, response)
|
||||||
|
}
|
||||||
158
backend_go/hierarchy_layout_bridge_test.go
Normal file
158
backend_go/hierarchy_layout_bridge_test.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrepareHierarchyLayoutRequestNormalizesEdges(t *testing.T) {
|
||||||
|
nodes := []Node{
|
||||||
|
{ID: 0, TermType: "uri", IRI: "http://example.com/root"},
|
||||||
|
{ID: 1, TermType: "uri", IRI: "http://example.com/child"},
|
||||||
|
{ID: 2, TermType: "uri", IRI: "http://example.com/leaf"},
|
||||||
|
}
|
||||||
|
preds := NewPredicateDict([]string{"http://www.w3.org/2000/01/rdf-schema#subClassOf"})
|
||||||
|
edges := []Edge{
|
||||||
|
{Source: 1, Target: 0, PredicateID: 0},
|
||||||
|
{Source: 1, Target: 0, PredicateID: 0},
|
||||||
|
{Source: 2, Target: 2, PredicateID: 0},
|
||||||
|
{Source: 2, Target: 1, PredicateID: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
prepared := prepareHierarchyLayoutRequest("http://example.com/root", nodes, edges, preds)
|
||||||
|
|
||||||
|
if got, want := len(prepared.Request.Nodes), 3; got != want {
|
||||||
|
t.Fatalf("len(request.nodes)=%d want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := len(prepared.Request.Edges), 2; got != want {
|
||||||
|
t.Fatalf("len(request.edges)=%d want %d", got, want)
|
||||||
|
}
|
||||||
|
if prepared.Request.Edges[0].ParentID != 0 || prepared.Request.Edges[0].ChildID != 1 {
|
||||||
|
t.Fatalf("first normalized edge = %+v, want parent=0 child=1", prepared.Request.Edges[0])
|
||||||
|
}
|
||||||
|
if prepared.Request.Edges[1].ParentID != 1 || prepared.Request.Edges[1].ChildID != 2 {
|
||||||
|
t.Fatalf("second normalized edge = %+v, want parent=1 child=2", prepared.Request.Edges[1])
|
||||||
|
}
|
||||||
|
if prepared.Request.Edges[0].PredicateIRI == nil || *prepared.Request.Edges[0].PredicateIRI == "" {
|
||||||
|
t.Fatalf("expected predicate iri to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyHierarchyLayoutResponsePreservesIDsAndRemapsRoutes(t *testing.T) {
|
||||||
|
nodes := []Node{
|
||||||
|
{ID: 0, TermType: "uri", IRI: "http://example.com/root"},
|
||||||
|
{ID: 1, TermType: "uri", IRI: "http://example.com/child"},
|
||||||
|
{ID: 2, TermType: "uri", IRI: "http://example.com/leaf"},
|
||||||
|
}
|
||||||
|
normalizedEdges := []Edge{
|
||||||
|
{Source: 1, Target: 0, PredicateID: 0},
|
||||||
|
{Source: 2, Target: 0, PredicateID: 0},
|
||||||
|
}
|
||||||
|
response := hierarchyLayoutResponse{
|
||||||
|
Nodes: []hierarchyLayoutResponseNode{
|
||||||
|
{NodeID: 0, X: 10, Y: 20},
|
||||||
|
{NodeID: 2, X: 30, Y: 40},
|
||||||
|
},
|
||||||
|
RouteSegments: []hierarchyLayoutResponseRouteSegment{
|
||||||
|
{
|
||||||
|
EdgeIndex: 1,
|
||||||
|
Kind: "spiral",
|
||||||
|
Points: []hierarchyLayoutResponseRoutePoint{
|
||||||
|
{X: 10, Y: 20},
|
||||||
|
{X: 30, Y: 40},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := applyHierarchyLayoutResponse(nodes, normalizedEdges, response)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("applyHierarchyLayoutResponse returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, want := len(result.Nodes), 2; got != want {
|
||||||
|
t.Fatalf("len(nodes)=%d want %d", got, want)
|
||||||
|
}
|
||||||
|
if result.Nodes[0].ID != 0 || result.Nodes[1].ID != 2 {
|
||||||
|
t.Fatalf("filtered node ids = [%d %d], want [0 2]", result.Nodes[0].ID, result.Nodes[1].ID)
|
||||||
|
}
|
||||||
|
if result.Nodes[0].X != 10 || result.Nodes[0].Y != 20 || result.Nodes[1].X != 30 || result.Nodes[1].Y != 40 {
|
||||||
|
t.Fatalf("positions were not applied to filtered nodes: %+v", result.Nodes)
|
||||||
|
}
|
||||||
|
if got, want := len(result.Edges), 1; got != want {
|
||||||
|
t.Fatalf("len(edges)=%d want %d", got, want)
|
||||||
|
}
|
||||||
|
if result.Edges[0] != normalizedEdges[1] {
|
||||||
|
t.Fatalf("filtered edge = %+v, want %+v", result.Edges[0], normalizedEdges[1])
|
||||||
|
}
|
||||||
|
if got, want := len(result.RouteSegments), 1; got != want {
|
||||||
|
t.Fatalf("len(route_segments)=%d want %d", got, want)
|
||||||
|
}
|
||||||
|
if result.RouteSegments[0].EdgeIndex != 0 {
|
||||||
|
t.Fatalf("route edge index = %d want 0", result.RouteSegments[0].EdgeIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHierarchyLayoutBridgeUsesConfiguredWorkingDirectory(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
outputPath := filepath.Join(tmpDir, "pwd.txt")
|
||||||
|
scriptPath := filepath.Join(tmpDir, "bridge.sh")
|
||||||
|
script := "#!/bin/sh\npwd > \"" + outputPath + "\"\ncat >/dev/null\nprintf '{\"nodes\":[{\"node_id\":1,\"x\":10,\"y\":20,\"level\":0}],\"route_segments\":[]}'\n"
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatalf("write script: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
HierarchyLayoutBridgeBin: scriptPath,
|
||||||
|
HierarchyLayoutBridgeWorkdir: tmpDir,
|
||||||
|
HierarchyLayoutTimeout: 2 * time.Second,
|
||||||
|
}
|
||||||
|
response, err := runHierarchyLayoutBridge(context.Background(), cfg, hierarchyLayoutRequest{
|
||||||
|
RootIRI: "root",
|
||||||
|
Nodes: []hierarchyLayoutRequestNode{
|
||||||
|
{NodeID: 1, IRI: "root"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runHierarchyLayoutBridge returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := len(response.Nodes), 1; got != want {
|
||||||
|
t.Fatalf("len(response.nodes)=%d want %d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
pwdBytes, err := os.ReadFile(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read pwd output: %v", err)
|
||||||
|
}
|
||||||
|
if got, want := strings.TrimSpace(string(pwdBytes)), tmpDir; got != want {
|
||||||
|
t.Fatalf("bridge working directory=%q want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunHierarchyLayoutBridgeReturnsSvgWriteFailure(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
scriptPath := filepath.Join(tmpDir, "bridge_fail.sh")
|
||||||
|
script := "#!/bin/sh\ncat >/dev/null\necho 'failed to write SVG output: permission denied' >&2\nexit 1\n"
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatalf("write script: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
HierarchyLayoutBridgeBin: scriptPath,
|
||||||
|
HierarchyLayoutBridgeWorkdir: tmpDir,
|
||||||
|
HierarchyLayoutTimeout: 2 * time.Second,
|
||||||
|
}
|
||||||
|
_, err := runHierarchyLayoutBridge(context.Background(), cfg, hierarchyLayoutRequest{
|
||||||
|
RootIRI: "root",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected hierarchy layout bridge error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to write SVG output") {
|
||||||
|
t.Fatalf("error=%q does not mention SVG write failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
149
backend_go/keycloak_token.go
Normal file
149
backend_go/keycloak_token.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keycloakTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type keycloakTokenManager struct {
|
||||||
|
cfg Config
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
token string
|
||||||
|
refreshCh chan struct{}
|
||||||
|
lastErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newKeycloakTokenManager(cfg Config, client *http.Client) *keycloakTokenManager {
|
||||||
|
return &keycloakTokenManager{
|
||||||
|
cfg: cfg,
|
||||||
|
client: client,
|
||||||
|
token: strings.TrimSpace(cfg.AccessToken),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *keycloakTokenManager) CurrentToken() string {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return strings.TrimSpace(m.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *keycloakTokenManager) EnsureToken(ctx context.Context, reason string) (string, error) {
|
||||||
|
if token := m.CurrentToken(); token != "" {
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
return m.Refresh(ctx, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *keycloakTokenManager) Refresh(ctx context.Context, reason string) (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
if ch := m.refreshCh; ch != nil {
|
||||||
|
m.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case <-ch:
|
||||||
|
m.mu.Lock()
|
||||||
|
token := strings.TrimSpace(m.token)
|
||||||
|
err := m.lastErr
|
||||||
|
m.mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return "", fmt.Errorf("keycloak token refresh completed without access_token")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan struct{})
|
||||||
|
m.refreshCh = ch
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[auth] keycloak_token_refresh_start reason=%s endpoint=%s", reason, m.cfg.KeycloakTokenEndpoint)
|
||||||
|
start := time.Now()
|
||||||
|
token, err := m.fetchToken(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[auth] keycloak_token_refresh_failed reason=%s endpoint=%s err=%v", reason, m.cfg.KeycloakTokenEndpoint, err)
|
||||||
|
} else {
|
||||||
|
log.Printf(
|
||||||
|
"[auth] keycloak_token_refresh_ok reason=%s endpoint=%s elapsed=%s",
|
||||||
|
reason,
|
||||||
|
m.cfg.KeycloakTokenEndpoint,
|
||||||
|
time.Since(start).Truncate(time.Millisecond),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
if err == nil {
|
||||||
|
m.token = token
|
||||||
|
}
|
||||||
|
m.lastErr = err
|
||||||
|
close(ch)
|
||||||
|
m.refreshCh = nil
|
||||||
|
currentToken := strings.TrimSpace(m.token)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return currentToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *keycloakTokenManager) fetchToken(ctx context.Context) (string, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("grant_type", "password")
|
||||||
|
form.Set("client_id", strings.TrimSpace(m.cfg.KeycloakClientID))
|
||||||
|
form.Set("username", strings.TrimSpace(m.cfg.KeycloakUsername))
|
||||||
|
form.Set("password", m.cfg.KeycloakPassword)
|
||||||
|
scope := strings.TrimSpace(m.cfg.KeycloakScope)
|
||||||
|
if scope != "" {
|
||||||
|
form.Set("scope", scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(m.cfg.KeycloakTokenEndpoint), strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := m.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return "", fmt.Errorf("keycloak token request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp keycloakTokenResponse
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return "", fmt.Errorf("keycloak token parse failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(tokenResp.AccessToken)
|
||||||
|
if token == "" {
|
||||||
|
return "", fmt.Errorf("keycloak token response missing access_token")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
273
backend_go/keycloak_token_test.go
Normal file
273
backend_go/keycloak_token_test.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKeycloakTokenManagerFetchTokenParsesAccessToken(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/token" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
||||||
|
t.Errorf("unexpected content-type: %s", got)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"access_token":"fresh-token"}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
KeycloakTokenEndpoint: server.URL + "/token",
|
||||||
|
KeycloakClientID: "anzograph",
|
||||||
|
KeycloakUsername: "user",
|
||||||
|
KeycloakPassword: "pass",
|
||||||
|
KeycloakScope: "openid",
|
||||||
|
}
|
||||||
|
manager := newKeycloakTokenManager(cfg, server.Client())
|
||||||
|
|
||||||
|
token, err := manager.fetchToken(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchToken returned error: %v", err)
|
||||||
|
}
|
||||||
|
if token != "fresh-token" {
|
||||||
|
t.Fatalf("expected fresh-token, got %q", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnzoGraphClientStartupFetchesFreshToken(t *testing.T) {
|
||||||
|
var tokenCalls atomic.Int32
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
tokenCalls.Add(1)
|
||||||
|
_, _ = io.WriteString(w, `{"access_token":"startup-token"}`)
|
||||||
|
case "/sparql":
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer startup-token" {
|
||||||
|
t.Errorf("expected startup bearer token, got %q", got)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"head":{},"boolean":true}`)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
SparqlSourceMode: "external",
|
||||||
|
ExternalSparqlEndpoint: server.URL + "/sparql",
|
||||||
|
KeycloakTokenEndpoint: server.URL + "/token",
|
||||||
|
KeycloakClientID: "anzograph",
|
||||||
|
KeycloakUsername: "user",
|
||||||
|
KeycloakPassword: "pass",
|
||||||
|
KeycloakScope: "openid",
|
||||||
|
SparqlReadyTimeout: 2 * time.Second,
|
||||||
|
SparqlReadyRetries: 1,
|
||||||
|
SparqlReadyDelay: 1 * time.Millisecond,
|
||||||
|
SparqlTimeout: 2 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewAnzoGraphClient(cfg)
|
||||||
|
client.client = server.Client()
|
||||||
|
client.tokenManager.client = server.Client()
|
||||||
|
|
||||||
|
if err := client.Startup(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Startup returned error: %v", err)
|
||||||
|
}
|
||||||
|
if tokenCalls.Load() != 1 {
|
||||||
|
t.Fatalf("expected 1 startup token request, got %d", tokenCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryRetriesOnceWhenJWTExpires(t *testing.T) {
|
||||||
|
var tokenCalls atomic.Int32
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
call := tokenCalls.Add(1)
|
||||||
|
if call != 1 {
|
||||||
|
t.Errorf("expected exactly 1 refresh call, got %d", call)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(w, `{"access_token":"fresh-token"}`)
|
||||||
|
case "/sparql":
|
||||||
|
switch r.Header.Get("Authorization") {
|
||||||
|
case "Bearer expired-token":
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = io.WriteString(w, "Jwt is expired")
|
||||||
|
case "Bearer fresh-token":
|
||||||
|
_, _ = io.WriteString(w, `{"results":{"bindings":[{"s":{"type":"uri","value":"http://example.com/s"},"p":{"type":"uri","value":"http://example.com/p"},"o":{"type":"uri","value":"http://example.com/o"}}]}}`)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected authorization header %q", r.Header.Get("Authorization"))
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
SparqlSourceMode: "external",
|
||||||
|
ExternalSparqlEndpoint: server.URL + "/sparql",
|
||||||
|
KeycloakTokenEndpoint: server.URL + "/token",
|
||||||
|
KeycloakClientID: "anzograph",
|
||||||
|
KeycloakUsername: "user",
|
||||||
|
KeycloakPassword: "pass",
|
||||||
|
KeycloakScope: "openid",
|
||||||
|
SparqlTimeout: 2 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewAnzoGraphClient(cfg)
|
||||||
|
client.client = server.Client()
|
||||||
|
client.tokenManager.client = server.Client()
|
||||||
|
client.tokenManager.token = "expired-token"
|
||||||
|
|
||||||
|
raw, err := client.Query(context.Background(), "SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Query returned error: %v", err)
|
||||||
|
}
|
||||||
|
if string(raw) == "" {
|
||||||
|
t.Fatalf("expected successful response body after refresh")
|
||||||
|
}
|
||||||
|
if tokenCalls.Load() != 1 {
|
||||||
|
t.Fatalf("expected 1 refresh call, got %d", tokenCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryDoesNotRefreshForNonExpiry401(t *testing.T) {
|
||||||
|
var tokenCalls atomic.Int32
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
tokenCalls.Add(1)
|
||||||
|
_, _ = io.WriteString(w, `{"access_token":"fresh-token"}`)
|
||||||
|
case "/sparql":
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = io.WriteString(w, "RBAC: access denied")
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
SparqlSourceMode: "external",
|
||||||
|
ExternalSparqlEndpoint: server.URL + "/sparql",
|
||||||
|
KeycloakTokenEndpoint: server.URL + "/token",
|
||||||
|
KeycloakClientID: "anzograph",
|
||||||
|
KeycloakUsername: "user",
|
||||||
|
KeycloakPassword: "pass",
|
||||||
|
KeycloakScope: "openid",
|
||||||
|
SparqlTimeout: 2 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewAnzoGraphClient(cfg)
|
||||||
|
client.client = server.Client()
|
||||||
|
client.tokenManager.client = server.Client()
|
||||||
|
client.tokenManager.token = "still-bad-token"
|
||||||
|
|
||||||
|
_, err := client.Query(context.Background(), "SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected non-expiry 401 to fail")
|
||||||
|
}
|
||||||
|
if tokenCalls.Load() != 0 {
|
||||||
|
t.Fatalf("expected no token refresh for non-expiry 401, got %d", tokenCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentExpiredQueriesShareOneRefresh(t *testing.T) {
|
||||||
|
var tokenCalls atomic.Int32
|
||||||
|
var sparqlCalls atomic.Int32
|
||||||
|
var mu sync.Mutex
|
||||||
|
seenFresh := 0
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
tokenCalls.Add(1)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
_, _ = io.WriteString(w, `{"access_token":"fresh-token"}`)
|
||||||
|
case "/sparql":
|
||||||
|
sparqlCalls.Add(1)
|
||||||
|
switch r.Header.Get("Authorization") {
|
||||||
|
case "Bearer expired-token":
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = io.WriteString(w, "Jwt is expired")
|
||||||
|
case "Bearer fresh-token":
|
||||||
|
mu.Lock()
|
||||||
|
seenFresh++
|
||||||
|
mu.Unlock()
|
||||||
|
_, _ = io.WriteString(w, `{"head":{},"boolean":true}`)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected authorization header %q", r.Header.Get("Authorization"))
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := Config{
|
||||||
|
SparqlSourceMode: "external",
|
||||||
|
ExternalSparqlEndpoint: server.URL + "/sparql",
|
||||||
|
KeycloakTokenEndpoint: server.URL + "/token",
|
||||||
|
KeycloakClientID: "anzograph",
|
||||||
|
KeycloakUsername: "user",
|
||||||
|
KeycloakPassword: "pass",
|
||||||
|
KeycloakScope: "openid",
|
||||||
|
SparqlTimeout: 2 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
client := NewAnzoGraphClient(cfg)
|
||||||
|
client.client = server.Client()
|
||||||
|
client.tokenManager.client = server.Client()
|
||||||
|
client.tokenManager.token = "expired-token"
|
||||||
|
|
||||||
|
const workers = 5
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, workers)
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, err := client.Query(context.Background(), "ASK WHERE { ?s ?p ?o }")
|
||||||
|
errs <- err
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("concurrent query returned error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tokenCalls.Load() != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 shared refresh, got %d", tokenCalls.Load())
|
||||||
|
}
|
||||||
|
if seenFresh != workers {
|
||||||
|
t.Fatalf("expected %d successful retried queries, got %d", workers, seenFresh)
|
||||||
|
}
|
||||||
|
if sparqlCalls.Load() < workers*2 {
|
||||||
|
t.Fatalf("expected each worker to hit sparql before and after refresh, got %d calls", sparqlCalls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
import selectionqueries "visualizador_instanciados/backend_go/selection_queries"
|
||||||
|
|
||||||
type ErrorResponse struct {
|
type ErrorResponse struct {
|
||||||
Detail string `json:"detail"`
|
Detail string `json:"detail"`
|
||||||
}
|
}
|
||||||
@@ -9,7 +11,7 @@ type HealthResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID int `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
TermType string `json:"termType"` // "uri" | "bnode"
|
TermType string `json:"termType"` // "uri" | "bnode"
|
||||||
IRI string `json:"iri"`
|
IRI string `json:"iri"`
|
||||||
Label *string `json:"label"`
|
Label *string `json:"label"`
|
||||||
@@ -18,27 +20,42 @@ type Node struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Edge struct {
|
type Edge struct {
|
||||||
Source int `json:"source"`
|
Source uint32 `json:"source"`
|
||||||
Target int `json:"target"`
|
Target uint32 `json:"target"`
|
||||||
Predicate string `json:"predicate"`
|
PredicateID uint32 `json:"predicate_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoutePoint struct {
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteSegment struct {
|
||||||
|
EdgeIndex int `json:"edge_index"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Points []RoutePoint `json:"points"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GraphMeta struct {
|
type GraphMeta struct {
|
||||||
Backend string `json:"backend"`
|
Backend string `json:"backend"`
|
||||||
TTLPath *string `json:"ttl_path"`
|
TTLPath *string `json:"ttl_path"`
|
||||||
SparqlEndpoint string `json:"sparql_endpoint"`
|
SparqlEndpoint string `json:"sparql_endpoint"`
|
||||||
IncludeBNodes bool `json:"include_bnodes"`
|
IncludeBNodes bool `json:"include_bnodes"`
|
||||||
GraphQueryID string `json:"graph_query_id"`
|
GraphQueryID string `json:"graph_query_id"`
|
||||||
NodeLimit int `json:"node_limit"`
|
Predicates []string `json:"predicates,omitempty"` // index = predicate_id
|
||||||
EdgeLimit int `json:"edge_limit"`
|
NodeLimit int `json:"node_limit"`
|
||||||
Nodes int `json:"nodes"`
|
EdgeLimit int `json:"edge_limit"`
|
||||||
Edges int `json:"edges"`
|
Nodes int `json:"nodes"`
|
||||||
|
Edges int `json:"edges"`
|
||||||
|
LayoutEngine string `json:"layout_engine,omitempty"`
|
||||||
|
LayoutRootIRI *string `json:"layout_root_iri,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GraphResponse struct {
|
type GraphResponse struct {
|
||||||
Nodes []Node `json:"nodes"`
|
Nodes []Node `json:"nodes"`
|
||||||
Edges []Edge `json:"edges"`
|
Edges []Edge `json:"edges"`
|
||||||
Meta *GraphMeta `json:"meta"`
|
RouteSegments []RouteSegment `json:"route_segments,omitempty"`
|
||||||
|
Meta *GraphMeta `json:"meta"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatsResponse struct {
|
type StatsResponse struct {
|
||||||
@@ -55,27 +72,33 @@ type SparqlQueryRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NeighborsRequest struct {
|
type NeighborsRequest struct {
|
||||||
SelectedIDs []int `json:"selected_ids"`
|
SelectedIDs []uint32 `json:"selected_ids"`
|
||||||
NodeLimit *int `json:"node_limit,omitempty"`
|
NodeLimit *int `json:"node_limit,omitempty"`
|
||||||
EdgeLimit *int `json:"edge_limit,omitempty"`
|
EdgeLimit *int `json:"edge_limit,omitempty"`
|
||||||
GraphQueryID *string `json:"graph_query_id,omitempty"`
|
GraphQueryID *string `json:"graph_query_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type NeighborsResponse struct {
|
type NeighborsResponse struct {
|
||||||
SelectedIDs []int `json:"selected_ids"`
|
SelectedIDs []uint32 `json:"selected_ids"`
|
||||||
NeighborIDs []int `json:"neighbor_ids"`
|
NeighborIDs []uint32 `json:"neighbor_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectionQueryRequest struct {
|
type SelectionQueryRequest struct {
|
||||||
QueryID string `json:"query_id"`
|
QueryID string `json:"query_id"`
|
||||||
SelectedIDs []int `json:"selected_ids"`
|
SelectedIDs []uint32 `json:"selected_ids"`
|
||||||
NodeLimit *int `json:"node_limit,omitempty"`
|
NodeLimit *int `json:"node_limit,omitempty"`
|
||||||
EdgeLimit *int `json:"edge_limit,omitempty"`
|
EdgeLimit *int `json:"edge_limit,omitempty"`
|
||||||
GraphQueryID *string `json:"graph_query_id,omitempty"`
|
GraphQueryID *string `json:"graph_query_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectionQueryResponse struct {
|
type SelectionQueryResponse struct {
|
||||||
QueryID string `json:"query_id"`
|
QueryID string `json:"query_id"`
|
||||||
SelectedIDs []int `json:"selected_ids"`
|
SelectedIDs []uint32 `json:"selected_ids"`
|
||||||
NeighborIDs []int `json:"neighbor_ids"`
|
NeighborIDs []uint32 `json:"neighbor_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectionTriplesResponse struct {
|
||||||
|
QueryID string `json:"query_id"`
|
||||||
|
SelectedIDs []uint32 `json:"selected_ids"`
|
||||||
|
Triples []selectionqueries.Triple `json:"triples"`
|
||||||
}
|
}
|
||||||
|
|||||||
40
backend_go/predicate_dict.go
Normal file
40
backend_go/predicate_dict.go
Normal 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
|
||||||
|
}
|
||||||
25
backend_go/queryscope/scope.go
Normal file
25
backend_go/queryscope/scope.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package queryscope
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// NamedGraph wraps a read pattern so app-generated queries read from any named graph via GRAPH ?g.
|
||||||
|
func NamedGraph(pattern string) string {
|
||||||
|
trimmed := strings.TrimSpace(pattern)
|
||||||
|
if trimmed == "" {
|
||||||
|
return " GRAPH ?g {\n }"
|
||||||
|
}
|
||||||
|
|
||||||
|
return indent("GRAPH ?g {\n"+indent(trimmed, " ")+"\n}", " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func AskAnyTripleQuery() string {
|
||||||
|
return "ASK WHERE {\n" + NamedGraph("?s ?p ?o .") + "\n}"
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(text string, prefix string) string {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
for i, line := range lines {
|
||||||
|
lines[i] = prefix + line
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package selection_queries
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -49,9 +50,9 @@ func termKeyFromSparqlTerm(term sparqlTerm, includeBNodes bool) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectedNodesFromIDs(idx Index, selectedIDs []int, includeBNodes bool) ([]NodeRef, map[int]struct{}) {
|
func selectedNodesFromIDs(idx Index, selectedIDs []uint32, includeBNodes bool) ([]NodeRef, map[uint32]struct{}) {
|
||||||
out := make([]NodeRef, 0, len(selectedIDs))
|
out := make([]NodeRef, 0, len(selectedIDs))
|
||||||
set := make(map[int]struct{}, len(selectedIDs))
|
set := make(map[uint32]struct{}, len(selectedIDs))
|
||||||
for _, nid := range selectedIDs {
|
for _, nid := range selectedIDs {
|
||||||
n, ok := idx.IDToNode[nid]
|
n, ok := idx.IDToNode[nid]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -66,37 +67,89 @@ func selectedNodesFromIDs(idx Index, selectedIDs []int, includeBNodes bool) ([]N
|
|||||||
return out, set
|
return out, set
|
||||||
}
|
}
|
||||||
|
|
||||||
func idsFromBindings(raw []byte, varName string, idx Index, selectedSet map[int]struct{}, includeBNodes bool) ([]int, error) {
|
func idFromSparqlTerm(term sparqlTerm, idx Index, includeBNodes bool) (uint32, bool) {
|
||||||
|
key, ok := termKeyFromSparqlTerm(term, includeBNodes)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
nid, ok := idx.KeyToID[key]
|
||||||
|
return nid, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func tripleTermFromSparqlTerm(term sparqlTerm) TripleTerm {
|
||||||
|
return TripleTerm{
|
||||||
|
Type: term.Type,
|
||||||
|
Value: term.Value,
|
||||||
|
Lang: term.Lang,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logQueryExecutionFailure(queryName string, selectedIDs []uint32, includeBNodes bool, sparql string, err error) {
|
||||||
|
log.Printf(
|
||||||
|
"%s: SPARQL execution failed selected_ids=%v include_bnodes=%t err=%v\nSPARQL:\n%s",
|
||||||
|
queryName,
|
||||||
|
selectedIDs,
|
||||||
|
includeBNodes,
|
||||||
|
err,
|
||||||
|
strings.TrimSpace(sparql),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resultFromTripleBindings(raw []byte, idx Index, selectedSet map[uint32]struct{}, includeBNodes bool) (Result, error) {
|
||||||
var res sparqlResponse
|
var res sparqlResponse
|
||||||
if err := json.Unmarshal(raw, &res); err != nil {
|
if err := json.Unmarshal(raw, &res); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse SPARQL JSON: %w", err)
|
return Result{}, fmt.Errorf("failed to parse SPARQL JSON: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
neighborSet := make(map[int]struct{})
|
neighborSet := make(map[uint32]struct{})
|
||||||
|
triples := make([]Triple, 0, len(res.Results.Bindings))
|
||||||
for _, b := range res.Results.Bindings {
|
for _, b := range res.Results.Bindings {
|
||||||
term, ok := b[varName]
|
sTerm, okS := b["s"]
|
||||||
if !ok {
|
pTerm, okP := b["p"]
|
||||||
|
oTerm, okO := b["o"]
|
||||||
|
if !okS || !okP || !okO {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key, ok := termKeyFromSparqlTerm(term, includeBNodes)
|
|
||||||
if !ok {
|
triple := Triple{
|
||||||
continue
|
S: tripleTermFromSparqlTerm(sTerm),
|
||||||
|
P: tripleTermFromSparqlTerm(pTerm),
|
||||||
|
O: tripleTermFromSparqlTerm(oTerm),
|
||||||
}
|
}
|
||||||
nid, ok := idx.KeyToID[key]
|
|
||||||
if !ok {
|
subjID, subjOK := idFromSparqlTerm(sTerm, idx, includeBNodes)
|
||||||
continue
|
if subjOK {
|
||||||
|
id := subjID
|
||||||
|
triple.SubjectID = &id
|
||||||
}
|
}
|
||||||
if _, sel := selectedSet[nid]; sel {
|
objID, objOK := idFromSparqlTerm(oTerm, idx, includeBNodes)
|
||||||
continue
|
if objOK {
|
||||||
|
id := objID
|
||||||
|
triple.ObjectID = &id
|
||||||
}
|
}
|
||||||
neighborSet[nid] = struct{}{}
|
if pTerm.Type == "uri" {
|
||||||
|
if predID, ok := idx.PredicateIDByIRI[pTerm.Value]; ok {
|
||||||
|
id := predID
|
||||||
|
triple.PredicateID = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, subjSelected := selectedSet[subjID]
|
||||||
|
_, objSelected := selectedSet[objID]
|
||||||
|
if subjOK && subjSelected && objOK && !objSelected {
|
||||||
|
neighborSet[objID] = struct{}{}
|
||||||
|
}
|
||||||
|
if objOK && objSelected && subjOK && !subjSelected {
|
||||||
|
neighborSet[subjID] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
triples = append(triples, triple)
|
||||||
}
|
}
|
||||||
|
|
||||||
ids := make([]int, 0, len(neighborSet))
|
ids := make([]uint32, 0, len(neighborSet))
|
||||||
for nid := range neighborSet {
|
for nid := range neighborSet {
|
||||||
ids = append(ids, nid)
|
ids = append(ids, nid)
|
||||||
}
|
}
|
||||||
sort.Ints(ids)
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||||
return ids, nil
|
return Result{NeighborIDs: ids, Triples: triples}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
backend_go/selection_queries/named_graph_test.go
Normal file
33
backend_go/selection_queries/named_graph_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package selection_queries
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSelectionQueriesUseNamedGraphs(t *testing.T) {
|
||||||
|
selected := []NodeRef{
|
||||||
|
{ID: 1, TermType: "uri", IRI: "http://example.com/A"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
}{
|
||||||
|
{name: "neighbors", query: neighborsQuery(selected, false)},
|
||||||
|
{name: "superclasses", query: superclassesQuery(selected, false)},
|
||||||
|
{name: "subclasses", query: subclassesQuery(selected, false)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if !strings.Contains(tt.query, "SELECT DISTINCT ?s ?p ?o") {
|
||||||
|
t.Fatalf("%s query should de-duplicate triples across named graphs:\n%s", tt.name, tt.query)
|
||||||
|
}
|
||||||
|
if !strings.Contains(tt.query, "GRAPH ?g") {
|
||||||
|
t.Fatalf("%s query should read from named graphs:\n%s", tt.name, tt.query)
|
||||||
|
}
|
||||||
|
if strings.Contains(tt.query, "owl:Class") {
|
||||||
|
t.Fatalf("%s query should no longer depend on owl:Class:\n%s", tt.name, tt.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"visualizador_instanciados/backend_go/queryscope"
|
||||||
)
|
)
|
||||||
|
|
||||||
func neighborsQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
func neighborsQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
||||||
@@ -17,61 +19,71 @@ func neighborsQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(valuesTerms) == 0 {
|
if len(valuesTerms) == 0 {
|
||||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
return "SELECT ?s ?p ?o WHERE { FILTER(false) }"
|
||||||
}
|
}
|
||||||
|
|
||||||
bnodeFilter := ""
|
bnodeFilter := ""
|
||||||
if !includeBNodes {
|
if !includeBNodes {
|
||||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||||
}
|
}
|
||||||
|
|
||||||
values := strings.Join(valuesTerms, " ")
|
values := strings.Join(valuesTerms, " ")
|
||||||
|
pattern := queryscope.NamedGraph(fmt.Sprintf(`
|
||||||
|
{
|
||||||
|
VALUES ?sel { %s }
|
||||||
|
BIND(?sel AS ?s)
|
||||||
|
VALUES ?p { rdf:type }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
VALUES ?sel { %s }
|
||||||
|
VALUES ?p { rdf:type }
|
||||||
|
?s ?p ?sel .
|
||||||
|
BIND(?sel AS ?o)
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
VALUES ?sel { %s }
|
||||||
|
BIND(?sel AS ?s)
|
||||||
|
VALUES ?p { rdfs:subClassOf }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
VALUES ?sel { %s }
|
||||||
|
VALUES ?p { rdfs:subClassOf }
|
||||||
|
?s ?p ?sel .
|
||||||
|
BIND(?sel AS ?o)
|
||||||
|
}
|
||||||
|
`, values, values, values, values))
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
return fmt.Sprintf(`
|
||||||
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
||||||
|
|
||||||
SELECT DISTINCT ?nbr
|
SELECT DISTINCT ?s ?p ?o
|
||||||
WHERE {
|
WHERE {
|
||||||
VALUES ?sel { %s }
|
%s
|
||||||
{
|
FILTER(!isLiteral(?o))
|
||||||
?sel rdf:type ?o .
|
FILTER(?s != ?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
|
%s
|
||||||
}
|
}
|
||||||
`, values, bnodeFilter)
|
`, pattern, bnodeFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runNeighbors(ctx context.Context, q Querier, idx Index, selectedIDs []int, includeBNodes bool) ([]int, error) {
|
func runNeighbors(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error) {
|
||||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
||||||
if len(selectedNodes) == 0 {
|
if len(selectedNodes) == 0 {
|
||||||
return []int{}, nil
|
return Result{NeighborIDs: []uint32{}, Triples: []Triple{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := q.Query(ctx, neighborsQuery(selectedNodes, includeBNodes))
|
query := neighborsQuery(selectedNodes, includeBNodes)
|
||||||
|
raw, err := q.Query(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
logQueryExecutionFailure("neighbors", selectedIDs, includeBNodes, query, err)
|
||||||
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return idsFromBindings(raw, "nbr", idx, selectedSet, includeBNodes)
|
return resultFromTripleBindings(raw, idx, selectedSet, includeBNodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"visualizador_instanciados/backend_go/queryscope"
|
||||||
)
|
)
|
||||||
|
|
||||||
func subclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
func subclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
||||||
@@ -17,39 +19,55 @@ func subclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(valuesTerms) == 0 {
|
if len(valuesTerms) == 0 {
|
||||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
return "SELECT ?s ?p ?o WHERE { FILTER(false) }"
|
||||||
}
|
}
|
||||||
|
|
||||||
bnodeFilter := ""
|
bnodeFilter := ""
|
||||||
if !includeBNodes {
|
if !includeBNodes {
|
||||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||||
}
|
}
|
||||||
|
|
||||||
values := strings.Join(valuesTerms, " ")
|
values := strings.Join(valuesTerms, " ")
|
||||||
|
pattern := queryscope.NamedGraph(fmt.Sprintf(`
|
||||||
|
{
|
||||||
|
VALUES ?sel { %s }
|
||||||
|
VALUES ?p { rdfs:subClassOf }
|
||||||
|
?s ?p ?sel .
|
||||||
|
BIND(?sel AS ?o)
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
VALUES ?sel { %s }
|
||||||
|
VALUES ?p { <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> }
|
||||||
|
?s ?p ?sel .
|
||||||
|
BIND(?sel AS ?o)
|
||||||
|
}
|
||||||
|
`, values, values))
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
return fmt.Sprintf(`
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||||
|
|
||||||
SELECT DISTINCT ?nbr
|
SELECT DISTINCT ?s ?p ?o
|
||||||
WHERE {
|
WHERE {
|
||||||
VALUES ?sel { %s }
|
%s
|
||||||
?nbr rdfs:subClassOf ?sel .
|
FILTER(!isLiteral(?o))
|
||||||
FILTER(!isLiteral(?nbr))
|
FILTER(?s != ?o)
|
||||||
FILTER(?nbr != ?sel)
|
|
||||||
%s
|
%s
|
||||||
}
|
}
|
||||||
`, values, bnodeFilter)
|
`, pattern, bnodeFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSubclasses(ctx context.Context, q Querier, idx Index, selectedIDs []int, includeBNodes bool) ([]int, error) {
|
func runSubclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error) {
|
||||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
||||||
if len(selectedNodes) == 0 {
|
if len(selectedNodes) == 0 {
|
||||||
return []int{}, nil
|
return Result{NeighborIDs: []uint32{}, Triples: []Triple{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := q.Query(ctx, subclassesQuery(selectedNodes, includeBNodes))
|
query := subclassesQuery(selectedNodes, includeBNodes)
|
||||||
|
raw, err := q.Query(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
logQueryExecutionFailure("subclasses", selectedIDs, includeBNodes, query, err)
|
||||||
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return idsFromBindings(raw, "nbr", idx, selectedSet, includeBNodes)
|
return resultFromTripleBindings(raw, idx, selectedSet, includeBNodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"visualizador_instanciados/backend_go/queryscope"
|
||||||
)
|
)
|
||||||
|
|
||||||
func superclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
func superclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
||||||
@@ -17,39 +19,55 @@ func superclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(valuesTerms) == 0 {
|
if len(valuesTerms) == 0 {
|
||||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
return "SELECT ?s ?p ?o WHERE { FILTER(false) }"
|
||||||
}
|
}
|
||||||
|
|
||||||
bnodeFilter := ""
|
bnodeFilter := ""
|
||||||
if !includeBNodes {
|
if !includeBNodes {
|
||||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||||
}
|
}
|
||||||
|
|
||||||
values := strings.Join(valuesTerms, " ")
|
values := strings.Join(valuesTerms, " ")
|
||||||
|
pattern := queryscope.NamedGraph(fmt.Sprintf(`
|
||||||
|
{
|
||||||
|
VALUES ?sel { %s }
|
||||||
|
BIND(?sel AS ?s)
|
||||||
|
VALUES ?p { rdfs:subClassOf }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
UNION
|
||||||
|
{
|
||||||
|
VALUES ?sel { %s }
|
||||||
|
BIND(?sel AS ?s)
|
||||||
|
VALUES ?p { <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> }
|
||||||
|
?s ?p ?o .
|
||||||
|
}
|
||||||
|
`, values, values))
|
||||||
|
|
||||||
return fmt.Sprintf(`
|
return fmt.Sprintf(`
|
||||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||||
|
|
||||||
SELECT DISTINCT ?nbr
|
SELECT DISTINCT ?s ?p ?o
|
||||||
WHERE {
|
WHERE {
|
||||||
VALUES ?sel { %s }
|
%s
|
||||||
?sel rdfs:subClassOf ?nbr .
|
FILTER(!isLiteral(?o))
|
||||||
FILTER(!isLiteral(?nbr))
|
FILTER(?s != ?o)
|
||||||
FILTER(?nbr != ?sel)
|
|
||||||
%s
|
%s
|
||||||
}
|
}
|
||||||
`, values, bnodeFilter)
|
`, pattern, bnodeFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSuperclasses(ctx context.Context, q Querier, idx Index, selectedIDs []int, includeBNodes bool) ([]int, error) {
|
func runSuperclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error) {
|
||||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
||||||
if len(selectedNodes) == 0 {
|
if len(selectedNodes) == 0 {
|
||||||
return []int{}, nil
|
return Result{NeighborIDs: []uint32{}, Triples: []Triple{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, err := q.Query(ctx, superclassesQuery(selectedNodes, includeBNodes))
|
query := superclassesQuery(selectedNodes, includeBNodes)
|
||||||
|
raw, err := q.Query(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
logQueryExecutionFailure("superclasses", selectedIDs, includeBNodes, query, err)
|
||||||
|
return Result{}, err
|
||||||
}
|
}
|
||||||
return idsFromBindings(raw, "nbr", idx, selectedSet, includeBNodes)
|
return resultFromTripleBindings(raw, idx, selectedSet, includeBNodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ type Querier interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NodeRef struct {
|
type NodeRef struct {
|
||||||
ID int
|
ID uint32
|
||||||
TermType string // "uri" | "bnode"
|
TermType string // "uri" | "bnode"
|
||||||
IRI string
|
IRI string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Index struct {
|
type Index struct {
|
||||||
IDToNode map[int]NodeRef
|
IDToNode map[uint32]NodeRef
|
||||||
KeyToID map[string]int
|
KeyToID map[string]uint32
|
||||||
|
PredicateIDByIRI map[string]uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
type Meta struct {
|
type Meta struct {
|
||||||
@@ -22,8 +23,27 @@ type Meta struct {
|
|||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Definition struct {
|
type TripleTerm struct {
|
||||||
Meta Meta
|
Type string `json:"type"`
|
||||||
Run func(ctx context.Context, q Querier, idx Index, selectedIDs []int, includeBNodes bool) ([]int, error)
|
Value string `json:"value"`
|
||||||
|
Lang string `json:"lang,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Triple struct {
|
||||||
|
S TripleTerm `json:"s"`
|
||||||
|
P TripleTerm `json:"p"`
|
||||||
|
O TripleTerm `json:"o"`
|
||||||
|
SubjectID *uint32 `json:"subject_id,omitempty"`
|
||||||
|
ObjectID *uint32 `json:"object_id,omitempty"`
|
||||||
|
PredicateID *uint32 `json:"predicate_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
NeighborIDs []uint32 `json:"neighbor_ids"`
|
||||||
|
Triples []Triple `json:"triples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Definition struct {
|
||||||
|
Meta Meta
|
||||||
|
Run func(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,22 +12,34 @@ func runSelectionQuery(
|
|||||||
sparql *AnzoGraphClient,
|
sparql *AnzoGraphClient,
|
||||||
snapshot GraphResponse,
|
snapshot GraphResponse,
|
||||||
queryID string,
|
queryID string,
|
||||||
selectedIDs []int,
|
selectedIDs []uint32,
|
||||||
includeBNodes bool,
|
includeBNodes bool,
|
||||||
) ([]int, error) {
|
) (selectionqueries.Result, error) {
|
||||||
def, ok := selectionqueries.Get(queryID)
|
def, ok := selectionqueries.Get(queryID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown query_id: %s", queryID)
|
return selectionqueries.Result{}, fmt.Errorf("unknown query_id: %s", queryID)
|
||||||
}
|
}
|
||||||
|
|
||||||
idToNode := make(map[int]selectionqueries.NodeRef, len(snapshot.Nodes))
|
idToNode := make(map[uint32]selectionqueries.NodeRef, len(snapshot.Nodes))
|
||||||
keyToID := make(map[string]int, len(snapshot.Nodes))
|
keyToID := make(map[string]uint32, len(snapshot.Nodes))
|
||||||
|
predicateIDByIRI := make(map[string]uint32)
|
||||||
for _, n := range snapshot.Nodes {
|
for _, n := range snapshot.Nodes {
|
||||||
nr := selectionqueries.NodeRef{ID: n.ID, TermType: n.TermType, IRI: n.IRI}
|
nr := selectionqueries.NodeRef{ID: n.ID, TermType: n.TermType, IRI: n.IRI}
|
||||||
idToNode[n.ID] = nr
|
idToNode[n.ID] = nr
|
||||||
keyToID[n.TermType+"\x00"+n.IRI] = n.ID
|
keyToID[n.TermType+"\x00"+n.IRI] = n.ID
|
||||||
}
|
}
|
||||||
|
if snapshot.Meta != nil {
|
||||||
|
for predID, iri := range snapshot.Meta.Predicates {
|
||||||
|
if iri == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
predicateIDByIRI[iri] = uint32(predID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return def.Run(ctx, sparql, selectionqueries.Index{IDToNode: idToNode, KeyToID: keyToID}, selectedIDs, includeBNodes)
|
return def.Run(ctx, sparql, selectionqueries.Index{
|
||||||
|
IDToNode: idToNode,
|
||||||
|
KeyToID: keyToID,
|
||||||
|
PredicateIDByIRI: predicateIDByIRI,
|
||||||
|
}, selectedIDs, includeBNodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -25,6 +26,7 @@ func (s *APIServer) handler() http.Handler {
|
|||||||
mux.HandleFunc("/api/graph_queries", s.handleGraphQueries)
|
mux.HandleFunc("/api/graph_queries", s.handleGraphQueries)
|
||||||
mux.HandleFunc("/api/selection_queries", s.handleSelectionQueries)
|
mux.HandleFunc("/api/selection_queries", s.handleSelectionQueries)
|
||||||
mux.HandleFunc("/api/selection_query", s.handleSelectionQuery)
|
mux.HandleFunc("/api/selection_query", s.handleSelectionQuery)
|
||||||
|
mux.HandleFunc("/api/selection_triples", s.handleSelectionTriples)
|
||||||
mux.HandleFunc("/api/neighbors", s.handleNeighbors)
|
mux.HandleFunc("/api/neighbors", s.handleNeighbors)
|
||||||
|
|
||||||
return s.corsMiddleware(mux)
|
return s.corsMiddleware(mux)
|
||||||
@@ -75,11 +77,12 @@ func (s *APIServer) handleStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
snap, err := s.snapshots.Get(ctx, s.cfg.DefaultNodeLimit, s.cfg.DefaultEdgeLimit, graphqueries.DefaultID)
|
snap, err := s.snapshots.Get(ctx, s.cfg.DefaultNodeLimit, s.cfg.DefaultEdgeLimit, graphqueries.DefaultID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
log.Printf("handleStats: snapshot error: %v", err)
|
||||||
return
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
endpoint := snap.Meta.SparqlEndpoint
|
endpoint := snap.Meta.SparqlEndpoint
|
||||||
writeJSON(w, http.StatusOK, StatsResponse{
|
writeJSON(w, http.StatusOK, StatsResponse{
|
||||||
@@ -132,26 +135,29 @@ func (s *APIServer) handleGraph(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
graphQueryID := strings.TrimSpace(r.URL.Query().Get("graph_query_id"))
|
graphQueryID := strings.TrimSpace(r.URL.Query().Get("graph_query_id"))
|
||||||
if graphQueryID == "" {
|
if graphQueryID == "" {
|
||||||
graphQueryID = graphqueries.DefaultID
|
graphQueryID = graphqueries.DefaultID
|
||||||
}
|
}
|
||||||
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
||||||
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
snap, err := s.snapshots.Get(r.Context(), nodeLimit, edgeLimit, graphQueryID)
|
snap, err := s.snapshots.Get(r.Context(), nodeLimit, edgeLimit, graphQueryID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := err.(*CycleError); ok {
|
log.Printf("handleGraph: snapshot error graph_query_id=%s node_limit=%d edge_limit=%d err=%v", graphQueryID, nodeLimit, edgeLimit, err)
|
||||||
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
if _, ok := err.(*CycleError); ok {
|
||||||
return
|
writeError(w, http.StatusUnprocessableEntity, err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, snap)
|
if err := writeGraphArrow(w, snap); err != nil {
|
||||||
|
log.Printf("handleGraph: arrow encode error graph_query_id=%s node_limit=%d edge_limit=%d err=%v", graphQueryID, nodeLimit, edgeLimit, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIServer) handleGraphQueries(w http.ResponseWriter, r *http.Request) {
|
func (s *APIServer) handleGraphQueries(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -189,7 +195,7 @@ func (s *APIServer) handleSelectionQuery(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, SelectionQueryResponse{
|
writeJSON(w, http.StatusOK, SelectionQueryResponse{
|
||||||
QueryID: req.QueryID,
|
QueryID: req.QueryID,
|
||||||
SelectedIDs: req.SelectedIDs,
|
SelectedIDs: req.SelectedIDs,
|
||||||
NeighborIDs: []int{},
|
NeighborIDs: []uint32{},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -222,8 +228,18 @@ func (s *APIServer) handleSelectionQuery(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ids, err := runSelectionQuery(r.Context(), s.sparql, snap, req.QueryID, req.SelectedIDs, s.cfg.IncludeBNodes)
|
result, err := runSelectionQuery(r.Context(), s.sparql, snap, req.QueryID, req.SelectedIDs, s.cfg.IncludeBNodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"handleSelectionQuery: returning 502 query_id=%s graph_query_id=%s selected_ids=%v node_limit=%d edge_limit=%d include_bnodes=%t err=%v",
|
||||||
|
req.QueryID,
|
||||||
|
graphQueryID,
|
||||||
|
req.SelectedIDs,
|
||||||
|
nodeLimit,
|
||||||
|
edgeLimit,
|
||||||
|
s.cfg.IncludeBNodes,
|
||||||
|
err,
|
||||||
|
)
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -231,23 +247,31 @@ func (s *APIServer) handleSelectionQuery(w http.ResponseWriter, r *http.Request)
|
|||||||
writeJSON(w, http.StatusOK, SelectionQueryResponse{
|
writeJSON(w, http.StatusOK, SelectionQueryResponse{
|
||||||
QueryID: req.QueryID,
|
QueryID: req.QueryID,
|
||||||
SelectedIDs: req.SelectedIDs,
|
SelectedIDs: req.SelectedIDs,
|
||||||
NeighborIDs: ids,
|
NeighborIDs: result.NeighborIDs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *APIServer) handleNeighbors(w http.ResponseWriter, r *http.Request) {
|
func (s *APIServer) handleSelectionTriples(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req NeighborsRequest
|
var req SelectionQueryRequest
|
||||||
if err := decodeJSON(r.Body, &req); err != nil {
|
if err := decodeJSON(r.Body, &req); err != nil || strings.TrimSpace(req.QueryID) == "" {
|
||||||
writeError(w, http.StatusUnprocessableEntity, "invalid request body")
|
writeError(w, http.StatusUnprocessableEntity, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if _, ok := selectionqueries.Get(req.QueryID); !ok {
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, "unknown query_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
if len(req.SelectedIDs) == 0 {
|
if len(req.SelectedIDs) == 0 {
|
||||||
writeJSON(w, http.StatusOK, NeighborsResponse{SelectedIDs: req.SelectedIDs, NeighborIDs: []int{}})
|
writeJSON(w, http.StatusOK, SelectionTriplesResponse{
|
||||||
|
QueryID: req.QueryID,
|
||||||
|
SelectedIDs: req.SelectedIDs,
|
||||||
|
Triples: []selectionqueries.Triple{},
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,13 +303,96 @@ func (s *APIServer) handleNeighbors(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nbrs, err := runSelectionQuery(r.Context(), s.sparql, snap, "neighbors", req.SelectedIDs, s.cfg.IncludeBNodes)
|
result, err := runSelectionQuery(r.Context(), s.sparql, snap, req.QueryID, req.SelectedIDs, s.cfg.IncludeBNodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"handleSelectionTriples: returning 502 query_id=%s graph_query_id=%s selected_ids=%v node_limit=%d edge_limit=%d include_bnodes=%t err=%v",
|
||||||
|
req.QueryID,
|
||||||
|
graphQueryID,
|
||||||
|
req.SelectedIDs,
|
||||||
|
nodeLimit,
|
||||||
|
edgeLimit,
|
||||||
|
s.cfg.IncludeBNodes,
|
||||||
|
err,
|
||||||
|
)
|
||||||
writeError(w, http.StatusBadGateway, err.Error())
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, NeighborsResponse{SelectedIDs: req.SelectedIDs, NeighborIDs: nbrs})
|
writeJSON(w, http.StatusOK, SelectionTriplesResponse{
|
||||||
|
QueryID: req.QueryID,
|
||||||
|
SelectedIDs: req.SelectedIDs,
|
||||||
|
Triples: result.Triples,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := runSelectionQuery(r.Context(), s.sparql, snap, "neighbors", req.SelectedIDs, s.cfg.IncludeBNodes)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf(
|
||||||
|
"handleNeighbors: returning 502 query_id=%s graph_query_id=%s selected_ids=%v node_limit=%d edge_limit=%d include_bnodes=%t err=%v",
|
||||||
|
"neighbors",
|
||||||
|
graphQueryID,
|
||||||
|
req.SelectedIDs,
|
||||||
|
nodeLimit,
|
||||||
|
edgeLimit,
|
||||||
|
s.cfg.IncludeBNodes,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
writeError(w, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, NeighborsResponse{
|
||||||
|
SelectedIDs: req.SelectedIDs,
|
||||||
|
NeighborIDs: result.NeighborIDs,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func intQuery(r *http.Request, name string, def int) (int, error) {
|
func intQuery(r *http.Request, name string, def int) (int, error) {
|
||||||
|
|||||||
@@ -2,14 +2,17 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
type snapshotKey struct {
|
type snapshotKey struct {
|
||||||
NodeLimit int
|
NodeLimit int
|
||||||
EdgeLimit int
|
EdgeLimit int
|
||||||
IncludeBNodes bool
|
IncludeBNodes bool
|
||||||
GraphQueryID string
|
GraphQueryID string
|
||||||
|
LayoutEngine string
|
||||||
|
LayoutRootIRI string
|
||||||
}
|
}
|
||||||
|
|
||||||
type snapshotInflight struct {
|
type snapshotInflight struct {
|
||||||
@@ -22,6 +25,8 @@ type GraphSnapshotService struct {
|
|||||||
sparql *AnzoGraphClient
|
sparql *AnzoGraphClient
|
||||||
cfg Config
|
cfg Config
|
||||||
|
|
||||||
|
fetchSnapshot func(context.Context, *AnzoGraphClient, Config, int, int, string) (GraphResponse, error)
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cache map[snapshotKey]GraphResponse
|
cache map[snapshotKey]GraphResponse
|
||||||
inflight map[snapshotKey]*snapshotInflight
|
inflight map[snapshotKey]*snapshotInflight
|
||||||
@@ -29,15 +34,23 @@ type GraphSnapshotService struct {
|
|||||||
|
|
||||||
func NewGraphSnapshotService(sparql *AnzoGraphClient, cfg Config) *GraphSnapshotService {
|
func NewGraphSnapshotService(sparql *AnzoGraphClient, cfg Config) *GraphSnapshotService {
|
||||||
return &GraphSnapshotService{
|
return &GraphSnapshotService{
|
||||||
sparql: sparql,
|
sparql: sparql,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
cache: make(map[snapshotKey]GraphResponse),
|
fetchSnapshot: fetchGraphSnapshot,
|
||||||
inflight: make(map[snapshotKey]*snapshotInflight),
|
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) {
|
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}
|
key := snapshotKey{
|
||||||
|
NodeLimit: nodeLimit,
|
||||||
|
EdgeLimit: edgeLimit,
|
||||||
|
IncludeBNodes: s.cfg.IncludeBNodes,
|
||||||
|
GraphQueryID: graphQueryID,
|
||||||
|
LayoutEngine: s.cfg.HierarchyLayoutEngine,
|
||||||
|
LayoutRootIRI: s.cfg.HierarchyLayoutRootIRI,
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if snap, ok := s.cache[key]; ok {
|
if snap, ok := s.cache[key]; ok {
|
||||||
@@ -60,7 +73,20 @@ func (s *GraphSnapshotService) Get(ctx context.Context, nodeLimit int, edgeLimit
|
|||||||
s.inflight[key] = inf
|
s.inflight[key] = inf
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
snap, err := fetchGraphSnapshot(ctx, s.sparql, s.cfg, nodeLimit, edgeLimit, graphQueryID)
|
log.Printf("[snapshot] build_start graph_query_id=%s node_limit=%d edge_limit=%d detached=true", graphQueryID, nodeLimit, edgeLimit)
|
||||||
|
go s.buildSnapshotInBackground(key, inf, nodeLimit, edgeLimit, graphQueryID)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Printf("[snapshot] requester_canceled graph_query_id=%s node_limit=%d edge_limit=%d err=%v build_continues=true", graphQueryID, nodeLimit, edgeLimit, ctx.Err())
|
||||||
|
return GraphResponse{}, ctx.Err()
|
||||||
|
case <-inf.ready:
|
||||||
|
return inf.snapshot, inf.err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GraphSnapshotService) buildSnapshotInBackground(key snapshotKey, inf *snapshotInflight, nodeLimit int, edgeLimit int, graphQueryID string) {
|
||||||
|
snap, err := s.fetchSnapshot(context.Background(), s.sparql, s.cfg, nodeLimit, edgeLimit, graphQueryID)
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
inf.snapshot = snap
|
inf.snapshot = snap
|
||||||
@@ -72,5 +98,9 @@ func (s *GraphSnapshotService) Get(ctx context.Context, nodeLimit int, edgeLimit
|
|||||||
close(inf.ready)
|
close(inf.ready)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
return snap, err
|
if err != nil {
|
||||||
|
log.Printf("[snapshot] build_done graph_query_id=%s node_limit=%d edge_limit=%d detached=true cached=false err=%v", graphQueryID, nodeLimit, edgeLimit, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[snapshot] build_done graph_query_id=%s node_limit=%d edge_limit=%d detached=true cached=true", graphQueryID, nodeLimit, edgeLimit)
|
||||||
}
|
}
|
||||||
|
|||||||
97
backend_go/snapshot_service_test.go
Normal file
97
backend_go/snapshot_service_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSnapshotBuildContinuesAfterRequesterCancellation(t *testing.T) {
|
||||||
|
svc := NewGraphSnapshotService(nil, Config{})
|
||||||
|
|
||||||
|
var fetchCalls atomic.Int32
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
expected := GraphResponse{
|
||||||
|
Nodes: []Node{{ID: 1}},
|
||||||
|
Edges: []Edge{{Source: 1, Target: 1, PredicateID: 0}},
|
||||||
|
Meta: &GraphMeta{GraphQueryID: "default", Nodes: 1, Edges: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.fetchSnapshot = func(ctx context.Context, _ *AnzoGraphClient, _ Config, nodeLimit int, edgeLimit int, graphQueryID string) (GraphResponse, error) {
|
||||||
|
fetchCalls.Add(1)
|
||||||
|
if nodeLimit != 10 || edgeLimit != 20 || graphQueryID != "default" {
|
||||||
|
t.Fatalf("unexpected fetch args nodeLimit=%d edgeLimit=%d graphQueryID=%s", nodeLimit, edgeLimit, graphQueryID)
|
||||||
|
}
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
return expected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx1, cancel1 := context.WithCancel(context.Background())
|
||||||
|
defer cancel1()
|
||||||
|
|
||||||
|
firstErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := svc.Get(ctx1, 10, 20, "default")
|
||||||
|
firstErrCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-started
|
||||||
|
cancel1()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-firstErrCh:
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("first Get error = %v, want context.Canceled", err)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for first Get to return after cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
secondSnapCh := make(chan GraphResponse, 1)
|
||||||
|
secondErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
snap, err := svc.Get(context.Background(), 10, 20, "default")
|
||||||
|
if err != nil {
|
||||||
|
secondErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
secondSnapCh <- snap
|
||||||
|
}()
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if got := fetchCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("fetchCalls after second waiter start = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-secondErrCh:
|
||||||
|
t.Fatalf("second Get error = %v", err)
|
||||||
|
case snap := <-secondSnapCh:
|
||||||
|
if snap.Meta == nil || snap.Meta.Nodes != expected.Meta.Nodes || snap.Meta.Edges != expected.Meta.Edges {
|
||||||
|
t.Fatalf("second Get snapshot meta = %#v, want %#v", snap.Meta, expected.Meta)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for second Get to return")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := fetchCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("fetchCalls after background completion = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
snap, err := svc.Get(context.Background(), 10, 20, "default")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cached Get error = %v", err)
|
||||||
|
}
|
||||||
|
if snap.Meta == nil || snap.Meta.Nodes != expected.Meta.Nodes || snap.Meta.Edges != expected.Meta.Edges {
|
||||||
|
t.Fatalf("cached Get snapshot meta = %#v, want %#v", snap.Meta, expected.Meta)
|
||||||
|
}
|
||||||
|
if got := fetchCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("fetchCalls after cached Get = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,50 +5,68 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"visualizador_instanciados/backend_go/queryscope"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AnzoGraphClient struct {
|
type AnzoGraphClient struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
endpoint string
|
endpoint string
|
||||||
authHeader string
|
basicAuthHeader string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
tokenManager *keycloakTokenManager
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAnzoGraphClient(cfg Config) *AnzoGraphClient {
|
func NewAnzoGraphClient(cfg Config) *AnzoGraphClient {
|
||||||
endpoint := cfg.EffectiveSparqlEndpoint()
|
endpoint := cfg.EffectiveSparqlEndpoint()
|
||||||
authHeader := ""
|
client := &http.Client{}
|
||||||
user := strings.TrimSpace(cfg.SparqlUser)
|
basicAuthHeader := ""
|
||||||
pass := strings.TrimSpace(cfg.SparqlPass)
|
if !cfg.UsesExternalSparql() {
|
||||||
if user != "" && pass != "" {
|
user := strings.TrimSpace(cfg.SparqlUser)
|
||||||
token := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
pass := strings.TrimSpace(cfg.SparqlPass)
|
||||||
authHeader = "Basic " + token
|
if user != "" && pass != "" {
|
||||||
|
token := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
||||||
|
basicAuthHeader = "Basic " + token
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &AnzoGraphClient{
|
agc := &AnzoGraphClient{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
authHeader: authHeader,
|
basicAuthHeader: basicAuthHeader,
|
||||||
client: &http.Client{},
|
client: client,
|
||||||
}
|
}
|
||||||
|
if cfg.UsesExternalSparql() {
|
||||||
|
agc.tokenManager = newKeycloakTokenManager(cfg, client)
|
||||||
|
}
|
||||||
|
return agc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AnzoGraphClient) Startup(ctx context.Context) error {
|
func (c *AnzoGraphClient) Startup(ctx context.Context) error {
|
||||||
|
log.Printf(
|
||||||
|
"[sparql] startup source_mode=%s endpoint=%s auth_mode=%s load_on_start=%t",
|
||||||
|
c.cfg.SparqlSourceMode,
|
||||||
|
c.endpoint,
|
||||||
|
c.authMode(),
|
||||||
|
c.cfg.SparqlLoadOnStart,
|
||||||
|
)
|
||||||
|
|
||||||
|
if c.cfg.UsesExternalSparql() {
|
||||||
|
tokenCtx, cancel := context.WithTimeout(ctx, c.cfg.SparqlReadyTimeout)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := c.refreshExternalToken(tokenCtx, "startup"); err != nil {
|
||||||
|
return fmt.Errorf("keycloak startup token fetch failed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := c.waitReady(ctx); err != nil {
|
if err := c.waitReady(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c.logNamedGraphDatasetProbe(ctx, "startup_initial")
|
||||||
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 {
|
if c.cfg.SparqlLoadOnStart {
|
||||||
df := strings.TrimSpace(c.cfg.SparqlDataFile)
|
df := strings.TrimSpace(c.cfg.SparqlDataFile)
|
||||||
@@ -68,6 +86,7 @@ func (c *AnzoGraphClient) Startup(ctx context.Context) error {
|
|||||||
if err := c.waitReady(ctx); err != nil {
|
if err := c.waitReady(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
c.logNamedGraphDatasetProbe(ctx, "startup_post_load")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -83,23 +102,7 @@ func (c *AnzoGraphClient) Query(ctx context.Context, query string) ([]byte, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *AnzoGraphClient) queryWithTimeout(ctx context.Context, query string, timeout time.Duration) ([]byte, error) {
|
func (c *AnzoGraphClient) queryWithTimeout(ctx context.Context, query string, timeout time.Duration) ([]byte, error) {
|
||||||
ctx2, cancel := context.WithTimeout(ctx, timeout)
|
resp, _, err := c.queryRequestWithTimeout(ctx, query, 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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -109,9 +112,6 @@ func (c *AnzoGraphClient) queryWithTimeout(ctx context.Context, query string, ti
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,8 +125,12 @@ func (c *AnzoGraphClient) update(ctx context.Context, update string) error {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/sparql-update")
|
req.Header.Set("Content-Type", "application/sparql-update")
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
if c.authHeader != "" {
|
authHeader, err := c.authorizationHeader(ctx2, "sparql_update")
|
||||||
req.Header.Set("Authorization", c.authHeader)
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", authHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
@@ -144,6 +148,13 @@ func (c *AnzoGraphClient) update(ctx context.Context, update string) error {
|
|||||||
|
|
||||||
func (c *AnzoGraphClient) waitReady(ctx context.Context) error {
|
func (c *AnzoGraphClient) waitReady(ctx context.Context) error {
|
||||||
var lastErr error
|
var lastErr error
|
||||||
|
log.Printf(
|
||||||
|
"[sparql] readiness_wait_start endpoint=%s retries=%d timeout=%s delay=%s query_scope=named_graphs",
|
||||||
|
c.endpoint,
|
||||||
|
c.cfg.SparqlReadyRetries,
|
||||||
|
c.cfg.SparqlReadyTimeout,
|
||||||
|
c.cfg.SparqlReadyDelay,
|
||||||
|
)
|
||||||
for i := 0; i < c.cfg.SparqlReadyRetries; i++ {
|
for i := 0; i < c.cfg.SparqlReadyRetries; i++ {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -154,16 +165,73 @@ func (c *AnzoGraphClient) waitReady(ctx context.Context) error {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := c.queryWithTimeout(ctx, "ASK WHERE { ?s ?p ?o }", c.cfg.SparqlReadyTimeout)
|
var ask sparqlBooleanResponse
|
||||||
|
_, err := c.queryJSONWithTimeout(ctx, namedGraphAnyTripleAskQuery(), c.cfg.SparqlReadyTimeout, &ask)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Ensure it's JSON, not HTML/text during boot.
|
log.Printf("[sparql] readiness_wait_ok endpoint=%s attempt=%d/%d", c.endpoint, i+1, c.cfg.SparqlReadyRetries)
|
||||||
if strings.HasPrefix(strings.TrimSpace(string(body)), "{") {
|
return nil
|
||||||
return nil
|
|
||||||
}
|
|
||||||
err = fmt.Errorf("unexpected readiness response: %s", strings.TrimSpace(string(body)))
|
|
||||||
}
|
}
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
log.Printf("[sparql] readiness_wait_retry endpoint=%s attempt=%d/%d err=%v", c.endpoint, i+1, c.cfg.SparqlReadyRetries, err)
|
||||||
time.Sleep(c.cfg.SparqlReadyDelay)
|
time.Sleep(c.cfg.SparqlReadyDelay)
|
||||||
}
|
}
|
||||||
return fmt.Errorf("anzograph not ready at %s: %w", c.endpoint, lastErr)
|
return fmt.Errorf("anzograph not ready at %s: %w", c.endpoint, lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func namedGraphAnyTripleAskQuery() string {
|
||||||
|
return queryscope.AskAnyTripleQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) authMode() string {
|
||||||
|
switch {
|
||||||
|
case c.cfg.UsesExternalSparql():
|
||||||
|
return "bearer"
|
||||||
|
case strings.HasPrefix(c.basicAuthHeader, "Basic "):
|
||||||
|
return "basic"
|
||||||
|
default:
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) authorizationHeader(ctx context.Context, reason string) (string, error) {
|
||||||
|
if !c.cfg.UsesExternalSparql() {
|
||||||
|
return c.basicAuthHeader, nil
|
||||||
|
}
|
||||||
|
if c.tokenManager == nil {
|
||||||
|
return "", fmt.Errorf("external sparql mode is enabled but token manager is not configured")
|
||||||
|
}
|
||||||
|
token, err := c.tokenManager.EnsureToken(ctx, reason)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "Bearer " + token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) refreshExternalToken(ctx context.Context, reason string) (string, error) {
|
||||||
|
if !c.cfg.UsesExternalSparql() {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if c.tokenManager == nil {
|
||||||
|
return "", fmt.Errorf("external sparql mode is enabled but token manager is not configured")
|
||||||
|
}
|
||||||
|
return c.tokenManager.Refresh(ctx, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) logNamedGraphDatasetProbe(ctx context.Context, stage string) {
|
||||||
|
var ask sparqlBooleanResponse
|
||||||
|
metrics, err := c.queryJSONWithTimeout(ctx, namedGraphAnyTripleAskQuery(), c.cfg.SparqlReadyTimeout, &ask)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[sparql] dataset_probe_failed stage=%s endpoint=%s err=%v", stage, c.endpoint, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"[sparql] dataset_probe stage=%s endpoint=%s named_graph_has_triples=%t bytes=%d round_trip_time=%s decode_time=%s",
|
||||||
|
stage,
|
||||||
|
c.endpoint,
|
||||||
|
ask.Boolean,
|
||||||
|
metrics.ResponseBytes,
|
||||||
|
metrics.RoundTripTime.Truncate(time.Millisecond),
|
||||||
|
metrics.BodyDecodeTime.Truncate(time.Millisecond),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
341
backend_go/sparql_decode.go
Normal file
341
backend_go/sparql_decode.go
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sparqlQueryMetrics struct {
|
||||||
|
ResponseBytes int64
|
||||||
|
RoundTripTime time.Duration
|
||||||
|
BodyDecodeTime time.Duration
|
||||||
|
BindingCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
type countingReadCloser struct {
|
||||||
|
io.ReadCloser
|
||||||
|
bytesRead int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *countingReadCloser) Read(p []byte) (int, error) {
|
||||||
|
n, err := c.ReadCloser.Read(p)
|
||||||
|
c.bytesRead += int64(n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type cancelOnCloseReadCloser struct {
|
||||||
|
io.ReadCloser
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cancelOnCloseReadCloser) Close() error {
|
||||||
|
err := c.ReadCloser.Close()
|
||||||
|
c.cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type sparqlHTTPStatusError struct {
|
||||||
|
StatusCode int
|
||||||
|
Status string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *sparqlHTTPStatusError) Error() string {
|
||||||
|
return fmt.Sprintf("sparql query failed: %s: %s", e.Status, e.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) queryRequestWithTimeout(ctx context.Context, query string, timeout time.Duration) (*http.Response, time.Duration, error) {
|
||||||
|
ctx2, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
resp, roundTripTime, err := c.queryRequest(ctx2, query, true)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return nil, roundTripTime, err
|
||||||
|
}
|
||||||
|
resp.Body = &cancelOnCloseReadCloser{ReadCloser: resp.Body, cancel: cancel}
|
||||||
|
return resp, roundTripTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) queryRequest(ctx context.Context, query string, allowRefresh bool) (*http.Response, time.Duration, error) {
|
||||||
|
resp, roundTripTime, err := c.queryRequestAttempt(ctx, query)
|
||||||
|
if err == nil {
|
||||||
|
return resp, roundTripTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var statusErr *sparqlHTTPStatusError
|
||||||
|
if allowRefresh && errors.As(err, &statusErr) && c.shouldRefreshExpiredJWT(statusErr) {
|
||||||
|
log.Printf("[auth] sparql_token_retry endpoint=%s reason=jwt_expired", c.endpoint)
|
||||||
|
if _, refreshErr := c.refreshExternalToken(ctx, "sparql_jwt_expired"); refreshErr != nil {
|
||||||
|
return nil, roundTripTime, fmt.Errorf("%w (token refresh failed: %v)", statusErr, refreshErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
retryResp, retryRoundTripTime, retryErr := c.queryRequest(ctx, query, false)
|
||||||
|
return retryResp, roundTripTime + retryRoundTripTime, retryErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, roundTripTime, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) queryRequestAttempt(ctx context.Context, query string) (*http.Response, time.Duration, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("query", query)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/sparql-results+json")
|
||||||
|
authHeader, err := c.authorizationHeader(ctx, "sparql_query")
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", authHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
roundTripTime := time.Since(start)
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
if readErr != nil {
|
||||||
|
return nil, roundTripTime, readErr
|
||||||
|
}
|
||||||
|
return nil, roundTripTime, &sparqlHTTPStatusError{
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
Status: resp.Status,
|
||||||
|
Body: strings.TrimSpace(string(body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, roundTripTime, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) shouldRefreshExpiredJWT(err *sparqlHTTPStatusError) bool {
|
||||||
|
if err == nil || !c.cfg.UsesExternalSparql() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return err.StatusCode == http.StatusUnauthorized && strings.Contains(err.Body, "Jwt is expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) QueryJSON(ctx context.Context, query string, target any) (sparqlQueryMetrics, error) {
|
||||||
|
return c.queryJSONWithTimeout(ctx, query, c.cfg.SparqlTimeout, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) queryJSONWithTimeout(ctx context.Context, query string, timeout time.Duration, target any) (sparqlQueryMetrics, error) {
|
||||||
|
resp, roundTripTime, err := c.queryRequestWithTimeout(ctx, query, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return sparqlQueryMetrics{}, err
|
||||||
|
}
|
||||||
|
counter := &countingReadCloser{ReadCloser: resp.Body}
|
||||||
|
defer counter.Close()
|
||||||
|
|
||||||
|
decodeStart := time.Now()
|
||||||
|
if err := json.NewDecoder(counter).Decode(target); err != nil {
|
||||||
|
return sparqlQueryMetrics{
|
||||||
|
ResponseBytes: counter.bytesRead,
|
||||||
|
RoundTripTime: roundTripTime,
|
||||||
|
BodyDecodeTime: time.Since(decodeStart),
|
||||||
|
}, wrapSparqlJSONDecodeError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sparqlQueryMetrics{
|
||||||
|
ResponseBytes: counter.bytesRead,
|
||||||
|
RoundTripTime: roundTripTime,
|
||||||
|
BodyDecodeTime: time.Since(decodeStart),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) QueryTripleBindingsStream(
|
||||||
|
ctx context.Context,
|
||||||
|
query string,
|
||||||
|
visit func(binding sparqlTripleBinding) error,
|
||||||
|
) (sparqlQueryMetrics, error) {
|
||||||
|
return c.queryTripleBindingsStreamWithTimeout(ctx, query, c.cfg.SparqlTimeout, visit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AnzoGraphClient) queryTripleBindingsStreamWithTimeout(
|
||||||
|
ctx context.Context,
|
||||||
|
query string,
|
||||||
|
timeout time.Duration,
|
||||||
|
visit func(binding sparqlTripleBinding) error,
|
||||||
|
) (sparqlQueryMetrics, error) {
|
||||||
|
resp, roundTripTime, err := c.queryRequestWithTimeout(ctx, query, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return sparqlQueryMetrics{}, err
|
||||||
|
}
|
||||||
|
counter := &countingReadCloser{ReadCloser: resp.Body}
|
||||||
|
defer counter.Close()
|
||||||
|
|
||||||
|
decodeStart := time.Now()
|
||||||
|
bindingCount, err := decodeBindingsStream(json.NewDecoder(counter), visit)
|
||||||
|
if err != nil {
|
||||||
|
return sparqlQueryMetrics{
|
||||||
|
ResponseBytes: counter.bytesRead,
|
||||||
|
RoundTripTime: roundTripTime,
|
||||||
|
BodyDecodeTime: time.Since(decodeStart),
|
||||||
|
BindingCount: bindingCount,
|
||||||
|
}, wrapSparqlJSONDecodeError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sparqlQueryMetrics{
|
||||||
|
ResponseBytes: counter.bytesRead,
|
||||||
|
RoundTripTime: roundTripTime,
|
||||||
|
BodyDecodeTime: time.Since(decodeStart),
|
||||||
|
BindingCount: bindingCount,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBindingsStream(dec *json.Decoder, visit func(binding sparqlTripleBinding) error) (int, error) {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
|
||||||
|
return 0, fmt.Errorf("invalid SPARQL JSON: expected top-level object")
|
||||||
|
}
|
||||||
|
|
||||||
|
foundResults := false
|
||||||
|
bindingCount := 0
|
||||||
|
for dec.More() {
|
||||||
|
keyToken, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, ok := keyToken.(string)
|
||||||
|
if !ok {
|
||||||
|
return bindingCount, fmt.Errorf("invalid SPARQL JSON: expected top-level field name")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "results":
|
||||||
|
foundResults = true
|
||||||
|
n, err := decodeTripleBindingsObject(dec, visit)
|
||||||
|
bindingCount += n
|
||||||
|
if err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if err := discardJSONValue(dec); err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err = dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
if delim, ok := tok.(json.Delim); !ok || delim != '}' {
|
||||||
|
return bindingCount, fmt.Errorf("invalid SPARQL JSON: expected top-level object terminator")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundResults {
|
||||||
|
return 0, fmt.Errorf("invalid SPARQL JSON: missing results field")
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindingCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTripleBindingsObject(dec *json.Decoder, visit func(binding sparqlTripleBinding) error) (int, error) {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
|
||||||
|
return 0, fmt.Errorf("invalid SPARQL JSON: expected results object")
|
||||||
|
}
|
||||||
|
|
||||||
|
bindingCount := 0
|
||||||
|
for dec.More() {
|
||||||
|
keyToken, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, ok := keyToken.(string)
|
||||||
|
if !ok {
|
||||||
|
return bindingCount, fmt.Errorf("invalid SPARQL JSON: expected results field name")
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != "bindings" {
|
||||||
|
if err := discardJSONValue(dec); err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
if delim, ok := tok.(json.Delim); !ok || delim != '[' {
|
||||||
|
return bindingCount, fmt.Errorf("invalid SPARQL JSON: expected bindings array")
|
||||||
|
}
|
||||||
|
|
||||||
|
for dec.More() {
|
||||||
|
var binding sparqlTripleBinding
|
||||||
|
if err := dec.Decode(&binding); err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
bindingCount++
|
||||||
|
if err := visit(binding); err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err = dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
if delim, ok := tok.(json.Delim); !ok || delim != ']' {
|
||||||
|
return bindingCount, fmt.Errorf("invalid SPARQL JSON: expected bindings array terminator")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err = dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return bindingCount, err
|
||||||
|
}
|
||||||
|
if delim, ok := tok.(json.Delim); !ok || delim != '}' {
|
||||||
|
return bindingCount, fmt.Errorf("invalid SPARQL JSON: expected results object terminator")
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindingCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardJSONValue(dec *json.Decoder) error {
|
||||||
|
var discard json.RawMessage
|
||||||
|
return dec.Decode(&discard)
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapSparqlJSONDecodeError(err error) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isTruncatedJSONError(err) {
|
||||||
|
return fmt.Errorf("truncated SPARQL JSON: %w", err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to decode SPARQL JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTruncatedJSONError(err error) bool {
|
||||||
|
return errors.Is(err, io.ErrUnexpectedEOF) ||
|
||||||
|
errors.Is(err, io.EOF) ||
|
||||||
|
strings.Contains(err.Error(), "unexpected end of JSON input") ||
|
||||||
|
strings.Contains(err.Error(), "unexpected EOF")
|
||||||
|
}
|
||||||
144
backend_go/sparql_decode_test.go
Normal file
144
backend_go/sparql_decode_test.go
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodeBindingsStreamStreamsTripleBindings(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"head": {"vars": ["s", "p", "o"]},
|
||||||
|
"results": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"s": {"type": "uri", "value": "http://example.com/s1"},
|
||||||
|
"p": {"type": "uri", "value": "http://example.com/p"},
|
||||||
|
"o": {"type": "uri", "value": "http://example.com/o1"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"s": {"type": "uri", "value": "http://example.com/s2"},
|
||||||
|
"p": {"type": "uri", "value": "http://example.com/p"},
|
||||||
|
"o": {"type": "uri", "value": "http://example.com/o2"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
var got []sparqlTripleBinding
|
||||||
|
count, err := decodeBindingsStream(json.NewDecoder(strings.NewReader(payload)), func(binding sparqlTripleBinding) error {
|
||||||
|
got = append(got, binding)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decodeBindingsStream returned error: %v", err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("expected 2 bindings, got %d", count)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("expected 2 streamed bindings, got %d", len(got))
|
||||||
|
}
|
||||||
|
if got[0].S.Value != "http://example.com/s1" || got[1].O.Value != "http://example.com/o2" {
|
||||||
|
t.Fatalf("unexpected streamed bindings: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryJSONDecodesTypedBindings(t *testing.T) {
|
||||||
|
t.Run("predicates", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/sparql-results+json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"head": {"vars": ["p"]},
|
||||||
|
"results": {
|
||||||
|
"bindings": [
|
||||||
|
{"p": {"type": "uri", "value": "http://example.com/p"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := &AnzoGraphClient{
|
||||||
|
cfg: Config{SparqlTimeout: 5 * time.Second},
|
||||||
|
endpoint: server.URL,
|
||||||
|
client: server.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var res sparqlBindingsResponse[sparqlPredicateBinding]
|
||||||
|
metrics, err := client.QueryJSON(context.Background(), "SELECT ?p WHERE { ?s ?p ?o }", &res)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QueryJSON returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Results.Bindings) != 1 || res.Results.Bindings[0].P.Value != "http://example.com/p" {
|
||||||
|
t.Fatalf("unexpected predicate bindings: %+v", res.Results.Bindings)
|
||||||
|
}
|
||||||
|
if metrics.ResponseBytes == 0 {
|
||||||
|
t.Fatalf("expected QueryJSON to record response bytes")
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
t.Run("labels", func(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/sparql-results+json")
|
||||||
|
_, _ = w.Write([]byte(`{
|
||||||
|
"head": {"vars": ["s", "label"]},
|
||||||
|
"results": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"s": {"type": "uri", "value": "http://example.com/s"},
|
||||||
|
"label": {"type": "literal", "xml:lang": "en", "value": "Example"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := &AnzoGraphClient{
|
||||||
|
cfg: Config{SparqlTimeout: 5 * time.Second},
|
||||||
|
endpoint: server.URL,
|
||||||
|
client: server.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var res sparqlBindingsResponse[sparqlLabelBinding]
|
||||||
|
_, err := client.QueryJSON(context.Background(), "SELECT ?s ?label WHERE { ?s ?p ?label }", &res)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("QueryJSON returned error: %v", err)
|
||||||
|
}
|
||||||
|
if len(res.Results.Bindings) != 1 {
|
||||||
|
t.Fatalf("expected 1 label binding, got %d", len(res.Results.Bindings))
|
||||||
|
}
|
||||||
|
if res.Results.Bindings[0].Label.Value != "Example" || res.Results.Bindings[0].Label.Lang != "en" {
|
||||||
|
t.Fatalf("unexpected label binding: %+v", res.Results.Bindings[0])
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryTripleBindingsStreamReportsTruncatedJSON(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/sparql-results+json")
|
||||||
|
_, _ = w.Write([]byte(`{"results":{"bindings":[{"s":{"type":"uri","value":"http://example.com/s"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := &AnzoGraphClient{
|
||||||
|
cfg: Config{SparqlTimeout: 5 * time.Second},
|
||||||
|
endpoint: server.URL,
|
||||||
|
client: server.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := client.QueryTripleBindingsStream(context.Background(), "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", func(binding sparqlTripleBinding) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected truncated JSON error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "truncated SPARQL JSON") {
|
||||||
|
t.Fatalf("expected truncated JSON error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend_go/sparql_named_graph_test.go
Normal file
20
backend_go/sparql_named_graph_test.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNamedGraphAnyTripleAskQueryUsesGraphVariable(t *testing.T) {
|
||||||
|
query := namedGraphAnyTripleAskQuery()
|
||||||
|
|
||||||
|
if !strings.Contains(query, "ASK WHERE") {
|
||||||
|
t.Fatalf("readiness query should be an ASK query:\n%s", query)
|
||||||
|
}
|
||||||
|
if !strings.Contains(query, "GRAPH ?g") {
|
||||||
|
t.Fatalf("readiness query should probe named graphs:\n%s", query)
|
||||||
|
}
|
||||||
|
if strings.Contains(query, "ASK WHERE { ?s ?p ?o }") {
|
||||||
|
t.Fatalf("readiness query should no longer probe only the default graph:\n%s", query)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,27 @@ type sparqlTerm struct {
|
|||||||
Lang string `json:"xml:lang,omitempty"`
|
Lang string `json:"xml:lang,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type sparqlResponse struct {
|
type sparqlBooleanResponse struct {
|
||||||
|
Boolean bool `json:"boolean"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sparqlTripleBinding struct {
|
||||||
|
S sparqlTerm `json:"s"`
|
||||||
|
P sparqlTerm `json:"p"`
|
||||||
|
O sparqlTerm `json:"o"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sparqlPredicateBinding struct {
|
||||||
|
P sparqlTerm `json:"p"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sparqlLabelBinding struct {
|
||||||
|
S sparqlTerm `json:"s"`
|
||||||
|
Label sparqlTerm `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sparqlBindingsResponse[T any] struct {
|
||||||
Results struct {
|
Results struct {
|
||||||
Bindings []map[string]sparqlTerm `json:"bindings"`
|
Bindings []T `json:"bindings"`
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,22 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./data:/data:Z
|
- ./data:/data:Z
|
||||||
|
|
||||||
|
radial_sugiyama:
|
||||||
|
profiles: ["radial"]
|
||||||
|
build: ./radial_sugiyama
|
||||||
|
working_dir: /workspace
|
||||||
|
env_file:
|
||||||
|
- ./radial_sugiyama/.env
|
||||||
|
volumes:
|
||||||
|
- ./radial_sugiyama:/workspace:Z
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend_go
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: backend_go/Dockerfile
|
||||||
|
env_file:
|
||||||
|
- ./radial_sugiyama/.env
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
@@ -22,21 +36,41 @@ services:
|
|||||||
- MAX_EDGE_LIMIT=${MAX_EDGE_LIMIT:-20000000}
|
- MAX_EDGE_LIMIT=${MAX_EDGE_LIMIT:-20000000}
|
||||||
- INCLUDE_BNODES=${INCLUDE_BNODES:-false}
|
- INCLUDE_BNODES=${INCLUDE_BNODES:-false}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173}
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173}
|
||||||
|
- SPARQL_SOURCE_MODE=${SPARQL_SOURCE_MODE:-local}
|
||||||
- SPARQL_HOST=${SPARQL_HOST:-http://anzograph:8080}
|
- SPARQL_HOST=${SPARQL_HOST:-http://anzograph:8080}
|
||||||
- SPARQL_ENDPOINT
|
- SPARQL_ENDPOINT
|
||||||
|
- EXTERNAL_SPARQL_ENDPOINT
|
||||||
|
- KEYCLOAK_TOKEN_ENDPOINT
|
||||||
|
- KEYCLOAK_CLIENT_ID
|
||||||
|
- KEYCLOAK_USERNAME
|
||||||
|
- KEYCLOAK_PASSWORD
|
||||||
|
- KEYCLOAK_SCOPE=${KEYCLOAK_SCOPE:-openid}
|
||||||
|
- ACCESS_TOKEN
|
||||||
- SPARQL_USER=${SPARQL_USER:-admin}
|
- SPARQL_USER=${SPARQL_USER:-admin}
|
||||||
- SPARQL_PASS=${SPARQL_PASS:-Passw0rd1}
|
- SPARQL_PASS=${SPARQL_PASS:-Passw0rd1}
|
||||||
- SPARQL_DATA_FILE=${SPARQL_DATA_FILE:-file:///opt/shared-files/o3po.ttl}
|
- SPARQL_DATA_FILE=${SPARQL_DATA_FILE:-file:///opt/shared-files/o3po.ttl}
|
||||||
- SPARQL_GRAPH_IRI
|
- SPARQL_GRAPH_IRI
|
||||||
- SPARQL_LOAD_ON_START=${SPARQL_LOAD_ON_START:-false}
|
- 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_TIMEOUT_S=${SPARQL_TIMEOUT_S:-300}
|
||||||
- SPARQL_READY_RETRIES=${SPARQL_READY_RETRIES:-30}
|
- SPARQL_READY_RETRIES=${SPARQL_READY_RETRIES:-30}
|
||||||
- SPARQL_READY_DELAY_S=${SPARQL_READY_DELAY_S:-4}
|
- SPARQL_READY_DELAY_S=${SPARQL_READY_DELAY_S:-4}
|
||||||
- SPARQL_READY_TIMEOUT_S=${SPARQL_READY_TIMEOUT_S:-10}
|
- 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}
|
||||||
|
- HIERARCHY_LAYOUT_ENGINE=${HIERARCHY_LAYOUT_ENGINE:-go}
|
||||||
|
- HIERARCHY_LAYOUT_BRIDGE_BIN=${HIERARCHY_LAYOUT_BRIDGE_BIN:-/app/radial_sugiyama_go_bridge}
|
||||||
|
- HIERARCHY_LAYOUT_BRIDGE_WORKDIR=${HIERARCHY_LAYOUT_BRIDGE_WORKDIR:-/workspace/radial_sugiyama}
|
||||||
|
- HIERARCHY_LAYOUT_TIMEOUT_S=${HIERARCHY_LAYOUT_TIMEOUT_S:-60}
|
||||||
|
- HIERARCHY_LAYOUT_ROOT_IRI=${HIERARCHY_LAYOUT_ROOT_IRI:-http://purl.obolibrary.org/obo/BFO_0000001}
|
||||||
depends_on:
|
depends_on:
|
||||||
- owl_imports_combiner
|
owl_imports_combiner:
|
||||||
- anzograph
|
condition: service_completed_successfully
|
||||||
|
anzograph:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- ./data:/data:Z
|
||||||
|
- ./radial_sugiyama:/workspace/radial_sugiyama:Z
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/health"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -49,19 +83,35 @@ services:
|
|||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
environment:
|
environment:
|
||||||
- VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://backend:8000}
|
- VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://backend:8000}
|
||||||
|
- VITE_COSMOS_ENABLE_SIMULATION=${VITE_COSMOS_ENABLE_SIMULATION:-true}
|
||||||
|
- VITE_COSMOS_DEBUG_LAYOUT=${VITE_COSMOS_DEBUG_LAYOUT:-false}
|
||||||
|
- VITE_COSMOS_SPACE_SIZE=${VITE_COSMOS_SPACE_SIZE:-4096}
|
||||||
|
- VITE_COSMOS_CURVED_LINKS=${VITE_COSMOS_CURVED_LINKS:-true}
|
||||||
|
- VITE_COSMOS_FIT_VIEW_PADDING=${VITE_COSMOS_FIT_VIEW_PADDING:-0.12}
|
||||||
|
- VITE_COSMOS_SIMULATION_DECAY=${VITE_COSMOS_SIMULATION_DECAY:-5000}
|
||||||
|
- VITE_COSMOS_SIMULATION_GRAVITY=${VITE_COSMOS_SIMULATION_GRAVITY:-0}
|
||||||
|
- VITE_COSMOS_SIMULATION_CENTER=${VITE_COSMOS_SIMULATION_CENTER:-0.05}
|
||||||
|
- VITE_COSMOS_SIMULATION_REPULSION=${VITE_COSMOS_SIMULATION_REPULSION:-0.5}
|
||||||
|
- VITE_COSMOS_SIMULATION_LINK_SPRING=${VITE_COSMOS_SIMULATION_LINK_SPRING:-1}
|
||||||
|
- VITE_COSMOS_SIMULATION_LINK_DISTANCE=${VITE_COSMOS_SIMULATION_LINK_DISTANCE:-10}
|
||||||
|
- VITE_COSMOS_SIMULATION_FRICTION=${VITE_COSMOS_SIMULATION_FRICTION:-0.1}
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
backend:
|
||||||
# Docker Compose v1 doesn't support depends_on:condition. Do an explicit wait here.
|
condition: service_healthy
|
||||||
command: sh -c "until wget -qO- http://backend:8000/api/health >/dev/null 2>&1; do echo 'waiting for backend...'; sleep 1; done; npm run dev -- --host --port 5173"
|
|
||||||
|
|
||||||
anzograph:
|
anzograph:
|
||||||
image: cambridgesemantics/anzograph:latest
|
image: cambridgesemantics/anzograph:latest
|
||||||
container_name: anzograph
|
container_name: anzograph
|
||||||
|
mem_limit: 20g
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
- "8443:8443"
|
- "8443:8443"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/opt/shared-files:Z
|
- ./data:/opt/shared-files:Z
|
||||||
|
- ./data/app_home:/opt/anzograph/app-home:Z
|
||||||
|
- ./data/persistence:/opt/anzograph/persistence:Z
|
||||||
|
- ./data/config:/opt/anzograph/config:Z
|
||||||
|
- ./data/internal:/opt/anzograph/internal:Z
|
||||||
|
|||||||
@@ -19,6 +19,23 @@ Open: `http://localhost:5173`
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
- `VITE_BACKEND_URL` controls where `/api/*` is proxied (see `frontend/vite.config.ts`).
|
- `VITE_BACKEND_URL` controls where `/api/*` is proxied (see `frontend/vite.config.ts`).
|
||||||
|
- The right-side cosmos graph reads these `VITE_...` settings at dev-server startup:
|
||||||
|
- `VITE_COSMOS_ENABLE_SIMULATION`
|
||||||
|
- `VITE_COSMOS_DEBUG_LAYOUT`
|
||||||
|
- `VITE_COSMOS_SIMULATION_REPULSION`
|
||||||
|
- `VITE_COSMOS_SIMULATION_LINK_SPRING`
|
||||||
|
- `VITE_COSMOS_SIMULATION_LINK_DISTANCE`
|
||||||
|
- `VITE_COSMOS_SIMULATION_GRAVITY`
|
||||||
|
- `VITE_COSMOS_SIMULATION_CENTER`
|
||||||
|
- `VITE_COSMOS_SIMULATION_DECAY`
|
||||||
|
- `VITE_COSMOS_SIMULATION_FRICTION`
|
||||||
|
- `VITE_COSMOS_SPACE_SIZE`
|
||||||
|
- `VITE_COSMOS_CURVED_LINKS`
|
||||||
|
- `VITE_COSMOS_FIT_VIEW_PADDING`
|
||||||
|
- The right pane keeps a static camera after an explicit `fitViewByPointPositions(...)` from the current seed positions.
|
||||||
|
- `VITE_COSMOS_SIMULATION_CENTER` is the main knob for keeping the graph mass near the viewport center during force layout.
|
||||||
|
- `VITE_COSMOS_DEBUG_LAYOUT=true` enables a small debug overlay and `console.debug` logs for graph-space centroid/bounds, screen-space origin/centroid placement, zoom, alpha/progress, and space-boundary pressure.
|
||||||
|
- In Docker Compose, set them in the repo-root `.env` and restart the `frontend` service.
|
||||||
|
|
||||||
## UI
|
## UI
|
||||||
|
|
||||||
|
|||||||
1044
frontend/package-lock.json
generated
1044
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,9 @@
|
|||||||
"layout": "tsx scripts/compute_layout.ts"
|
"layout": "tsx scripts/compute_layout.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@cosmos.gl/graph": "^2.6.4",
|
||||||
"@webgpu/types": "^0.1.69",
|
"@webgpu/types": "^0.1.69",
|
||||||
|
"apache-arrow": "^21.1.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
@@ -23,8 +25,8 @@
|
|||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@vitejs/plugin-react": "5.1.1",
|
"@vitejs/plugin-react": "5.1.1",
|
||||||
"tailwindcss": "4.1.17",
|
"tailwindcss": "4.1.17",
|
||||||
"typescript": "5.9.3",
|
|
||||||
"tsx": "^4.0.0",
|
"tsx": "^4.0.0",
|
||||||
|
"typescript": "5.9.3",
|
||||||
"vite": "7.2.4",
|
"vite": "7.2.4",
|
||||||
"vite-plugin-singlefile": "2.3.0"
|
"vite-plugin-singlefile": "2.3.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,71 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Renderer } from "./renderer";
|
import { Renderer } from "./renderer";
|
||||||
import { fetchGraphQueries } from "./graph_queries";
|
import { fetchGraphQueries } from "./graph_queries";
|
||||||
import type { GraphQueryMeta } from "./graph_queries";
|
import type { GraphQueryMeta } from "./graph_queries";
|
||||||
import { fetchSelectionQueries, runSelectionQuery } from "./selection_queries";
|
import { fetchSelectionQueries, runSelectionQuery, runSelectionTripleQuery } from "./selection_queries";
|
||||||
import type { GraphMeta, SelectionQueryMeta } from "./selection_queries";
|
import { cosmosRuntimeConfig } from "./cosmos_config";
|
||||||
|
import type { GraphMeta, SelectionQueryMeta, SelectionTriple } from "./selection_queries";
|
||||||
|
import { TripleGraphView } from "./TripleGraphView";
|
||||||
|
import { buildTripleGraphModel, type TripleGraphModel } from "./triple_graph";
|
||||||
|
import { readGraphArrow } from "./graph_arrow";
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GraphNodeLookup = {
|
||||||
|
vertexIds: Uint32Array;
|
||||||
|
labels: (string | undefined)[];
|
||||||
|
iris: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TripleResultState = {
|
||||||
|
status: "idle" | "loading" | "ready" | "error";
|
||||||
|
queryId: string;
|
||||||
|
selectedIds: number[];
|
||||||
|
triples: SelectionTriple[];
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function idleTripleResult(queryId: string): TripleResultState {
|
||||||
|
return {
|
||||||
|
status: "idle",
|
||||||
|
queryId,
|
||||||
|
selectedIds: [],
|
||||||
|
triples: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let value = bytes;
|
||||||
|
let unitIndex = 0;
|
||||||
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unitIndex++;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(value >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateFrontendTypedArrayBytes(nodeCount: number, edgeCount: number, routeLineFloatCount: number): { app: number; renderer: number; total: number } {
|
||||||
|
const app =
|
||||||
|
nodeCount * Float32Array.BYTES_PER_ELEMENT * 2 +
|
||||||
|
nodeCount * Uint32Array.BYTES_PER_ELEMENT +
|
||||||
|
edgeCount * Uint32Array.BYTES_PER_ELEMENT * 2 +
|
||||||
|
routeLineFloatCount * Float32Array.BYTES_PER_ELEMENT;
|
||||||
|
|
||||||
|
const renderer =
|
||||||
|
nodeCount * Float32Array.BYTES_PER_ELEMENT * 2 +
|
||||||
|
nodeCount * Uint32Array.BYTES_PER_ELEMENT * 4 +
|
||||||
|
edgeCount * Uint32Array.BYTES_PER_ELEMENT * 4;
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
renderer,
|
||||||
|
total: app + renderer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const rendererRef = useRef<Renderer | null>(null);
|
const rendererRef = useRef<Renderer | null>(null);
|
||||||
@@ -28,57 +86,107 @@ export default function App() {
|
|||||||
const [activeGraphQueryId, setActiveGraphQueryId] = useState<string>("default");
|
const [activeGraphQueryId, setActiveGraphQueryId] = useState<string>("default");
|
||||||
const [selectionQueries, setSelectionQueries] = useState<SelectionQueryMeta[]>([]);
|
const [selectionQueries, setSelectionQueries] = useState<SelectionQueryMeta[]>([]);
|
||||||
const [activeSelectionQueryId, setActiveSelectionQueryId] = useState<string>("neighbors");
|
const [activeSelectionQueryId, setActiveSelectionQueryId] = useState<string>("neighbors");
|
||||||
|
const [tripleResult, setTripleResult] = useState<TripleResultState>(() => idleTripleResult("neighbors"));
|
||||||
|
const [tripleGraphModel, setTripleGraphModel] = useState<TripleGraphModel | null>(null);
|
||||||
const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; backend?: string } | null>(null);
|
const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; backend?: string } | null>(null);
|
||||||
const graphMetaRef = useRef<GraphMeta | null>(null);
|
const graphMetaRef = useRef<GraphMeta | null>(null);
|
||||||
const selectionReqIdRef = useRef(0);
|
const selectionReqIdRef = useRef(0);
|
||||||
|
const tripleReqIdRef = useRef(0);
|
||||||
const graphInitializedRef = useRef(false);
|
const graphInitializedRef = useRef(false);
|
||||||
|
|
||||||
// Store mouse position in a ref so it can be accessed in render loop without re-renders
|
// 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 mousePos = useRef({ x: 0, y: 0 });
|
||||||
const nodesRef = useRef<any[]>([]);
|
const nodeLookupRef = useRef<GraphNodeLookup | null>(null);
|
||||||
|
|
||||||
async function loadGraph(graphQueryId: string, signal: AbortSignal): Promise<void> {
|
async function loadGraph(graphQueryId: string, signal: AbortSignal): Promise<void> {
|
||||||
const renderer = rendererRef.current;
|
const renderer = rendererRef.current;
|
||||||
if (!renderer) return;
|
if (!renderer) return;
|
||||||
|
|
||||||
setStatus("Fetching graph…");
|
const loadStartedAt = performance.now();
|
||||||
|
const logPhase = (phase: string, extra?: Record<string, unknown>) => {
|
||||||
|
console.log(`[graph-load] ${phase}`, {
|
||||||
|
elapsed_ms: Math.round(performance.now() - loadStartedAt),
|
||||||
|
graph_query_id: graphQueryId,
|
||||||
|
...(extra || {}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStatus = async (nextStatus: string, extra?: Record<string, unknown>): Promise<void> => {
|
||||||
|
setStatus(nextStatus);
|
||||||
|
logPhase(nextStatus, extra);
|
||||||
|
await sleep(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateStatus("Fetching graph…");
|
||||||
const graphRes = await fetch(`/api/graph?graph_query_id=${encodeURIComponent(graphQueryId)}`, { signal });
|
const graphRes = await fetch(`/api/graph?graph_query_id=${encodeURIComponent(graphQueryId)}`, { signal });
|
||||||
if (!graphRes.ok) throw new Error(`Failed to fetch graph: ${graphRes.status}`);
|
logPhase("graph response headers received", {
|
||||||
const graph = await graphRes.json();
|
status: graphRes.status,
|
||||||
|
content_type: graphRes.headers.get("content-type"),
|
||||||
|
content_length: graphRes.headers.get("content-length"),
|
||||||
|
});
|
||||||
|
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})` : ""}`);
|
||||||
|
}
|
||||||
|
await updateStatus("Streaming Arrow graph…");
|
||||||
|
await updateStatus("Decoding Arrow batches…");
|
||||||
|
const decodeStartedAt = performance.now();
|
||||||
|
let graph: Awaited<ReturnType<typeof readGraphArrow>>;
|
||||||
|
try {
|
||||||
|
graph = await readGraphArrow(graphRes, logPhase);
|
||||||
|
} catch (e) {
|
||||||
|
logPhase("arrow graph decode failed", {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
logPhase("arrow graph decoded", {
|
||||||
|
decode_ms: Math.round(performance.now() - decodeStartedAt),
|
||||||
|
});
|
||||||
if (signal.aborted) return;
|
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 meta = graph.meta || null;
|
||||||
const count = nodes.length;
|
const vertexIds = graph.vertexIds;
|
||||||
|
const xs = graph.xs;
|
||||||
|
const ys = graph.ys;
|
||||||
|
const edgeData = graph.edgeData;
|
||||||
|
const routeLineVertices = graph.routeLineVertices;
|
||||||
|
const labels = graph.labels;
|
||||||
|
const iris = graph.iris;
|
||||||
|
const count = vertexIds.length;
|
||||||
|
const edgeCount = edgeData.length / 2;
|
||||||
|
logPhase("graph payload ready", {
|
||||||
|
nodes: count,
|
||||||
|
edges: edgeCount,
|
||||||
|
route_line_segments: routeLineVertices.length / 4,
|
||||||
|
backend_nodes: meta && typeof meta.nodes === "number" ? meta.nodes : undefined,
|
||||||
|
backend_edges: meta && typeof meta.edges === "number" ? meta.edges : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
nodesRef.current = nodes;
|
nodeLookupRef.current = { vertexIds, labels, iris };
|
||||||
graphMetaRef.current = meta && typeof meta === "object" ? (meta as GraphMeta) : null;
|
graphMetaRef.current = meta && typeof meta === "object" ? (meta as GraphMeta) : null;
|
||||||
|
setTripleResult(idleTripleResult(activeSelectionQueryId));
|
||||||
|
|
||||||
// Build positions from backend-provided node coordinates.
|
await updateStatus("Preparing buffers…", {
|
||||||
setStatus("Preparing buffers…");
|
nodes: count,
|
||||||
const xs = new Float32Array(count);
|
edges: edgeCount,
|
||||||
const ys = new Float32Array(count);
|
});
|
||||||
for (let i = 0; i < count; i++) {
|
const bufferPrepStartedAt = performance.now();
|
||||||
const nx = nodes[i]?.x;
|
const typedArrayBytes = estimateFrontendTypedArrayBytes(count, edgeCount, routeLineVertices.length);
|
||||||
const ny = nodes[i]?.y;
|
logPhase("buffer prep done", {
|
||||||
xs[i] = typeof nx === "number" ? nx : 0;
|
buffer_prep_ms: Math.round(performance.now() - bufferPrepStartedAt),
|
||||||
ys[i] = typeof ny === "number" ? ny : 0;
|
app_typed_arrays: formatBytes(typedArrayBytes.app),
|
||||||
}
|
renderer_typed_arrays_estimate: formatBytes(typedArrayBytes.renderer),
|
||||||
const vertexIds = new Uint32Array(count);
|
total_typed_arrays_estimate: formatBytes(typedArrayBytes.total),
|
||||||
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.
|
// Use /api/graph meta; don't do a second expensive backend call.
|
||||||
if (meta && typeof meta.nodes === "number" && typeof meta.edges === "number") {
|
if (meta && typeof meta.nodes === "number" && typeof meta.edges === "number") {
|
||||||
@@ -88,18 +196,52 @@ export default function App() {
|
|||||||
backend: typeof meta.backend === "string" ? meta.backend : undefined,
|
backend: typeof meta.backend === "string" ? meta.backend : undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setBackendStats({ nodes: nodes.length, edges: edges.length });
|
setBackendStats({ nodes: count, edges: edgeCount });
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus("Building spatial index…");
|
await updateStatus("Building spatial index…", {
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
nodes: count,
|
||||||
|
edges: edgeCount,
|
||||||
|
});
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
const buildMs = renderer.init(xs, ys, vertexIds, edgeData);
|
let buildMs: number;
|
||||||
|
try {
|
||||||
|
buildMs = renderer.init(
|
||||||
|
xs,
|
||||||
|
ys,
|
||||||
|
vertexIds,
|
||||||
|
edgeData,
|
||||||
|
routeLineVertices.length > 0 ? routeLineVertices : null
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logPhase("renderer.init failed", {
|
||||||
|
error: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
setNodeCount(renderer.getNodeCount());
|
setNodeCount(renderer.getNodeCount());
|
||||||
setSelectedNodes(new Set());
|
setSelectedNodes(new Set());
|
||||||
setStatus("");
|
setStatus("");
|
||||||
console.log(`Init complete: ${count.toLocaleString()} nodes, ${edges.length.toLocaleString()} edges in ${buildMs.toFixed(0)}ms`);
|
logPhase("init complete", {
|
||||||
|
renderer_init_ms: Math.round(buildMs),
|
||||||
|
nodes: count,
|
||||||
|
edges: edgeCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedIds(renderer: Renderer, selected: Set<number>): number[] {
|
||||||
|
const lookup = nodeLookupRef.current;
|
||||||
|
if (!lookup) return [];
|
||||||
|
const selectedIds: number[] = [];
|
||||||
|
for (const sortedIdx of selected) {
|
||||||
|
const origIdx = renderer.sortedIndexToOriginalIndex(sortedIdx);
|
||||||
|
if (origIdx === null) continue;
|
||||||
|
const nodeId = lookup.vertexIds[origIdx];
|
||||||
|
if (typeof nodeId !== "number") continue;
|
||||||
|
selectedIds.push(nodeId);
|
||||||
|
}
|
||||||
|
return selectedIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -175,14 +317,14 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ── Input handling ──
|
// Input handling
|
||||||
let dragging = false;
|
let dragging = false;
|
||||||
let didDrag = false; // true if mouse moved significantly during drag
|
let didDrag = false;
|
||||||
let downX = 0;
|
let downX = 0;
|
||||||
let downY = 0;
|
let downY = 0;
|
||||||
let lastX = 0;
|
let lastX = 0;
|
||||||
let lastY = 0;
|
let lastY = 0;
|
||||||
const DRAG_THRESHOLD = 5; // pixels
|
const DRAG_THRESHOLD = 5;
|
||||||
|
|
||||||
const onDown = (e: MouseEvent) => {
|
const onDown = (e: MouseEvent) => {
|
||||||
dragging = true;
|
dragging = true;
|
||||||
@@ -196,7 +338,6 @@ export default function App() {
|
|||||||
mousePos.current = { x: e.clientX, y: e.clientY };
|
mousePos.current = { x: e.clientX, y: e.clientY };
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
|
|
||||||
// Check if we've moved enough to consider it a drag
|
|
||||||
const dx = e.clientX - downX;
|
const dx = e.clientX - downX;
|
||||||
const dy = e.clientY - downY;
|
const dy = e.clientY - downY;
|
||||||
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
||||||
@@ -209,15 +350,14 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
const onUp = (e: MouseEvent) => {
|
const onUp = (e: MouseEvent) => {
|
||||||
if (dragging && !didDrag) {
|
if (dragging && !didDrag) {
|
||||||
// This was a click, not a drag - handle selection
|
|
||||||
const node = renderer.findNodeIndexAt(e.clientX, e.clientY);
|
const node = renderer.findNodeIndexAt(e.clientX, e.clientY);
|
||||||
if (node) {
|
if (node) {
|
||||||
setSelectedNodes((prev: Set<number>) => {
|
setSelectedNodes((prev: Set<number>) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(node.index)) {
|
if (next.has(node.index)) {
|
||||||
next.delete(node.index); // Deselect if already selected
|
next.delete(node.index);
|
||||||
} else {
|
} else {
|
||||||
next.add(node.index); // Select
|
next.add(node.index);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
@@ -241,7 +381,7 @@ export default function App() {
|
|||||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||||
canvas.addEventListener("mouseleave", onMouseLeave);
|
canvas.addEventListener("mouseleave", onMouseLeave);
|
||||||
|
|
||||||
// ── Render loop ──
|
// Render loop
|
||||||
let frameCount = 0;
|
let frameCount = 0;
|
||||||
let lastTime = performance.now();
|
let lastTime = performance.now();
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
@@ -250,18 +390,19 @@ export default function App() {
|
|||||||
const result = renderer.render();
|
const result = renderer.render();
|
||||||
frameCount++;
|
frameCount++;
|
||||||
|
|
||||||
// Find hovered node using quadtree
|
|
||||||
const hit = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y);
|
const hit = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
const origIdx = renderer.sortedIndexToOriginalIndex(hit.index);
|
const origIdx = renderer.sortedIndexToOriginalIndex(hit.index);
|
||||||
const meta = origIdx === null ? null : nodesRef.current[origIdx];
|
const lookup = nodeLookupRef.current;
|
||||||
|
const label = origIdx === null || !lookup ? undefined : lookup.labels[origIdx];
|
||||||
|
const iri = origIdx === null || !lookup ? undefined : lookup.iris[origIdx];
|
||||||
setHoveredNode({
|
setHoveredNode({
|
||||||
x: hit.x,
|
x: hit.x,
|
||||||
y: hit.y,
|
y: hit.y,
|
||||||
screenX: mousePos.current.x,
|
screenX: mousePos.current.x,
|
||||||
screenY: mousePos.current.y,
|
screenY: mousePos.current.y,
|
||||||
label: meta && typeof meta.label === "string" ? meta.label : undefined,
|
label: typeof label === "string" ? label : undefined,
|
||||||
iri: meta && typeof meta.iri === "string" ? meta.iri : undefined,
|
iri: typeof iri === "string" ? iri : undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setHoveredNode(null);
|
setHoveredNode(null);
|
||||||
@@ -317,44 +458,30 @@ export default function App() {
|
|||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
}, [activeGraphQueryId]);
|
}, [activeGraphQueryId]);
|
||||||
|
|
||||||
// Sync selection state to renderer
|
// Left-side selection highlighting path
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const renderer = rendererRef.current;
|
const renderer = rendererRef.current;
|
||||||
if (!renderer) return;
|
if (!renderer) return;
|
||||||
|
|
||||||
// Optimistically reflect selection immediately; highlights will be filled in by backend.
|
|
||||||
renderer.updateSelection(selectedNodes, new Set());
|
renderer.updateSelection(selectedNodes, new Set());
|
||||||
|
|
||||||
// Invalidate any in-flight request for the previous selection/mode.
|
|
||||||
const reqId = ++selectionReqIdRef.current;
|
const reqId = ++selectionReqIdRef.current;
|
||||||
|
const selectedIds = getSelectedIds(renderer, selectedNodes);
|
||||||
// Convert selected sorted indices to backend node IDs (graph-export dense IDs).
|
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||||
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) {
|
if (selectedIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const neighborIds = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
const result = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
if (reqId !== selectionReqIdRef.current) return;
|
if (reqId !== selectionReqIdRef.current) return;
|
||||||
|
|
||||||
const neighborSorted = new Set<number>();
|
const neighborSorted = new Set<number>();
|
||||||
for (const id of neighborIds) {
|
for (const id of result.neighborIds) {
|
||||||
if (typeof id !== "number") continue;
|
if (typeof id !== "number") continue;
|
||||||
const sorted = renderer.vertexIdToSortedIndexOrNull(id);
|
const sorted = renderer.vertexIdToSortedIndexOrNull(id);
|
||||||
if (sorted === null) continue;
|
if (sorted === null) continue;
|
||||||
@@ -364,8 +491,8 @@ export default function App() {
|
|||||||
renderer.updateSelection(selectedNodes, neighborSorted);
|
renderer.updateSelection(selectedNodes, neighborSorted);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
|
if (reqId !== selectionReqIdRef.current) return;
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
// Keep the UI usable even if neighbors fail to load.
|
|
||||||
renderer.updateSelection(selectedNodes, new Set());
|
renderer.updateSelection(selectedNodes, new Set());
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -373,213 +500,369 @@ export default function App() {
|
|||||||
return () => ctrl.abort();
|
return () => ctrl.abort();
|
||||||
}, [selectedNodes, activeSelectionQueryId]);
|
}, [selectedNodes, activeSelectionQueryId]);
|
||||||
|
|
||||||
|
// Right-side triple graph path
|
||||||
|
useEffect(() => {
|
||||||
|
const renderer = rendererRef.current;
|
||||||
|
if (!renderer) return;
|
||||||
|
|
||||||
|
const reqId = ++tripleReqIdRef.current;
|
||||||
|
const selectedIds = getSelectedIds(renderer, selectedNodes);
|
||||||
|
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
setTripleResult(idleTripleResult(queryId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
setTripleResult({
|
||||||
|
status: "loading",
|
||||||
|
queryId,
|
||||||
|
selectedIds,
|
||||||
|
triples: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const result = await runSelectionTripleQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
if (reqId !== tripleReqIdRef.current) return;
|
||||||
|
|
||||||
|
setTripleResult({
|
||||||
|
status: "ready",
|
||||||
|
queryId: result.queryId,
|
||||||
|
selectedIds: result.selectedIds,
|
||||||
|
triples: result.triples,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (ctrl.signal.aborted) return;
|
||||||
|
if (reqId !== tripleReqIdRef.current) return;
|
||||||
|
console.warn(e);
|
||||||
|
setTripleResult({
|
||||||
|
status: "error",
|
||||||
|
queryId,
|
||||||
|
selectedIds,
|
||||||
|
triples: [],
|
||||||
|
errorMessage: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => ctrl.abort();
|
||||||
|
}, [selectedNodes, activeSelectionQueryId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tripleResult.status !== "ready") {
|
||||||
|
setTripleGraphModel(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTripleGraphModel(buildTripleGraphModel(tripleResult.triples, tripleResult.selectedIds));
|
||||||
|
}, [tripleResult]);
|
||||||
|
|
||||||
|
const resultQueryId = (tripleResult.queryId || activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||||
|
const resultQueryLabel = selectionQueries.find((q) => q.id === resultQueryId)?.label ?? resultQueryId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
|
<div style={{ width: "100vw", height: "100vh", display: "flex", overflow: "hidden", background: "#000" }}>
|
||||||
<canvas
|
<div style={{ position: "relative", flex: "1 1 50%", minWidth: 0, background: "#000" }}>
|
||||||
ref={canvasRef}
|
<canvas
|
||||||
style={{ display: "block", width: "100%", height: "100%" }}
|
ref={canvasRef}
|
||||||
/>
|
style={{ display: "block", width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Loading overlay */}
|
{status && (
|
||||||
{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
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 10,
|
inset: 0,
|
||||||
left: 10,
|
display: "flex",
|
||||||
background: "rgba(0,0,0,0.75)",
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "rgba(0,0,0,0.9)",
|
||||||
color: "#0f0",
|
color: "#0f0",
|
||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
padding: "8px 12px",
|
fontSize: "16px",
|
||||||
fontSize: "12px",
|
|
||||||
lineHeight: "1.6",
|
|
||||||
borderRadius: "4px",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>FPS: {stats.fps}</div>
|
{status}
|
||||||
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 10,
|
inset: 0,
|
||||||
left: 10,
|
display: "flex",
|
||||||
background: "rgba(0,0,0,0.75)",
|
alignItems: "center",
|
||||||
color: "#888",
|
justifyContent: "center",
|
||||||
|
background: "rgba(0,0,0,0.9)",
|
||||||
|
color: "#f44",
|
||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
padding: "6px 10px",
|
fontSize: "16px",
|
||||||
fontSize: "11px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Drag to pan · Scroll to zoom · Click to select
|
Error: {error}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Selection query buttons */}
|
{!status && !error && (
|
||||||
{selectionQueries.length > 0 && (
|
<>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: 10,
|
top: 10,
|
||||||
right: 10,
|
left: 10,
|
||||||
display: "flex",
|
background: "rgba(0,0,0,0.75)",
|
||||||
flexDirection: "column",
|
color: "#0f0",
|
||||||
gap: "6px",
|
fontFamily: "monospace",
|
||||||
background: "rgba(0,0,0,0.55)",
|
padding: "8px 12px",
|
||||||
padding: "8px",
|
fontSize: "12px",
|
||||||
borderRadius: "6px",
|
lineHeight: "1.6",
|
||||||
border: "1px solid rgba(255,255,255,0.08)",
|
borderRadius: "4px",
|
||||||
pointerEvents: "auto",
|
pointerEvents: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectionQueries.map((q) => {
|
<div>FPS: {stats.fps}</div>
|
||||||
const active = q.id === activeSelectionQueryId;
|
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
|
||||||
return (
|
<div>Mode: {stats.mode}</div>
|
||||||
<button
|
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
||||||
key={q.id}
|
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
||||||
onClick={() => setActiveSelectionQueryId(q.id)}
|
<div style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
|
||||||
style={{
|
{backendStats && (
|
||||||
cursor: "pointer",
|
<div style={{ color: "#8f8" }}>
|
||||||
fontFamily: "monospace",
|
Backend{backendStats.backend ? ` (${backendStats.backend})` : ""}: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges
|
||||||
fontSize: "12px",
|
</div>
|
||||||
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>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Graph query buttons */}
|
|
||||||
{graphQueries.length > 0 && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
bottom: 10,
|
bottom: 10,
|
||||||
right: 10,
|
left: 10,
|
||||||
display: "flex",
|
background: "rgba(0,0,0,0.75)",
|
||||||
flexDirection: "column",
|
color: "#888",
|
||||||
gap: "6px",
|
fontFamily: "monospace",
|
||||||
background: "rgba(0,0,0,0.55)",
|
padding: "6px 10px",
|
||||||
padding: "8px",
|
fontSize: "11px",
|
||||||
borderRadius: "6px",
|
borderRadius: "4px",
|
||||||
border: "1px solid rgba(255,255,255,0.08)",
|
pointerEvents: "none",
|
||||||
pointerEvents: "auto",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{graphQueries.map((q) => {
|
Drag to pan · Scroll to zoom · Click to select
|
||||||
const active = q.id === activeGraphQueryId;
|
</div>
|
||||||
return (
|
|
||||||
<button
|
{selectionQueries.length > 0 && (
|
||||||
key={q.id}
|
<div
|
||||||
onClick={() => setActiveGraphQueryId(q.id)}
|
style={{
|
||||||
style={{
|
position: "absolute",
|
||||||
cursor: "pointer",
|
top: 10,
|
||||||
fontFamily: "monospace",
|
right: 10,
|
||||||
fontSize: "12px",
|
display: "flex",
|
||||||
padding: "6px 10px",
|
flexDirection: "column",
|
||||||
borderRadius: "4px",
|
gap: "6px",
|
||||||
border: active ? "1px solid rgba(0,255,0,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
background: "rgba(0,0,0,0.55)",
|
||||||
background: active ? "rgba(0,255,0,0.12)" : "rgba(255,255,255,0.04)",
|
padding: "8px",
|
||||||
color: active ? "#8f8" : "#bbb",
|
borderRadius: "6px",
|
||||||
textAlign: "left",
|
border: "1px solid rgba(255,255,255,0.08)",
|
||||||
}}
|
pointerEvents: "auto",
|
||||||
aria-pressed={active}
|
}}
|
||||||
>
|
>
|
||||||
{q.label}
|
{selectionQueries.map((q) => {
|
||||||
</button>
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: "1 1 50%",
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "#050505",
|
||||||
|
borderLeft: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "18px 20px 14px",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
background: "rgba(255,255,255,0.02)",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#688", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||||
|
{resultQueryLabel}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "6px", color: "#eee", fontSize: "16px" }}>Selection Graph</div>
|
||||||
|
<div style={{ marginTop: "8px", color: "#8ab", fontSize: "12px" }}>
|
||||||
|
Nodes: {(tripleGraphModel?.nodeCount ?? 0).toLocaleString()} · Edges: {(tripleGraphModel?.edgeCount ?? 0).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "4px", color: "#6f8b98", fontSize: "11px" }}>
|
||||||
|
Layout: {cosmosRuntimeConfig.enableSimulation ? "force-directed" : "static"} · Camera: static · Center force: {cosmosRuntimeConfig.simulationCenter} · Repulsion: {cosmosRuntimeConfig.simulationRepulsion} · Link spring: {cosmosRuntimeConfig.simulationLinkSpring} · Friction: {cosmosRuntimeConfig.simulationFriction}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
position: "relative",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tripleResult.status === "idle" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#8a8a8a",
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
padding: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select nodes on the left to view returned triples
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hover tooltip */}
|
{tripleResult.status === "loading" && (
|
||||||
{hoveredNode && (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
color: "#8ab",
|
||||||
left: hoveredNode.screenX + 15,
|
fontSize: "13px",
|
||||||
top: hoveredNode.screenY + 15,
|
lineHeight: 1.6,
|
||||||
background: "rgba(0,0,0,0.85)",
|
padding: "14px",
|
||||||
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" }}>
|
Running triple query…
|
||||||
{hoveredNode.label || hoveredNode.iri || "(unknown)"}
|
|
||||||
</div>
|
|
||||||
<div style={{ color: "#688" }}>
|
|
||||||
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
{tripleResult.status === "error" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#f88",
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
padding: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tripleResult.errorMessage || "Triple query failed"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tripleResult.status === "ready" && (!tripleGraphModel || tripleGraphModel.edgeCount === 0) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
color: "#8a8a8a",
|
||||||
|
fontSize: "13px",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
padding: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No returned graph
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tripleResult.status === "ready" && tripleGraphModel && tripleGraphModel.edgeCount > 0 && (
|
||||||
|
<TripleGraphView model={tripleGraphModel} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
542
frontend/src/TripleGraphView.tsx
Normal file
542
frontend/src/TripleGraphView.tsx
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Graph, type GraphConfig } from "@cosmos.gl/graph";
|
||||||
|
import { cosmosBackgroundCss, cosmosRuntimeConfig } from "./cosmos_config";
|
||||||
|
import {
|
||||||
|
computeLayoutMetrics,
|
||||||
|
type GraphLayoutMetrics,
|
||||||
|
type TripleGraphLink,
|
||||||
|
type TripleGraphModel,
|
||||||
|
type TripleGraphNode,
|
||||||
|
} from "./triple_graph";
|
||||||
|
|
||||||
|
type TripleGraphViewProps = {
|
||||||
|
model: TripleGraphModel;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InspectState =
|
||||||
|
| { kind: "node"; node: TripleGraphNode }
|
||||||
|
| { kind: "link"; link: TripleGraphLink }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
type LayoutDebugState = {
|
||||||
|
phase: "idle" | "running" | "ended";
|
||||||
|
alpha: number | null;
|
||||||
|
progress: number;
|
||||||
|
currentMetrics: GraphLayoutMetrics;
|
||||||
|
lastEvent: string;
|
||||||
|
zoomLevel: number;
|
||||||
|
screenCenter: { x: number; y: number };
|
||||||
|
screenOrigin: { x: number; y: number };
|
||||||
|
screenCentroid: { x: number; y: number };
|
||||||
|
originDelta: { x: number; y: number };
|
||||||
|
centroidDelta: { x: number; y: number };
|
||||||
|
nearSpaceBoundary: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TripleGraphView = memo(function TripleGraphView({ model }: TripleGraphViewProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const graphRef = useRef<Graph | null>(null);
|
||||||
|
const modelRef = useRef(model);
|
||||||
|
const debugLogTimeRef = useRef(0);
|
||||||
|
const [hovered, setHovered] = useState<InspectState>(null);
|
||||||
|
const [pinned, setPinned] = useState<InspectState>(null);
|
||||||
|
const [layoutDebug, setLayoutDebug] = useState<LayoutDebugState>({
|
||||||
|
phase: "idle",
|
||||||
|
alpha: null,
|
||||||
|
progress: 0,
|
||||||
|
currentMetrics: model.seedMetrics,
|
||||||
|
lastEvent: "seed",
|
||||||
|
zoomLevel: 0,
|
||||||
|
screenCenter: { x: 0, y: 0 },
|
||||||
|
screenOrigin: { x: 0, y: 0 },
|
||||||
|
screenCentroid: { x: 0, y: 0 },
|
||||||
|
originDelta: { x: 0, y: 0 },
|
||||||
|
centroidDelta: { x: 0, y: 0 },
|
||||||
|
nearSpaceBoundary: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeDetail = useMemo(() => pinned ?? hovered, [pinned, hovered]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
modelRef.current = model;
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLayoutDebug({
|
||||||
|
phase: "idle",
|
||||||
|
alpha: null,
|
||||||
|
progress: 0,
|
||||||
|
currentMetrics: model.seedMetrics,
|
||||||
|
lastEvent: "seed",
|
||||||
|
zoomLevel: 0,
|
||||||
|
screenCenter: { x: 0, y: 0 },
|
||||||
|
screenOrigin: { x: 0, y: 0 },
|
||||||
|
screenCentroid: { x: 0, y: 0 },
|
||||||
|
originDelta: { x: 0, y: 0 },
|
||||||
|
centroidDelta: { x: 0, y: 0 },
|
||||||
|
nearSpaceBoundary: false,
|
||||||
|
});
|
||||||
|
if (cosmosRuntimeConfig.debugLayout) {
|
||||||
|
console.debug("[cosmos-layout]", {
|
||||||
|
event: "seed-applied",
|
||||||
|
seedCentroid: {
|
||||||
|
x: Number(model.seedMetrics.centroidX.toFixed(3)),
|
||||||
|
y: Number(model.seedMetrics.centroidY.toFixed(3)),
|
||||||
|
},
|
||||||
|
bounds: {
|
||||||
|
width: Number(model.seedMetrics.width.toFixed(3)),
|
||||||
|
height: Number(model.seedMetrics.height.toFixed(3)),
|
||||||
|
maxRadius: Number(model.seedMetrics.maxRadius.toFixed(3)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const reheatSimulation = () => {
|
||||||
|
if (!cosmosRuntimeConfig.enableSimulation) return;
|
||||||
|
graphRef.current?.start(0.25);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reportLayout = (event: string, phase: LayoutDebugState["phase"], alpha?: number) => {
|
||||||
|
const graph = graphRef.current;
|
||||||
|
if (!graph || !cosmosRuntimeConfig.debugLayout) return;
|
||||||
|
const currentMetrics = computeLayoutMetrics(graph.getPointPositions());
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const screenCenter = {
|
||||||
|
x: containerRect.width / 2,
|
||||||
|
y: containerRect.height / 2,
|
||||||
|
};
|
||||||
|
const screenOriginTuple = graph.spaceToScreenPosition([0, 0]);
|
||||||
|
const screenCentroidTuple = graph.spaceToScreenPosition([
|
||||||
|
currentMetrics.centroidX,
|
||||||
|
currentMetrics.centroidY,
|
||||||
|
]);
|
||||||
|
const screenOrigin = { x: screenOriginTuple[0], y: screenOriginTuple[1] };
|
||||||
|
const screenCentroid = { x: screenCentroidTuple[0], y: screenCentroidTuple[1] };
|
||||||
|
const originDelta = {
|
||||||
|
x: screenOrigin.x - screenCenter.x,
|
||||||
|
y: screenOrigin.y - screenCenter.y,
|
||||||
|
};
|
||||||
|
const centroidDelta = {
|
||||||
|
x: screenCentroid.x - screenCenter.x,
|
||||||
|
y: screenCentroid.y - screenCenter.y,
|
||||||
|
};
|
||||||
|
const boundaryMargin = cosmosRuntimeConfig.spaceSize * 0.02;
|
||||||
|
const nearSpaceBoundary =
|
||||||
|
currentMetrics.minX <= boundaryMargin ||
|
||||||
|
currentMetrics.maxX >= cosmosRuntimeConfig.spaceSize - boundaryMargin ||
|
||||||
|
currentMetrics.minY <= boundaryMargin ||
|
||||||
|
currentMetrics.maxY >= cosmosRuntimeConfig.spaceSize - boundaryMargin;
|
||||||
|
const now = performance.now();
|
||||||
|
const shouldPublish = event !== "tick" || now - debugLogTimeRef.current >= 250;
|
||||||
|
const next: LayoutDebugState = {
|
||||||
|
phase,
|
||||||
|
alpha: typeof alpha === "number" ? alpha : null,
|
||||||
|
progress: graph.progress,
|
||||||
|
currentMetrics,
|
||||||
|
lastEvent: event,
|
||||||
|
zoomLevel: graph.getZoomLevel(),
|
||||||
|
screenCenter,
|
||||||
|
screenOrigin,
|
||||||
|
screenCentroid,
|
||||||
|
originDelta,
|
||||||
|
centroidDelta,
|
||||||
|
nearSpaceBoundary,
|
||||||
|
};
|
||||||
|
if (!shouldPublish) return;
|
||||||
|
debugLogTimeRef.current = now;
|
||||||
|
setLayoutDebug(next);
|
||||||
|
console.debug("[cosmos-layout]", {
|
||||||
|
event,
|
||||||
|
phase,
|
||||||
|
alpha: next.alpha,
|
||||||
|
progress: Number(next.progress.toFixed(4)),
|
||||||
|
seedCentroid: {
|
||||||
|
x: Number(modelRef.current.seedMetrics.centroidX.toFixed(3)),
|
||||||
|
y: Number(modelRef.current.seedMetrics.centroidY.toFixed(3)),
|
||||||
|
},
|
||||||
|
currentCentroid: {
|
||||||
|
x: Number(currentMetrics.centroidX.toFixed(3)),
|
||||||
|
y: Number(currentMetrics.centroidY.toFixed(3)),
|
||||||
|
},
|
||||||
|
screenCenter: {
|
||||||
|
x: Number(screenCenter.x.toFixed(2)),
|
||||||
|
y: Number(screenCenter.y.toFixed(2)),
|
||||||
|
},
|
||||||
|
screenOrigin: {
|
||||||
|
x: Number(screenOrigin.x.toFixed(2)),
|
||||||
|
y: Number(screenOrigin.y.toFixed(2)),
|
||||||
|
},
|
||||||
|
screenCentroid: {
|
||||||
|
x: Number(screenCentroid.x.toFixed(2)),
|
||||||
|
y: Number(screenCentroid.y.toFixed(2)),
|
||||||
|
},
|
||||||
|
originDelta: {
|
||||||
|
x: Number(originDelta.x.toFixed(2)),
|
||||||
|
y: Number(originDelta.y.toFixed(2)),
|
||||||
|
},
|
||||||
|
centroidDelta: {
|
||||||
|
x: Number(centroidDelta.x.toFixed(2)),
|
||||||
|
y: Number(centroidDelta.y.toFixed(2)),
|
||||||
|
},
|
||||||
|
zoomLevel: Number(next.zoomLevel.toFixed(4)),
|
||||||
|
nearSpaceBoundary,
|
||||||
|
bounds: {
|
||||||
|
width: Number(currentMetrics.width.toFixed(3)),
|
||||||
|
height: Number(currentMetrics.height.toFixed(3)),
|
||||||
|
maxRadius: Number(currentMetrics.maxRadius.toFixed(3)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const config: GraphConfig = {
|
||||||
|
backgroundColor: cosmosRuntimeConfig.backgroundColor,
|
||||||
|
spaceSize: cosmosRuntimeConfig.spaceSize,
|
||||||
|
enableSimulation: cosmosRuntimeConfig.enableSimulation,
|
||||||
|
pointDefaultColor: cosmosRuntimeConfig.pointDefaultColor,
|
||||||
|
pointGreyoutColor: cosmosRuntimeConfig.pointGreyoutColor,
|
||||||
|
pointGreyoutOpacity: cosmosRuntimeConfig.pointGreyoutOpacity,
|
||||||
|
pointDefaultSize: cosmosRuntimeConfig.pointDefaultSize,
|
||||||
|
pointOpacity: cosmosRuntimeConfig.pointOpacity,
|
||||||
|
pointSizeScale: cosmosRuntimeConfig.pointSizeScale,
|
||||||
|
hoveredPointCursor: cosmosRuntimeConfig.hoveredPointCursor,
|
||||||
|
hoveredLinkCursor: cosmosRuntimeConfig.hoveredLinkCursor,
|
||||||
|
renderHoveredPointRing: cosmosRuntimeConfig.renderHoveredPointRing,
|
||||||
|
hoveredPointRingColor: cosmosRuntimeConfig.hoveredPointRingColor,
|
||||||
|
focusedPointRingColor: cosmosRuntimeConfig.focusedPointRingColor,
|
||||||
|
renderLinks: cosmosRuntimeConfig.renderLinks,
|
||||||
|
linkDefaultColor: cosmosRuntimeConfig.linkDefaultColor,
|
||||||
|
linkOpacity: cosmosRuntimeConfig.linkOpacity,
|
||||||
|
linkGreyoutOpacity: cosmosRuntimeConfig.linkGreyoutOpacity,
|
||||||
|
linkDefaultWidth: cosmosRuntimeConfig.linkDefaultWidth,
|
||||||
|
hoveredLinkColor: cosmosRuntimeConfig.hoveredLinkColor,
|
||||||
|
hoveredLinkWidthIncrease: cosmosRuntimeConfig.hoveredLinkWidthIncrease,
|
||||||
|
linkWidthScale: cosmosRuntimeConfig.linkWidthScale,
|
||||||
|
scaleLinksOnZoom: cosmosRuntimeConfig.scaleLinksOnZoom,
|
||||||
|
enableDrag: cosmosRuntimeConfig.enableDrag,
|
||||||
|
enableZoom: cosmosRuntimeConfig.enableZoom,
|
||||||
|
enableSimulationDuringZoom: cosmosRuntimeConfig.enableSimulationDuringZoom,
|
||||||
|
fitViewOnInit: cosmosRuntimeConfig.fitViewOnInit,
|
||||||
|
fitViewDelay: cosmosRuntimeConfig.fitViewDelay,
|
||||||
|
fitViewPadding: cosmosRuntimeConfig.fitViewPadding,
|
||||||
|
fitViewDuration: cosmosRuntimeConfig.fitViewDuration,
|
||||||
|
initialZoomLevel: cosmosRuntimeConfig.initialZoomLevel,
|
||||||
|
pointSamplingDistance: cosmosRuntimeConfig.pointSamplingDistance,
|
||||||
|
rescalePositions: cosmosRuntimeConfig.rescalePositions,
|
||||||
|
curvedLinks: cosmosRuntimeConfig.curvedLinks,
|
||||||
|
curvedLinkSegments: cosmosRuntimeConfig.curvedLinkSegments,
|
||||||
|
curvedLinkWeight: cosmosRuntimeConfig.curvedLinkWeight,
|
||||||
|
curvedLinkControlPointDistance: cosmosRuntimeConfig.curvedLinkControlPointDistance,
|
||||||
|
linkDefaultArrows: cosmosRuntimeConfig.linkDefaultArrows,
|
||||||
|
linkArrowsSizeScale: cosmosRuntimeConfig.linkArrowsSizeScale,
|
||||||
|
linkVisibilityDistanceRange: cosmosRuntimeConfig.linkVisibilityDistanceRange,
|
||||||
|
linkVisibilityMinTransparency: cosmosRuntimeConfig.linkVisibilityMinTransparency,
|
||||||
|
useClassicQuadtree: cosmosRuntimeConfig.useClassicQuadtree,
|
||||||
|
simulationDecay: cosmosRuntimeConfig.simulationDecay,
|
||||||
|
simulationGravity: cosmosRuntimeConfig.simulationGravity,
|
||||||
|
simulationCenter: cosmosRuntimeConfig.simulationCenter,
|
||||||
|
simulationRepulsion: cosmosRuntimeConfig.simulationRepulsion,
|
||||||
|
simulationRepulsionTheta: cosmosRuntimeConfig.simulationRepulsionTheta,
|
||||||
|
simulationRepulsionQuadtreeLevels:
|
||||||
|
cosmosRuntimeConfig.simulationRepulsionQuadtreeLevels,
|
||||||
|
simulationLinkSpring: cosmosRuntimeConfig.simulationLinkSpring,
|
||||||
|
simulationLinkDistance: cosmosRuntimeConfig.simulationLinkDistance,
|
||||||
|
simulationLinkDistRandomVariationRange:
|
||||||
|
cosmosRuntimeConfig.simulationLinkDistRandomVariationRange,
|
||||||
|
simulationRepulsionFromMouse: cosmosRuntimeConfig.simulationRepulsionFromMouse,
|
||||||
|
enableRightClickRepulsion: cosmosRuntimeConfig.enableRightClickRepulsion,
|
||||||
|
simulationFriction: cosmosRuntimeConfig.simulationFriction,
|
||||||
|
simulationCluster: cosmosRuntimeConfig.simulationCluster,
|
||||||
|
randomSeed: cosmosRuntimeConfig.randomSeed,
|
||||||
|
showFPSMonitor: cosmosRuntimeConfig.showFPSMonitor,
|
||||||
|
pixelRatio: cosmosRuntimeConfig.pixelRatio,
|
||||||
|
scalePointsOnZoom: cosmosRuntimeConfig.scalePointsOnZoom,
|
||||||
|
attribution: cosmosRuntimeConfig.attribution,
|
||||||
|
onSimulationStart: () => {
|
||||||
|
reportLayout("simulation-start", "running", 1);
|
||||||
|
},
|
||||||
|
onSimulationTick: (alpha) => {
|
||||||
|
reportLayout("tick", "running", alpha);
|
||||||
|
},
|
||||||
|
onSimulationEnd: () => {
|
||||||
|
reportLayout("simulation-end", "ended", 0);
|
||||||
|
},
|
||||||
|
onPointMouseOver: (index) => {
|
||||||
|
const node = modelRef.current.nodes[index];
|
||||||
|
if (!node) return;
|
||||||
|
setHovered({ kind: "node", node });
|
||||||
|
},
|
||||||
|
onPointMouseOut: () => {
|
||||||
|
setHovered((prev) => (prev?.kind === "node" ? null : prev));
|
||||||
|
},
|
||||||
|
onLinkMouseOver: (linkIndex) => {
|
||||||
|
const link = modelRef.current.linksMeta[linkIndex];
|
||||||
|
if (!link) return;
|
||||||
|
setHovered({ kind: "link", link });
|
||||||
|
},
|
||||||
|
onLinkMouseOut: () => {
|
||||||
|
setHovered((prev) => (prev?.kind === "link" ? null : prev));
|
||||||
|
},
|
||||||
|
onPointClick: (index) => {
|
||||||
|
const node = modelRef.current.nodes[index];
|
||||||
|
if (!node) return;
|
||||||
|
setPinned({ kind: "node", node });
|
||||||
|
},
|
||||||
|
onLinkClick: (linkIndex) => {
|
||||||
|
const link = modelRef.current.linksMeta[linkIndex];
|
||||||
|
if (!link) return;
|
||||||
|
setPinned({ kind: "link", link });
|
||||||
|
},
|
||||||
|
onClick: (index) => {
|
||||||
|
if (typeof index === "number") return;
|
||||||
|
setPinned(null);
|
||||||
|
},
|
||||||
|
onDragStart: () => {
|
||||||
|
reportLayout("drag-start", "running");
|
||||||
|
reheatSimulation();
|
||||||
|
},
|
||||||
|
onDragEnd: () => {
|
||||||
|
reportLayout("drag-end", "running");
|
||||||
|
reheatSimulation();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const graph = new Graph(container, config);
|
||||||
|
graphRef.current = graph;
|
||||||
|
if (cosmosRuntimeConfig.debugLayout) {
|
||||||
|
console.debug("[cosmos-layout]", {
|
||||||
|
event: "graph-created",
|
||||||
|
seedCentroid: {
|
||||||
|
x: Number(modelRef.current.seedMetrics.centroidX.toFixed(3)),
|
||||||
|
y: Number(modelRef.current.seedMetrics.centroidY.toFixed(3)),
|
||||||
|
},
|
||||||
|
seedRadius: Number(modelRef.current.seedMetrics.maxRadius.toFixed(3)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setHovered(null);
|
||||||
|
setPinned(null);
|
||||||
|
graphRef.current = null;
|
||||||
|
graph.destroy();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const graph = graphRef.current;
|
||||||
|
if (!graph) return;
|
||||||
|
setHovered(null);
|
||||||
|
setPinned(null);
|
||||||
|
applyGraphModel(graph, model);
|
||||||
|
if (cosmosRuntimeConfig.debugLayout) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const positionedGraph = graphRef.current;
|
||||||
|
if (!positionedGraph) return;
|
||||||
|
const currentMetrics = computeLayoutMetrics(positionedGraph.getPointPositions());
|
||||||
|
const origin = positionedGraph.spaceToScreenPosition([0, 0]);
|
||||||
|
const centroid = positionedGraph.spaceToScreenPosition([
|
||||||
|
currentMetrics.centroidX,
|
||||||
|
currentMetrics.centroidY,
|
||||||
|
]);
|
||||||
|
console.debug("[cosmos-layout]", {
|
||||||
|
event: "after-fit-requested",
|
||||||
|
screenOrigin: { x: Number(origin[0].toFixed(2)), y: Number(origin[1].toFixed(2)) },
|
||||||
|
screenCentroid: { x: Number(centroid[0].toFixed(2)), y: Number(centroid[1].toFixed(2)) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [model]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const graph = graphRef.current;
|
||||||
|
if (!graph) return;
|
||||||
|
graph.setConfig({
|
||||||
|
focusedPointIndex: activeDetail?.kind === "node" ? activeDetail.node.index : undefined,
|
||||||
|
});
|
||||||
|
}, [activeDetail]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
background: cosmosBackgroundCss,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{ position: "absolute", inset: 0, width: "100%", height: "100%" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{cosmosRuntimeConfig.debugLayout && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 12,
|
||||||
|
left: 12,
|
||||||
|
maxWidth: "min(340px, calc(100% - 24px))",
|
||||||
|
background: "rgba(4, 7, 11, 0.94)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: "10px",
|
||||||
|
padding: "10px 12px",
|
||||||
|
color: "#d7e2ea",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "11px",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
boxShadow: "0 10px 28px rgba(0,0,0,0.35)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#88a9b9", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||||
|
Layout Debug
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "6px" }}>
|
||||||
|
phase: {layoutDebug.phase} · event: {layoutDebug.lastEvent}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
alpha: {formatMaybeNumber(layoutDebug.alpha)} · progress: {formatNumber(layoutDebug.progress)}
|
||||||
|
</div>
|
||||||
|
<div>zoom: {formatNumber(layoutDebug.zoomLevel)}</div>
|
||||||
|
<div style={{ marginTop: "8px", color: "#9ac7d8" }}>seed centroid</div>
|
||||||
|
<div>
|
||||||
|
({formatNumber(model.seedMetrics.centroidX)}, {formatNumber(model.seedMetrics.centroidY)})
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
bounds: {formatNumber(model.seedMetrics.width)} × {formatNumber(model.seedMetrics.height)} · r={formatNumber(model.seedMetrics.maxRadius)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "8px", color: "#f0c674" }}>current centroid</div>
|
||||||
|
<div>
|
||||||
|
({formatNumber(layoutDebug.currentMetrics.centroidX)}, {formatNumber(layoutDebug.currentMetrics.centroidY)})
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
bounds: {formatNumber(layoutDebug.currentMetrics.width)} × {formatNumber(layoutDebug.currentMetrics.height)} · r={formatNumber(layoutDebug.currentMetrics.maxRadius)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "8px", color: "#9ac7d8" }}>screen center</div>
|
||||||
|
<div>
|
||||||
|
({formatNumber(layoutDebug.screenCenter.x)}, {formatNumber(layoutDebug.screenCenter.y)})
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "8px", color: "#f0c674" }}>screen origin</div>
|
||||||
|
<div>
|
||||||
|
({formatNumber(layoutDebug.screenOrigin.x)}, {formatNumber(layoutDebug.screenOrigin.y)}) d=({formatNumber(layoutDebug.originDelta.x)}, {formatNumber(layoutDebug.originDelta.y)})
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "8px", color: "#f0c674" }}>screen centroid</div>
|
||||||
|
<div>
|
||||||
|
({formatNumber(layoutDebug.screenCentroid.x)}, {formatNumber(layoutDebug.screenCentroid.y)}) d=({formatNumber(layoutDebug.centroidDelta.x)}, {formatNumber(layoutDebug.centroidDelta.y)})
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "8px", color: layoutDebug.nearSpaceBoundary ? "#ff8b8b" : "#8fd2a8" }}>
|
||||||
|
near space boundary: {layoutDebug.nearSpaceBoundary ? "yes" : "no"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 12,
|
||||||
|
bottom: 12,
|
||||||
|
width: "min(420px, calc(100% - 24px))",
|
||||||
|
maxHeight: "calc(100% - 24px)",
|
||||||
|
overflowY: "auto",
|
||||||
|
background: "rgba(4, 7, 11, 0.94)",
|
||||||
|
border: "1px solid rgba(255,255,255,0.1)",
|
||||||
|
borderRadius: "10px",
|
||||||
|
padding: "12px 14px",
|
||||||
|
color: "#d7e2ea",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "12px",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
boxShadow: "0 10px 28px rgba(0,0,0,0.35)",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ color: "#88a9b9", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||||
|
{pinned ? "Pinned details" : activeDetail ? "Hovered details" : "Inspector"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!activeDetail && (
|
||||||
|
<div style={{ marginTop: "8px", color: "#a7b7c2" }}>
|
||||||
|
Hover a node or edge to inspect it. Click a node or edge to pin its details.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeDetail?.kind === "node" && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginTop: "8px", color: activeDetail.node.isSelectedSource ? "#35d6ff" : "#9ac7d8", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||||
|
Node
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "4px" }}>{activeDetail.node.text}</div>
|
||||||
|
{typeof activeDetail.node.backendId === "number" && (
|
||||||
|
<div style={{ marginTop: "6px", color: "#88a9b9" }}>
|
||||||
|
backend id: {activeDetail.node.backendId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeDetail.node.isSelectedSource && (
|
||||||
|
<div style={{ marginTop: "4px", color: "#35d6ff" }}>
|
||||||
|
selected source node
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeDetail?.kind === "link" && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginTop: "8px", color: "#f0c674", fontSize: "11px", textTransform: "uppercase", letterSpacing: "0.08em" }}>
|
||||||
|
Edge
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "4px", color: "#f0c674" }}>{activeDetail.link.predicateText}</div>
|
||||||
|
<div style={{ marginTop: "8px", color: "#88a9b9" }}>from</div>
|
||||||
|
<div>{activeDetail.link.sourceText}</div>
|
||||||
|
<div style={{ marginTop: "8px", color: "#88a9b9" }}>to</div>
|
||||||
|
<div>{activeDetail.link.targetText}</div>
|
||||||
|
{typeof activeDetail.link.predicateId === "number" && (
|
||||||
|
<div style={{ marginTop: "6px", color: "#88a9b9" }}>
|
||||||
|
predicate id: {activeDetail.link.predicateId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyGraphModel(graph: Graph, model: TripleGraphModel): void {
|
||||||
|
graph.setPointPositions(model.pointPositions);
|
||||||
|
graph.setLinks(model.links);
|
||||||
|
graph.setPointColors(model.pointColors);
|
||||||
|
graph.setPointSizes(model.pointSizes);
|
||||||
|
graph.setLinkColors(model.linkColors);
|
||||||
|
graph.setLinkWidths(model.linkWidths);
|
||||||
|
graph.render(0);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (typeof cosmosRuntimeConfig.initialZoomLevel === "number") {
|
||||||
|
graph.setZoomLevel(
|
||||||
|
cosmosRuntimeConfig.initialZoomLevel,
|
||||||
|
cosmosRuntimeConfig.fitViewDuration,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
graph.fitViewByPointPositions(
|
||||||
|
Array.from(model.pointPositions),
|
||||||
|
cosmosRuntimeConfig.fitViewDuration,
|
||||||
|
cosmosRuntimeConfig.fitViewPadding,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cosmosRuntimeConfig.enableSimulation) {
|
||||||
|
graph.start(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number): string {
|
||||||
|
return value.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMaybeNumber(value: number | null): string {
|
||||||
|
return value === null ? "-" : value.toFixed(3);
|
||||||
|
}
|
||||||
180
frontend/src/cosmos_config.ts
Normal file
180
frontend/src/cosmos_config.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
type CosmosColor = string | [number, number, number, number];
|
||||||
|
|
||||||
|
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
||||||
|
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string | undefined, fallback: number): number {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumber(value: string | undefined): number | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) return undefined;
|
||||||
|
const parsed = Number(trimmed);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalString(value: string | undefined): string | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumberList(value: string | undefined, fallback: number[]): number[] {
|
||||||
|
return parseOptionalNumberList(value) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalNumberList(value: string | undefined): number[] | undefined {
|
||||||
|
const raw = parseOptionalString(value);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const normalized = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1) : raw;
|
||||||
|
const parts = normalized
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
if (parts.length === 0) return undefined;
|
||||||
|
const parsed = parts.map((entry) => Number(entry));
|
||||||
|
return parsed.every((entry) => Number.isFinite(entry)) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseColor(value: string | undefined, fallback: CosmosColor): CosmosColor {
|
||||||
|
return parseOptionalColor(value) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalColor(value: string | undefined): CosmosColor | undefined {
|
||||||
|
const raw = parseOptionalString(value);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const rgba = parseOptionalNumberList(raw);
|
||||||
|
if (rgba && rgba.length === 4) {
|
||||||
|
return [rgba[0], rgba[1], rgba[2], rgba[3]];
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalSeed(value: string | undefined): number | string | undefined {
|
||||||
|
const raw = parseOptionalString(value);
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const numeric = Number(raw);
|
||||||
|
return Number.isFinite(numeric) ? numeric : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCssColor(color: CosmosColor): string {
|
||||||
|
if (typeof color === "string") return color;
|
||||||
|
return `rgba(${color[0]}, ${color[1]}, ${color[2]}, ${color[3] / 255})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cosmosRuntimeConfig = {
|
||||||
|
enableSimulation: parseBoolean(import.meta.env.VITE_COSMOS_ENABLE_SIMULATION, true),
|
||||||
|
debugLayout: parseBoolean(import.meta.env.VITE_COSMOS_DEBUG_LAYOUT, false),
|
||||||
|
backgroundColor: parseColor(import.meta.env.VITE_COSMOS_BACKGROUND_COLOR, "#05070a"),
|
||||||
|
spaceSize: parseNumber(import.meta.env.VITE_COSMOS_SPACE_SIZE, 4096),
|
||||||
|
pointDefaultColor: parseOptionalColor(import.meta.env.VITE_COSMOS_POINT_DEFAULT_COLOR),
|
||||||
|
pointGreyoutColor: parseOptionalColor(import.meta.env.VITE_COSMOS_POINT_GREYOUT_COLOR),
|
||||||
|
pointGreyoutOpacity: parseOptionalNumber(import.meta.env.VITE_COSMOS_POINT_GREYOUT_OPACITY),
|
||||||
|
pointDefaultSize: parseNumber(import.meta.env.VITE_COSMOS_POINT_DEFAULT_SIZE, 4),
|
||||||
|
pointOpacity: parseNumber(import.meta.env.VITE_COSMOS_POINT_OPACITY, 1),
|
||||||
|
pointSizeScale: parseNumber(import.meta.env.VITE_COSMOS_POINT_SIZE_SCALE, 1),
|
||||||
|
hoveredPointCursor: parseOptionalString(import.meta.env.VITE_COSMOS_HOVERED_POINT_CURSOR) ?? "pointer",
|
||||||
|
hoveredLinkCursor: parseOptionalString(import.meta.env.VITE_COSMOS_HOVERED_LINK_CURSOR) ?? "pointer",
|
||||||
|
renderHoveredPointRing: parseBoolean(
|
||||||
|
import.meta.env.VITE_COSMOS_RENDER_HOVERED_POINT_RING,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
hoveredPointRingColor: parseColor(
|
||||||
|
import.meta.env.VITE_COSMOS_HOVERED_POINT_RING_COLOR,
|
||||||
|
"#35d6ff",
|
||||||
|
),
|
||||||
|
focusedPointRingColor: parseColor(
|
||||||
|
import.meta.env.VITE_COSMOS_FOCUSED_POINT_RING_COLOR,
|
||||||
|
"white",
|
||||||
|
),
|
||||||
|
renderLinks: parseBoolean(import.meta.env.VITE_COSMOS_RENDER_LINKS, true),
|
||||||
|
linkDefaultColor: parseOptionalColor(import.meta.env.VITE_COSMOS_LINK_DEFAULT_COLOR),
|
||||||
|
linkOpacity: parseNumber(import.meta.env.VITE_COSMOS_LINK_OPACITY, 1),
|
||||||
|
linkGreyoutOpacity: parseNumber(import.meta.env.VITE_COSMOS_LINK_GREYOUT_OPACITY, 0.1),
|
||||||
|
linkDefaultWidth: parseNumber(import.meta.env.VITE_COSMOS_LINK_DEFAULT_WIDTH, 1),
|
||||||
|
hoveredLinkColor: parseColor(import.meta.env.VITE_COSMOS_HOVERED_LINK_COLOR, "#ffd166"),
|
||||||
|
hoveredLinkWidthIncrease: parseNumber(
|
||||||
|
import.meta.env.VITE_COSMOS_HOVERED_LINK_WIDTH_INCREASE,
|
||||||
|
2.5,
|
||||||
|
),
|
||||||
|
linkWidthScale: parseNumber(import.meta.env.VITE_COSMOS_LINK_WIDTH_SCALE, 1),
|
||||||
|
scaleLinksOnZoom: parseBoolean(import.meta.env.VITE_COSMOS_SCALE_LINKS_ON_ZOOM, false),
|
||||||
|
curvedLinks: parseBoolean(import.meta.env.VITE_COSMOS_CURVED_LINKS, true),
|
||||||
|
curvedLinkSegments: parseNumber(import.meta.env.VITE_COSMOS_CURVED_LINK_SEGMENTS, 19),
|
||||||
|
curvedLinkWeight: parseNumber(import.meta.env.VITE_COSMOS_CURVED_LINK_WEIGHT, 0.8),
|
||||||
|
curvedLinkControlPointDistance: parseNumber(
|
||||||
|
import.meta.env.VITE_COSMOS_CURVED_LINK_CONTROL_POINT_DISTANCE,
|
||||||
|
0.5,
|
||||||
|
),
|
||||||
|
linkDefaultArrows: parseBoolean(import.meta.env.VITE_COSMOS_LINK_DEFAULT_ARROWS, false),
|
||||||
|
linkArrowsSizeScale: parseNumber(import.meta.env.VITE_COSMOS_LINK_ARROWS_SIZE_SCALE, 1),
|
||||||
|
linkVisibilityDistanceRange: parseNumberList(
|
||||||
|
import.meta.env.VITE_COSMOS_LINK_VISIBILITY_DISTANCE_RANGE,
|
||||||
|
[50, 150],
|
||||||
|
),
|
||||||
|
linkVisibilityMinTransparency: parseNumber(
|
||||||
|
import.meta.env.VITE_COSMOS_LINK_VISIBILITY_MIN_TRANSPARENCY,
|
||||||
|
0.25,
|
||||||
|
),
|
||||||
|
useClassicQuadtree: parseBoolean(import.meta.env.VITE_COSMOS_USE_CLASSIC_QUADTREE, false),
|
||||||
|
simulationDecay: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_DECAY, 5000),
|
||||||
|
simulationGravity: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_GRAVITY, 0),
|
||||||
|
simulationCenter: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_CENTER, 0.05),
|
||||||
|
simulationRepulsion: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_REPULSION, 0.5),
|
||||||
|
simulationRepulsionTheta: parseNumber(
|
||||||
|
import.meta.env.VITE_COSMOS_SIMULATION_REPULSION_THETA,
|
||||||
|
1.15,
|
||||||
|
),
|
||||||
|
simulationRepulsionQuadtreeLevels: parseNumber(
|
||||||
|
import.meta.env.VITE_COSMOS_SIMULATION_REPULSION_QUADTREE_LEVELS,
|
||||||
|
12,
|
||||||
|
),
|
||||||
|
simulationLinkSpring: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_LINK_SPRING, 1),
|
||||||
|
simulationLinkDistance: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_LINK_DISTANCE, 10),
|
||||||
|
simulationLinkDistRandomVariationRange: parseNumberList(
|
||||||
|
import.meta.env.VITE_COSMOS_SIMULATION_LINK_DISTANCE_RANDOM_VARIATION_RANGE,
|
||||||
|
[1, 1.2],
|
||||||
|
),
|
||||||
|
simulationRepulsionFromMouse: parseNumber(
|
||||||
|
import.meta.env.VITE_COSMOS_SIMULATION_REPULSION_FROM_MOUSE,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
enableRightClickRepulsion: parseBoolean(
|
||||||
|
import.meta.env.VITE_COSMOS_ENABLE_RIGHT_CLICK_REPULSION,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
simulationFriction: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_FRICTION, 0.1),
|
||||||
|
simulationCluster: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_CLUSTER, 0.1),
|
||||||
|
showFPSMonitor: parseBoolean(import.meta.env.VITE_COSMOS_SHOW_FPS_MONITOR, false),
|
||||||
|
pixelRatio: parseNumber(import.meta.env.VITE_COSMOS_PIXEL_RATIO, 2),
|
||||||
|
scalePointsOnZoom: parseBoolean(import.meta.env.VITE_COSMOS_SCALE_POINTS_ON_ZOOM, false),
|
||||||
|
initialZoomLevel: parseOptionalNumber(import.meta.env.VITE_COSMOS_INITIAL_ZOOM_LEVEL),
|
||||||
|
enableZoom: parseBoolean(import.meta.env.VITE_COSMOS_ENABLE_ZOOM, true),
|
||||||
|
enableSimulationDuringZoom: parseBoolean(
|
||||||
|
import.meta.env.VITE_COSMOS_ENABLE_SIMULATION_DURING_ZOOM,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
enableDrag: parseBoolean(import.meta.env.VITE_COSMOS_ENABLE_DRAG, true),
|
||||||
|
fitViewOnInit: parseBoolean(import.meta.env.VITE_COSMOS_FIT_VIEW_ON_INIT, false),
|
||||||
|
fitViewDelay: parseNumber(import.meta.env.VITE_COSMOS_FIT_VIEW_DELAY, 250),
|
||||||
|
fitViewPadding: parseNumber(import.meta.env.VITE_COSMOS_FIT_VIEW_PADDING, 0.12),
|
||||||
|
fitViewDuration: parseNumber(import.meta.env.VITE_COSMOS_FIT_VIEW_DURATION, 250),
|
||||||
|
randomSeed: parseOptionalSeed(import.meta.env.VITE_COSMOS_RANDOM_SEED),
|
||||||
|
pointSamplingDistance: parseNumber(
|
||||||
|
import.meta.env.VITE_COSMOS_POINT_SAMPLING_DISTANCE,
|
||||||
|
150,
|
||||||
|
),
|
||||||
|
rescalePositions: parseBoolean(import.meta.env.VITE_COSMOS_RESCALE_POSITIONS, false),
|
||||||
|
attribution: parseOptionalString(import.meta.env.VITE_COSMOS_ATTRIBUTION),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cosmosBackgroundCss = toCssColor(cosmosRuntimeConfig.backgroundColor);
|
||||||
301
frontend/src/graph_arrow.ts
Normal file
301
frontend/src/graph_arrow.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { RecordBatchReader } from "apache-arrow";
|
||||||
|
import type { GraphMeta } from "./selection_queries";
|
||||||
|
|
||||||
|
export type ArrowGraphLoadResult = {
|
||||||
|
meta: GraphMeta | null;
|
||||||
|
vertexIds: Uint32Array;
|
||||||
|
xs: Float32Array;
|
||||||
|
ys: Float32Array;
|
||||||
|
edgeData: Uint32Array;
|
||||||
|
routeLineVertices: Float32Array;
|
||||||
|
labels: (string | undefined)[];
|
||||||
|
iris: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ArrowBatchLog = (phase: string, extra?: Record<string, unknown>) => void;
|
||||||
|
|
||||||
|
type ArrowLikeVector = {
|
||||||
|
data?: Array<{ valueOffsets?: ArrayLike<number | bigint> }>;
|
||||||
|
getChildAt?: (index: number) => ArrowLikeVector | null;
|
||||||
|
get?: (index: number) => unknown;
|
||||||
|
toArray?: () => ArrayLike<number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const graphTransportVersion = "1";
|
||||||
|
|
||||||
|
export async function readGraphArrow(response: Response, logPhase?: ArrowBatchLog): Promise<ArrowGraphLoadResult> {
|
||||||
|
validateArrowResponse(response);
|
||||||
|
|
||||||
|
const reader = await openArrowReader(response);
|
||||||
|
|
||||||
|
let meta: GraphMeta | null = null;
|
||||||
|
let vertexIds: Uint32Array | null = null;
|
||||||
|
let xs: Float32Array | null = null;
|
||||||
|
let ys: Float32Array | null = null;
|
||||||
|
let edgeData: Uint32Array | null = null;
|
||||||
|
let routeLineVertices: Float32Array | null = null;
|
||||||
|
let labels: (string | undefined)[] | null = null;
|
||||||
|
let iris: string[] | null = null;
|
||||||
|
|
||||||
|
let batchIndex = 0;
|
||||||
|
for await (const batch of reader) {
|
||||||
|
const batchStartedAt = performance.now();
|
||||||
|
|
||||||
|
switch (batchIndex) {
|
||||||
|
case 0: {
|
||||||
|
meta = decodeMetaBatch(batch);
|
||||||
|
const nodeCount = typeof meta.nodes === "number" ? meta.nodes : 0;
|
||||||
|
const edgeCount = typeof meta.edges === "number" ? meta.edges : 0;
|
||||||
|
const routeLineSegments = readScalarNumber(batch, "meta_route_line_segments") ?? 0;
|
||||||
|
|
||||||
|
vertexIds = new Uint32Array(nodeCount);
|
||||||
|
xs = new Float32Array(nodeCount);
|
||||||
|
ys = new Float32Array(nodeCount);
|
||||||
|
edgeData = new Uint32Array(edgeCount * 2);
|
||||||
|
routeLineVertices = new Float32Array(routeLineSegments * 4);
|
||||||
|
labels = new Array<string | undefined>(nodeCount);
|
||||||
|
iris = new Array<string>(nodeCount);
|
||||||
|
|
||||||
|
logPhase?.("arrow_meta_batch_decoded", {
|
||||||
|
batch_index: batchIndex,
|
||||||
|
rows: batch.numRows,
|
||||||
|
node_count: nodeCount,
|
||||||
|
edge_count: edgeCount,
|
||||||
|
route_line_segments: routeLineSegments,
|
||||||
|
decode_ms: Math.round(performance.now() - batchStartedAt),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 1: {
|
||||||
|
ensureAllocated(meta, vertexIds, xs, ys, edgeData, routeLineVertices, labels, iris);
|
||||||
|
const nodeIDs = readUint32List(batch, "node_id");
|
||||||
|
const nodeXs = readFloat32List(batch, "node_x");
|
||||||
|
const nodeYs = readFloat32List(batch, "node_y");
|
||||||
|
const nodeIRIs = readStringList(batch, "node_iri");
|
||||||
|
const nodeLabels = readNullableStringList(batch, "node_label");
|
||||||
|
|
||||||
|
if (nodeIDs.length !== vertexIds.length || nodeXs.length !== xs.length || nodeYs.length !== ys.length) {
|
||||||
|
throw new Error("Arrow node batch length mismatch");
|
||||||
|
}
|
||||||
|
if (nodeIRIs.length !== iris.length || nodeLabels.length !== labels.length) {
|
||||||
|
throw new Error("Arrow node metadata batch length mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
vertexIds.set(nodeIDs);
|
||||||
|
xs.set(nodeXs);
|
||||||
|
ys.set(nodeYs);
|
||||||
|
for (let i = 0; i < iris.length; i++) {
|
||||||
|
iris[i] = nodeIRIs[i] ?? "";
|
||||||
|
labels[i] = nodeLabels[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
logPhase?.("arrow_node_batch_decoded", {
|
||||||
|
batch_index: batchIndex,
|
||||||
|
rows: batch.numRows,
|
||||||
|
nodes: vertexIds.length,
|
||||||
|
decode_ms: Math.round(performance.now() - batchStartedAt),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
ensureAllocated(meta, vertexIds, xs, ys, edgeData, routeLineVertices, labels, iris);
|
||||||
|
const edgeSources = readUint32List(batch, "edge_source");
|
||||||
|
const edgeTargets = readUint32List(batch, "edge_target");
|
||||||
|
if (edgeSources.length !== edgeTargets.length) {
|
||||||
|
throw new Error("Arrow edge batch source/target length mismatch");
|
||||||
|
}
|
||||||
|
if (edgeData.length !== edgeSources.length * 2) {
|
||||||
|
throw new Error("Arrow edge batch size mismatch");
|
||||||
|
}
|
||||||
|
for (let i = 0; i < edgeSources.length; i++) {
|
||||||
|
edgeData[i * 2] = edgeSources[i];
|
||||||
|
edgeData[i * 2 + 1] = edgeTargets[i];
|
||||||
|
}
|
||||||
|
logPhase?.("arrow_edge_batch_decoded", {
|
||||||
|
batch_index: batchIndex,
|
||||||
|
rows: batch.numRows,
|
||||||
|
edges: edgeSources.length,
|
||||||
|
decode_ms: Math.round(performance.now() - batchStartedAt),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
ensureAllocated(meta, vertexIds, xs, ys, edgeData, routeLineVertices, labels, iris);
|
||||||
|
const routeX1 = readFloat32List(batch, "route_x1");
|
||||||
|
const routeY1 = readFloat32List(batch, "route_y1");
|
||||||
|
const routeX2 = readFloat32List(batch, "route_x2");
|
||||||
|
const routeY2 = readFloat32List(batch, "route_y2");
|
||||||
|
if (
|
||||||
|
routeX1.length !== routeY1.length ||
|
||||||
|
routeX1.length !== routeX2.length ||
|
||||||
|
routeX1.length !== routeY2.length
|
||||||
|
) {
|
||||||
|
throw new Error("Arrow route batch axis length mismatch");
|
||||||
|
}
|
||||||
|
if (routeLineVertices.length !== routeX1.length * 4) {
|
||||||
|
throw new Error("Arrow route batch size mismatch");
|
||||||
|
}
|
||||||
|
for (let i = 0; i < routeX1.length; i++) {
|
||||||
|
routeLineVertices[i * 4] = routeX1[i];
|
||||||
|
routeLineVertices[i * 4 + 1] = routeY1[i];
|
||||||
|
routeLineVertices[i * 4 + 2] = routeX2[i];
|
||||||
|
routeLineVertices[i * 4 + 3] = routeY2[i];
|
||||||
|
}
|
||||||
|
logPhase?.("arrow_route_batch_decoded", {
|
||||||
|
batch_index: batchIndex,
|
||||||
|
rows: batch.numRows,
|
||||||
|
route_line_segments: routeX1.length,
|
||||||
|
decode_ms: Math.round(performance.now() - batchStartedAt),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected Arrow batch index ${batchIndex}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
batchIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (batchIndex !== 4) {
|
||||||
|
throw new Error(`Expected 4 Arrow batches, received ${batchIndex}`);
|
||||||
|
}
|
||||||
|
ensureAllocated(meta, vertexIds, xs, ys, edgeData, routeLineVertices, labels, iris);
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta,
|
||||||
|
vertexIds,
|
||||||
|
xs,
|
||||||
|
ys,
|
||||||
|
edgeData,
|
||||||
|
routeLineVertices,
|
||||||
|
labels,
|
||||||
|
iris,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openArrowReader(response: Response) {
|
||||||
|
if (response.body) {
|
||||||
|
return RecordBatchReader.from(response.body);
|
||||||
|
}
|
||||||
|
return RecordBatchReader.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArrowResponse(response: Response): void {
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (!contentType.includes("application/vnd.apache.arrow.stream")) {
|
||||||
|
throw new Error(`Unexpected graph content type: ${contentType || "(missing)"}`);
|
||||||
|
}
|
||||||
|
const version = response.headers.get("X-Graph-Transport-Version");
|
||||||
|
if (version !== graphTransportVersion) {
|
||||||
|
throw new Error(`Unexpected graph transport version: ${version || "(missing)"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeMetaBatch(batch: any): GraphMeta {
|
||||||
|
return {
|
||||||
|
backend: readScalarString(batch, "meta_backend"),
|
||||||
|
graph_query_id: readScalarString(batch, "meta_graph_query_id"),
|
||||||
|
node_limit: readScalarNumber(batch, "meta_node_limit"),
|
||||||
|
edge_limit: readScalarNumber(batch, "meta_edge_limit"),
|
||||||
|
nodes: readScalarNumber(batch, "meta_nodes"),
|
||||||
|
edges: readScalarNumber(batch, "meta_edges"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAllocated(
|
||||||
|
meta: GraphMeta | null,
|
||||||
|
vertexIds: Uint32Array | null,
|
||||||
|
xs: Float32Array | null,
|
||||||
|
ys: Float32Array | null,
|
||||||
|
edgeData: Uint32Array | null,
|
||||||
|
routeLineVertices: Float32Array | null,
|
||||||
|
labels: (string | undefined)[] | null,
|
||||||
|
iris: string[] | null
|
||||||
|
): asserts meta is GraphMeta & {} & {
|
||||||
|
nodes?: number;
|
||||||
|
edges?: number;
|
||||||
|
} {
|
||||||
|
if (!meta || !vertexIds || !xs || !ys || !edgeData || !routeLineVertices || !labels || !iris) {
|
||||||
|
throw new Error("Arrow graph stream is missing the meta batch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScalarString(batch: any, name: string): string | undefined {
|
||||||
|
const vector = batch.getChild(name);
|
||||||
|
if (!vector || batch.numRows < 1) return undefined;
|
||||||
|
const value = vector.get(0);
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readScalarNumber(batch: any, name: string): number | undefined {
|
||||||
|
const vector = batch.getChild(name);
|
||||||
|
if (!vector || batch.numRows < 1) return undefined;
|
||||||
|
const value = vector.get(0);
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUint32List(batch: any, name: string): Uint32Array {
|
||||||
|
return readPrimitiveList(batch, name, Uint32Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFloat32List(batch: any, name: string): Float32Array {
|
||||||
|
return readPrimitiveList(batch, name, Float32Array);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPrimitiveList<T extends Uint32Array | Float32Array>(
|
||||||
|
batch: any,
|
||||||
|
name: string,
|
||||||
|
ctor: { new(length: number): T }
|
||||||
|
): T {
|
||||||
|
const vector = batch.getChild(name) as ArrowLikeVector | null;
|
||||||
|
if (!vector) return new ctor(0);
|
||||||
|
|
||||||
|
const [begin, end] = getListBounds(vector);
|
||||||
|
const child = vector.getChildAt?.(0);
|
||||||
|
if (!child || typeof child.toArray !== "function") {
|
||||||
|
return new ctor(0);
|
||||||
|
}
|
||||||
|
const values = child.toArray();
|
||||||
|
if (typeof (values as any).subarray === "function") {
|
||||||
|
return (values as T).subarray(begin, end) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new ctor(end - begin);
|
||||||
|
for (let i = begin; i < end; i++) {
|
||||||
|
out[i - begin] = Number((values as ArrayLike<number>)[i]) as T[number];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringList(batch: any, name: string): string[] {
|
||||||
|
return readNullableStringList(batch, name).map((value) => value ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNullableStringList(batch: any, name: string): (string | undefined)[] {
|
||||||
|
const vector = batch.getChild(name) as ArrowLikeVector | null;
|
||||||
|
if (!vector) return [];
|
||||||
|
|
||||||
|
const [begin, end] = getListBounds(vector);
|
||||||
|
const child = vector.getChildAt?.(0);
|
||||||
|
if (!child || typeof child.get !== "function") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = new Array<string | undefined>(Math.max(0, end - begin));
|
||||||
|
for (let i = begin; i < end; i++) {
|
||||||
|
const value = child.get(i);
|
||||||
|
out[i - begin] = typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getListBounds(vector: ArrowLikeVector): [number, number] {
|
||||||
|
const offsets = vector.data?.[0]?.valueOffsets;
|
||||||
|
if (!offsets || offsets.length < 2) return [0, 0];
|
||||||
|
return [offsetToNumber(offsets[0]), offsetToNumber(offsets[1])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetToNumber(value: number | bigint | undefined): number {
|
||||||
|
if (typeof value === "bigint") return Number(value);
|
||||||
|
return typeof value === "number" ? value : 0;
|
||||||
|
}
|
||||||
@@ -76,6 +76,9 @@ export class Renderer {
|
|||||||
private selectedProgram: WebGLProgram;
|
private selectedProgram: WebGLProgram;
|
||||||
private neighborProgram: WebGLProgram;
|
private neighborProgram: WebGLProgram;
|
||||||
private vao: WebGLVertexArrayObject;
|
private vao: WebGLVertexArrayObject;
|
||||||
|
private nodeVbo: WebGLBuffer;
|
||||||
|
private lineVao: WebGLVertexArrayObject;
|
||||||
|
private lineVbo: WebGLBuffer;
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
private leaves: Leaf[] = [];
|
private leaves: Leaf[] = [];
|
||||||
@@ -88,6 +91,8 @@ export class Renderer {
|
|||||||
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
|
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
|
||||||
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
|
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
|
||||||
private maxPtSize = 256;
|
private maxPtSize = 256;
|
||||||
|
private useRawLineSegments = false;
|
||||||
|
private rawLineVertexCount = 0;
|
||||||
|
|
||||||
// Multi-draw extension
|
// Multi-draw extension
|
||||||
private multiDrawExt: any = null;
|
private multiDrawExt: any = null;
|
||||||
@@ -163,15 +168,23 @@ export class Renderer {
|
|||||||
|
|
||||||
// Create VAO + VBO (empty for now)
|
// Create VAO + VBO (empty for now)
|
||||||
this.vao = gl.createVertexArray()!;
|
this.vao = gl.createVertexArray()!;
|
||||||
|
this.nodeVbo = gl.createBuffer()!;
|
||||||
gl.bindVertexArray(this.vao);
|
gl.bindVertexArray(this.vao);
|
||||||
const vbo = gl.createBuffer()!;
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeVbo);
|
||||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
|
||||||
|
|
||||||
// We forced a_pos to location 0 in compileProgram
|
// We forced a_pos to location 0 in compileProgram
|
||||||
gl.enableVertexAttribArray(0);
|
gl.enableVertexAttribArray(0);
|
||||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||||
gl.bindVertexArray(null);
|
gl.bindVertexArray(null);
|
||||||
|
|
||||||
|
this.lineVao = gl.createVertexArray()!;
|
||||||
|
this.lineVbo = gl.createBuffer()!;
|
||||||
|
gl.bindVertexArray(this.lineVao);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineVbo);
|
||||||
|
gl.enableVertexAttribArray(0);
|
||||||
|
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
gl.bindVertexArray(null);
|
||||||
|
|
||||||
this.linesIbo = gl.createBuffer()!;
|
this.linesIbo = gl.createBuffer()!;
|
||||||
this.selectionIbo = gl.createBuffer()!;
|
this.selectionIbo = gl.createBuffer()!;
|
||||||
this.neighborIbo = gl.createBuffer()!;
|
this.neighborIbo = gl.createBuffer()!;
|
||||||
@@ -192,19 +205,31 @@ export class Renderer {
|
|||||||
xs: Float32Array,
|
xs: Float32Array,
|
||||||
ys: Float32Array,
|
ys: Float32Array,
|
||||||
vertexIds: Uint32Array,
|
vertexIds: Uint32Array,
|
||||||
edges: Uint32Array
|
edges: Uint32Array,
|
||||||
|
routeLineVertices: Float32Array | null = null
|
||||||
): number {
|
): number {
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const gl = this.gl;
|
const gl = this.gl;
|
||||||
const count = xs.length;
|
const count = xs.length;
|
||||||
const edgeCount = edges.length / 2;
|
const edgeCount = edges.length / 2;
|
||||||
this.nodeCount = count;
|
this.nodeCount = count;
|
||||||
|
console.log("[renderer.init] start", {
|
||||||
|
nodes: count,
|
||||||
|
edges: edgeCount,
|
||||||
|
route_line_vertices: routeLineVertices ? routeLineVertices.length / 2 : 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Build quadtree (spatially sorts the array)
|
// Build quadtree (spatially sorts the array)
|
||||||
|
const spatialStart = performance.now();
|
||||||
const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
|
const { sorted, leaves, order } = buildSpatialIndex(xs, ys);
|
||||||
this.leaves = leaves;
|
this.leaves = leaves;
|
||||||
this.sorted = sorted;
|
this.sorted = sorted;
|
||||||
this.sortedToOriginal = order;
|
this.sortedToOriginal = order;
|
||||||
|
console.log("[renderer.init] spatial index built", {
|
||||||
|
nodes: count,
|
||||||
|
leaves: leaves.length,
|
||||||
|
spatial_ms: Math.round(performance.now() - spatialStart),
|
||||||
|
});
|
||||||
|
|
||||||
// Pre-allocate arrays for render loop (zero-allocation rendering)
|
// Pre-allocate arrays for render loop (zero-allocation rendering)
|
||||||
this.visibleLeafIndices = new Uint32Array(leaves.length);
|
this.visibleLeafIndices = new Uint32Array(leaves.length);
|
||||||
@@ -212,11 +237,18 @@ export class Renderer {
|
|||||||
this.countsArray = new Int32Array(leaves.length);
|
this.countsArray = new Int32Array(leaves.length);
|
||||||
|
|
||||||
// Upload sorted particles to GPU as STATIC VBO (never changes)
|
// Upload sorted particles to GPU as STATIC VBO (never changes)
|
||||||
|
const uploadNodesStart = performance.now();
|
||||||
gl.bindVertexArray(this.vao);
|
gl.bindVertexArray(this.vao);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeVbo);
|
||||||
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
||||||
gl.bindVertexArray(null);
|
gl.bindVertexArray(null);
|
||||||
|
console.log("[renderer.init] node buffer uploaded", {
|
||||||
|
upload_ms: Math.round(performance.now() - uploadNodesStart),
|
||||||
|
sorted_bytes: sorted.byteLength,
|
||||||
|
});
|
||||||
|
|
||||||
// Build vertex ID → original input index mapping
|
// Build vertex ID → original input index mapping
|
||||||
|
const mapsStart = performance.now();
|
||||||
const vertexIdToOriginal = new Map<number, number>();
|
const vertexIdToOriginal = new Map<number, number>();
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
vertexIdToOriginal.set(vertexIds[i], i);
|
vertexIdToOriginal.set(vertexIds[i], i);
|
||||||
@@ -235,8 +267,31 @@ export class Renderer {
|
|||||||
vertexIdToSortedIndex.set(vertexIds[i], originalToSorted[i]);
|
vertexIdToSortedIndex.set(vertexIds[i], originalToSorted[i]);
|
||||||
}
|
}
|
||||||
this.vertexIdToSortedIndex = vertexIdToSortedIndex;
|
this.vertexIdToSortedIndex = vertexIdToSortedIndex;
|
||||||
|
console.log("[renderer.init] index maps built", {
|
||||||
|
maps_ms: Math.round(performance.now() - mapsStart),
|
||||||
|
vertex_id_map_size: vertexIdToSortedIndex.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.useRawLineSegments = routeLineVertices !== null && routeLineVertices.length > 0;
|
||||||
|
this.rawLineVertexCount = this.useRawLineSegments && routeLineVertices ? routeLineVertices.length / 2 : 0;
|
||||||
|
if (this.useRawLineSegments && routeLineVertices) {
|
||||||
|
this.edgeCount = edgeCount;
|
||||||
|
this.leafEdgeStarts = new Uint32Array(0);
|
||||||
|
this.leafEdgeCounts = new Uint32Array(0);
|
||||||
|
const uploadRoutesStart = performance.now();
|
||||||
|
gl.bindVertexArray(this.lineVao);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineVbo);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, routeLineVertices, gl.STATIC_DRAW);
|
||||||
|
gl.bindVertexArray(null);
|
||||||
|
console.log("[renderer.init] raw line segments uploaded", {
|
||||||
|
upload_ms: Math.round(performance.now() - uploadRoutesStart),
|
||||||
|
total_ms: Math.round(performance.now() - t0),
|
||||||
|
});
|
||||||
|
return performance.now() - t0;
|
||||||
|
}
|
||||||
|
|
||||||
// Remap edges from vertex IDs to sorted indices
|
// Remap edges from vertex IDs to sorted indices
|
||||||
|
const remapEdgesStart = performance.now();
|
||||||
const lineIndices = new Uint32Array(edgeCount * 2);
|
const lineIndices = new Uint32Array(edgeCount * 2);
|
||||||
let validEdges = 0;
|
let validEdges = 0;
|
||||||
for (let i = 0; i < edgeCount; i++) {
|
for (let i = 0; i < edgeCount; i++) {
|
||||||
@@ -250,9 +305,15 @@ export class Renderer {
|
|||||||
validEdges++;
|
validEdges++;
|
||||||
}
|
}
|
||||||
this.edgeCount = validEdges;
|
this.edgeCount = validEdges;
|
||||||
|
console.log("[renderer.init] edges remapped", {
|
||||||
|
remap_ms: Math.round(performance.now() - remapEdgesStart),
|
||||||
|
valid_edges: validEdges,
|
||||||
|
line_indices_bytes: lineIndices.byteLength,
|
||||||
|
});
|
||||||
|
|
||||||
// Build per-leaf edge index for efficient visible-only edge drawing
|
// Build per-leaf edge index for efficient visible-only edge drawing
|
||||||
// Find which leaf each sorted index belongs to
|
// Find which leaf each sorted index belongs to
|
||||||
|
const edgeIndexStart = performance.now();
|
||||||
const nodeToLeaf = new Uint32Array(count);
|
const nodeToLeaf = new Uint32Array(count);
|
||||||
for (let li = 0; li < leaves.length; li++) {
|
for (let li = 0; li < leaves.length; li++) {
|
||||||
const lf = leaves[li];
|
const lf = leaves[li];
|
||||||
@@ -286,11 +347,22 @@ export class Renderer {
|
|||||||
|
|
||||||
this.leafEdgeStarts = leafEdgeOffsets;
|
this.leafEdgeStarts = leafEdgeOffsets;
|
||||||
this.leafEdgeCounts = leafEdgeCounts;
|
this.leafEdgeCounts = leafEdgeCounts;
|
||||||
|
console.log("[renderer.init] leaf edge index built", {
|
||||||
|
leaf_index_ms: Math.round(performance.now() - edgeIndexStart),
|
||||||
|
leaves: leaves.length,
|
||||||
|
sorted_edge_indices_bytes: sortedEdgeIndices.byteLength,
|
||||||
|
});
|
||||||
|
|
||||||
// Upload sorted edges to GPU
|
// Upload sorted edges to GPU
|
||||||
|
const uploadEdgesStart = performance.now();
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
||||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, sortedEdgeIndices, gl.STATIC_DRAW);
|
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, sortedEdgeIndices, gl.STATIC_DRAW);
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
|
console.log("[renderer.init] edge buffer uploaded", {
|
||||||
|
upload_ms: Math.round(performance.now() - uploadEdgesStart),
|
||||||
|
total_ms: Math.round(performance.now() - t0),
|
||||||
|
valid_edges: validEdges,
|
||||||
|
});
|
||||||
|
|
||||||
return performance.now() - t0;
|
return performance.now() - t0;
|
||||||
}
|
}
|
||||||
@@ -572,24 +644,30 @@ export class Renderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Draw Lines if deeply zoomed in (< 20k total visible particles)
|
// 5. Draw Lines if deeply zoomed in (< 20k total visible particles)
|
||||||
if (totalVisibleParticles < 20000 && visibleCount > 0) {
|
if (totalVisibleParticles < 20000) {
|
||||||
gl.useProgram(this.lineProgram);
|
gl.useProgram(this.lineProgram);
|
||||||
gl.uniform2f(this.uCenterLine, this.cx, this.cy);
|
gl.uniform2f(this.uCenterLine, this.cx, this.cy);
|
||||||
gl.uniform2f(this.uScaleLine, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
|
gl.uniform2f(this.uScaleLine, (this.zoom * 2) / cw, (-this.zoom * 2) / ch);
|
||||||
|
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
if (this.useRawLineSegments) {
|
||||||
|
gl.bindVertexArray(this.lineVao);
|
||||||
|
gl.drawArrays(gl.LINES, 0, this.rawLineVertexCount);
|
||||||
|
gl.bindVertexArray(this.vao);
|
||||||
|
} else if (visibleCount > 0) {
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.linesIbo);
|
||||||
|
|
||||||
for (let i = 0; i < visibleCount; i++) {
|
for (let i = 0; i < visibleCount; i++) {
|
||||||
const leafIdx = this.visibleLeafIndices[i];
|
const leafIdx = this.visibleLeafIndices[i];
|
||||||
const edgeCount = this.leafEdgeCounts[leafIdx];
|
const edgeCount = this.leafEdgeCounts[leafIdx];
|
||||||
if (edgeCount === 0) continue;
|
if (edgeCount === 0) continue;
|
||||||
// Each edge is 2 indices (1 line segment)
|
// Each edge is 2 indices (1 line segment)
|
||||||
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
|
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
|
||||||
const edgeStart = this.leafEdgeStarts[leafIdx];
|
const edgeStart = this.leafEdgeStarts[leafIdx];
|
||||||
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
|
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Draw Neighbor Nodes (yellow) - drawn before selected so selected appears on top
|
// 6. Draw Neighbor Nodes (yellow) - drawn before selected so selected appears on top
|
||||||
|
|||||||
@@ -1,4 +1,53 @@
|
|||||||
import type { GraphMeta, SelectionQueryMeta } from "./types";
|
import type {
|
||||||
|
GraphMeta,
|
||||||
|
SelectionQueryMeta,
|
||||||
|
SelectionQueryResult,
|
||||||
|
SelectionTriple,
|
||||||
|
SelectionTripleResult,
|
||||||
|
SelectionTripleTerm,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
function numberArray(value: unknown): number[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const out: number[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (typeof item === "number") out.push(item);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tripleTerm(value: unknown): SelectionTripleTerm | null {
|
||||||
|
if (!value || typeof value !== "object") return null;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.type !== "string" || typeof record.value !== "string") return null;
|
||||||
|
return {
|
||||||
|
type: record.type,
|
||||||
|
value: record.value,
|
||||||
|
lang: typeof record.lang === "string" ? record.lang : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tripleArray(value: unknown): SelectionTriple[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const out: SelectionTriple[] = [];
|
||||||
|
for (const item of value) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
const s = tripleTerm(record.s);
|
||||||
|
const p = tripleTerm(record.p);
|
||||||
|
const o = tripleTerm(record.o);
|
||||||
|
if (!s || !p || !o) continue;
|
||||||
|
out.push({
|
||||||
|
s,
|
||||||
|
p,
|
||||||
|
o,
|
||||||
|
subject_id: typeof record.subject_id === "number" ? record.subject_id : undefined,
|
||||||
|
predicate_id: typeof record.predicate_id === "number" ? record.predicate_id : undefined,
|
||||||
|
object_id: typeof record.object_id === "number" ? record.object_id : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSelectionQueries(signal?: AbortSignal): Promise<SelectionQueryMeta[]> {
|
export async function fetchSelectionQueries(signal?: AbortSignal): Promise<SelectionQueryMeta[]> {
|
||||||
const res = await fetch("/api/selection_queries", { signal });
|
const res = await fetch("/api/selection_queries", { signal });
|
||||||
@@ -12,7 +61,7 @@ export async function runSelectionQuery(
|
|||||||
selectedIds: number[],
|
selectedIds: number[],
|
||||||
graphMeta: GraphMeta | null,
|
graphMeta: GraphMeta | null,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<number[]> {
|
): Promise<SelectionQueryResult> {
|
||||||
const body = {
|
const body = {
|
||||||
query_id: queryId,
|
query_id: queryId,
|
||||||
selected_ids: selectedIds,
|
selected_ids: selectedIds,
|
||||||
@@ -29,9 +78,40 @@ export async function runSelectionQuery(
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`POST /api/selection_query failed: ${res.status}`);
|
if (!res.ok) throw new Error(`POST /api/selection_query failed: ${res.status}`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const ids: unknown = data?.neighbor_ids;
|
|
||||||
if (!Array.isArray(ids)) return [];
|
return {
|
||||||
const out: number[] = [];
|
queryId: typeof data?.query_id === "string" ? data.query_id : queryId,
|
||||||
for (const id of ids) if (typeof id === "number") out.push(id);
|
selectedIds: numberArray(data?.selected_ids),
|
||||||
return out;
|
neighborIds: numberArray(data?.neighbor_ids),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runSelectionTripleQuery(
|
||||||
|
queryId: string,
|
||||||
|
selectedIds: number[],
|
||||||
|
graphMeta: GraphMeta | null,
|
||||||
|
signal: AbortSignal
|
||||||
|
): Promise<SelectionTripleResult> {
|
||||||
|
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_triples", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`POST /api/selection_triples failed: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryId: typeof data?.query_id === "string" ? data.query_id : queryId,
|
||||||
|
selectedIds: numberArray(data?.selected_ids),
|
||||||
|
triples: tripleArray(data?.triples),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export { fetchSelectionQueries, runSelectionQuery } from "./api";
|
export { fetchSelectionQueries, runSelectionQuery, runSelectionTripleQuery } from "./api";
|
||||||
export type { GraphMeta, SelectionQueryMeta } from "./types";
|
export type {
|
||||||
|
GraphMeta,
|
||||||
|
SelectionQueryMeta,
|
||||||
|
SelectionTriple,
|
||||||
|
SelectionTripleResult,
|
||||||
|
} from "./types";
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
export type GraphMeta = {
|
export type GraphMeta = {
|
||||||
backend?: string;
|
backend?: string;
|
||||||
ttl_path?: string | null;
|
|
||||||
sparql_endpoint?: string | null;
|
|
||||||
include_bnodes?: boolean;
|
|
||||||
graph_query_id?: string;
|
graph_query_id?: string;
|
||||||
node_limit?: number;
|
node_limit?: number;
|
||||||
edge_limit?: number;
|
edge_limit?: number;
|
||||||
@@ -14,3 +11,30 @@ export type SelectionQueryMeta = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SelectionQueryResult = {
|
||||||
|
queryId: string;
|
||||||
|
selectedIds: number[];
|
||||||
|
neighborIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectionTripleTerm = {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
lang?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectionTriple = {
|
||||||
|
s: SelectionTripleTerm;
|
||||||
|
p: SelectionTripleTerm;
|
||||||
|
o: SelectionTripleTerm;
|
||||||
|
subject_id?: number;
|
||||||
|
predicate_id?: number;
|
||||||
|
object_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectionTripleResult = {
|
||||||
|
queryId: string;
|
||||||
|
selectedIds: number[];
|
||||||
|
triples: SelectionTriple[];
|
||||||
|
};
|
||||||
|
|||||||
363
frontend/src/triple_graph.ts
Normal file
363
frontend/src/triple_graph.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { cosmosRuntimeConfig } from "./cosmos_config";
|
||||||
|
import type { SelectionTriple } from "./selection_queries";
|
||||||
|
|
||||||
|
export type TripleGraphTerm = SelectionTriple["s"];
|
||||||
|
|
||||||
|
export type TripleGraphNode = {
|
||||||
|
key: string;
|
||||||
|
index: number;
|
||||||
|
term: TripleGraphTerm;
|
||||||
|
text: string;
|
||||||
|
backendId?: number;
|
||||||
|
isSelectedSource: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TripleGraphLink = {
|
||||||
|
index: number;
|
||||||
|
sourceIndex: number;
|
||||||
|
targetIndex: number;
|
||||||
|
sourceText: string;
|
||||||
|
targetText: string;
|
||||||
|
predicate: SelectionTriple["p"];
|
||||||
|
predicateText: string;
|
||||||
|
predicateId?: number;
|
||||||
|
triple: SelectionTriple;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TripleGraphModel = {
|
||||||
|
nodes: TripleGraphNode[];
|
||||||
|
linksMeta: TripleGraphLink[];
|
||||||
|
pointPositions: Float32Array;
|
||||||
|
seedMetrics: GraphLayoutMetrics;
|
||||||
|
pointColors: Float32Array;
|
||||||
|
pointSizes: Float32Array;
|
||||||
|
links: Float32Array;
|
||||||
|
linkColors: Float32Array;
|
||||||
|
linkWidths: Float32Array;
|
||||||
|
nodeCount: number;
|
||||||
|
edgeCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GraphLayoutMetrics = {
|
||||||
|
centroidX: number;
|
||||||
|
centroidY: number;
|
||||||
|
minX: number;
|
||||||
|
maxX: number;
|
||||||
|
minY: number;
|
||||||
|
maxY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
maxRadius: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MutableNode = {
|
||||||
|
term: TripleGraphTerm;
|
||||||
|
text: string;
|
||||||
|
backendId?: number;
|
||||||
|
isSelectedSource: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildTripleGraphModel(triples: SelectionTriple[], selectedIds: number[]): TripleGraphModel {
|
||||||
|
const selectedSet = new Set<number>(selectedIds);
|
||||||
|
const nodeMap = new Map<string, MutableNode>();
|
||||||
|
|
||||||
|
for (const triple of triples) {
|
||||||
|
addNode(nodeMap, triple.s, triple.subject_id, selectedSet);
|
||||||
|
addNode(nodeMap, triple.o, triple.object_id, selectedSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = Array.from(nodeMap.entries())
|
||||||
|
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
||||||
|
.map(([key, node], index) => ({
|
||||||
|
key,
|
||||||
|
index,
|
||||||
|
term: node.term,
|
||||||
|
text: node.text,
|
||||||
|
backendId: node.backendId,
|
||||||
|
isSelectedSource: node.isSelectedSource,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const nodeIndexByKey = new Map<string, number>();
|
||||||
|
for (const node of nodes) {
|
||||||
|
nodeIndexByKey.set(node.key, node.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linksMeta: TripleGraphLink[] = [];
|
||||||
|
for (const triple of triples) {
|
||||||
|
const sourceIndex = nodeIndexByKey.get(termKey(triple.s));
|
||||||
|
const targetIndex = nodeIndexByKey.get(termKey(triple.o));
|
||||||
|
if (sourceIndex === undefined || targetIndex === undefined) continue;
|
||||||
|
linksMeta.push({
|
||||||
|
index: linksMeta.length,
|
||||||
|
sourceIndex,
|
||||||
|
targetIndex,
|
||||||
|
sourceText: formatTermText(triple.s),
|
||||||
|
targetText: formatTermText(triple.o),
|
||||||
|
predicate: triple.p,
|
||||||
|
predicateText: formatTermText(triple.p),
|
||||||
|
predicateId: triple.predicate_id,
|
||||||
|
triple,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const pointPositions = buildPointPositions(nodes);
|
||||||
|
const seedMetrics = computeLayoutMetrics(pointPositions);
|
||||||
|
const pointColors = buildPointColors(nodes);
|
||||||
|
const pointSizes = buildPointSizes(nodes);
|
||||||
|
const links = buildLinks(linksMeta);
|
||||||
|
const linkColors = buildLinkColors(linksMeta);
|
||||||
|
const linkWidths = buildLinkWidths(linksMeta);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
linksMeta,
|
||||||
|
pointPositions,
|
||||||
|
seedMetrics,
|
||||||
|
pointColors,
|
||||||
|
pointSizes,
|
||||||
|
links,
|
||||||
|
linkColors,
|
||||||
|
linkWidths,
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
edgeCount: linksMeta.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNode(
|
||||||
|
nodeMap: Map<string, MutableNode>,
|
||||||
|
term: TripleGraphTerm,
|
||||||
|
backendId: number | undefined,
|
||||||
|
selectedSet: Set<number>
|
||||||
|
): void {
|
||||||
|
const key = termKey(term);
|
||||||
|
const existing = nodeMap.get(key);
|
||||||
|
const isSelectedSource = typeof backendId === "number" && selectedSet.has(backendId);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.backendId === undefined && typeof backendId === "number") {
|
||||||
|
existing.backendId = backendId;
|
||||||
|
}
|
||||||
|
if (isSelectedSource) existing.isSelectedSource = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nodeMap.set(key, {
|
||||||
|
term,
|
||||||
|
text: formatTermText(term),
|
||||||
|
backendId,
|
||||||
|
isSelectedSource,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function termKey(term: TripleGraphTerm): string {
|
||||||
|
return `${term.type}\x00${term.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTermText(term: TripleGraphTerm): string {
|
||||||
|
if (term.type === "literal") {
|
||||||
|
if (term.lang) return `"${term.value}"@${term.lang}`;
|
||||||
|
return `"${term.value}"`;
|
||||||
|
}
|
||||||
|
return term.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPointPositions(nodes: TripleGraphNode[]): Float32Array {
|
||||||
|
const out = new Float32Array(nodes.length * 2);
|
||||||
|
const simulationSpaceCenter = cosmosRuntimeConfig.spaceSize / 2;
|
||||||
|
if (nodes.length === 0) return out;
|
||||||
|
if (nodes.length === 1) {
|
||||||
|
out[0] = simulationSpaceCenter;
|
||||||
|
out[1] = simulationSpaceCenter;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const primaryHash = hashString(node.key);
|
||||||
|
const secondaryHash = hashString(`${node.key}\x01`);
|
||||||
|
const angle = ((primaryHash % 3600) / 3600) * Math.PI * 2;
|
||||||
|
const radius = 80 + (((primaryHash >>> 12) % 1000) / 1000) * 70;
|
||||||
|
const jitterX = ((((secondaryHash >>> 4) % 200) / 200) - 0.5) * 18;
|
||||||
|
const jitterY = ((((secondaryHash >>> 12) % 200) / 200) - 0.5) * 18;
|
||||||
|
out[node.index * 2] = Math.cos(angle) * radius + jitterX;
|
||||||
|
out[node.index * 2 + 1] = Math.sin(angle) * radius + jitterY;
|
||||||
|
}
|
||||||
|
|
||||||
|
recenterPointPositions(out);
|
||||||
|
offsetPointPositionsToSimulationCenter(out, simulationSpaceCenter);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeLayoutMetrics(pointPositions: ArrayLike<number>): GraphLayoutMetrics {
|
||||||
|
const pairCount = Math.floor(pointPositions.length / 2);
|
||||||
|
if (pairCount === 0) {
|
||||||
|
return {
|
||||||
|
centroidX: 0,
|
||||||
|
centroidY: 0,
|
||||||
|
minX: 0,
|
||||||
|
maxX: 0,
|
||||||
|
minY: 0,
|
||||||
|
maxY: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
maxRadius: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let sumX = 0;
|
||||||
|
let sumY = 0;
|
||||||
|
let minX = Number.POSITIVE_INFINITY;
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY;
|
||||||
|
let minY = Number.POSITIVE_INFINITY;
|
||||||
|
let maxY = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (let i = 0; i < pairCount; i++) {
|
||||||
|
const x = pointPositions[i * 2];
|
||||||
|
const y = pointPositions[i * 2 + 1];
|
||||||
|
sumX += x;
|
||||||
|
sumY += y;
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (y < minY) minY = y;
|
||||||
|
if (y > maxY) maxY = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centroidX = sumX / pairCount;
|
||||||
|
const centroidY = sumY / pairCount;
|
||||||
|
let maxRadius = 0;
|
||||||
|
for (let i = 0; i < pairCount; i++) {
|
||||||
|
const dx = pointPositions[i * 2] - centroidX;
|
||||||
|
const dy = pointPositions[i * 2 + 1] - centroidY;
|
||||||
|
const radius = Math.hypot(dx, dy);
|
||||||
|
if (radius > maxRadius) maxRadius = radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
centroidX,
|
||||||
|
centroidY,
|
||||||
|
minX,
|
||||||
|
maxX,
|
||||||
|
minY,
|
||||||
|
maxY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
maxRadius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function recenterPointPositions(pointPositions: Float32Array): void {
|
||||||
|
const metrics = computeLayoutMetrics(pointPositions);
|
||||||
|
if (metrics.centroidX === 0 && metrics.centroidY === 0) return;
|
||||||
|
const pairCount = Math.floor(pointPositions.length / 2);
|
||||||
|
for (let i = 0; i < pairCount; i++) {
|
||||||
|
pointPositions[i * 2] -= metrics.centroidX;
|
||||||
|
pointPositions[i * 2 + 1] -= metrics.centroidY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetPointPositionsToSimulationCenter(pointPositions: Float32Array, center: number): void {
|
||||||
|
if (center === 0) return;
|
||||||
|
const pairCount = Math.floor(pointPositions.length / 2);
|
||||||
|
for (let i = 0; i < pairCount; i++) {
|
||||||
|
pointPositions[i * 2] += center;
|
||||||
|
pointPositions[i * 2 + 1] += center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPointColors(nodes: TripleGraphNode[]): Float32Array {
|
||||||
|
const out = new Float32Array(nodes.length * 4);
|
||||||
|
for (const node of nodes) {
|
||||||
|
const offset = node.index * 4;
|
||||||
|
const color = node.isSelectedSource ? [53, 214, 255, 1] : colorFromHash(node.key, 210, 35, 58, 18, 8);
|
||||||
|
out[offset] = color[0];
|
||||||
|
out[offset + 1] = color[1];
|
||||||
|
out[offset + 2] = color[2];
|
||||||
|
out[offset + 3] = color[3];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPointSizes(nodes: TripleGraphNode[]): Float32Array {
|
||||||
|
const out = new Float32Array(nodes.length);
|
||||||
|
for (const node of nodes) {
|
||||||
|
out[node.index] = node.isSelectedSource ? 11 : 7.5;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinks(linksMeta: TripleGraphLink[]): Float32Array {
|
||||||
|
const out = new Float32Array(linksMeta.length * 2);
|
||||||
|
for (const link of linksMeta) {
|
||||||
|
const offset = link.index * 2;
|
||||||
|
out[offset] = link.sourceIndex;
|
||||||
|
out[offset + 1] = link.targetIndex;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinkColors(linksMeta: TripleGraphLink[]): Float32Array {
|
||||||
|
const out = new Float32Array(linksMeta.length * 4);
|
||||||
|
for (const link of linksMeta) {
|
||||||
|
const offset = link.index * 4;
|
||||||
|
const color = colorFromHash(link.predicateText, 28, 65, 58, 32, 10);
|
||||||
|
out[offset] = color[0];
|
||||||
|
out[offset + 1] = color[1];
|
||||||
|
out[offset + 2] = color[2];
|
||||||
|
out[offset + 3] = color[3];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinkWidths(linksMeta: TripleGraphLink[]): Float32Array {
|
||||||
|
const out = new Float32Array(linksMeta.length);
|
||||||
|
for (const link of linksMeta) {
|
||||||
|
out[link.index] = 1.8;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorFromHash(
|
||||||
|
value: string,
|
||||||
|
baseHue: number,
|
||||||
|
hueRange: number,
|
||||||
|
lightness: number,
|
||||||
|
saturation: number,
|
||||||
|
lightnessRange: number
|
||||||
|
): [number, number, number, number] {
|
||||||
|
const hash = hashString(value);
|
||||||
|
const hue = (baseHue + (hash % hueRange) + 360) % 360;
|
||||||
|
const sat = saturation + ((hash >>> 10) % 10);
|
||||||
|
const light = lightness + ((hash >>> 20) % lightnessRange) - lightnessRange / 2;
|
||||||
|
const [r, g, b] = hslToRgb(hue / 360, sat / 100, light / 100);
|
||||||
|
return [r, g, b, 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||||
|
if (s === 0) {
|
||||||
|
const value = Math.round(l * 255);
|
||||||
|
return [value, value, value];
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
const r = hueToRgb(p, q, h + 1 / 3);
|
||||||
|
const g = hueToRgb(p, q, h);
|
||||||
|
const b = hueToRgb(p, q, h - 1 / 3);
|
||||||
|
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueToRgb(p: number, q: number, t: number): number {
|
||||||
|
let value = t;
|
||||||
|
if (value < 0) value += 1;
|
||||||
|
if (value > 1) value -= 1;
|
||||||
|
if (value < 1 / 6) return p + (q - p) * 6 * value;
|
||||||
|
if (value < 1 / 2) return q;
|
||||||
|
if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashString(value: string): number {
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
hash ^= value.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 16777619);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
70
frontend/src/vite-env.d.ts
vendored
Normal file
70
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BACKEND_URL?: string;
|
||||||
|
readonly VITE_COSMOS_ENABLE_SIMULATION?: string;
|
||||||
|
readonly VITE_COSMOS_DEBUG_LAYOUT?: string;
|
||||||
|
readonly VITE_COSMOS_BACKGROUND_COLOR?: string;
|
||||||
|
readonly VITE_COSMOS_SPACE_SIZE?: string;
|
||||||
|
readonly VITE_COSMOS_POINT_DEFAULT_COLOR?: string;
|
||||||
|
readonly VITE_COSMOS_POINT_GREYOUT_COLOR?: string;
|
||||||
|
readonly VITE_COSMOS_POINT_GREYOUT_OPACITY?: string;
|
||||||
|
readonly VITE_COSMOS_POINT_DEFAULT_SIZE?: string;
|
||||||
|
readonly VITE_COSMOS_POINT_OPACITY?: string;
|
||||||
|
readonly VITE_COSMOS_POINT_SIZE_SCALE?: string;
|
||||||
|
readonly VITE_COSMOS_HOVERED_POINT_CURSOR?: string;
|
||||||
|
readonly VITE_COSMOS_HOVERED_LINK_CURSOR?: string;
|
||||||
|
readonly VITE_COSMOS_RENDER_HOVERED_POINT_RING?: string;
|
||||||
|
readonly VITE_COSMOS_HOVERED_POINT_RING_COLOR?: string;
|
||||||
|
readonly VITE_COSMOS_FOCUSED_POINT_RING_COLOR?: string;
|
||||||
|
readonly VITE_COSMOS_RENDER_LINKS?: string;
|
||||||
|
readonly VITE_COSMOS_LINK_DEFAULT_COLOR?: string;
|
||||||
|
readonly VITE_COSMOS_LINK_OPACITY?: string;
|
||||||
|
readonly VITE_COSMOS_LINK_GREYOUT_OPACITY?: string;
|
||||||
|
readonly VITE_COSMOS_LINK_DEFAULT_WIDTH?: string;
|
||||||
|
readonly VITE_COSMOS_HOVERED_LINK_COLOR?: string;
|
||||||
|
readonly VITE_COSMOS_HOVERED_LINK_WIDTH_INCREASE?: string;
|
||||||
|
readonly VITE_COSMOS_LINK_WIDTH_SCALE?: string;
|
||||||
|
readonly VITE_COSMOS_SCALE_LINKS_ON_ZOOM?: string;
|
||||||
|
readonly VITE_COSMOS_CURVED_LINKS?: string;
|
||||||
|
readonly VITE_COSMOS_CURVED_LINK_SEGMENTS?: string;
|
||||||
|
readonly VITE_COSMOS_CURVED_LINK_WEIGHT?: string;
|
||||||
|
readonly VITE_COSMOS_CURVED_LINK_CONTROL_POINT_DISTANCE?: string;
|
||||||
|
readonly VITE_COSMOS_LINK_DEFAULT_ARROWS?: string;
|
||||||
|
readonly VITE_COSMOS_LINK_ARROWS_SIZE_SCALE?: string;
|
||||||
|
readonly VITE_COSMOS_LINK_VISIBILITY_DISTANCE_RANGE?: string;
|
||||||
|
readonly VITE_COSMOS_LINK_VISIBILITY_MIN_TRANSPARENCY?: string;
|
||||||
|
readonly VITE_COSMOS_USE_CLASSIC_QUADTREE?: string;
|
||||||
|
readonly VITE_COSMOS_FIT_VIEW_PADDING?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_DECAY?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_GRAVITY?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_CENTER?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_REPULSION?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_REPULSION_THETA?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_REPULSION_QUADTREE_LEVELS?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_LINK_SPRING?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_LINK_DISTANCE?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_LINK_DISTANCE_RANDOM_VARIATION_RANGE?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_REPULSION_FROM_MOUSE?: string;
|
||||||
|
readonly VITE_COSMOS_ENABLE_RIGHT_CLICK_REPULSION?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_FRICTION?: string;
|
||||||
|
readonly VITE_COSMOS_SIMULATION_CLUSTER?: string;
|
||||||
|
readonly VITE_COSMOS_SHOW_FPS_MONITOR?: string;
|
||||||
|
readonly VITE_COSMOS_PIXEL_RATIO?: string;
|
||||||
|
readonly VITE_COSMOS_SCALE_POINTS_ON_ZOOM?: string;
|
||||||
|
readonly VITE_COSMOS_INITIAL_ZOOM_LEVEL?: string;
|
||||||
|
readonly VITE_COSMOS_ENABLE_ZOOM?: string;
|
||||||
|
readonly VITE_COSMOS_ENABLE_SIMULATION_DURING_ZOOM?: string;
|
||||||
|
readonly VITE_COSMOS_ENABLE_DRAG?: string;
|
||||||
|
readonly VITE_COSMOS_FIT_VIEW_ON_INIT?: string;
|
||||||
|
readonly VITE_COSMOS_FIT_VIEW_DELAY?: string;
|
||||||
|
readonly VITE_COSMOS_FIT_VIEW_DURATION?: string;
|
||||||
|
readonly VITE_COSMOS_RANDOM_SEED?: string;
|
||||||
|
readonly VITE_COSMOS_POINT_SAMPLING_DISTANCE?: string;
|
||||||
|
readonly VITE_COSMOS_RESCALE_POSITIONS?: string;
|
||||||
|
readonly VITE_COSMOS_ATTRIBUTION?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -10,7 +10,23 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss(), viteSingleFile()],
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "src"),
|
"@": path.resolve(__dirname, "src"),
|
||||||
@@ -19,7 +35,20 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
// Backend is reachable as http://backend:8000 inside docker-compose; localhost outside.
|
// Backend is reachable as http://backend:8000 inside docker-compose; localhost outside.
|
||||||
"/api": process.env.VITE_BACKEND_URL || "http://localhost:8000",
|
"/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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ def main() -> None:
|
|||||||
output_location=os.getenv("COMBINE_OUTPUT_LOCATION"),
|
output_location=os.getenv("COMBINE_OUTPUT_LOCATION"),
|
||||||
output_name=output_name,
|
output_name=output_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
output_path = output_location_to_path(output_location)
|
output_path = output_location_to_path(output_location)
|
||||||
|
|
||||||
force = _env_bool("COMBINE_FORCE", default=False)
|
force = _env_bool("COMBINE_FORCE", default=False)
|
||||||
if output_path.exists() and not force:
|
if output_path.exists() and not force:
|
||||||
logger.info("Skipping combine step (output exists): %s", output_location)
|
logger.info("Skipping combine step (output exists): %s", output_location)
|
||||||
|
|||||||
6
radial_sugiyama/.dockerignore
Normal file
6
radial_sugiyama/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
target
|
||||||
|
out
|
||||||
|
data
|
||||||
|
.env
|
||||||
|
*.pdf
|
||||||
|
VISUALIZATION_TIMELINE.md
|
||||||
Binary file not shown.
286
radial_sugiyama/Cargo.lock
generated
Normal file
286
radial_sugiyama/Cargo.lock
generated
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dotenvy"
|
||||||
|
version = "0.15.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"r-efi",
|
||||||
|
"wasip2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.183"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oxilangtag"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "23f3f87617a86af77fa3691e6350483e7154c2ead9f1261b75130e21ca0f8acb"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oxiri"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "54b4ed3a7192fa19f5f48f99871f2755047fabefd7f222f12a1df1773796a102"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oxrdf"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0afd5c28e4a399c57ee2bc3accd40c7b671fdc7b6537499f14e95b265af7d7e0"
|
||||||
|
dependencies = [
|
||||||
|
"oxilangtag",
|
||||||
|
"oxiri",
|
||||||
|
"rand",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oxttl"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f03fd471bd54c23d76631c0a2677aa4bb308d905f6e491ee35dcb0732b7c5c6c"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
"oxilangtag",
|
||||||
|
"oxiri",
|
||||||
|
"oxrdf",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "r-efi"
|
||||||
|
version = "5.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "radial_sugiyama"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"dotenvy",
|
||||||
|
"oxrdf",
|
||||||
|
"oxttl",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"svg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "svg"
|
||||||
|
version = "0.17.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "700efb40f3f559c23c18b446e8ed62b08b56b2bb3197b36d57e0470b4102779e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "2.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasip2"
|
||||||
|
version = "1.0.2+wasi-0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
|
||||||
|
dependencies = [
|
||||||
|
"wit-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wit-bindgen"
|
||||||
|
version = "0.51.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy"
|
||||||
|
version = "0.8.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zerocopy-derive"
|
||||||
|
version = "0.8.42"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
15
radial_sugiyama/Cargo.toml
Normal file
15
radial_sugiyama/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "radial_sugiyama"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
oxrdf = "0.3.3"
|
||||||
|
oxttl = "0.2.3"
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.145"
|
||||||
|
svg = "0.17.0"
|
||||||
20
radial_sugiyama/Dockerfile
Normal file
20
radial_sugiyama/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM rust:bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY --from=builder /src/target/release/radial_sugiyama /usr/local/bin/radial_sugiyama
|
||||||
|
|
||||||
|
CMD ["radial_sugiyama"]
|
||||||
141
radial_sugiyama/VISUALIZATION_TIMELINE.md
Normal file
141
radial_sugiyama/VISUALIZATION_TIMELINE.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Graph Visualization Improvement Timeline
|
||||||
|
|
||||||
|
This document records the main ways the graph visualization pipeline has been refined during the current Rust migration and tuning work.
|
||||||
|
|
||||||
|
## 2026-03-16 — Baseline migration and pipeline setup
|
||||||
|
|
||||||
|
- Ported the radial Sugiyama-style layout pipeline from the Java implementation into the Rust crate `radial_sugiyama`.
|
||||||
|
- Kept the overall structure:
|
||||||
|
- hierarchy leveling
|
||||||
|
- dummy-node insertion
|
||||||
|
- crossing reduction
|
||||||
|
- coordinate assignment
|
||||||
|
- radial projection
|
||||||
|
- Changed the leveling source on purpose for the target use case:
|
||||||
|
- instead of centrality-based levels, levels are computed as hierarchy rings for a DAG
|
||||||
|
- this guarantees superclass/interface nodes are placed on inner rings and subclasses on outer rings
|
||||||
|
|
||||||
|
## 2026-03-16 — Improve parity with the Java implementation
|
||||||
|
|
||||||
|
- Closed Java → Rust gaps in crossing reduction:
|
||||||
|
- restored horizontal crossing counting
|
||||||
|
- restored mixed horizontal/vertical crossing counting
|
||||||
|
- aligned the sifting stage more closely with the active Java implementation
|
||||||
|
- Added richer layout artifacts so the pipeline outputs not only node coordinates, but also:
|
||||||
|
- edge offsets
|
||||||
|
- routed edge shapes
|
||||||
|
- routed node information
|
||||||
|
- layout center
|
||||||
|
- Ported route generation logic for:
|
||||||
|
- spiral inter-level edges
|
||||||
|
- intra-level edges
|
||||||
|
- straight root-level edges
|
||||||
|
|
||||||
|
## 2026-03-16 — Add ontology input through Turtle
|
||||||
|
|
||||||
|
- Added a Turtle import layer using `oxttl`.
|
||||||
|
- Imported only `rdfs:subClassOf` triples.
|
||||||
|
- Mapped ontology class IRIs to graph nodes.
|
||||||
|
- Preserved edge direction as:
|
||||||
|
- `superclass -> subclass`
|
||||||
|
- This made the layout pipeline usable directly from ontology data instead of requiring manual graph construction.
|
||||||
|
|
||||||
|
## 2026-03-16 — Add environment-based execution and layout controls
|
||||||
|
|
||||||
|
- Added a `.env`-driven runner so the pipeline can be configured without recompiling.
|
||||||
|
- Moved the main geometric drawing constants into env-backed config:
|
||||||
|
- input file location
|
||||||
|
- output location
|
||||||
|
- minimum radius
|
||||||
|
- level spacing
|
||||||
|
- positive-coordinate shifting
|
||||||
|
- spiral sampling quality
|
||||||
|
- border and node-distance scaling
|
||||||
|
- This was the first step toward making the visualization tunable instead of fixed.
|
||||||
|
|
||||||
|
## 2026-03-16 — Add SVG export as the final output step
|
||||||
|
|
||||||
|
- Added SVG generation after layout execution.
|
||||||
|
- Reused the computed graph geometry instead of inventing a separate renderer:
|
||||||
|
- node coordinates become SVG circles
|
||||||
|
- routed edge points become SVG paths
|
||||||
|
- ring levels become background circles
|
||||||
|
- labels are drawn from node IRIs
|
||||||
|
- This made the pipeline produce a directly inspectable visual artifact.
|
||||||
|
|
||||||
|
## 2026-03-16 — Investigate readability problems in the first SVG output
|
||||||
|
|
||||||
|
- Observed two major problems in the rendered output:
|
||||||
|
- many nodes on the same level were visually packed into a small arc of the ring
|
||||||
|
- some edges wrapped around the center with very long spiral paths
|
||||||
|
- Determined that these were not only SVG issues:
|
||||||
|
- node clustering came from the current radial projection rule
|
||||||
|
- edge wrapping came from the routed edge model and its offset-based spiral construction
|
||||||
|
- Compared this behavior with the paper and confirmed:
|
||||||
|
- the paper intentionally allows packed angular spans
|
||||||
|
- the paper intentionally allows winding spiral edges
|
||||||
|
- but these choices may be undesirable for the current ontology-navigation use case
|
||||||
|
|
||||||
|
## 2026-03-16 — Add an SVG straight-edge mode for experimentation
|
||||||
|
|
||||||
|
- Added `RADIAL_SVG_SHORTEST_EDGES` so the SVG renderer could ignore routed edge directions/offsets and draw direct shortest node-to-node segments instead.
|
||||||
|
- This improved edge length visually, but it introduced a conceptual mismatch:
|
||||||
|
- crossing reduction had optimized the graph for wrapped spiral edges
|
||||||
|
- rendering direct shortest segments reintroduced many crossings
|
||||||
|
- Result:
|
||||||
|
- useful as a diagnostic/preview mode
|
||||||
|
- not a principled replacement for the original routing objective
|
||||||
|
|
||||||
|
## 2026-03-16 — Add configurable ring distribution mode
|
||||||
|
|
||||||
|
- Added `RADIAL_RING_DISTRIBUTION` with two modes:
|
||||||
|
- `packed`
|
||||||
|
- `distributed`
|
||||||
|
- `packed` keeps the paper/Java-style projection:
|
||||||
|
- one global width is used to derive angular positions
|
||||||
|
- narrower levels may occupy only part of the circle
|
||||||
|
- `distributed` changes only the projection step:
|
||||||
|
- nodes on the same level are spread around the full ring
|
||||||
|
- ring order is preserved, but the level fills the full `2π`
|
||||||
|
- This was introduced specifically to improve readability when the packed projection makes ontology branches appear collapsed.
|
||||||
|
|
||||||
|
## 2026-03-16 — Restrict the ontology view to the BFO `entity` subtree
|
||||||
|
|
||||||
|
- Added `RADIAL_ROOT_CLASS_IRI`.
|
||||||
|
- Defaulted it to:
|
||||||
|
- `http://purl.obolibrary.org/obo/BFO_0000001`
|
||||||
|
- Added a preprocessing filter step that:
|
||||||
|
- imports the full `subClassOf` graph
|
||||||
|
- finds the configured root class by exact IRI
|
||||||
|
- keeps only the root and its descendants
|
||||||
|
- discards unrelated ontology branches before layout
|
||||||
|
- This makes the visualization more focused and reduces clutter for the target ontology exploration workflow.
|
||||||
|
|
||||||
|
## Current visualization controls
|
||||||
|
|
||||||
|
The current pipeline now supports these major readability/behavior controls through `.env`:
|
||||||
|
|
||||||
|
- `RADIAL_ROOT_CLASS_IRI` — choose the ontology subtree root
|
||||||
|
- `RADIAL_RING_DISTRIBUTION` — choose packed vs distributed ring projection
|
||||||
|
- `RADIAL_SVG_SHORTEST_EDGES` — choose routed edges vs direct shortest SVG segments
|
||||||
|
- `RADIAL_MIN_RADIUS`
|
||||||
|
- `RADIAL_LEVEL_DISTANCE`
|
||||||
|
- `RADIAL_NODE_DISTANCE`
|
||||||
|
- `RADIAL_SPIRAL_QUALITY`
|
||||||
|
|
||||||
|
## Current tradeoffs
|
||||||
|
|
||||||
|
- `packed` rings are closer to the paper and Java behavior, but can visually cluster nodes.
|
||||||
|
- `distributed` rings are more readable, but deviate from the original projection philosophy.
|
||||||
|
- routed spiral edges are more consistent with the crossing-reduction objective, but can look long and unintuitive.
|
||||||
|
- shortest SVG edges are visually direct, but may contradict the layout’s crossing-minimization assumptions.
|
||||||
|
- subtree filtering around BFO `entity` improves focus, but intentionally hides unrelated ontology regions.
|
||||||
|
|
||||||
|
## Current direction of improvement
|
||||||
|
|
||||||
|
The visualization work is moving toward a readable ontology-browser layout rather than strict reproduction of the original paper. The main current themes are:
|
||||||
|
|
||||||
|
1. keep the hierarchical ring semantics
|
||||||
|
2. reduce clutter by filtering to a meaningful ontology root
|
||||||
|
3. make projection and rendering behavior configurable
|
||||||
|
4. improve readability without discarding the useful parts of the original radial Sugiyama pipeline
|
||||||
985
radial_sugiyama/out/layout.svg
Normal file
985
radial_sugiyama/out/layout.svg
Normal file
@@ -0,0 +1,985 @@
|
|||||||
|
<svg height="1672" viewBox="-160.15951021695878 -303.5633714289372 1744 1672" width="1744" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect fill="#ffffff" height="1672" width="1744" x="-160.15951021695878" y="-303.5633714289372"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="80" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="160" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="240" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="320" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="400" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="480" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="560" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="640" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="720" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="none" r="800" stroke="#d9d9d9" stroke-width="1"/>
|
||||||
|
<path d="M921.7547,290.90814 L969.0688,309.60815 L1013.63904,336.12738 L1054.3667,369.77853 L1090.314,409.7768 L1094.1555,414.81326" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M711.8405,532.43665 L631.8405,532.43665" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M711.8405,532.43665 L791.8405,532.43665" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M631.8405,532.43665 L612.93976,550.3258 L599.78625,571.896 L591.88324,595.7154 L588.8923,620.7367 L589.2734,635.28265" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M631.8405,532.43665 L551.8405,532.43665" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M631.8405,532.43665 L612.93976,514.5475 L599.78625,492.9773 L591.88324,469.15787 L588.8923,444.1366 L589.2734,429.5906" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M791.8405,532.43665 L798.63434,516.73737 L802.054,499.57138 L802.0332,481.66696 L798.6176,463.6862 L791.9446,446.22455 L782.22687,429.81006 L769.73737,414.9026 L754.7966,401.89386 L737.76056,391.10803 L719.01086,382.80313 L698.9451,377.173 L684.05676,374.8674" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M791.8405,532.43665 L804.01514,515.7641 L811.9047,496.55426 L815.5634,475.83548 L815.114,454.4548 L810.74335,433.1299 L802.6928,412.47507 L791.8405,393.87256" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M791.8405,532.43665 L830.9194,510.89764 L858.1413,483.23456 L862.1913,477.7134" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M791.8405,532.43665 L830.9194,553.9756 L858.1413,581.6387 L862.1913,587.15985" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M791.8405,532.43665 L804.01514,549.1092 L811.9047,568.319 L815.5634,589.0378 L815.114,610.41846 L810.74335,631.74335 L802.6928,652.3982 L791.8405,671.0007" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M791.8405,532.43665 L798.63434,548.13586 L802.054,565.3019 L802.0332,583.2063 L798.6176,601.1871 L791.9446,618.64874 L782.22687,635.0632 L769.73737,649.97064 L754.7966,662.9794 L737.76056,673.7652 L719.01086,682.0701 L698.9451,687.70026 L684.05676,690.00586" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M589.2734,635.28265 L590.7664,671.1211 L599.8072,705.97974 L615.4669,738.8155 L627.6875,757.19934" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M589.2734,635.28265 L562.23883,703.7981 L559.2668,717.69666" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M693.8863,851.93256 L740.0462,862.2427 L787.9035,865.70184 L836.4246,862.1732 L884.59235,851.6693 L931.4242,834.34235 L975.9885,810.4731 L1017.41895,780.459 L1042.1716,758.00555" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M693.8863,851.93256 L740.0788,862.6239 L788.04407,866.4415 L836.74084,863.24097 L885.1444,853.02844 L932.2655,835.95105 L977.1661,812.28516 L1018.97345,782.4242 L1034.1891,769.2729" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M693.8863,851.93256 L740.1138,863.03265 L788.19464,867.2341 L837.07904,864.3844 L885.73444,854.4835 L933.16406,837.673 L978.4229,814.2247 L1020.6318,784.5279 L1025.8223,780.258" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M693.8863,851.93256 L740.1513,863.4719 L788.3562,868.08527 L837.44165,865.61194 L886.3665,856.04504 L934.1259,839.52057 L979.76746,816.3056 L1017.08136,790.94775" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M684.05676,374.8674 L678.8756,294.71133" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M684.05676,374.8674 L646.42523,341.13947 L603.8589,318.4502 L603.4725,318.29562" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M862.1913,477.7134 L862.4948,449.3449 L857.6714,420.77826 L847.9071,392.87448 L833.49164,366.40222 L814.799,342.03738 L792.27045,320.36334 L766.3993,301.87158 L757.851,296.88828" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M862.1913,477.7134 L867.4927,446.5884 L866.6389,414.5052 L859.91296,382.53516 L847.67896,351.59552 L831.8405,324.59055" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M862.1913,477.7134 L881.4865,438.87024 L891.1777,397.55856 L892.82605,374.81613" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M862.1913,477.7134 L934.19904,442.12238" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M862.1913,477.7134 L910.2163,493.43933 L951.149,518.9582 L951.4761,519.2156" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M862.1913,477.7134 L888.30896,497.74594 L910.1766,522.91846 L927.32074,552.12976 L939.4315,584.3782 L942.7848,597.74146" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M862.1913,477.7134 L881.56824,499.07104 L897.10803,524.07947 L908.38464,551.9281 L915.1154,581.81775 L917.14465,612.97125 L914.4292,644.64294 L909.06714,669.19055" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M862.1913,477.7134 L878.2941,499.7147 L890.67114,524.6313 L898.95996,551.75574 L902.92365,580.3712 L902.4411,609.767 L897.49756,639.25214 L888.17474,668.1661 L874.64075,695.88873 L857.1397,721.84814 L853.9769,725.82025" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M862.1913,477.7134 L876.35944,500.095 L886.83856,524.9533 L893.3149,551.62823 L895.5888,579.44434 L893.5696,607.7272 L887.26807,635.81757 L876.7887,663.08356 L862.3207,688.93146 L844.1287,712.8143 L822.5431,734.2391 L797.94995,752.7729 L783.4841,761.49384" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M411.36288,642.4967 L420.98666,688.5316 L437.45282,733.26624 L460.46753,775.7212 L489.60895,814.9892 L524.3425,850.2486 L564.0372,880.774 L607.9814,905.9449 L655.40027,925.2515 L672.4866,930.49603" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M672.4866,930.49603 L667.9934,1010.42975" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M411.36288,642.4967 L421.25232,688.38904 L437.9449,732.93286 L461.14316,775.15656 L490.42252,814.16 L525.2464,849.12805 L564.98206,879.34174 L608.9168,904.1864 L656.27466,923.1581 L686.2495,931.6172" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M477.64935,584.9201 L411.36288,642.4967" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M477.64935,584.9201 L403.73764,618.881" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L515.59344,696.81226 L530.37695,734.35504 L551.30914,769.75507 L577.86633,802.1451 L609.4433,830.7804 L620.68945,839.18" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L514.1376,698.03174 L527.8143,736.99164 L547.9648,773.94775 L574.0444,807.988 L597.19324,831.1941" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L512.2893,699.57983 L524.571,740.32294 L543.7382,779.2235 L569.2125,815.31537 L574.38654,821.4114" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L509.86533,701.6101 L520.33417,744.66547 L538.2266,786.06604 L552.40656,809.8908" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L506.5471,704.38947 L514.5641,750.563 L530.73755,795.2989 L531.3854,796.70154" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L501.72775,708.42615 L506.24316,759.0354 L511.44946,781.923" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L494.09097,714.8227 L492.71875,765.644" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L480.1455,726.5033 L475.30588,747.96246" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L459.31552,728.9847" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L444.8439,708.8249" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L431.97803,687.60425" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M507.37982,658.1181 L420.79526,665.45044" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M551.8405,532.43665 L498.2012,565.8093 L477.64935,584.9201" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M551.8405,532.43665 L531.53503,560.60223 L517.38293,592.59973 L509.39133,627.1098 L507.37982,658.1181" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L410.10663,665.3728 L423.3544,711.0351 L443.2587,754.878 L469.46518,795.9728 L501.50137,833.46674 L538.7916,866.59546 L580.67267,894.6923 L626.40955,917.1961 L658.77057,928.90045" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L409.83478,665.49255 L422.84558,711.325 L442.55188,755.38116 L468.60257,796.7255 L500.52783,834.49896 L537.754,867.931 L579.6196,896.34937 L625.3911,919.18713 L645.1178,926.83246" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L409.5458,665.6199 L422.3049,711.63306 L441.801,755.91547 L467.68637,797.5243 L499.49374,835.59375 L536.6517,869.347 L578.50024,898.1055 L624.3075,921.29675 L631.5445,924.29443" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L409.23804,665.75543 L421.72925,711.9609 L441.0018,756.48376 L466.71136,798.3735 L498.3933,836.7571 L535.4783,870.8508 L577.3081,899.9701 L618.06696,921.2894" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L408.90952,665.9002 L421.11514,712.31055 L440.1495,757.0895 L465.67175,799.278 L497.2199,837.9955 L534.22675,872.4511 L576.0359,901.9533 L604.7011,917.82104" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L408.55817,666.055 L420.4586,712.68427 L439.23862,757.7364 L464.56085,800.24347 L495.966,839.3166 L532.88904,874.1573 L574.6752,904.067 L591.46295,913.8934" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L408.18143,666.22095 L419.75504,713.08466 L438.26288,758.42896 L463.37106,801.27625 L494.62308,840.72894 L531.4559,875.9804 L573.2165,906.3245 L578.3683,909.5111" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L407.77652,666.39935 L418.99924,713.51465 L437.21506,759.17206 L462.09372,802.38367 L493.18124,842.2423 L529.91675,877.9329 L565.4327,904.6795" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L407.34012,666.5916 L418.18515,713.9776 L436.08694,759.97156 L460.7187,803.57416 L491.62918,843.8681 L528.2594,880.02905 L552.6715,899.40424" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L406.8684,666.79944 L417.30576,714.47754 L434.86887,760.83405 L459.2344,804.8573 L489.95367,845.6192 L526.46967,882.28546 L540.10004,893.6917" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L406.35693,667.0248 L416.3529,715.01904 L433.54962,761.7673 L457.62726,806.24457 L488.13947,847.5108 L524.531,884.72125 L527.7333,887.54865" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L405.80045,667.26996 L415.31693,715.60754 L432.1161,762.78046 L455.88132,807.7491 L486.16852,849.5606 L515.5859,880.98236" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L405.19272,667.5377 L414.1865,716.24945 L430.5527,763.88416 L453.97778,809.3864 L484.01962,851.7893 L503.67233,874.00073" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L404.52634,667.8313 L412.94806,716.9523 L428.841,765.09125 L451.89426,811.175 L481.66745,854.22156 L492.00693,866.612" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L403.7924,668.15466 L411.58536,717.7253 L426.95877,766.4169 L449.60397,813.13684 L479.0818,856.8866 L480.60345,858.8251" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L402.98,668.5126 L410.07867,718.5794 L424.87918,767.8795 L447.07446,815.2986 L469.4756,850.64923" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.73764,618.881 L402.07596,668.9109 L408.4039,719.5282 L422.56946,769.5015 L444.2661,817.6924 L458.6365,842.0941" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M903.45483,788.72546 L948.4072,769.92865 L990.708,744.0579 L1029.3577,711.6764 L1063.4814,673.45746 L1092.3342,630.16235 L1105.0259,605.9569" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M903.45483,788.72546 L948.92957,770.4531 L991.8277,744.97485 L1031.1282,712.8574 L1065.9388,674.7782 L1095.4995,631.50244 L1102.254,619.4844" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M903.45483,788.72546 L949.50543,771.0312 L993.0603,745.98474 L1033.0752,714.1577 L1068.6383,676.23267 L1098.9735,632.9799 L1099.0168,632.9081" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M669.18176,849.5805 L714.40924,864.1255 L761.9983,871.9409 L810.916,872.79565 L860.13495,866.6113 L908.65204,853.45337 L955.50574,833.52075 L999.79095,807.1348 L1007.9767,801.32947" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M669.18176,849.5805 L714.4126,864.5585 L762.09174,872.7868 L811.17786,874.02563 L860.63544,868.189 L909.4541,855.3365 L956.66534,835.6617 L998.5191,811.39075" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M669.18176,849.5805 L714.4162,865.0247 L762.1922,873.6972 L811.459,875.3486 L861.17236,869.8854 L910.3139,857.3607 L957.90753,837.9628 L988.71985,821.11957" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M883.0182,802.8034 L931.3729,789.988 L977.99066,769.36346 L1021.7376,741.4282 L1061.6127,706.80176 L1081.5009,685.2478" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M883.0182,802.8034 L929.6493,787.9659 L974.24835,765.7766 L1015.75903,736.72107 L1053.2438,701.40466 L1085.8894,660.5307 L1095.3182,646.21204" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M883.0182,802.8034 L930.16583,788.5718 L975.3715,766.85254 L1017.55597,738.1338 L1055.7623,703.0243 L1089.1611,662.23096 L1091.1626,659.38043" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M883.0182,802.8034 L930.73724,789.2422 L976.6125,768.0419 L1019.5387,739.69464 L1058.5381,704.8139 L1086.5549,672.3975" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M589.2734,429.5906 L556.81744,438.8412 L526.6001,454.659 L499.45966,476.11105 L476.04,502.32333 L473.29712,506.03467" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M589.2734,429.5906 L533.6085,424.82874 L494.79477,430.01028" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M589.2734,429.5906 L539.8127,365.0854" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M667.9934,1010.42975 L675.4168,1091.2509" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M667.9934,1010.42975 L656.1504,1089.6606" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M678.8756,294.71133 L714.01184,270.92163 L753.51544,253.3886 L796.1646,242.45445 L837.98596,238.34937" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M678.8756,294.71133 L713.9895,273.61206 L752.94995,258.5359 L794.6316,249.81377 L837.9597,247.60922 L860.3962,249.00902" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L659.0199,777.6558 L693.7464,793.25977 L731.04517,803.59644 L770.0726,808.38684 L809.9816,807.48193 L849.9383,800.85504 L889.13605,788.59326 L922.7391,773.1061" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L658.84283,778.4779 L693.5699,794.8958 L731.03046,806.01843 L770.3662,811.5508 L810.7167,811.3307 L851.2358,805.32007 L891.1058,793.5962 L903.45483,788.72546" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L658.636,779.43823 L693.3633,796.8035 L731.0106,808.83844 L770.70123,815.23047 L811.55853,815.8031 L852.72205,810.5063 L883.0182,802.8034" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L658.3911,780.57477 L693.1183,799.05676 L730.9835,812.1635 L771.0869,819.5634 L812.5322,821.06464 L854.44165,816.6044 L861.55206,815.2554" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L658.0968,781.9411 L692.8231,801.7588 L730.94574,816.1429 L771.5355,824.741 L813.6713,827.34515 L839.18555,826.0065" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L657.7363,783.6146 L692.4605,805.05853 L730.892,820.9911 L772.0633,831.0378 L815.02185,834.97375 L816.05316,834.99194" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L657.28455,785.71216 L692.0046,809.17926 L730.81366,827.0282 L772.6926,838.86194 L792.29407,842.15784" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L656.70166,788.4181 L691.41394,814.471 L730.69495,834.75385 L768.0511,847.461" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L655.9211,792.0421 L690.61884,821.51654 L730.50684,844.9945 L743.47003,850.8696" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L654.8215,797.1469 L689.49146,831.36255 L718.6988,852.3631" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M627.6875,757.19934 L659.17316,776.94415 L693.899,791.8412 L731.05615,801.49365 L769.8131,805.6372 L809.3342,804.13477 L848.7957,796.9702 L887.40063,784.24005 L924.3913,766.1446 L940.75494,756.03937" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M644.7338,845.3211 L688.6092,864.5988 L735.6194,877.30963 L784.729,883.1156 L834.89746,881.8362 L885.0991,873.43976 L934.3407,858.03394 L957.391,848.1975" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M644.7338,845.3211 L688.6756,863.6495 L735.5591,875.4417 L784.3666,880.3802 L834.07434,878.3012 L883.672,869.18726 L932.1808,853.15796 L978.59064,830.50433" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M644.7338,845.3211 L688.6437,864.1061 L735.5882,876.34045 L784.54126,881.6967 L834.4712,880.00287 L884.3604,871.2347 L933.22296,855.50574 L968.14355,839.53394" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M559.2668,717.69666 L578.3201,753.74756 L603.7284,786.66974 L634.7154,815.6005 L670.45844,839.844 L693.8863,851.93256" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M559.2668,717.69666 L576.98724,755.95685 L601.6045,791.1628 L632.27136,822.3932 L668.11066,848.91064 L669.18176,849.5805" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M559.2668,717.69666 L575.22144,758.88367 L598.7991,797.08014 L629.03937,831.29706 L644.7338,845.3211" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M620.68945,839.18 L663.0286,861.19617 L708.87714,876.9185 L757.2394,885.9436 L807.10187,888.02234 L857.4537,883.0534 L907.30475,871.0754 L946.3459,856.4848" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M620.68945,839.18 L662.95746,861.67554 L708.83405,877.8691 L757.3153,887.3463 L807.3799,889.8488 L858.0096,885.26764 L908.20746,873.63477 L935.02124,864.386" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M978.59064,830.50433 L966.5184,911.64264 L956.47217,945.4201" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M978.59064,830.50433 L961.7607,904.5586 L942.07184,953.61774" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M978.59064,830.50433 L958.246,899.3253 L927.3971,961.3135" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M603.4725,318.29562 L631.1487,290.87717 L663.5419,267.8779 L699.806,249.89342 L739.0672,237.36713 L780.44165,230.60004 L814.8171,229.45837" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M349.35626,701.56726 L280.35812,742.7292" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M349.35626,701.56726 L273.35672,727.71075" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M448.0992,833.17 L456.3845,921.1858 L461.02014,941.69104" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M448.0992,833.17 L447.04373,932.7898" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M448.0992,833.17 L433.38287,923.4115" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M394.08072,570.2351 L387.65204,618.6248 L388.70007,668.2975 L397.23932,718.09937 L413.12585,766.9308 L436.0743,813.761 L448.0992,833.17" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M258.15613,689.18646 L199.73492,759.03735" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M258.15613,689.18646 L192.21869,741.22644" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M258.15613,689.18646 L185.3217,723.1667" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M258.15613,689.18646 L179.05215,704.87964" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M258.15613,689.18646 L173.41756,686.3871" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.10715,545.49744 L382.79135,593.088 L380.75467,642.3784 L386.0897,692.2417 L398.72986,741.5896 L418.46408,789.3893 L437.87622,823.88745" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.10715,545.49744 L382.12274,593.21124 L379.47733,642.7508 L384.2725,692.9715 L396.44827,742.76984 L415.7988,791.0996 L427.9797,814.25757" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.10715,545.49744 L381.3888,593.34656 L378.07648,643.1588 L382.28104,693.76996 L393.94916,744.0595 L412.88007,792.9664 L418.42148,804.2919" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.10715,545.49744 L380.5794,593.4957 L376.53333,643.6079 L380.089,694.64734 L391.19977,745.47455 L409.21292,794.0022" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.10715,545.49744 L379.68234,593.6611 L374.82498,644.1046 L377.66443,695.6159 L388.1605,747.0342 L400.365,783.4008" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.0565,520.6813 L377.0537,567.6198 L369.32983,617.2058 L369.09372,668.27094 L376.38522,719.6803 L391.09103,770.34875 L391.88828,772.50037" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.0565,520.6813 L376.16974,567.71277 L367.63214,617.5596 L366.6638,669.0297 L373.31262,720.968 L383.79285,761.3138" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.0565,520.6813 L375.1859,567.8161 L365.74515,617.9522 L363.96555,669.8701 L369.903,722.39185 L376.08838,749.8545" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.0565,520.6813 L374.08435,567.9319 L363.63528,618.39056 L360.95187,670.8062 L366.0976,723.97473 L368.784,738.13605" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.0565,520.6813 L372.84253,568.0624 L361.2606,618.8831 L357.5639,671.8553 L361.8231,725.74493 L361.88846,726.1725" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M392.0565,520.6813 L371.4319,568.21063 L358.5678,619.44055 L353.72726,673.0392 L355.40997,713.9781" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M383.79285,761.3138 L291.3212,763.8746" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L433.8501,542.4098 L402.82224,585.9767 L397.96533,594.7454" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L420.90814,542.8741 L394.08072,570.2351" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L394.67447,543.81525 L392.10715,545.49744" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L392.0565,520.6813" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L393.92905,495.9358" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L397.71356,471.40982" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L408.6774,452.9811 L403.38727,447.25085" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L431.93738,459.0773 L410.91608,423.60425" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L443.71844,462.16498 L423.74887,413.2531 L420.25464,400.61215" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L450.83615,464.03043 L436.7376,418.0327 L431.34686,378.41284" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L455.60193,465.27948 L445.51776,421.28522 L443.03986,375.44662 L444.126,357.1399" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L459.0162,466.17435 L451.85086,423.6425 L451.83408,379.70166 L458.5152,336.92117" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M473.29712,506.03467 L461.5825,466.84692 L456.63528,425.4297 L458.5002,382.9509 L467.07236,340.48923 L474.42792,317.8783" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M337.3221,832.66315 L264.98068,869.95154" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M337.3221,832.66315 L253.59723,854.32654" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M337.3221,832.66315 L242.75987,838.31793" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M393.92905,495.9358 L371.42392,541.67676 L356.23752,591.37665 L348.71875,643.7803 L349.03143,697.67053 L349.35626,701.56726" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M393.92905,495.9358 L370.05103,541.714 L353.59494,591.71313 L344.92307,644.637 L343.7345,688.95496" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M393.92905,495.9358 L368.48135,541.7566 L350.5797,592.09576 L340.59854,645.6082 L338.55142,676.15607" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M393.92905,495.9358 L366.66943,541.8058 L347.10693,592.5348 L335.62622,646.71857 L333.81323,663.186" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M393.92905,495.9358 L364.5544,541.8632 L343.0638,593.04364 L329.8484,648.0004 L329.5255,650.06" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M393.92905,495.9358 L362.0533,541.9311 L338.29718,593.64044 L325.69342,636.7939" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M356.5833,209.64824 L349.24814,105.67347" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M356.5833,209.64824 L254.67172,219.06874 L246.61687,220.72044" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M356.5833,209.64824 L257.65332,204.84846" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M356.5833,209.64824 L269.23102,189.36687" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M356.5833,209.64824 L281.3362,174.29411" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M356.5833,209.64824 L293.9544,159.64818" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M356.5833,209.64824 L307.07065,145.44649" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M356.5833,209.64824 L320.66922,131.706" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M356.5833,209.64824 L334.73398,118.44305" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M248.42279,657.51276 L168.4246,667.7111" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M434.96115,243.75372 L382.70248,183.05408" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M437.87622,823.88745 L420.05383,913.56726" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M437.87622,823.88745 L407.07254,903.2688" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M406.5996,273.9255 L345.6536,222.10268" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M427.9797,814.25757 L394.45444,892.52844" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M427.9797,814.25757 L382.2146,881.35895" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M427.9797,814.25757 L370.36755,869.7736" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M427.9797,814.25757 L358.92746,857.78625" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M325.69342,636.7939 L258.15613,689.18646" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M325.69342,636.7939 L253.01607,673.4336" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M325.69342,636.7939 L248.42279,657.51276" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M325.69342,636.7939 L244.38177,641.4429" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M325.69342,636.7939 L240.89784,625.24304" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M418.42148,804.2919 L347.90793,845.4112" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M418.42148,804.2919 L337.3221,832.66315" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M418.42148,804.2919 L327.18262,819.5574" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M861.55206,815.2554 L910.04565,805.2499 L957.26495,787.5223 L1002.07666,762.47705 L1043.4639,730.6492 L1076.0061,697.916" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L365.42218,514.9508 L340.5421,564.2334 L323.5751,617.835 L322.3215,623.4034" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L363.02786,514.8299 L335.94055,564.45056 L319.41376,609.9045" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L360.16425,514.68536 L330.45618,564.7054 L316.97372,596.3133" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L356.67834,514.5094 L323.80765,565.00854 L315.0042,582.64594" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L352.34262,514.2906 L315.57968,565.37524 L313.50766,568.91876" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L346.80316,514.011 L312.48578,555.14813" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L339.4778,513.64124 L311.93982,541.3504" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L329.33704,513.12933 L311.87042,527.54205" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L314.37158,512.37396 L312.2777,513.73956" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L313.16113,499.95935" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L314.51968,486.2178" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L316.3517,472.53137" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L318.6551,458.91632" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L321.427,445.3889" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L324.66418,431.9652" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L328.36276,418.66122" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L332.51837,405.49286" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L344.8752,405.37292 L337.12598,392.47577" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L356.0167,409.2307 L342.18018,379.62546" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L363.87946,411.95325 L347.6749,366.95728" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L369.7252,413.97736 L353.6036,354.48627" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L374.24176,415.54126 L361.37735,355.64478 L359.9592,342.22736" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L377.83627,416.78586 L367.96072,358.65564 L366.73416,330.1951" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L380.76486,417.7999 L373.34653,361.12482 L373.9204,318.40387" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L383.19696,418.64203 L377.8345,363.18655 L381.50934,306.8677" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L385.2489,419.35254 L381.632,364.93414 L386.8043,309.61996 L389.49194,295.60037" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M397.71356,471.40982 L387.00342,419.96002 L384.88702,366.4343 L391.31506,312.23688 L397.85867,284.61526" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M313.50766,568.91876 L235.11624,588.41864" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M312.48578,555.14813 L233.46802,571.9306" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M312.48578,555.14813 L232.38988,555.3955" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M312.48578,555.14813 L231.88312,538.83307" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.38727,447.25085 L394.5321,394.1829 L394.75696,339.37573 L403.8853,284.30234 L406.5996,273.9255" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M403.38727,447.25085 L396.29938,394.9529 L398.0038,341.18893 L408.33987,287.36908 L415.7043,263.5438" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M415.7043,263.5438 L356.5833,209.64824" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M410.91608,423.60425 L405.73697,369.76364 L409.83032,314.82593 L422.89944,260.26276 L425.1619,253.48254" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M410.91608,423.60425 L407.50565,370.70355 L413.04587,316.96375 L427.26288,263.79245 L434.96115,243.75372" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M410.91608,423.60425 L409.027,371.51205 L415.81784,318.80875 L431.02948,266.8476 L445.09033,234.36893" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M410.91608,423.60425 L410.34952,372.21487 L418.23215,320.4173 L434.31396,269.51797 L455.5374,225.33934" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M410.91608,423.60425 L411.50977,372.83148 L420.35382,321.83215 L437.2034,271.87216 L461.665,224.1037 L466.28998,216.67574" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M410.91608,423.60425 L412.53592,373.3768 L422.2331,323.0863 L439.7651,273.9632 L464.7419,227.13138 L477.3351,208.38843" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M445.09033,234.36893 L382.70248,183.05408" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M445.09033,234.36893 L394.95795,171.90169" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L424.5201,349.87222 L437.07834,299.50647 L457.58545,250.76733 L485.55807,204.78052 L488.65976,200.4873" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L425.5304,350.5142 L438.90753,300.94373 L460.048,253.11697 L488.47455,208.13087 L500.25034,192.98175" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L426.4288,351.08502 L440.5363,302.22433 L462.24243,255.21432 L491.07407,211.12617 L512.0931,185.88075" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L427.2329,351.59595 L441.99585,303.37262 L464.2103,257.09802 L493.4057,213.82016 L524.1739,179.19273" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L427.95682,352.05594 L443.31128,304.40808 L465.98502,258.79913 L495.50882,216.25621 L531.29877,177.69467 L536.47833,172.92569" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L428.61197,352.47223 L444.5029,305.34656 L467.59366,260.34305 L497.41553,218.46974 L533.38556,180.62593 L548.99176,167.0871" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L429.2077,352.85077 L445.58743,306.20108 L469.05853,261.75058 L499.15207,220.48997 L535.2855,183.30371 L561.6992,161.68388" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L429.75177,353.19647 L446.5787,306.98245 L470.3981,263.03906 L500.74026,222.34122 L537.0227,185.75964 L574.58563,156.7225" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L430.2506,353.51343 L447.48822,307.69968 L471.62778,264.22302 L502.1984,224.04384 L538.6172,188.02025 L580.2081,156.90016 L587.6356,152.20886" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L430.70963,353.80508 L448.3257,308.36032 L472.76056,265.31464 L503.5418,225.61508 L540.0859,190.10799 L581.71643,159.53078 L600.8336,148.14835" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L431.1334,354.07434 L449.09943,308.97083 L473.80746,266.32437 L504.7835,227.06961 L541.44305,192.042 L583.10925,161.96915 L614.16394,144.5458" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L431.52585,354.32373 L449.81635,309.53674 L474.77792,267.26105 L505.93466,228.41998 L542.701,193.83871 L584.39935,164.23566 L627.6106,141.40552" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L431.89032,354.5553 L450.48254,310.0627 L475.68,268.1324 L507.00482,229.677 L543.8701,195.51225 L585.59766,166.3479 L631.4355,142.78127 L641.1577,138.73123" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L432.2297,354.77097 L451.10318,310.5529 L476.52066,268.94498 L508.00223,230.85004 L544.9596,197.0749 L586.7137,168.32115 L632.5119,145.17853 L654.789,136.52612" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L432.5465,354.97226 L451.68283,311.01077 L477.30603,269.70453 L508.93408,231.94724 L545.9773,198.53734 L587.75555,170.16872 L633.5156,147.42395 L668.4883,134.79283" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L432.84293,355.16058 L452.22534,311.43945 L478.04132,270.41614 L509.8066,232.97575 L546.93005,199.90892 L588.7304,171.90228 L634.45386,149.53156 L682.23926,133.53342" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L433.12082,355.33716 L452.73425,311.84164 L478.73123,271.08417 L510.62534,233.94183 L547.82385,201.19786 L589.64453,173.53207 L635.3327,151.51369 L684.0782,135.59671 L696.0255,132.74939" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L433.38193,355.50308 L453.21255,312.21973 L479.3798,271.7125 L511.3951,234.85098 L548.6641,202.41145 L590.5034,175.06718 L636.1576,153.38127 L684.816,137.80177 L709.83057,132.44168" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L433.6277,355.65924 L453.66293,312.5758 L479.99066,272.3046 L512.1201,235.70813 L549.4554,203.55609 L591.31195,176.51563 L636.9334,155.14395 L685.5086,139.88348 L723.63806,132.61064" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L433.85944,355.8065 L454.08777,312.91174 L480.56702,272.8635 L512.80426,236.5176 L550.20197,204.63751 L592.0744,177.88455 L637.66437,156.81036 L686.1601,141.85194 L736.7116,133.32881 L737.43146,133.25609" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L434.0783,355.94556 L454.48914,313.2292 L481.11166,273.3919 L513.4508,237.28325 L550.9074,205.66081 L592.79456,179.18036 L638.35425,158.38821 L686.7739,143.71617 L737.20337,135.47867 L751.1944,134.37724" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L434.28537,356.07712 L454.869,313.52966 L481.6272,273.89227 L514.0628,238.00856 L551.5751,206.63057 L593.4759,180.40875 L639.00635,159.88437 L687.3533,145.48424 L737.6661,137.51791 L764.9104,135.97278" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L434.48157,356.20178 L455.229,313.81448 L482.11588,274.36676 L514.64294,238.69664 L552.20795,207.55087 L594.12146,181.57487 L639.6237,161.30502 L687.90094,147.16344 L738.1021,139.45493 L778.56323,138.04079" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L434.6677,356.32007 L455.57065,314.0848 L482.57974,274.81732 L515.1937,239.35028 L552.80865,208.42543 L594.734,182.68335 L640.2091,162.65579 L688.4195,148.7603 L738.51373,141.2972 L789.6194,140.44864 L792.1365,140.5788" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L434.84454,356.43243 L455.89532,314.34177 L483.02066,275.24576 L515.7172,239.97203 L553.3796,209.25755 L595.316,183.73834 L640.76483,163.94168 L688.9111,150.28076 L738.9028,143.05153 L789.8675,142.4318 L805.614,143.5838" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L435.0128,356.53934 L456.20428,314.58627 L483.44025,275.6536 L516.21545,240.56413 L553.92285,210.05028 L595.8696,184.74365 L641.29315,165.1673 L689.37775,151.73018 L739.2712,144.7241 L790.1006,144.32262 L818.97986,147.0522" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L435.173,356.64114 L456.4986,314.81927 L483.84006,276.04233 L516.6902,241.12868 L554.4405,210.80632 L596.3969,185.70271 L641.796,166.33676 L689.8214,153.11345 L739.62036,146.3205 L790.31995,146.12746 L832.218,150.97987" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L435.3258,356.73822 L456.7793,315.0415 L484.22144,276.41327 L517.14307,241.66756 L554.93427,211.5282 L596.8998,186.61864 L642.2752,167.45387 L690.24365,154.43498 L739.95184,147.84583 L790.52673,147.85202 L841.09094,154.50186 L845.3127,155.36214" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L435.47165,356.8309 L457.04733,315.25372 L484.58563,276.7676 L517.57556,242.18246 L555.40576,212.21815 L597.37976,187.49428 L642.7324,168.52206 L690.646,155.69884 L740.2669,149.30473 L790.72186,149.50159 L841.13354,156.33382 L858.2483,160.19377" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L435.61102,356.91946 L457.30353,315.45657 L484.93378,277.10645 L517.989,242.67497 L555.85645,212.87828 L597.8385,188.33224 L643.16907,169.54448 L691.0298,156.90872 L740.5667,150.70148 L790.90625,151.08093 L841.17126,158.08781 L871.00946,165.46901" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L435.74435,357.00418 L457.54865,315.6507 L485.26694,277.43076 L518.3847,243.1465 L556.2877,213.51045 L598.2773,189.13492 L643.5865,170.52402 L691.3963,158.06801 L740.85236,152.03996 L791.0808,152.59448 L841.2045,159.7687 L883.58093,171.18156" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L435.872,357.0853 L457.7834,315.8366 L485.58603,277.74146 L518.7637,243.59839 L556.70074,214.11642 L598.69745,189.90448 L643.98596,171.4633 L691.7467,159.17984 L741.1247,153.32375 L791.2461,154.04625 L841.2337,161.38098 L890.2223,175.24686 L895.94775,177.32465" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L435.99435,357.16302 L458.0084,316.01483 L485.8919,278.0394 L519.127,244.03181 L557.0967,214.6978 L599.1001,190.64294 L644.3686,172.36479 L692.0819,160.24706 L741.38477,154.55614 L791.40295,155.43994 L841.25934,162.92877 L890.089,176.93806 L908.0951,183.8909" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L436.11172,357.2376 L458.22427,316.18585 L486.18542,278.32532 L519.47565,244.4479 L557.47656,215.25603 L599.4864,191.35216 L644.7355,173.23073 L692.403,161.2723 L741.63324,155.74016 L791.5518,156.779 L841.2816,164.41588 L889.95795,178.56287 L920.0086,190.87254" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L436.2244,357.30917 L458.43155,316.35007 L486.46732,278.6 L519.8105,244.84767 L557.8414,215.79247 L599.8572,192.03383 L645.0875,174.06314 L692.7108,162.258 L741.871,156.8786 L791.6934,158.06659 L841.30096,165.84581 L889.8293,180.1251 L931.6741,198.26123" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L436.33264,357.37796 L458.63074,316.5079 L486.73822,278.864 L520.13226,245.23206 L558.192,216.30838 L600.2135,192.68953 L645.4256,174.86398 L693.00616,163.2064 L742.0986,157.97408 L791.82806,159.3056 L841.3176,167.22179 L889.703,181.62834 L936.1474,202.32053 L943.0775,206.04816" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L436.43674,357.44412 L458.82233,316.6597 L486.99878,279.11804 L520.4418,245.60194 L558.5292,216.80493 L600.5561,193.32071 L645.7505,175.635 L693.28973,164.1196 L742.31665,159.02895 L791.9564,160.49876 L841.3318,168.54684 L889.57904,183.07585 L935.86176,203.87794 L954.2054,214.22404" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L436.53693,357.50775 L459.0067,316.8058 L487.24957,279.36258 L520.7397,245.95811 L558.85376,217.28317 L600.8858,193.92874 L646.06305,176.37782 L693.5622,164.99951 L742.5258,160.04546 L792.07874,161.64856 L841.3438,169.82373 L889.4574,184.47069 L935.58374,205.37854 L965.04443,222.77916" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M420.25464,400.61215 L436.6334,357.56906 L459.1843,316.94653 L487.49115,279.59818 L521.0267,246.30133 L559.1664,217.74411 L601.2033,194.51486 L646.3639,177.09398 L693.8243,165.84792 L742.7266,161.02563 L792.1955,162.75731 L841.35376,171.05504 L889.3381,185.81566 L935.3131,206.82535 L975.5818,231.7033" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M488.65976,200.4873 L444.15475,134.00928" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M512.0931,185.88075 L472.28055,116.49068" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M524.1739,179.19273 L486.78015,108.46984" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M400.365,783.4008 L317.5015,806.1094" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M400.365,783.4008 L308.29034,792.3352" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M400.365,783.4008 L301.01953,778.4891 L299.56012,778.2514" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1014.4681,270.87106 L1059.7994,201.79398" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1014.4681,270.87106 L1071.0046,214.00119" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1014.4681,270.87106 L1081.7817,226.58789" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M312.2777,513.73956 L232.37256,509.8423" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M845.3127,155.36214 L872.1561,80" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M985.80475,240.98582 L1036.1589,178.57571" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M444.126,357.1399 L466.917,317.10767 L495.4408,280.41302 L529.15497,247.83649 L567.41895,220.06595 L609.5097,197.68616 L654.6382,181.1705 L701.9662,170.8754 L750.62305,167.03691 L799.72284,169.76979 L848.3803,179.06874 L895.727,194.81152 L940.92535,216.76418 L983.18274,244.58778 L1014.4681,270.87106" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M444.126,357.1399 L466.6443,316.86792 L494.95258,279.88428 L528.5125,246.97893 L566.68677,218.84889 L608.7547,196.08781 L653.9289,179.17775 L701.3722,168.48366 L750.2144,164.2501 L799.56964,166.60037 L848.55194,175.53772 L896.2913,190.94864 L941.94824,212.60796 L984.72705,240.1856 L985.80475,240.98582" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M444.126,357.1399 L466.73874,316.95093 L495.1216,280.06732 L528.7349,247.27574 L566.94025,219.27005 L609.0161,196.64082 L654.1746,179.86713 L701.5781,169.31094 L750.3565,165.21396 L799.6236,167.6965 L848.49396,176.7589 L896.0979,192.28464 L941.59674,214.04553 L984.1958,241.70848 L995.7013,250.61568" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M444.126,357.1399 L466.82956,317.0308 L495.28424,280.24344 L528.9489,247.56139 L567.18414,219.67545 L609.2676,197.17322 L654.4109,180.5309 L701.776,170.10764 L750.49255,166.14224 L799.6746,168.75226 L848.4367,177.93509 L895.9099,193.57138 L941.256,215.42998 L983.6813,243.17485 L1005.2595,260.5814" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M995.7013,250.61568 L1048.1796,189.98079" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M474.42792,317.8783 L502.85764,281.5204 L536.511,249.29898 L574.7345,221.91315 L616.7911,199.95474 L661.8771,183.9005 L709.1402,174.1066 L757.69684,170.80571 L806.64935,174.10652 L855.1031,183.99538 L902.182,200.34009 L947.0436,222.8955 L988.8926,251.31087 L1023.316,281.47244" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M358.92746,857.78625 L358.17303,961.5076 L358.7382,967.0845" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M358.92746,857.78625 L343.94623,954.63776" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M358.92746,857.78625 L329.59268,941.6879" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M358.92746,857.78625 L315.69467,928.25037" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M358.92746,857.78625 L302.26877,914.34106" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M358.92746,857.78625 L289.33096,899.9767" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M358.92746,857.78625 L276.89664,885.17426" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M315.69467,928.25037 L306.39255,1027.6255" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M289.54214,1013.3359 L236.75484,1073.4482" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M302.26877,914.34106 L289.54214,1013.3359" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M302.26877,914.34106 L273.19498,998.4732" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M302.26877,914.34106 L257.37057,983.0552" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M302.26877,914.34106 L242.08775,967.10016" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M302.26877,914.34106 L227.36476,950.6271" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M302.26877,914.34106 L199.6677,916.2061" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M302.26877,914.34106 L213.21912,933.6557" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M276.89664,885.17426 L186.72662,898.2992" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M213.21912,933.6557 L158.76598,993.42316" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M213.21912,933.6557 L143.18405,974.0585" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M143.18405,974.0585 L80,1023.12756" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L921.7547,290.90814" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L893.81525,319.94986 L883.4498,266.39905 L881.91296,261.37323" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L903.0867,309.1239 L902.40686,275.36768" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L939.84015,307.90118" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L956.5543,326.24463" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L971.79675,345.82813" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L971.2117,366.03717 L985.4758,366.5339" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L953.34283,377.5009 L997.50916,388.23746" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L943.96173,383.51935 L992.3254,402.39532 L1007.8245,410.80823" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L938.18146,387.2277 L980.80853,408.2997 L1016.35974,434.11047" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L934.2626,389.74182 L972.9157,412.32578 L1007.7744,441.64264 L1023.06354,458.0041" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L931.4307,391.5586 L967.1676,415.2471 L999.09595,445.06464 L1026.4568,480.15805 L1027.8956,482.34534" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L929.2887,392.93283 L962.7941,417.46362 L992.46173,447.6592 L1017.57526,482.7188 L1030.8269,506.98785" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M892.82605,374.81613 L927.61176,394.00864 L959.35455,419.203 L987.22455,449.6941 L1010.54297,484.71704 L1028.774,523.4669 L1031.8398,531.7834" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M923.43066,871.8915 L912.46545,968.4981" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M911.5879,878.9925 L897.2948,975.1631" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M574.38654,821.4114 L613.132,848.69073 L656.0994,870.2755 L702.3804,885.64075 L751.0251,894.40674 L801.06323,896.33704 L851.52295,891.33453 L901.4486,879.4347 L923.43066,871.8915" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M574.38654,821.4114 L613.01184,849.07587 L655.94165,871.0533 L702.263,886.80804 L751.0213,895.95166 L801.2414,898.2401 L851.9468,893.5693 L902.1775,881.9685 L911.5879,878.9925" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M839.18555,826.0065 L888.19336,819.6012 L936.4991,805.43195 L982.94806,783.8142 L1026.4904,755.202 L1063.7218,722.6459" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M839.18555,826.0065 L888.67957,820.3928 L937.59735,806.8726 L984.7568,785.76196 L1029.0851,757.51685 L1056.9468,734.67816" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M839.18555,826.0065 L889.222,821.27606 L938.82056,808.478 L986.7679,787.9309 L1031.9662,760.09393 L1049.7606,746.46936" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M839.18555,826.0065 L887.7551,818.8875 L935.50745,804.1319 L981.3126,782.0554 L1024.1411,753.11145 L1063.0725,717.8695 L1070.0774,710.38696" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M645.1178,926.83246 L651.52106,1008.63153" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M645.1178,926.83246 L635.12054,1006.26575" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M645.1178,926.83246 L618.8115,1003.3353" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M645.1178,926.83246 L602.61334,999.84375" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M530.308,268.91064 L566.051,239.47607 L606.05804,215.22722 L649.5319,196.71292 L695.6172,184.35715 L743.4195,178.45528 L792.02356,179.1732 L840.5115,186.54874 L887.97955,200.49524 L933.5541,220.8073 L976.40594,247.1681 L1015.76355,279.15848 L1039.8882,303.55948" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1157.5848,710.5151 L1210.8278,656.3625 L1255.5873,593.2588 L1270.8354,565.9718" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1157.5848,710.5151 L1214.8331,657.35724 L1263.4722,594.43506 L1269.3448,585.2462" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1157.5848,710.5151 L1219.9396,658.6255 L1267.19,604.45764" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1157.5848,710.5151 L1226.6741,660.298 L1264.3732,623.5832" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1030.9282,556.5829 L1064.407,509.16104 L1089.18,455.73685 L1097.9875,428.07938" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1030.9282,556.5829 L1068.1724,508.91245 L1096.2695,454.58136 L1101.3595,441.46985" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1030.9282,556.5829 L1072.8993,508.60037 L1104.2672,454.96875" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1030.9282,556.5829 L1079.0095,508.197 L1106.7073,468.55997" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1030.9282,556.5829 L1087.2148,507.6553 L1108.6768,482.22733" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1030.9282,556.5829 L1098.8154,506.88947 L1110.1733,495.9545" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M686.2495,931.6172 L667.9934,1010.42975" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M686.2495,931.6172 L701.07513,1012.3159" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M686.2495,931.6172 L684.518,1011.6584" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1189.2848,482.96954 L1265.783,450.2921" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1105.0259,605.9569 L1145.7429,552.52325 L1177.4299,491.95258 L1187.2928,466.51947" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M667.8873,215.46956 L713.04877,202.45934 L760.25275,196.09607 L808.4986,196.57771 L856.78906,203.9558 L904.1496,218.14246 L949.6452,238.9195 L992.3956,265.94885 L1031.5887,298.78436 L1047.5927,315.0188" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M667.8873,215.46956 L713.04767,202.7542 L760.19086,196.6741 L808.3212,197.42058 L856.4465,205.03947 L903.5967,219.43805 L948.8415,240.3938 L991.3054,267.5648 L1030.1807,300.50137 L1054.897,326.7372" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M667.8873,215.46956 L713.0467,203.03212 L760.1325,197.2192 L808.15375,198.21567 L856.1227,206.062 L903.07385,220.66074 L948.0813,241.78523 L990.2737,269.0898 L1028.8477,302.1214 L1061.7925,338.70078" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L490.34943,385.091 L494.36945,339.466 L506.36667,294.48843 L510.4326,283.77048" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L493.54083,387.21405 L500.0351,344.11026 L513.8828,301.9304 L530.308,268.91064" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L495.9128,388.79202 L504.26675,347.58795 L519.51105,307.53772 L541.1891,269.6426 L551.27515,255.63568" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L497.74506,390.0109 L507.54785,350.28983 L523.884,311.9156 L546.3192,275.83627 L573.20795,244.02545" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L499.20297,390.98077 L510.16647,352.4496 L527.3797,315.429 L550.4214,280.8237 L578.77545,249.42838 L595.9745,234.14973" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L500.39066,391.77087 L512.3049,354.21555 L530.2383,318.31134 L553.77673,284.9268 L582.4108,254.82733 L615.5548,228.6723 L619.43787,226.06796" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L501.37683,392.4269 L514.0841,355.68646 L532.6195,320.7188 L556.5722,288.36215 L585.4368,259.35672 L618.6311,234.34044 L643.457,219.82874" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L502.2088,392.98038 L515.58765,356.93063 L534.6337,322.75995 L558.9373,291.28076 L587.9949,263.21155 L621.22614,239.1713 L657.991,219.67656 L667.8873,215.46956" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L485.82538,382.0814 L486.39096,332.94833 L491.7685,300.12582" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L502.9201,393.45358 L516.875,357.9967 L536.3598,324.5125 L560.96436,293.7913 L590.18567,266.53244 L623.4444,243.33827 L660.10156,224.71025 L692.5821,213.01666" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L503.53522,393.8628 L517.9897,358.9204 L537.8554,326.03372 L562.721,295.97385 L592.083,269.4234 L625.3623,246.96983 L661.9203,229.10103 L701.0742,216.203 L717.3926,212.4848" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L504.07242,394.22015 L518.96423,359.72845 L539.1639,327.36658 L564.258,297.88885 L593.7421,271.963 L627.03687,250.16321 L663.50354,232.96506 L702.4595,220.74303 L742.16974,213.87715" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L504.54562,394.53494 L519.8235,360.4413 L540.31824,328.54404 L565.61414,299.5827 L595.2053,274.2118 L628.5116,252.99341 L664.894,236.39207 L703.6697,224.77167 L744.12726,218.39438 L766.76447,217.18538" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M494.79477,430.01028 L504.96564,394.81436 L520.58685,361.07486 L541.34424,329.59183 L566.8196,301.09164 L596.50525,276.21704 L629.8202,255.5192 L666.12463,239.45247 L704.7357,228.37103 L744.94183,222.52763 L786.01685,222.07405 L791.02893,222.38954" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1023.36487,605.59796 L1069.7744,564.45593 L1107.9594,514.89386 L1111.1952,509.72513" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1157.5848,710.5151" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1132.3367,641.3302 L1175.8032,588.57104 L1190.6858,565.71" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1136.3235,642.3627 L1183.6067,589.815 L1189.2521,582.2181" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1141.3959,643.6762 L1187.2493,598.6669" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1148.0673,645.4039 L1184.68,615.0367" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1157.2349,647.77795 L1181.5472,631.30804" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1170.6224,651.2449 L1177.8547,647.4616" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1173.6068,663.4781" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1168.8086,679.3384" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1163.4658,695.0236" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1126.4716,639.81134 L1164.2579,586.74384 L1191.8405,532.59467" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1129.1206,640.4974 L1169.4823,587.5687 L1191.549,549.1623" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1151.1727,725.7944" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1144.2369,740.8432" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1136.7859,755.6437" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1128.8285,770.17816" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1116.3512,769.7624 L1120.374,784.4293" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1105.8287,763.58887 L1111.4329,798.3801" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1098.4685,759.2706 L1102.0154,812.0141" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1093.0311,756.08057 L1092.1329,825.3148" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1088.8503,753.6277 L1084.2755,823.3745 L1081.7974,838.26654" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1085.5355,751.6829 L1078.2926,819.04724 L1071.0209,850.85376" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1082.8429,750.10315 L1073.417,815.5152 L1059.8163,863.0616" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.5009,685.2478 L1080.6124,748.7945 L1069.3672,812.5776 L1048.197,874.87537" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M491.7685,300.12582 L522.83344,265.9439 L558.81665,236.27507 L599.0133,211.77023 L642.6429,192.9668 L688.8673,180.282 L736.8091,174.00885 L785.56866,174.31479 L834.24207,181.24239 L881.9373,194.7124 L927.79016,214.52885 L970.978,240.3857 L1010.73334,271.87524 L1031.7927,292.37292" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1262.6176,431.2211 L1341.3,416.76172" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1187.2928,466.51947 L1262.6176,431.2211" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M717.3926,212.4848 L764.09717,206.1396 L811.8846,206.7111 L859.7258,214.24945 L906.6184,228.65901 L951.60406,249.70882 L993.78424,277.04425 L1032.3335,310.20013 L1066.5101,348.61447 L1068.271,350.8952" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M717.3926,212.4848 L764.046,206.45871 L811.7199,207.32616 L859.39185,215.13109 L906.0657,229.77278 L950.78925,251.0159 L992.66974,278.50217 L1030.8872,311.76315 L1064.7054,350.23398 L1074.3247,363.30597" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M909.06714,669.19055 L946.6038,648.8026 L980.53217,621.58704 L1009.98615,588.4958 L1030.9282,556.5829" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M909.06714,669.19055 L949.981,650.47656 L987.27563,624.1289 L1020.02625,591.2375 L1028.0975,581.2372" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M909.06714,669.19055 L954.7531,652.842 L996.72375,627.7082 L1023.36487,605.59796" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M909.06714,669.19055 L962.0094,656.4387 L1010.9176,633.1234 L1016.7586,629.51874" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M909.06714,669.19055 L974.3719,662.56647 L1008.3186,652.85565" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M684.05676,690.00586 L705.2275,772.3455" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1070.0774,710.38696 L1066.9419,775.1804 L1053.0255,839.7863 L1036.177,886.281" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M602.61334,999.84375 L589.3905,1078.8851" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1099.0168,632.9081 L1140.0232,582.0286 L1172.715,524.04785 L1189.2848,482.96954" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M766.76447,217.18538 L814.16754,217.27379 L861.73334,224.45538 L908.41565,238.63428 L953.2188,259.5718 L995.2132,286.90024 L1033.5485,320.1375 L1067.4645,358.70242 L1079.9465,375.9183" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M766.76447,217.18538 L814.0501,217.6356 L861.4305,225.13995 L907.86884,239.59694 L952.37787,260.76352 L994.0358,288.26846 L1031.9995,321.62683 L1065.5159,360.25507 L1085.1295,388.71716" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M766.76447,217.18538 L813.94025,217.97398 L861.1471,225.78047 L907.35675,240.49797 L951.5897,261.87918 L992.9316,289.54947 L1030.5461,323.02103 L1063.6866,361.7079 L1089.8678,401.68732" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M940.75494,756.03937 L982.5508,730.73315 L1020.65985,698.7793 L1054.1718,660.8919 L1082.3181,617.8745 L1104.4735,570.5973 L1111.4033,551.13367" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M940.75494,756.03937 L983.177,731.1919 L1021.9677,699.5476 L1056.1952,661.82825 L1085.0737,618.8447 L1107.9633,571.474 L1110.5199,564.91394" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M940.75494,756.03937 L983.8689,731.69867 L1023.4109,700.39594 L1058.4257,662.86206 L1088.1083,619.9169 L1109.1613,578.65546" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M940.75494,756.03937 L984.63745,732.26166 L1025.0117,701.3374 L1060.8964,664.00934 L1091.4661,621.10803 L1107.3292,592.34186" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1110.5199,564.91394 L1145.4945,507.55707 L1170.7496,444.03503 L1177.9303,417.71857" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1109.1613,578.65546 L1146.0948,522.54 L1173.5276,459.9275 L1181.6123,433.87454" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1109.1613,578.65546 L1150.2122,522.4461 L1181.3744,458.99637 L1184.7344,450.14798" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1187.2493,598.6669 L1235.3741,533.36255 L1268.2882,469.461" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1007.9767,801.32947 L1023.7703,897.265" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1007.9767,801.32947 L1012.1699,892.6513 L1010.992,907.8143" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1007.9767,801.32947 L1004.35754,883.28107 L997.8572,917.91614" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1007.9767,801.32947 L998.8929,876.72687 L984.3815,927.55865" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1007.9767,801.32947 L994.856,871.88495 L970.581,936.7303" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1177.8547,647.4616 L1223.1365,586.4954 L1258.7834,517.8311 L1270.1304,488.70496" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1177.8547,647.4616 L1227.2406,586.9293 L1266.7552,517.9108 L1271.3071,508.00104" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1177.8547,647.4616 L1232.473,587.4826 L1271.8171,527.32623" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1177.8547,647.4616 L1239.3737,588.21216 L1271.6599,546.6575" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1191.2214,821.9085 L1281.2229,824.6756" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1191.2214,821.9085 L1270.7968,844.15436" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1191.2214,821.9085 L1259.7043,863.2616" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1191.2214,821.9085 L1247.9591,881.9746" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1191.2214,821.9085 L1235.575,900.2711" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1270.8354,565.9718 L1350.9989,565.24744" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1254.3223,393.46353 L1331.8197,373.61023" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1151.1727,725.7944 L1221.954,677.9919 L1260.8978,642.6002" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1151.1727,725.7944 L1231.1571,680.6179 L1256.7683,661.4859" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M783.4841,761.49384 L822.3759,761.7141 L861.54474,755.4995 L899.9735,743.013 L936.73047,724.546 L970.9754,700.49866 L998.0955,675.4683" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M783.4841,761.49384 L823.0495,763.1113 L863.12494,758.06384 L902.64307,746.51654 L940.63214,728.7645 L976.2199,705.21204 L986.1508,697.2208" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M705.2275,772.3455 L741.6326,782.59674 L779.8854,787.0716 L819.0454,785.6274 L858.1992,778.2611 L896.4756,765.09656 L933.05743,746.3707 L967.19257,722.4206 L972.55646,717.9823" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M705.2275,772.3455 L741.7539,783.61505 L780.32764,789.0243 L819.98004,788.41614 L859.77344,781.7769 L898.81573,769.2221 L936.2717,750.9821 L957.3941,737.6278" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1031.7927,292.37292 L1092.118,239.53908" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1031.7927,292.37292 L1102.0011,252.83931" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1031.7927,292.37292 L1111.4193,266.47275" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1086.5549,672.3975 L1127.6921,624.938 L1161.451,570.557 L1187.0569,510.6078 L1190.7076,499.47855" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1086.5549,672.3975 L1129.9307,625.4359 L1165.8628,571.09576 L1191.5599,516.02686" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1144.2369,740.8432 L1225.733,698.4544 L1251.9893,680.21783" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1144.2369,740.8432 L1239.1018,702.7733 L1246.5667,698.7736" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1136.7859,755.6437 L1232.9082,720.87085 L1240.5067,717.13116" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1136.7859,755.6437 L1233.8168,735.2686" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1269.3448,585.2462 L1349.4855,587.2892" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M492.71875,765.644 L522.5468,801.65607 L557.7205,833.35095 L597.5381,860.0133 L641.21747,881.04675 L687.91547,895.97943 L736.74677,904.4675 L786.80206,906.29535 L837.1654,901.3739 L886.93085,889.737 L899.5071,885.68054" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M492.71875,765.644 L522.3971,801.8689 L557.47,833.7992 L597.23553,860.7121 L640.9113,882.0045 L687.65344,897.1987 L736.5755,905.9452 L786.76697,908.0229 L837.3105,903.33777 L887.20264,891.9476" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1181.6123,433.87454 L1254.3223,393.46353" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1181.6123,433.87454 L1258.7958,412.2707" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1267.19,604.45764 L1347.2123,609.2655" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1267.19,604.45764 L1344.1819,631.1503" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1120.374,784.4293 L1218.6664,756.3709 L1226.5048,753.16437" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1120.374,784.4293 L1218.5793,770.79706" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M459.31552,728.9847 L483.6307,768.7488 L513.7933,805.00543 L549.21735,836.9557 L589.22064,863.9046 L633.04236,885.2698 L679.86096,900.5877 L728.8121,909.51733 L779.00586,911.8412 L829.5438,907.46454 L874.6892,897.7862" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M459.31552,728.9847 L483.47287,768.91223 L513.51654,805.35864 L548.8618,837.51874 L588.82715,864.6918 L632.6521,886.2899 L679.5152,901.8446 L728.5517,911.0099 L778.8714,913.56366 L829.5751,909.40656 L861.98175,903.1894" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M459.31552,728.9847 L483.3064,769.0846 L513.2247,805.731 L548.4869,838.1122 L588.41223,865.5211 L632.2404,887.3644 L679.14996,903.1681 L728.2759,912.58124 L778.7274,915.37683 L829.60455,911.4508 L849.09534,908.15076" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M459.31552,728.9847 L483.13055,769.26666 L512.91644,806.12427 L548.091,838.7385 L587.974,866.3961 L631.80536,888.4977 L678.76355,904.56366 L727.98315,914.23785 L778.5729,917.28815 L829.6319,913.6055 L836.04535,912.66437" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M459.31552,728.9847 L482.94452,769.45935 L512.59045,806.5401 L547.6723,839.40063 L587.51044,867.32074 L631.34503,889.6948 L678.3541,906.0375 L727.67206,915.98694 L778.40674,919.3058 L822.84735,916.7249" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M459.31552,728.9847 L482.74734,769.6635 L512.2451,806.9806 L547.2288,840.1016 L587.0194,868.2993 L630.857,890.9613 L677.9195,907.5961 L727.3407,917.8363 L778.2278,921.4389 L809.517,920.32745" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M459.31552,728.9847 L482.53806,769.8802 L511.87857,807.44794 L546.75824,840.8451 L586.4982,869.3366 L630.3388,892.30334 L677.4574,909.2474 L726.9872,919.79504 L778.0345,923.69775 L796.0704,923.4677" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M459.31552,728.9847 L482.31546,770.1107 L511.48892,807.9447 L546.25793,841.6349 L585.9442,870.4382 L629.7876,893.72797 L676.9651,910.9996 L726.6092,921.8731 L777.8253,926.09375 L782.52325,926.142" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M459.31552,728.9847 L482.07828,770.3563 L511.07382,808.47375 L545.72516,842.4756 L585.354,871.6101 L629.19995,895.243 L676.4395,912.86237 L726.20416,924.0817 L768.89197,928.3471" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1111.4329,798.3801 L1210.0502,788.1457" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1111.4329,798.3801 L1200.9272,805.18964" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1177.9303,417.71857 L1249.2021,374.82196" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M933.07776,1046.8821 L964.6831,1120.3743" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1102.0154,812.0141 L1191.2214,821.9085" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1102.0154,812.0141 L1180.9443,838.2824" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1102.0154,812.0141 L1170.1082,854.2918" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1102.0154,812.0141 L1158.726,869.91766" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1092.1329,825.3148 L1146.811,885.1413" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1092.1329,825.3148 L1134.3779,899.94464" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M444.8439,708.8249 L464.64835,751.55585 L490.84952,791.47296 L522.9369,827.6846 L560.2914,859.3992 L602.2038,885.93506 L647.89264,906.72705 L696.5225,921.3307 L747.22144,929.4242 L755.1927,930.08044" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M444.8439,708.8249 L464.39685,751.77875 L490.4018,791.9605 L522.34985,828.4691 L559.62274,860.50464 L601.5116,887.37775 L647.235,908.51605 L695.9571,923.46857 L741.4417,931.33984" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M444.8439,708.8249 L464.12845,752.01666 L489.92416,792.4806 L521.7237,829.3055 L558.90955,861.6825 L600.77295,888.91406 L646.5324,910.4205 L695.3516,925.7438 L727.65546,932.12384" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M444.8439,708.8249 L463.84137,752.2712 L489.4135,793.03644 L521.0544,830.1989 L558.1471,862.94 L599.983,890.5536 L645.78015,912.452 L694.70184,928.17004 L713.8504,932.4316" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M444.8439,708.8249 L463.53357,752.544 L488.86627,793.632 L520.3373,831.15546 L557.3302,864.2856 L599.13617,892.307 L644.97284,914.62384 L694.00275,930.76294 L700.0429,932.26263" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1081.7974,838.26654 L1121.4412,914.31" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1108.0164,928.22034" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1094.1193,941.65894" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1079.7667,954.6099" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1067.9106,947.6792 L1064.9757,967.05774" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1060.2937,938.7965 L1049.7639,978.9876" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1054.9417,932.55505 L1034.1493,990.3854" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1050.975,927.92926 L1018.15063,1001.23737" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1047.9176,924.36365 L1012.60284,994.4443 L1001.7869,1011.53064" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1045.4888,921.5313 L1008.44836,988.5401 L985.0777,1021.253" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1071.0209,850.85376 L1043.513,919.22705 L1005.061,983.7196 L968.0428,1030.3928" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1059.8163,863.0616 L1029.9647,930.44464 L989.30975,993.57153 L950.70264,1038.9392" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1059.8163,863.0616 L1028.3928,928.47797 L986.63495,989.4666 L935.496,1044.6361 L933.07776,1046.8821" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1048.197,874.87537 L1014.53436,939.16815 L970.69635,998.67914 L917.68353,1052.0507 L915.1893,1054.2118" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1048.197,874.87537 L1013.2675,937.4658 L968.5596,995.1349 L915.0492,1046.5791 L897.0585,1060.9197" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M1048.197,874.87537 L1012.1852,936.01154 L966.7319,992.10065 L912.79535,1041.8864 L878.7069,1066.9978" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M957.3941,737.6278 L997.26025,709.25616 L1032.9462,674.50616 L1063.5944,634.1625 L1088.4973,589.0877 L1107.0955,540.19977 L1111.7412,523.5228" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<path d="M957.3941,737.6278 L997.9363,709.675 L1034.3425,675.18695 L1065.7344,634.9581 L1091.3867,589.8601 L1110.7258,540.81915 L1111.8105,537.3312" fill="none" stroke="#5c6773" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"/>
|
||||||
|
<circle cx="921.7547282971575" cy="290.90813652708323" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="347.90792480567717" cy="845.4112092465493" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="881.9129356763076" cy="261.3732353437921" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="425.16189872148334" cy="253.48253660078885" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1094.1554662056706" cy="414.81325163023365" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="902.406876322498" cy="275.3676644363498" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="711.8404897830412" cy="532.4366285710628" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="631.8404897830412" cy="532.4366285710628" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="791.8404897830413" cy="532.4366285710628" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="589.2733788840048" cy="635.282646120909" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="693.8863003397058" cy="851.932555120108" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="684.0567813563324" cy="374.8673880891095" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1042.171637846957" cy="758.0055373745042" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="791.8404897830413" cy="393.8725639655526" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="862.1913091087866" cy="477.71340563895586" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="411.362892788932" cy="642.4966744762215" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="477.6493579524567" cy="584.920090460593" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1034.1890391076024" cy="769.2728858214065" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="507.3798316618427" cy="658.1181279281533" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="551.8404897830412" cy="532.4366285710628" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="403.7376272243992" cy="618.880981094351" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="922.7390503901448" cy="773.1061055178745" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1025.8222915103204" cy="780.2579920248451" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="903.4548056603154" cy="788.7254382192489" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1017.0813658663358" cy="790.9477648261628" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="669.1817769005393" cy="849.5804984711576" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="883.0181698886134" cy="802.8034198633264" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="589.2733788840047" cy="429.5906110212166" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="667.9933836319066" cy="1010.4297574779239" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="862.1913091087866" cy="587.1598515031699" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="678.8756359066231" cy="294.71132825806205" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="627.6874765367801" cy="757.1993264583558" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="644.7338079800612" cy="845.32110127208" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="559.2667902126491" cy="717.6966247909799" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="957.3910167712418" cy="848.1975170544483" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="757.8510414447385" cy="296.8882701904825" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="620.6894265400142" cy="839.1799800388096" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="978.5906417837386" cy="830.5043329732755" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="968.143549877637" cy="839.5339118318334" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="603.4724837884278" cy="318.29561130264824" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="831.8404897830414" cy="324.5905316627977" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="837.985951705408" cy="238.34935846847446" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="814.8170904309416" cy="229.45837899033322" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="349.35625342637707" cy="701.5672833706171" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="448.09921232069644" cy="833.1699632091278" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="343.7345058985991" cy="688.9549489954361" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="397.96532852803733" cy="594.7454012049807" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="338.55143722823095" cy="676.1560887799436" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="394.08071198340065" cy="570.2350890254652" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="258.1561373240347" cy="689.1864557921858" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="382.70247859809274" cy="183.05407377873104" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="597.193238319548" cy="831.1940681970352" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="391.8882826104074" cy="772.500339125907" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="253.0160706124372" cy="673.433622768662" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="392.1071401314696" cy="545.4974524572425" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="392.0564822655809" cy="520.6812665573748" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="383.7928567611624" cy="761.3137800491925" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="473.29712722673344" cy="506.03465553248355" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="291.32119814481473" cy="763.8745799966753" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="337.3221033427115" cy="832.6631733970189" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="393.9290430481007" cy="495.9357787878446" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="280.3581108613869" cy="742.729178796005" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="349.2481484797896" cy="105.67346939700023" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="356.58332013765664" cy="209.64824058978735" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="273.35672682605593" cy="727.7107682390749" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="246.61686690959095" cy="220.7204424008518" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="248.42279363196445" cy="657.5127609626115" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="168.424608785365" cy="667.7110913477425" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="394.4544522491518" cy="892.5284268371945" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="257.6533140021029" cy="204.84845821667568" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="434.9611370733883" cy="243.7537159435857" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="333.81322417615655" cy="663.1859553677307" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="329.525513360412" cy="650.0600055118919" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="437.87621148175344" cy="823.8874378251367" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="406.5996136997468" cy="273.92549231596286" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="427.9796990506011" cy="814.2575850039616" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="325.6934145270045" cy="636.793881654922" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="418.42146888812573" cy="804.2918808231925" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="376.08837147558745" cy="749.8544637457733" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="368.7840083343954" cy="738.1360464994441" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="361.88847208517916" cy="726.1724933694918" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="409.2129117178417" cy="794.0022016008321" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="322.321494460533" cy="623.4033932873249" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="861.5520514072348" cy="815.2553838120216" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="269.23102453515816" cy="189.3668670458257" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="281.33620113232075" cy="174.29411859306938" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="293.9544178258775" cy="159.6481753375806" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="307.0706372485378" cy="145.44649112671357" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="320.66922855374685" cy="131.70599037592416" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="345.65359462162013" cy="222.1026829382359" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1076.006085210422" cy="697.9159901453124" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="382.21459116776947" cy="881.3589221581516" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="397.71356067814907" cy="471.4098114237624" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="319.41377154186205" cy="609.9044981071368" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="232.37256006679877" cy="509.8422847297293" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="316.9737109593442" cy="596.3132830028463" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="315.0042205792872" cy="582.6459448823376" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="313.5076474805962" cy="568.9187713707315" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="312.48577515771495" cy="555.1481214001171" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="233.46801564340367" cy="571.9306279320008" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="403.3872746213959" cy="447.2508665186889" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="415.7043006641586" cy="263.54379212102765" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="410.91606249459227" cy="423.60423880993073" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="445.0903377823438" cy="234.36892416885016" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="394.95793540686213" cy="171.90168334102287" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="455.5374296884454" cy="225.33934531029217" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="420.2546452815984" cy="400.6121418988955" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="488.65974789153427" cy="200.48730043684813" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="500.25034138619105" cy="192.98175807809292" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="512.0930908592933" cy="185.88075050347456" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="524.1738830824917" cy="179.19274011791316" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="536.478321147391" cy="172.92569714977526" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="548.9917416226241" cy="167.08709015259836" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="561.6992320285322" cy="161.6838771046791" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="574.5856486086545" cy="156.72249711711567" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="587.6356343768277" cy="152.20886276019831" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="600.8336374183938" cy="148.14835301728877" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="614.1639294237145" cy="144.54580687458233" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="627.6106244318935" cy="141.40551755439765" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="232.38987803181595" cy="555.3955242626198" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="231.88311077484215" cy="538.8330600433692" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="235.11623877263924" cy="588.4186658559914" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="641.1576977623754" cy="138.73122739886236" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="400.3650015513517" cy="783.4008097420631" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="317.5015057676771" cy="806.1093632117836" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="308.2903502363053" cy="792.3352335534865" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="299.560113032465" cy="778.2513778161049" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="264.98066796433375" cy="869.9515166043952" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="370.3675514989908" cy="869.7736001879566" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="253.5972228318081" cy="854.3265294593336" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="311.9398213952019" cy="541.3504057143" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="242.759874860325" cy="838.317940036354" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="311.87043681647305" cy="527.5420673118099" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="431.34685964671405" cy="378.41285296175863" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="658.7705530000283" cy="928.9004753589471" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="939.8401313419565" cy="307.90118938827766" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="956.5543177149755" cy="326.24462480054865" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="466.2899627948407" cy="216.6757400876773" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="971.7967663327024" cy="345.82812318495763" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="334.7339860432623" cy="118.4430478996228" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="985.4758073156564" cy="366.5339070779395" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="997.5091733018292" cy="388.23744945882726" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="355.40998026879726" cy="713.9780615482662" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1014.4680678482407" cy="270.8710555412935" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1023.3159780147304" cy="281.4724474000619" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1007.8244942122814" cy="410.8082226700085" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="327.18260251846243" cy="819.5573519003804" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="420.0538420049711" cy="913.5672520827881" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="312.2777041084421" cy="513.7395618504692" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="199.73493179522757" cy="759.037373225598" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="192.21869193766906" cy="741.2264444240951" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="185.32169519634238" cy="723.1666968533596" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="179.05216085260264" cy="704.8796526558623" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="173.41756042900798" cy="686.3871048477591" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="313.1611379229809" cy="499.95933803687313" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="654.7890051118625" cy="136.52612341009302" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="668.4883017937825" cy="134.7928334521851" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="314.5196854553175" cy="486.21781802415876" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="682.2392620973928" cy="133.53342311953674" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="316.351727698684" cy="472.5313778414096" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="696.0254987434367" cy="132.74939327524152" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="318.65508137371734" cy="458.91632787803536" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="709.8305824131705" cy="132.4416782624814" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="321.4270015303143" cy="445.3888934463746" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="723.6380613275014" cy="132.61064479105215" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="737.4314808528732" cy="133.25609150034865" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="324.6641848188415" cy="431.96519544567644" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="751.194403110572" cy="134.3772491993298" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="328.36277342679756" cy="418.66123115052943" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="764.9104265660546" cy="135.9727817831785" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="332.5183596762432" cy="405.4928551465998" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="778.5632055749637" cy="138.04078782556212" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="792.1364698625473" cy="140.57880284459918" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="337.1259912765135" cy="392.47576043642516" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="805.614043913248" cy="143.58380223982834" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="342.1801772259556" cy="379.6254597377679" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="818.9798662473722" cy="147.05220489668235" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="347.6748943556605" cy="366.95726699681325" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="353.6035945073862" cy="354.48627913825226" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="832.21800856186" cy="150.97987745416992" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="845.3126947123408" cy="155.3621392306792" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="359.9592123371223" cy="342.22735807399096" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="858.2483195138684" cy="160.19376780203595" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="871.009467337912" cy="165.4690052251646" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="883.5809304834493" cy="171.1815648999363" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="366.7341737349939" cy="330.19511299192845" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="895.9477273002784" cy="177.32463906103283" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="373.92040485147015" cy="318.4038829459138" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="908.095120042925" cy="183.89090689088448" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="381.50934171912536" cy="306.86771976762134" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="920.0086324338986" cy="190.87254324403034" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="389.49194045848037" cy="295.6003713207188" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="397.858688055762" cy="284.61526511728084" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="931.6740669153435" cy="198.26122797249076" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="943.0775215685354" cy="206.0481558410438" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="985.804768084329" cy="240.98581931698888" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="244.3817802775971" cy="641.4428435659189" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1036.1589351936705" cy="178.57571526410715" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1016.359732493311" cy="434.11048342561395" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="954.2054066810636" cy="214.22404702059248" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="486.7801386073501" cy="108.46983938979378" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="965.0444609419463" cy="222.77915814711133" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="472.28054633025533" cy="116.49067836491156" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="975.5817672453861" cy="231.7032939329977" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="872.1561070112145" cy="80" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="444.1547653279655" cy="134.00927895389452" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1059.7994420195791" cy="201.79397016405886" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="444.1259957075887" cy="357.1398811342535" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="458.51519823630304" cy="336.92116457202235" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1023.0635562176021" cy="458.0040891876126" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="995.7012805154814" cy="250.61567213816403" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1048.1795994431604" cy="189.98078716514016" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1005.2595106779568" cy="260.58137631893294" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="474.42792887575195" cy="317.87830101549434" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="358.92745158387265" cy="857.7862673737394" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="358.738201888334" cy="967.084502468455" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="343.9462348046851" cy="954.6377857863412" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="329.5926942571077" cy="941.6879240064765" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="315.69468563112343" cy="928.2503497238677" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="289.5421344071752" cy="1013.3358893802666" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="302.2687714462546" cy="914.3410767497295" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="273.1949791559743" cy="998.4732322908991" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="257.3705660492487" cy="983.0551905737846" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="242.08775333914508" cy="967.1001382006054" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="227.3647538423826" cy="950.6270891087911" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="236.7548399851919" cy="1073.4482969814171" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="199.66768914631473" cy="916.2061196564433" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="289.33095161815623" cy="899.9766810275202" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="276.8966443912144" cy="885.1742808790787" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="186.72663106242277" cy="898.299219415689" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="306.3925505890121" cy="1027.6254497325615" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="213.21911323569475" cy="933.6556745422496" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="158.765969201353" cy="993.4231518917945" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="143.18404897830413" cy="974.0584717030445" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="80" cy="1023.1275653843755" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="892.8260761294964" cy="374.81614279960655" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1027.8956478004757" cy="482.3453409995245" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1030.8269464745867" cy="506.9878477088539" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1031.8398230648" cy="531.7834063807045" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="240.89784630131624" cy="625.2430214028328" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="923.4306381798913" cy="871.8914990640326" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="912.4654738455637" cy="968.49811021088" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="897.2947708535028" cy="975.1630786874279" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="911.5878887067893" cy="878.9925066386511" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="574.3865521592845" cy="821.4113939602303" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="461.0201559349549" cy="941.6910239394382" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="447.0437280163642" cy="932.7898163414135" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="433.38286340286254" cy="923.4115002837046" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="510.4325832290999" cy="283.77047651662895" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="839.1855499785249" cy="826.0064424889938" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="645.1177739911187" cy="926.8324693165636" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="530.3079707152523" cy="268.91064404410423" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="551.2751161309961" cy="255.63568785015573" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1157.5848777084411" cy="710.5150961998323" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1111.1952044083675" cy="509.7251357420083" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="675.4167794308239" cy="1091.250831436539" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1341.3000339131738" cy="416.7617293773066" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1030.9281860121218" cy="556.5828936076689" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1063.72176722896" cy="722.645899068135" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="651.5210330805012" cy="1008.6315079462094" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1097.987565039078" cy="428.07937548720315" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="686.2494987132094" cy="931.617165641777" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1101.3594851055495" cy="441.469863854801" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1039.8881228049202" cy="303.5594770929332" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1068.2709992972852" cy="350.89519559385946" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1028.0975180090545" cy="581.237162355547" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1056.9468058310886" cy="734.6781441501971" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="635.1205663655568" cy="1006.2657679776798" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="573.2079205967137" cy="244.02544508332403" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="595.9744776456382" cy="234.14974110972105" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="619.4378665248643" cy="226.0679695750627" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="656.1503741584521" cy="1089.6606508652562" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="643.4569756528656" cy="219.82873520429564" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1074.3247261397055" cy="363.30597377150855" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1189.2847343218252" cy="482.9695348946615" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="631.5445097035351" cy="924.2944542975263" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="791.8404897830412" cy="671.000693176573" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="934.1990659145938" cy="442.1223793035823" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1105.0258981923653" cy="605.9569292640899" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1102.2539780357681" cy="619.4843636957513" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="667.8873512811515" cy="215.4695614870616" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="494.7947712156227" cy="430.0102840519259" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="951.4760537270371" cy="519.2155660856552" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="816.0531803738172" cy="834.991937762343" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="618.0669356528344" cy="921.2894549022973" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1049.7605747146124" cy="746.4693741962118" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="618.8115282331912" cy="1003.3353568693633" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1023.3648430260378" cy="605.5979389511588" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1081.5008023401267" cy="685.2477974043577" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="792.2940633857712" cy="842.1578298062223" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="604.7011133187101" cy="917.8210522454433" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1016.7586239272864" cy="629.5187148176619" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="768.0510891395558" cy="847.461022102709" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="591.4629710042225" cy="913.8933796879555" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="743.4700577348231" cy="850.8696206293349" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="578.3682848537417" cy="909.5111179114464" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="942.7848098572613" cy="597.7414601339433" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1079.9464736674836" cy="375.91830814668975" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="701.0751523269292" cy="1012.3158920420487" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="491.76848659150716" cy="300.1258164867661" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1008.3185912917775" cy="652.8556275943711" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="692.5820662561082" cy="213.01666500701555" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1262.6175908969074" cy="431.2210917765261" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1047.5926080904949" cy="315.0187933963523" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1187.2928315776178" cy="466.51947843505275" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1095.318206139285" cy="646.2120259915962" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1054.896971231687" cy="326.73721064268136" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1190.6858513289403" cy="565.7100372629458" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="717.3926036561876" cy="212.48479777220706" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="909.0671654371" cy="669.1905623735267" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="684.0567813563324" cy="690.0058690530161" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1104.2672080242205" cy="454.96875903498875" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1106.7072686067381" cy="468.5599741392792" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="718.6988023867532" cy="852.3631256734906" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="565.432660052214" cy="904.6794893400896" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="742.1697499901475" cy="213.8771584947682" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1108.6767589867954" cy="482.2273122597883" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1036.1769549549601" cy="886.2810256720834" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="589.390483997109" cy="1078.8851587026986" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1070.077385058696" cy="710.3869780038734" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="602.6133544723529" cy="999.8437668475558" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1110.1733320854862" cy="495.9544857713937" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1099.016794747241" cy="632.9080616964493" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1085.1295423378515" cy="388.7171683621816" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1089.8677553899258" cy="401.6873017743949" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="766.7644925845468" cy="217.18537335348208" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1091.1626198898393" cy="659.3804019955261" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="853.9769504226364" cy="725.8202539296976" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="552.406530153328" cy="809.8907914823282" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="539.8126866226131" cy="365.08539630850373" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="860.3961593290408" cy="249.00901339261935" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="940.7549263612271" cy="756.0393582639083" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1111.4032754576403" cy="551.1336952916569" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1110.5198416431017" cy="564.9139191052521" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1109.161294110765" cy="578.6554391179669" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1107.3292518673986" cy="592.341879300716" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="956.4721802963211" cy="945.4200858627744" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="946.345856461455" cy="856.4848240590529" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1108.0162979923052" cy="928.2203182169812" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1268.2882147571975" cy="469.4610033571298" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1094.119308059785" cy="941.6589460183599" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1189.2520571972823" cy="582.218104032096" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1187.2493226786805" cy="598.666845276176" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1079.7667492352732" cy="954.609895850611" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1270.1303255540247" cy="488.70495498305047" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1240.5066663409762" cy="717.1311703518485" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1184.6800344697076" cy="615.0366587193992" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1064.9757257343538" cy="967.0577338220744" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1071.0046153369387" cy="214.00118626743944" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1023.7703536235642" cy="897.2650266494371" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1271.3071118030807" cy="508.00102247082094" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1281.2229543283227" cy="824.6755874186838" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1007.9766789019238" cy="801.329465021098" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1081.7817659784423" cy="226.58788790516127" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1181.5472544396766" cy="631.3080361457309" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1177.8547159817522" cy="647.461586647198" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1270.7967218908223" cy="844.1543383468671" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1010.9920195786233" cy="907.8142547303253" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="477.3351231046272" cy="208.3884330830728" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1049.763864299251" cy="978.9876256090017" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="997.8571810023014" cy="917.916138200286" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1251.989286601386" cy="680.2178090227508" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1173.6068195637956" cy="663.4780597324003" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1271.8171711059545" cy="527.3262103321617" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1259.704370748443" cy="863.2616093895135" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="552.6715122281704" cy="899.4042519169611" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1168.8086274842444" cy="679.3383682676852" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1034.1492931927091" cy="990.3853541339013" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1018.150620594198" cy="1001.2373365082971" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1163.4658578392755" cy="695.0236112236452" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="984.3814909299505" cy="927.5586384538842" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="970.5810085960914" cy="936.730264341345" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1271.6598956153352" cy="546.657488375494" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1247.9591198656628" cy="881.9746300506347" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="407.07254826937975" cy="903.2688033152797" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1191.2213856277676" cy="821.9084867872072" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1235.5749662799192" cy="900.2710996698727" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="998.5190808445989" cy="811.390720541337" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="942.071819787951" cy="953.6177471936459" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="684.5179876083287" cy="1011.6583735648735" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1191.8404637664516" cy="532.5946663046716" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="988.7198424926944" cy="821.1195411985398" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="874.6892379434585" cy="897.7861669895274" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1191.5489963940922" cy="549.1623179438614" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1210.0501462601628" cy="788.1457175627304" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1001.7869124241931" cy="1011.5306402197145" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="531.3853627402881" cy="796.7015470221954" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="511.4494736944307" cy="781.9229822479207" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1270.8354727593976" cy="565.9718191514532" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1260.897851862543" cy="642.60020306962" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="861.9817475375504" cy="903.1893800374464" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1331.8196595478996" cy="373.6102168579067" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1121.4411577273077" cy="914.310027513187" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1246.5665980753965" cy="698.7736031453061" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1254.3222633272924" cy="393.46351832205124" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="935.0212316745481" cy="864.3859567052775" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1226.50471099601" cy="753.1643780799275" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="927.3970882527345" cy="961.3134790260336" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="899.5070964835912" cy="885.6805170242123" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1151.1726955669017" cy="725.7943617008445" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="783.4840444234478" cy="761.4938290221446" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="705.2274476146824" cy="772.3455022570511" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1031.7926969556752" cy="292.37291801621865" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1347.2122869515724" cy="609.2655252365166" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1180.944291105335" cy="838.2823798656884" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1086.554988289569" cy="672.3974967057002" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1233.816713133293" cy="735.2686335826195" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="985.0776696229605" cy="1021.2529985435992" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="964.6831033934598" cy="1120.374221128855" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1144.2369529332914" cy="740.8431991372386" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1136.7859152631777" cy="755.6436745250511" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="968.0428049109149" cy="1030.392825161805" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="849.0953309574276" cy="908.15076002501" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="755.1926777722999" cy="930.0804236899404" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="836.045345189255" cy="912.6643943819272" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="972.556453381668" cy="717.9822734265525" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1092.1180506100063" cy="239.5390752670211" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1102.001151285901" cy="252.83931417828995" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1111.419290128281" cy="266.4727544929113" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="822.8473421476888" cy="916.7249041248368" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1190.7076577371417" cy="499.4785422246266" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="998.0955044688723" cy="675.4683263419229" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="540.1000490826325" cy="893.691692242189" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="915.1892816703378" cy="1054.2117418506987" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="527.733252265804" cy="887.5486180810926" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1269.3448850184386" cy="585.2461854070544" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="950.7026190582562" cy="1038.9392279702272" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="492.7187597982217" cy="765.6439771869668" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1258.795835623061" cy="412.2707030471257" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1128.8284620989607" cy="770.1781498580649" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="515.5858595231574" cy="880.982350251241" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="897.0584520503695" cy="1060.9196523793387" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1349.48553153684" cy="587.2891647639874" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1256.7682695985516" cy="661.4859012747033" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="475.3058697689721" cy="747.9624356912309" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1181.6122584896325" cy="433.8745398843826" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1267.1899087540382" cy="604.4576175157868" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1265.7829746908483" cy="450.2921009738816" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1120.3740764879547" cy="784.4293041273554" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1200.9271928773499" cy="805.1896246248913" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="459.31552677619464" cy="728.9846966317556" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="809.5170501423679" cy="920.3274502675433" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1170.108156717809" cy="854.2917907825222" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="796.0703551341891" cy="923.4677395877279" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="503.6723471321838" cy="874.0007138980951" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1191.5599060988436" cy="516.026826328928" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="986.1508463056175" cy="697.2208156293395" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="492.0069126507389" cy="866.6120291696348" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1146.8110736809545" cy="885.141308357862" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="878.7068946845443" cy="1066.9977597013085" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1249.2022052502468" cy="374.8219504787543" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1111.4328336812625" cy="798.3801539630633" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1177.9303577921544" cy="417.71856138351154" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="480.60345799754714" cy="858.8251013010819" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="469.475572885019" cy="850.6492101215332" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="933.0777766921574" cy="1046.882022059131" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1218.5793737802562" cy="770.7970771468638" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1102.015389126907" cy="812.0140738737985" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1092.1329657715332" cy="825.3148160595513" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1350.9988824757238" cy="565.2474354874817" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1061.7925074809034" cy="338.70076377263376" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="444.84389862414304" cy="708.8248943642502" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="741.4417174686896" cy="931.3398340225888" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1158.725896091319" cy="869.9176408201752" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="727.6554808226462" cy="932.1238638668841" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1134.3778886057455" cy="899.9446510632839" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1081.7973406858105" cy="838.2665297745032" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1344.1818577884524" cy="631.150327260988" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="782.5232818037068" cy="926.1420297432633" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="713.8503971529117" cy="932.4315788796443" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="458.6365186241362" cy="842.0940989950143" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1071.0208310294747" cy="850.8537802166608" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="791.028915763497" cy="222.38954635492922" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1059.8162793727356" cy="863.0615669217997" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="431.97801938731857" cy="687.6042723116418" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1184.734323292662" cy="450.14797668895403" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1048.1970383915454" cy="874.8753416398029" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="957.3940839756449" cy="737.6278378834847" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1111.7411581708807" cy="523.5228514278256" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1111.8105427496096" cy="537.3311898303157" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="887.2026584186913" cy="891.9475599923505" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="420.7952659773085" cy="665.4504537918679" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="1264.373112092135" cy="623.5832208509511" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="768.89197445422" cy="928.3471337320326" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
<circle cx="700.0429182385811" cy="932.2626123510735" fill="#4f81bd" r="6" stroke="#355c8a" stroke-width="1"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 154 KiB |
38
radial_sugiyama/src/bin/radial_sugiyama_go_bridge.rs
Normal file
38
radial_sugiyama/src/bin/radial_sugiyama_go_bridge.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use radial_sugiyama::{
|
||||||
|
process_go_bridge_request_with_options, BridgeRuntimeConfig, EnvConfig, GoBridgeRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
if let Err(error) = run() {
|
||||||
|
eprintln!("{error}");
|
||||||
|
return ExitCode::FAILURE;
|
||||||
|
}
|
||||||
|
ExitCode::SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let env_config = EnvConfig::from_env()?;
|
||||||
|
let mut input = String::new();
|
||||||
|
io::stdin().read_to_string(&mut input)?;
|
||||||
|
|
||||||
|
let request: GoBridgeRequest = serde_json::from_str(&input)?;
|
||||||
|
let response = process_go_bridge_request_with_options(
|
||||||
|
request,
|
||||||
|
BridgeRuntimeConfig {
|
||||||
|
layout: env_config.layout,
|
||||||
|
svg: env_config.svg,
|
||||||
|
svg_output_path: Some(env_config.output_path()),
|
||||||
|
canonicalize_input: true,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let stdout = io::stdout();
|
||||||
|
let mut handle = stdout.lock();
|
||||||
|
serde_json::to_writer(&mut handle, &response)?;
|
||||||
|
handle.write_all(b"\n")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
784
radial_sugiyama/src/bridge.rs
Normal file
784
radial_sugiyama/src/bridge.rs
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::fs::create_dir_all;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
layout_radial_hierarchy_with_artifacts, write_svg_path_with_options, Edge, EdgeRouteKind,
|
||||||
|
Graph, LayoutArtifacts, LayoutConfig, LayoutError, Node, SvgConfig, SvgExportError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BridgeError {
|
||||||
|
DuplicateNodeId {
|
||||||
|
node_id: u32,
|
||||||
|
},
|
||||||
|
DuplicateEdgeIndex {
|
||||||
|
edge_index: usize,
|
||||||
|
},
|
||||||
|
MissingNodeRef {
|
||||||
|
edge_index: usize,
|
||||||
|
node_id: u32,
|
||||||
|
},
|
||||||
|
RootNotFound {
|
||||||
|
root_iri: String,
|
||||||
|
},
|
||||||
|
NoDescendants {
|
||||||
|
root_iri: String,
|
||||||
|
},
|
||||||
|
CreateOutputDir {
|
||||||
|
path: PathBuf,
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
SvgExport(SvgExportError),
|
||||||
|
Layout(LayoutError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for BridgeError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
BridgeError::DuplicateNodeId { node_id } => {
|
||||||
|
write!(f, "bridge request contains duplicate node_id {node_id}")
|
||||||
|
}
|
||||||
|
BridgeError::DuplicateEdgeIndex { edge_index } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"bridge request contains duplicate edge_index {edge_index}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeError::MissingNodeRef {
|
||||||
|
edge_index,
|
||||||
|
node_id,
|
||||||
|
} => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"bridge request edge {edge_index} references unknown node_id {node_id}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeError::RootNotFound { root_iri } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"root class IRI {root_iri} was not found in the bridge graph"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeError::NoDescendants { root_iri } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"root class IRI {root_iri} has no subclass descendants in the bridge graph"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BridgeError::CreateOutputDir { path, source } => write!(
|
||||||
|
f,
|
||||||
|
"failed to create SVG output directory {}: {source}",
|
||||||
|
path.display()
|
||||||
|
),
|
||||||
|
BridgeError::SvgExport(error) => Display::fmt(error, f),
|
||||||
|
BridgeError::Layout(error) => Display::fmt(error, f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for BridgeError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
BridgeError::CreateOutputDir { source, .. } => Some(source),
|
||||||
|
BridgeError::SvgExport(error) => Some(error),
|
||||||
|
BridgeError::Layout(error) => Some(error),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LayoutError> for BridgeError {
|
||||||
|
fn from(value: LayoutError) -> Self {
|
||||||
|
Self::Layout(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SvgExportError> for BridgeError {
|
||||||
|
fn from(value: SvgExportError) -> Self {
|
||||||
|
Self::SvgExport(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct BridgeRuntimeConfig {
|
||||||
|
pub layout: LayoutConfig,
|
||||||
|
pub svg: SvgConfig,
|
||||||
|
pub svg_output_path: Option<PathBuf>,
|
||||||
|
pub canonicalize_input: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BridgeRuntimeConfig {
|
||||||
|
pub fn json_only(layout: LayoutConfig) -> Self {
|
||||||
|
Self {
|
||||||
|
layout,
|
||||||
|
svg: SvgConfig::default(),
|
||||||
|
svg_output_path: None,
|
||||||
|
canonicalize_input: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct GoBridgeRequest {
|
||||||
|
pub root_iri: String,
|
||||||
|
pub nodes: Vec<GoBridgeNode>,
|
||||||
|
pub edges: Vec<GoBridgeEdge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct GoBridgeNode {
|
||||||
|
pub node_id: u32,
|
||||||
|
pub iri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct GoBridgeEdge {
|
||||||
|
pub edge_index: usize,
|
||||||
|
pub parent_id: u32,
|
||||||
|
pub child_id: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub predicate_iri: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct GoBridgeResponse {
|
||||||
|
pub nodes: Vec<GoBridgeRoutedNode>,
|
||||||
|
pub route_segments: Vec<GoBridgeRouteSegment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct GoBridgeRoutedNode {
|
||||||
|
pub node_id: u32,
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub level: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct GoBridgeRouteSegment {
|
||||||
|
pub edge_index: usize,
|
||||||
|
pub kind: String,
|
||||||
|
pub points: Vec<GoBridgePoint>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct GoBridgePoint {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BridgeGraph {
|
||||||
|
root_iri: String,
|
||||||
|
graph: Graph,
|
||||||
|
node_ids: Vec<u32>,
|
||||||
|
edge_indices: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_go_bridge_request(
|
||||||
|
request: GoBridgeRequest,
|
||||||
|
config: LayoutConfig,
|
||||||
|
) -> Result<GoBridgeResponse, BridgeError> {
|
||||||
|
process_go_bridge_request_with_options(request, BridgeRuntimeConfig::json_only(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_go_bridge_request_with_options(
|
||||||
|
request: GoBridgeRequest,
|
||||||
|
config: BridgeRuntimeConfig,
|
||||||
|
) -> Result<GoBridgeResponse, BridgeError> {
|
||||||
|
let bridge_graph = build_bridge_graph(request)?;
|
||||||
|
let mut filtered = filter_bridge_graph_to_descendants(bridge_graph)?;
|
||||||
|
if config.canonicalize_input {
|
||||||
|
filtered = canonicalize_bridge_graph(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
let artifacts = layout_radial_hierarchy_with_artifacts(&mut filtered.graph, config.layout)?;
|
||||||
|
write_debug_svg_if_configured(&filtered.graph, &artifacts, &config)?;
|
||||||
|
|
||||||
|
Ok(build_bridge_response(&filtered, &artifacts))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_bridge_graph(request: GoBridgeRequest) -> Result<BridgeGraph, BridgeError> {
|
||||||
|
let mut node_id_to_index = HashMap::with_capacity(request.nodes.len());
|
||||||
|
let mut nodes = Vec::with_capacity(request.nodes.len());
|
||||||
|
let mut node_ids = Vec::with_capacity(request.nodes.len());
|
||||||
|
|
||||||
|
for node in request.nodes {
|
||||||
|
if node_id_to_index.insert(node.node_id, nodes.len()).is_some() {
|
||||||
|
return Err(BridgeError::DuplicateNodeId {
|
||||||
|
node_id: node.node_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
nodes.push(Node {
|
||||||
|
label: Some(node.iri),
|
||||||
|
..Node::default()
|
||||||
|
});
|
||||||
|
node_ids.push(node.node_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen_edge_indices = HashSet::with_capacity(request.edges.len());
|
||||||
|
let mut edges = Vec::with_capacity(request.edges.len());
|
||||||
|
let mut edge_indices = Vec::with_capacity(request.edges.len());
|
||||||
|
for edge in request.edges {
|
||||||
|
if !seen_edge_indices.insert(edge.edge_index) {
|
||||||
|
return Err(BridgeError::DuplicateEdgeIndex {
|
||||||
|
edge_index: edge.edge_index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(&source) = node_id_to_index.get(&edge.parent_id) else {
|
||||||
|
return Err(BridgeError::MissingNodeRef {
|
||||||
|
edge_index: edge.edge_index,
|
||||||
|
node_id: edge.parent_id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let Some(&target) = node_id_to_index.get(&edge.child_id) else {
|
||||||
|
return Err(BridgeError::MissingNodeRef {
|
||||||
|
edge_index: edge.edge_index,
|
||||||
|
node_id: edge.child_id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
edges.push(Edge::new(source, target));
|
||||||
|
edge_indices.push(edge.edge_index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(BridgeGraph {
|
||||||
|
root_iri: request.root_iri,
|
||||||
|
graph: Graph::new(nodes, edges),
|
||||||
|
node_ids,
|
||||||
|
edge_indices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_bridge_graph_to_descendants(
|
||||||
|
bridge_graph: BridgeGraph,
|
||||||
|
) -> Result<BridgeGraph, BridgeError> {
|
||||||
|
let BridgeGraph {
|
||||||
|
root_iri,
|
||||||
|
graph,
|
||||||
|
node_ids,
|
||||||
|
edge_indices,
|
||||||
|
} = bridge_graph;
|
||||||
|
|
||||||
|
let Some(root_index) = graph
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.position(|node| node.label.as_deref() == Some(root_iri.as_str()))
|
||||||
|
else {
|
||||||
|
return Err(BridgeError::RootNotFound { root_iri });
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut adjacency = vec![Vec::new(); graph.nodes.len()];
|
||||||
|
for edge in &graph.edges {
|
||||||
|
adjacency[edge.source].push(edge.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visited = HashSet::from([root_index]);
|
||||||
|
let mut queue = VecDeque::from([root_index]);
|
||||||
|
while let Some(node) = queue.pop_front() {
|
||||||
|
for &child in &adjacency[node] {
|
||||||
|
if visited.insert(child) {
|
||||||
|
queue.push_back(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if visited.len() <= 1 {
|
||||||
|
return Err(BridgeError::NoDescendants { root_iri });
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reindex = HashMap::with_capacity(visited.len());
|
||||||
|
let mut filtered_nodes = Vec::with_capacity(visited.len());
|
||||||
|
let mut filtered_node_ids = Vec::with_capacity(visited.len());
|
||||||
|
for (old_index, node) in graph.nodes.iter().enumerate() {
|
||||||
|
if !visited.contains(&old_index) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let new_index = filtered_nodes.len();
|
||||||
|
reindex.insert(old_index, new_index);
|
||||||
|
filtered_nodes.push(node.clone());
|
||||||
|
filtered_node_ids.push(node_ids[old_index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut filtered_edges = Vec::new();
|
||||||
|
let mut filtered_edge_indices = Vec::new();
|
||||||
|
for (old_edge_index, edge) in graph.edges.iter().enumerate() {
|
||||||
|
if !visited.contains(&edge.source) || !visited.contains(&edge.target) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filtered_edges.push(Edge::new(reindex[&edge.source], reindex[&edge.target]));
|
||||||
|
filtered_edge_indices.push(edge_indices[old_edge_index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(BridgeGraph {
|
||||||
|
root_iri,
|
||||||
|
graph: Graph::new(filtered_nodes, filtered_edges),
|
||||||
|
node_ids: filtered_node_ids,
|
||||||
|
edge_indices: filtered_edge_indices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonicalize_bridge_graph(bridge_graph: BridgeGraph) -> BridgeGraph {
|
||||||
|
let BridgeGraph {
|
||||||
|
root_iri,
|
||||||
|
graph,
|
||||||
|
node_ids,
|
||||||
|
edge_indices,
|
||||||
|
} = bridge_graph;
|
||||||
|
|
||||||
|
let mut node_order = (0..graph.nodes.len()).collect::<Vec<_>>();
|
||||||
|
node_order.sort_by(|left, right| {
|
||||||
|
graph.nodes[*left]
|
||||||
|
.label
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.cmp(graph.nodes[*right].label.as_deref().unwrap_or(""))
|
||||||
|
.then(node_ids[*left].cmp(&node_ids[*right]))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut reindex = vec![0usize; graph.nodes.len()];
|
||||||
|
let mut nodes = Vec::with_capacity(graph.nodes.len());
|
||||||
|
let mut canonical_node_ids = Vec::with_capacity(node_ids.len());
|
||||||
|
for (new_index, old_index) in node_order.into_iter().enumerate() {
|
||||||
|
reindex[old_index] = new_index;
|
||||||
|
nodes.push(graph.nodes[old_index].clone());
|
||||||
|
canonical_node_ids.push(node_ids[old_index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut edge_order = (0..graph.edges.len()).collect::<Vec<_>>();
|
||||||
|
edge_order.sort_by(|left, right| {
|
||||||
|
let left_edge = graph.edges[*left];
|
||||||
|
let right_edge = graph.edges[*right];
|
||||||
|
|
||||||
|
graph.nodes[left_edge.source]
|
||||||
|
.label
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.cmp(
|
||||||
|
graph.nodes[right_edge.source]
|
||||||
|
.label
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(""),
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
graph.nodes[left_edge.target]
|
||||||
|
.label
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("")
|
||||||
|
.cmp(
|
||||||
|
graph.nodes[right_edge.target]
|
||||||
|
.label
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or(""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(edge_indices[*left].cmp(&edge_indices[*right]))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut edges = Vec::with_capacity(graph.edges.len());
|
||||||
|
let mut canonical_edge_indices = Vec::with_capacity(edge_indices.len());
|
||||||
|
for old_edge_index in edge_order {
|
||||||
|
let edge = graph.edges[old_edge_index];
|
||||||
|
edges.push(Edge::new(reindex[edge.source], reindex[edge.target]));
|
||||||
|
canonical_edge_indices.push(edge_indices[old_edge_index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
BridgeGraph {
|
||||||
|
root_iri,
|
||||||
|
graph: Graph::new(nodes, edges),
|
||||||
|
node_ids: canonical_node_ids,
|
||||||
|
edge_indices: canonical_edge_indices,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_debug_svg_if_configured(
|
||||||
|
graph: &Graph,
|
||||||
|
artifacts: &LayoutArtifacts,
|
||||||
|
config: &BridgeRuntimeConfig,
|
||||||
|
) -> Result<(), BridgeError> {
|
||||||
|
let Some(path) = &config.svg_output_path else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
create_dir_all(parent).map_err(|source| BridgeError::CreateOutputDir {
|
||||||
|
path: parent.to_path_buf(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
write_svg_path_with_options(path, graph, artifacts, config.layout, config.svg)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_bridge_response(graph: &BridgeGraph, artifacts: &LayoutArtifacts) -> GoBridgeResponse {
|
||||||
|
let nodes = graph
|
||||||
|
.graph
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(node_index, node)| GoBridgeRoutedNode {
|
||||||
|
node_id: graph.node_ids[node_index],
|
||||||
|
x: node.x,
|
||||||
|
y: node.y,
|
||||||
|
level: artifacts.node_levels[node_index],
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let route_segments = artifacts
|
||||||
|
.edge_routes
|
||||||
|
.iter()
|
||||||
|
.map(|route| GoBridgeRouteSegment {
|
||||||
|
edge_index: graph.edge_indices[route.original_edge_index],
|
||||||
|
kind: route_kind_name(route.kind).to_owned(),
|
||||||
|
points: route
|
||||||
|
.points
|
||||||
|
.iter()
|
||||||
|
.map(|point| GoBridgePoint {
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
GoBridgeResponse {
|
||||||
|
nodes,
|
||||||
|
route_segments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn route_kind_name(kind: EdgeRouteKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
EdgeRouteKind::Straight => "straight",
|
||||||
|
EdgeRouteKind::Spiral => "spiral",
|
||||||
|
EdgeRouteKind::IntraLevel => "intra_level",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{filter_graph_to_descendants, RingDistribution};
|
||||||
|
|
||||||
|
fn node(node_id: u32, iri: &str) -> GoBridgeNode {
|
||||||
|
GoBridgeNode {
|
||||||
|
node_id,
|
||||||
|
iri: iri.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edge(edge_index: usize, parent_id: u32, child_id: u32) -> GoBridgeEdge {
|
||||||
|
GoBridgeEdge {
|
||||||
|
edge_index,
|
||||||
|
parent_id,
|
||||||
|
child_id,
|
||||||
|
predicate_iri: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime_config() -> BridgeRuntimeConfig {
|
||||||
|
BridgeRuntimeConfig {
|
||||||
|
layout: LayoutConfig {
|
||||||
|
ring_distribution: RingDistribution::Adaptive,
|
||||||
|
..LayoutConfig::default()
|
||||||
|
},
|
||||||
|
svg: SvgConfig {
|
||||||
|
shortest_edges: false,
|
||||||
|
show_labels: false,
|
||||||
|
},
|
||||||
|
svg_output_path: None,
|
||||||
|
canonicalize_input: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sorted_nodes(mut nodes: Vec<GoBridgeRoutedNode>) -> Vec<(u32, usize, i64, i64)> {
|
||||||
|
nodes.sort_by_key(|node| node.node_id);
|
||||||
|
nodes
|
||||||
|
.into_iter()
|
||||||
|
.map(|node| {
|
||||||
|
(
|
||||||
|
node.node_id,
|
||||||
|
node.level,
|
||||||
|
(node.x * 1_000_000.0).round() as i64,
|
||||||
|
(node.y * 1_000_000.0).round() as i64,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sorted_segments(
|
||||||
|
mut segments: Vec<GoBridgeRouteSegment>,
|
||||||
|
) -> Vec<(usize, String, Vec<(i64, i64)>)> {
|
||||||
|
segments.sort_by(|left, right| {
|
||||||
|
left.edge_index
|
||||||
|
.cmp(&right.edge_index)
|
||||||
|
.then(left.kind.cmp(&right.kind))
|
||||||
|
.then(left.points.len().cmp(&right.points.len()))
|
||||||
|
});
|
||||||
|
segments
|
||||||
|
.into_iter()
|
||||||
|
.map(|segment| {
|
||||||
|
(
|
||||||
|
segment.edge_index,
|
||||||
|
segment.kind,
|
||||||
|
segment
|
||||||
|
.points
|
||||||
|
.into_iter()
|
||||||
|
.map(|point| {
|
||||||
|
(
|
||||||
|
(point.x * 1_000_000.0).round() as i64,
|
||||||
|
(point.y * 1_000_000.0).round() as i64,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filters_to_root_descendants_and_preserves_node_ids() {
|
||||||
|
let response = process_go_bridge_request_with_options(
|
||||||
|
GoBridgeRequest {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
nodes: vec![
|
||||||
|
node(10, "root"),
|
||||||
|
node(11, "child"),
|
||||||
|
node(12, "leaf"),
|
||||||
|
node(13, "other"),
|
||||||
|
],
|
||||||
|
edges: vec![edge(0, 10, 11), edge(1, 11, 12), edge(2, 13, 12)],
|
||||||
|
},
|
||||||
|
runtime_config(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut kept_ids = response
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.map(|node| node.node_id)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
kept_ids.sort();
|
||||||
|
assert_eq!(kept_ids, vec![10, 11, 12]);
|
||||||
|
assert!(response
|
||||||
|
.route_segments
|
||||||
|
.iter()
|
||||||
|
.all(|segment| segment.edge_index == 0 || segment.edge_index == 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_multiple_route_segments_for_long_edges_with_dummies() {
|
||||||
|
let response = process_go_bridge_request_with_options(
|
||||||
|
GoBridgeRequest {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
nodes: vec![node(1, "root"), node(2, "child"), node(3, "leaf")],
|
||||||
|
edges: vec![edge(10, 1, 2), edge(11, 2, 3), edge(12, 1, 3)],
|
||||||
|
},
|
||||||
|
runtime_config(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let long_edge_routes = response
|
||||||
|
.route_segments
|
||||||
|
.iter()
|
||||||
|
.filter(|segment| segment.edge_index == 12)
|
||||||
|
.count();
|
||||||
|
assert!(long_edge_routes >= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_error_when_root_is_missing() {
|
||||||
|
let error = process_go_bridge_request_with_options(
|
||||||
|
GoBridgeRequest {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
nodes: vec![node(1, "other")],
|
||||||
|
edges: vec![],
|
||||||
|
},
|
||||||
|
runtime_config(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
BridgeError::RootNotFound { root_iri } if root_iri == "root"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_error_when_root_has_no_descendants() {
|
||||||
|
let error = process_go_bridge_request_with_options(
|
||||||
|
GoBridgeRequest {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
nodes: vec![node(1, "root"), node(2, "other")],
|
||||||
|
edges: vec![edge(0, 2, 1)],
|
||||||
|
},
|
||||||
|
runtime_config(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
BridgeError::NoDescendants { root_iri } if root_iri == "root"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bridge_matches_direct_layout_for_same_graph_and_config() {
|
||||||
|
let request = GoBridgeRequest {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
nodes: vec![
|
||||||
|
node(5, "leaf"),
|
||||||
|
node(1, "root"),
|
||||||
|
node(4, "sibling"),
|
||||||
|
node(2, "child"),
|
||||||
|
node(3, "grandchild"),
|
||||||
|
],
|
||||||
|
edges: vec![
|
||||||
|
edge(12, 2, 3),
|
||||||
|
edge(10, 1, 2),
|
||||||
|
edge(11, 1, 4),
|
||||||
|
edge(13, 1, 5),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let config = runtime_config();
|
||||||
|
|
||||||
|
let response =
|
||||||
|
process_go_bridge_request_with_options(request.clone(), config.clone()).unwrap();
|
||||||
|
|
||||||
|
let node_index_by_id = request
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, node)| (node.node_id, index))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let direct_edges = request
|
||||||
|
.edges
|
||||||
|
.iter()
|
||||||
|
.map(|edge| {
|
||||||
|
Edge::new(
|
||||||
|
*node_index_by_id.get(&edge.parent_id).unwrap(),
|
||||||
|
*node_index_by_id.get(&edge.child_id).unwrap(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let direct_nodes = request
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.map(|node| Node {
|
||||||
|
label: Some(node.iri.clone()),
|
||||||
|
..Node::default()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let direct_graph = Graph::new(direct_nodes, direct_edges);
|
||||||
|
let filtered_direct =
|
||||||
|
filter_graph_to_descendants(&direct_graph, &request.root_iri).unwrap();
|
||||||
|
|
||||||
|
let mut filtered =
|
||||||
|
filter_bridge_graph_to_descendants(build_bridge_graph(request).unwrap()).unwrap();
|
||||||
|
filtered = canonicalize_bridge_graph(filtered);
|
||||||
|
let mut direct_expected_iris = filtered_direct
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|node| node.label.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut actual_iris = filtered
|
||||||
|
.graph
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|node| node.label.clone())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
direct_expected_iris.sort();
|
||||||
|
actual_iris.sort();
|
||||||
|
assert_eq!(actual_iris, direct_expected_iris);
|
||||||
|
|
||||||
|
let artifacts =
|
||||||
|
layout_radial_hierarchy_with_artifacts(&mut filtered.graph, config.layout).unwrap();
|
||||||
|
let expected = build_bridge_response(&filtered, &artifacts);
|
||||||
|
|
||||||
|
assert_eq!(sorted_nodes(response.nodes), sorted_nodes(expected.nodes));
|
||||||
|
assert_eq!(
|
||||||
|
sorted_segments(response.route_segments),
|
||||||
|
sorted_segments(expected.route_segments)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn canonicalization_makes_bridge_positions_independent_of_input_order() {
|
||||||
|
let config = runtime_config();
|
||||||
|
let request_a = GoBridgeRequest {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
nodes: vec![
|
||||||
|
node(1, "root"),
|
||||||
|
node(2, "child"),
|
||||||
|
node(3, "leaf"),
|
||||||
|
node(4, "sibling"),
|
||||||
|
],
|
||||||
|
edges: vec![edge(0, 1, 2), edge(1, 2, 3), edge(2, 1, 4)],
|
||||||
|
};
|
||||||
|
let request_b = GoBridgeRequest {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
nodes: vec![
|
||||||
|
node(4, "sibling"),
|
||||||
|
node(3, "leaf"),
|
||||||
|
node(2, "child"),
|
||||||
|
node(1, "root"),
|
||||||
|
],
|
||||||
|
edges: vec![edge(2, 1, 4), edge(1, 2, 3), edge(0, 1, 2)],
|
||||||
|
};
|
||||||
|
|
||||||
|
let response_a = process_go_bridge_request_with_options(request_a, config.clone()).unwrap();
|
||||||
|
let response_b = process_go_bridge_request_with_options(request_b, config).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
sorted_nodes(response_a.nodes),
|
||||||
|
sorted_nodes(response_b.nodes)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sorted_segments(response_a.route_segments),
|
||||||
|
sorted_segments(response_b.route_segments)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_debug_svg_to_configured_output_path() {
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"radial_sugiyama_bridge_svg_{}_{}",
|
||||||
|
std::process::id(),
|
||||||
|
unique
|
||||||
|
));
|
||||||
|
let path = dir.join("layout.svg");
|
||||||
|
let mut config = runtime_config();
|
||||||
|
config.svg_output_path = Some(path.clone());
|
||||||
|
|
||||||
|
let response = process_go_bridge_request_with_options(
|
||||||
|
GoBridgeRequest {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
nodes: vec![node(1, "root"), node(2, "child"), node(3, "leaf")],
|
||||||
|
edges: vec![edge(0, 1, 2), edge(1, 2, 3)],
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.nodes.len(), 3);
|
||||||
|
let svg = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(svg.contains("<svg"));
|
||||||
|
assert!(svg.contains("<path"));
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
324
radial_sugiyama/src/env_config.rs
Normal file
324
radial_sugiyama/src/env_config.rs
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use dotenvy::dotenv;
|
||||||
|
|
||||||
|
use crate::model::{LayoutConfig, RingDistribution, SvgConfig};
|
||||||
|
|
||||||
|
const INPUT_DIR_KEY: &str = "RADIAL_INPUT_DIR";
|
||||||
|
const INPUT_FILE_KEY: &str = "RADIAL_INPUT_FILE";
|
||||||
|
const ROOT_CLASS_IRI_KEY: &str = "RADIAL_ROOT_CLASS_IRI";
|
||||||
|
const OUTPUT_DIR_KEY: &str = "RADIAL_OUTPUT_DIR";
|
||||||
|
const OUTPUT_FILE_KEY: &str = "RADIAL_OUTPUT_FILE";
|
||||||
|
const SVG_SHORTEST_EDGES_KEY: &str = "RADIAL_SVG_SHORTEST_EDGES";
|
||||||
|
const SVG_SHOW_LABELS_KEY: &str = "RADIAL_SVG_SHOW_LABELS";
|
||||||
|
const MIN_RADIUS_KEY: &str = "RADIAL_MIN_RADIUS";
|
||||||
|
const LEVEL_DISTANCE_KEY: &str = "RADIAL_LEVEL_DISTANCE";
|
||||||
|
const ALIGN_POSITIVE_KEY: &str = "RADIAL_ALIGN_POSITIVE_COORDS";
|
||||||
|
const SPIRAL_QUALITY_KEY: &str = "RADIAL_SPIRAL_QUALITY";
|
||||||
|
const LEFT_BORDER_KEY: &str = "RADIAL_LEFT_BORDER";
|
||||||
|
const UPPER_BORDER_KEY: &str = "RADIAL_UPPER_BORDER";
|
||||||
|
const NODE_DISTANCE_KEY: &str = "RADIAL_NODE_DISTANCE";
|
||||||
|
const RING_DISTRIBUTION_KEY: &str = "RADIAL_RING_DISTRIBUTION";
|
||||||
|
const DEFAULT_OUTPUT_DIR: &str = "./out";
|
||||||
|
const DEFAULT_OUTPUT_FILE: &str = "layout.svg";
|
||||||
|
const DEFAULT_ROOT_CLASS_IRI: &str = "http://purl.obolibrary.org/obo/BFO_0000001";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct EnvConfig {
|
||||||
|
pub input_dir: PathBuf,
|
||||||
|
pub input_file: PathBuf,
|
||||||
|
pub root_class_iri: String,
|
||||||
|
pub output_dir: PathBuf,
|
||||||
|
pub output_file: PathBuf,
|
||||||
|
pub layout: LayoutConfig,
|
||||||
|
pub svg: SvgConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EnvConfig {
|
||||||
|
pub fn from_env() -> Result<Self, EnvConfigError> {
|
||||||
|
let _ = dotenv();
|
||||||
|
Self::from_lookup(|key| env::var(key).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn input_path(&self) -> PathBuf {
|
||||||
|
self.input_dir.join(&self.input_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn output_path(&self) -> PathBuf {
|
||||||
|
self.output_dir.join(&self.output_file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_lookup<F>(mut lookup: F) -> Result<Self, EnvConfigError>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
let defaults = LayoutConfig::default();
|
||||||
|
let input_dir = PathBuf::from(require_var(&mut lookup, INPUT_DIR_KEY)?);
|
||||||
|
let input_file = PathBuf::from(require_var(&mut lookup, INPUT_FILE_KEY)?);
|
||||||
|
let root_class_iri =
|
||||||
|
lookup(ROOT_CLASS_IRI_KEY).unwrap_or_else(|| DEFAULT_ROOT_CLASS_IRI.to_owned());
|
||||||
|
let output_dir =
|
||||||
|
PathBuf::from(lookup(OUTPUT_DIR_KEY).unwrap_or_else(|| DEFAULT_OUTPUT_DIR.to_owned()));
|
||||||
|
let output_file = PathBuf::from(
|
||||||
|
lookup(OUTPUT_FILE_KEY).unwrap_or_else(|| DEFAULT_OUTPUT_FILE.to_owned()),
|
||||||
|
);
|
||||||
|
let layout = LayoutConfig {
|
||||||
|
min_radius: parse_f64(&mut lookup, MIN_RADIUS_KEY, defaults.min_radius)?,
|
||||||
|
level_distance: parse_f64(&mut lookup, LEVEL_DISTANCE_KEY, defaults.level_distance)?,
|
||||||
|
align_positive_coords: parse_bool(
|
||||||
|
&mut lookup,
|
||||||
|
ALIGN_POSITIVE_KEY,
|
||||||
|
defaults.align_positive_coords,
|
||||||
|
)?,
|
||||||
|
spiral_quality: parse_usize(&mut lookup, SPIRAL_QUALITY_KEY, defaults.spiral_quality)?,
|
||||||
|
left_border: parse_f64(&mut lookup, LEFT_BORDER_KEY, defaults.left_border)?,
|
||||||
|
upper_border: parse_f64(&mut lookup, UPPER_BORDER_KEY, defaults.upper_border)?,
|
||||||
|
node_distance: parse_f64(&mut lookup, NODE_DISTANCE_KEY, defaults.node_distance)?,
|
||||||
|
ring_distribution: parse_ring_distribution(
|
||||||
|
&mut lookup,
|
||||||
|
RING_DISTRIBUTION_KEY,
|
||||||
|
defaults.ring_distribution,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
let svg = SvgConfig {
|
||||||
|
shortest_edges: parse_bool(
|
||||||
|
&mut lookup,
|
||||||
|
SVG_SHORTEST_EDGES_KEY,
|
||||||
|
SvgConfig::default().shortest_edges,
|
||||||
|
)?,
|
||||||
|
show_labels: parse_bool(
|
||||||
|
&mut lookup,
|
||||||
|
SVG_SHOW_LABELS_KEY,
|
||||||
|
SvgConfig::default().show_labels,
|
||||||
|
)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
input_dir,
|
||||||
|
input_file,
|
||||||
|
root_class_iri,
|
||||||
|
output_dir,
|
||||||
|
output_file,
|
||||||
|
layout,
|
||||||
|
svg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum EnvConfigError {
|
||||||
|
MissingVar(&'static str),
|
||||||
|
InvalidFloat { key: &'static str, value: String },
|
||||||
|
InvalidUsize { key: &'static str, value: String },
|
||||||
|
InvalidBool { key: &'static str, value: String },
|
||||||
|
InvalidRingDistribution { key: &'static str, value: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for EnvConfigError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
EnvConfigError::MissingVar(key) => write!(f, "missing required environment variable {key}"),
|
||||||
|
EnvConfigError::InvalidFloat { key, value } => {
|
||||||
|
write!(f, "environment variable {key} must be a float, got {value}")
|
||||||
|
}
|
||||||
|
EnvConfigError::InvalidUsize { key, value } => {
|
||||||
|
write!(f, "environment variable {key} must be a non-negative integer, got {value}")
|
||||||
|
}
|
||||||
|
EnvConfigError::InvalidBool { key, value } => {
|
||||||
|
write!(f, "environment variable {key} must be a boolean, got {value}")
|
||||||
|
}
|
||||||
|
EnvConfigError::InvalidRingDistribution { key, value } => write!(
|
||||||
|
f,
|
||||||
|
"environment variable {key} must be 'packed', 'distributed', or 'adaptive', got {value}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for EnvConfigError {}
|
||||||
|
|
||||||
|
fn require_var<F>(lookup: &mut F, key: &'static str) -> Result<String, EnvConfigError>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
lookup(key).ok_or(EnvConfigError::MissingVar(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_f64<F>(lookup: &mut F, key: &'static str, default: f64) -> Result<f64, EnvConfigError>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
match lookup(key) {
|
||||||
|
Some(value) => value
|
||||||
|
.parse::<f64>()
|
||||||
|
.map_err(|_| EnvConfigError::InvalidFloat { key, value }),
|
||||||
|
None => Ok(default),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_usize<F>(
|
||||||
|
lookup: &mut F,
|
||||||
|
key: &'static str,
|
||||||
|
default: usize,
|
||||||
|
) -> Result<usize, EnvConfigError>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
match lookup(key) {
|
||||||
|
Some(value) => value
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| EnvConfigError::InvalidUsize { key, value }),
|
||||||
|
None => Ok(default),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bool<F>(lookup: &mut F, key: &'static str, default: bool) -> Result<bool, EnvConfigError>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
match lookup(key) {
|
||||||
|
Some(value) => match value.to_ascii_lowercase().as_str() {
|
||||||
|
"true" | "1" | "yes" | "on" => Ok(true),
|
||||||
|
"false" | "0" | "no" | "off" => Ok(false),
|
||||||
|
_ => Err(EnvConfigError::InvalidBool { key, value }),
|
||||||
|
},
|
||||||
|
None => Ok(default),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ring_distribution<F>(
|
||||||
|
lookup: &mut F,
|
||||||
|
key: &'static str,
|
||||||
|
default: RingDistribution,
|
||||||
|
) -> Result<RingDistribution, EnvConfigError>
|
||||||
|
where
|
||||||
|
F: FnMut(&str) -> Option<String>,
|
||||||
|
{
|
||||||
|
match lookup(key) {
|
||||||
|
Some(value) => match value.to_ascii_lowercase().as_str() {
|
||||||
|
"packed" => Ok(RingDistribution::Packed),
|
||||||
|
"distributed" => Ok(RingDistribution::Distributed),
|
||||||
|
"adaptive" => Ok(RingDistribution::Adaptive),
|
||||||
|
_ => Err(EnvConfigError::InvalidRingDistribution { key, value }),
|
||||||
|
},
|
||||||
|
None => Ok(default),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn config_from_map(entries: &[(&str, &str)]) -> Result<EnvConfig, EnvConfigError> {
|
||||||
|
let vars = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
|
||||||
|
.collect::<std::collections::HashMap<_, _>>();
|
||||||
|
EnvConfig::from_lookup(|key| vars.get(key).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_env_config_and_path() {
|
||||||
|
let config = config_from_map(&[
|
||||||
|
(INPUT_DIR_KEY, "./ttl"),
|
||||||
|
(INPUT_FILE_KEY, "ontology.ttl"),
|
||||||
|
(ROOT_CLASS_IRI_KEY, "http://example.com/root"),
|
||||||
|
(OUTPUT_DIR_KEY, "./svg"),
|
||||||
|
(OUTPUT_FILE_KEY, "graph.svg"),
|
||||||
|
(SVG_SHORTEST_EDGES_KEY, "true"),
|
||||||
|
(SVG_SHOW_LABELS_KEY, "false"),
|
||||||
|
(MIN_RADIUS_KEY, "2.5"),
|
||||||
|
(LEVEL_DISTANCE_KEY, "3.0"),
|
||||||
|
(ALIGN_POSITIVE_KEY, "false"),
|
||||||
|
(SPIRAL_QUALITY_KEY, "800"),
|
||||||
|
(LEFT_BORDER_KEY, "120.0"),
|
||||||
|
(UPPER_BORDER_KEY, "140.0"),
|
||||||
|
(NODE_DISTANCE_KEY, "90.0"),
|
||||||
|
(RING_DISTRIBUTION_KEY, "adaptive"),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.input_path(),
|
||||||
|
PathBuf::from("./ttl").join("ontology.ttl")
|
||||||
|
);
|
||||||
|
assert_eq!(config.root_class_iri, "http://example.com/root");
|
||||||
|
assert_eq!(
|
||||||
|
config.output_path(),
|
||||||
|
PathBuf::from("./svg").join("graph.svg")
|
||||||
|
);
|
||||||
|
assert_eq!(config.layout.min_radius, 2.5);
|
||||||
|
assert_eq!(config.layout.level_distance, 3.0);
|
||||||
|
assert!(!config.layout.align_positive_coords);
|
||||||
|
assert_eq!(config.layout.spiral_quality, 800);
|
||||||
|
assert_eq!(config.layout.left_border, 120.0);
|
||||||
|
assert_eq!(config.layout.upper_border, 140.0);
|
||||||
|
assert_eq!(config.layout.node_distance, 90.0);
|
||||||
|
assert_eq!(config.layout.ring_distribution, RingDistribution::Adaptive);
|
||||||
|
assert!(config.svg.shortest_edges);
|
||||||
|
assert!(!config.svg.show_labels);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_input_file_is_reported() {
|
||||||
|
let error = config_from_map(&[(INPUT_DIR_KEY, "./ttl")]).unwrap_err();
|
||||||
|
assert_eq!(error, EnvConfigError::MissingVar(INPUT_FILE_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_boolean_is_reported() {
|
||||||
|
let error = config_from_map(&[
|
||||||
|
(INPUT_DIR_KEY, "./ttl"),
|
||||||
|
(INPUT_FILE_KEY, "ontology.ttl"),
|
||||||
|
(ALIGN_POSITIVE_KEY, "maybe"),
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
EnvConfigError::InvalidBool {
|
||||||
|
key: ALIGN_POSITIVE_KEY,
|
||||||
|
value: "maybe".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_default_output_location_when_not_provided() {
|
||||||
|
let config =
|
||||||
|
config_from_map(&[(INPUT_DIR_KEY, "./ttl"), (INPUT_FILE_KEY, "ontology.ttl")]).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
config.root_class_iri,
|
||||||
|
"http://purl.obolibrary.org/obo/BFO_0000001"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.output_path(),
|
||||||
|
PathBuf::from("./out").join("layout.svg")
|
||||||
|
);
|
||||||
|
assert!(!config.svg.shortest_edges);
|
||||||
|
assert!(config.svg.show_labels);
|
||||||
|
assert_eq!(config.layout.ring_distribution, RingDistribution::Packed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_ring_distribution_is_reported() {
|
||||||
|
let error = config_from_map(&[
|
||||||
|
(INPUT_DIR_KEY, "./ttl"),
|
||||||
|
(INPUT_FILE_KEY, "ontology.ttl"),
|
||||||
|
(RING_DISTRIBUTION_KEY, "arc"),
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
EnvConfigError::InvalidRingDistribution {
|
||||||
|
key: RING_DISTRIBUTION_KEY,
|
||||||
|
value: "arc".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
radial_sugiyama/src/error.rs
Normal file
70
radial_sugiyama/src/error.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LayoutError {
|
||||||
|
InvalidNodeIndex {
|
||||||
|
edge_index: usize,
|
||||||
|
node_index: usize,
|
||||||
|
node_count: usize,
|
||||||
|
},
|
||||||
|
SelfLoop {
|
||||||
|
edge_index: usize,
|
||||||
|
node: usize,
|
||||||
|
},
|
||||||
|
DuplicateEdge {
|
||||||
|
edge_index: usize,
|
||||||
|
source: usize,
|
||||||
|
target: usize,
|
||||||
|
},
|
||||||
|
CycleDetected,
|
||||||
|
InvalidHierarchyEdge {
|
||||||
|
edge_index: usize,
|
||||||
|
source: usize,
|
||||||
|
target: usize,
|
||||||
|
source_level: usize,
|
||||||
|
target_level: usize,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for LayoutError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
LayoutError::InvalidNodeIndex {
|
||||||
|
edge_index,
|
||||||
|
node_index,
|
||||||
|
node_count,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"edge {} references node {} but graph only has {} nodes",
|
||||||
|
edge_index, node_index, node_count
|
||||||
|
),
|
||||||
|
LayoutError::SelfLoop { edge_index, node } => {
|
||||||
|
write!(f, "edge {} is a self-loop on node {}", edge_index, node)
|
||||||
|
}
|
||||||
|
LayoutError::DuplicateEdge {
|
||||||
|
edge_index,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"edge {} duplicates existing directed edge {} -> {}",
|
||||||
|
edge_index, source, target
|
||||||
|
),
|
||||||
|
LayoutError::CycleDetected => write!(f, "graph must be a directed acyclic graph"),
|
||||||
|
LayoutError::InvalidHierarchyEdge {
|
||||||
|
edge_index,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
source_level,
|
||||||
|
target_level,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"edge {} ({} -> {}) violates hierarchy levels {} -> {}",
|
||||||
|
edge_index, source, target, source_level, target_level
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for LayoutError {}
|
||||||
159
radial_sugiyama/src/filter.rs
Normal file
159
radial_sugiyama/src/filter.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
|
||||||
|
use crate::model::{Edge, Graph};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum GraphFilterError {
|
||||||
|
RootNotFound { root_iri: String },
|
||||||
|
NoDescendants { root_iri: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for GraphFilterError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
GraphFilterError::RootNotFound { root_iri } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"root class IRI {root_iri} was not found in the imported graph"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GraphFilterError::NoDescendants { root_iri } => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"root class IRI {root_iri} has no subclass descendants in the imported graph"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for GraphFilterError {}
|
||||||
|
|
||||||
|
pub fn filter_graph_to_descendants(
|
||||||
|
graph: &Graph,
|
||||||
|
root_iri: &str,
|
||||||
|
) -> Result<Graph, GraphFilterError> {
|
||||||
|
let Some(root_index) = graph
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.position(|node| node.label.as_deref() == Some(root_iri))
|
||||||
|
else {
|
||||||
|
return Err(GraphFilterError::RootNotFound {
|
||||||
|
root_iri: root_iri.to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut adjacency = vec![Vec::new(); graph.nodes.len()];
|
||||||
|
for edge in &graph.edges {
|
||||||
|
adjacency[edge.source].push(edge.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visited = HashSet::from([root_index]);
|
||||||
|
let mut queue = VecDeque::from([root_index]);
|
||||||
|
while let Some(node) = queue.pop_front() {
|
||||||
|
for &child in &adjacency[node] {
|
||||||
|
if visited.insert(child) {
|
||||||
|
queue.push_back(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if visited.len() <= 1 {
|
||||||
|
return Err(GraphFilterError::NoDescendants {
|
||||||
|
root_iri: root_iri.to_owned(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reindex = HashMap::new();
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
for (old_index, node) in graph.nodes.iter().enumerate() {
|
||||||
|
if visited.contains(&old_index) {
|
||||||
|
let new_index = nodes.len();
|
||||||
|
reindex.insert(old_index, new_index);
|
||||||
|
nodes.push(node.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut edges = Vec::new();
|
||||||
|
for edge in &graph.edges {
|
||||||
|
if visited.contains(&edge.source) && visited.contains(&edge.target) {
|
||||||
|
edges.push(Edge::new(reindex[&edge.source], reindex[&edge.target]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Graph::new(nodes, edges))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::model::Node;
|
||||||
|
|
||||||
|
fn node(label: &str) -> Node {
|
||||||
|
Node {
|
||||||
|
label: Some(label.to_owned()),
|
||||||
|
..Node::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_root_and_all_descendants() {
|
||||||
|
let graph = Graph::new(
|
||||||
|
vec![
|
||||||
|
node("root"),
|
||||||
|
node("child"),
|
||||||
|
node("grandchild"),
|
||||||
|
node("other"),
|
||||||
|
node("other_child"),
|
||||||
|
],
|
||||||
|
vec![Edge::new(0, 1), Edge::new(1, 2), Edge::new(3, 4)],
|
||||||
|
);
|
||||||
|
|
||||||
|
let filtered = filter_graph_to_descendants(&graph, "root").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(filtered.nodes.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
filtered
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.map(|node| node.label.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
Some("root".to_owned()),
|
||||||
|
Some("child".to_owned()),
|
||||||
|
Some("grandchild".to_owned()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(filtered.edges, vec![Edge::new(0, 1), Edge::new(1, 2)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_error_when_root_is_missing() {
|
||||||
|
let graph = Graph::new(vec![node("other")], vec![]);
|
||||||
|
|
||||||
|
let error = filter_graph_to_descendants(&graph, "root").unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
GraphFilterError::RootNotFound {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_error_when_root_has_no_descendants() {
|
||||||
|
let graph = Graph::new(vec![node("root"), node("other")], vec![Edge::new(1, 0)]);
|
||||||
|
|
||||||
|
let error = filter_graph_to_descendants(&graph, "root").unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
GraphFilterError::NoDescendants {
|
||||||
|
root_iri: "root".to_owned(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
radial_sugiyama/src/layering.rs
Normal file
88
radial_sugiyama/src/layering.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
use std::collections::{HashSet, VecDeque};
|
||||||
|
|
||||||
|
use crate::error::LayoutError;
|
||||||
|
use crate::model::Graph;
|
||||||
|
|
||||||
|
pub fn compute_hierarchy_levels(graph: &Graph) -> Result<Vec<usize>, LayoutError> {
|
||||||
|
validate_simple_dag(graph)?;
|
||||||
|
|
||||||
|
let node_count = graph.nodes.len();
|
||||||
|
if node_count == 0 {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut indegree = vec![0usize; node_count];
|
||||||
|
let mut outgoing = vec![Vec::new(); node_count];
|
||||||
|
|
||||||
|
for edge in &graph.edges {
|
||||||
|
indegree[edge.target] += 1;
|
||||||
|
outgoing[edge.source].push(edge.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
for (node_index, degree) in indegree.iter().enumerate() {
|
||||||
|
if *degree == 0 {
|
||||||
|
queue.push_back(node_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut levels = vec![0usize; node_count];
|
||||||
|
let mut visited = 0usize;
|
||||||
|
|
||||||
|
while let Some(node) = queue.pop_front() {
|
||||||
|
visited += 1;
|
||||||
|
let next_level = levels[node] + 1;
|
||||||
|
for &child in &outgoing[node] {
|
||||||
|
if levels[child] < next_level {
|
||||||
|
levels[child] = next_level;
|
||||||
|
}
|
||||||
|
indegree[child] -= 1;
|
||||||
|
if indegree[child] == 0 {
|
||||||
|
queue.push_back(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if visited != node_count {
|
||||||
|
return Err(LayoutError::CycleDetected);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(levels)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn validate_simple_dag(graph: &Graph) -> Result<(), LayoutError> {
|
||||||
|
let node_count = graph.nodes.len();
|
||||||
|
let mut seen_edges = HashSet::new();
|
||||||
|
|
||||||
|
for (edge_index, edge) in graph.edges.iter().enumerate() {
|
||||||
|
if edge.source >= node_count {
|
||||||
|
return Err(LayoutError::InvalidNodeIndex {
|
||||||
|
edge_index,
|
||||||
|
node_index: edge.source,
|
||||||
|
node_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if edge.target >= node_count {
|
||||||
|
return Err(LayoutError::InvalidNodeIndex {
|
||||||
|
edge_index,
|
||||||
|
node_index: edge.target,
|
||||||
|
node_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if edge.source == edge.target {
|
||||||
|
return Err(LayoutError::SelfLoop {
|
||||||
|
edge_index,
|
||||||
|
node: edge.source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if !seen_edges.insert((edge.source, edge.target)) {
|
||||||
|
return Err(LayoutError::DuplicateEdge {
|
||||||
|
edge_index,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
2888
radial_sugiyama/src/layout.rs
Normal file
2888
radial_sugiyama/src/layout.rs
Normal file
File diff suppressed because it is too large
Load Diff
42
radial_sugiyama/src/lib.rs
Normal file
42
radial_sugiyama/src/lib.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
//! Hierarchical radial Sugiyama layout for directed acyclic graphs.
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use radial_sugiyama::{layout_radial_hierarchy, Edge, Graph, LayoutConfig, Node};
|
||||||
|
//!
|
||||||
|
//! let mut graph = Graph::new(
|
||||||
|
//! vec![Node::default(), Node::default(), Node::default()],
|
||||||
|
//! vec![Edge::new(0, 1), Edge::new(1, 2)],
|
||||||
|
//! );
|
||||||
|
//!
|
||||||
|
//! layout_radial_hierarchy(&mut graph, LayoutConfig::default()).unwrap();
|
||||||
|
//! assert!(graph.nodes.iter().all(|node| node.x.is_finite() && node.y.is_finite()));
|
||||||
|
//! ```
|
||||||
|
mod bridge;
|
||||||
|
mod env_config;
|
||||||
|
mod error;
|
||||||
|
mod filter;
|
||||||
|
mod layering;
|
||||||
|
mod layout;
|
||||||
|
mod model;
|
||||||
|
mod svg_export;
|
||||||
|
mod ttl;
|
||||||
|
|
||||||
|
pub use bridge::{
|
||||||
|
process_go_bridge_request, process_go_bridge_request_with_options, BridgeError,
|
||||||
|
BridgeRuntimeConfig, GoBridgeEdge, GoBridgeNode, GoBridgePoint, GoBridgeRequest,
|
||||||
|
GoBridgeResponse, GoBridgeRouteSegment, GoBridgeRoutedNode,
|
||||||
|
};
|
||||||
|
pub use env_config::{EnvConfig, EnvConfigError};
|
||||||
|
pub use error::LayoutError;
|
||||||
|
pub use filter::{filter_graph_to_descendants, GraphFilterError};
|
||||||
|
pub use layering::compute_hierarchy_levels;
|
||||||
|
pub use layout::{layout_radial_hierarchy, layout_radial_hierarchy_with_artifacts};
|
||||||
|
pub use model::{
|
||||||
|
Edge, EdgeRoute, EdgeRouteKind, Graph, LayoutArtifacts, LayoutConfig, Node, Point,
|
||||||
|
RingDistribution, RoutedNode, SvgConfig,
|
||||||
|
};
|
||||||
|
pub use svg_export::{
|
||||||
|
render_svg_string, render_svg_string_with_options, write_svg_path, write_svg_path_with_options,
|
||||||
|
SvgExportError,
|
||||||
|
};
|
||||||
|
pub use ttl::{graph_from_ttl_path, graph_from_ttl_reader, TtlImportError};
|
||||||
29
radial_sugiyama/src/main.rs
Normal file
29
radial_sugiyama/src/main.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::fs::create_dir_all;
|
||||||
|
|
||||||
|
use radial_sugiyama::{
|
||||||
|
filter_graph_to_descendants, graph_from_ttl_path, layout_radial_hierarchy_with_artifacts,
|
||||||
|
write_svg_path_with_options, EnvConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let config = EnvConfig::from_env()?;
|
||||||
|
let input_path = config.input_path();
|
||||||
|
let output_path = config.output_path();
|
||||||
|
let imported_graph = graph_from_ttl_path(&input_path)?;
|
||||||
|
let mut graph = filter_graph_to_descendants(&imported_graph, &config.root_class_iri)?;
|
||||||
|
let artifacts = layout_radial_hierarchy_with_artifacts(&mut graph, config.layout)?;
|
||||||
|
if let Some(parent) = output_path.parent() {
|
||||||
|
create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
write_svg_path_with_options(&output_path, &graph, &artifacts, config.layout, config.svg)?;
|
||||||
|
|
||||||
|
println!("input={}", input_path.display());
|
||||||
|
println!("root={}", config.root_class_iri);
|
||||||
|
println!("output={}", output_path.display());
|
||||||
|
println!("nodes={}", graph.nodes.len());
|
||||||
|
println!("edges={}", graph.edges.len());
|
||||||
|
println!("routes={}", artifacts.edge_routes.len());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
133
radial_sugiyama/src/model.rs
Normal file
133
radial_sugiyama/src/model.rs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Graph {
|
||||||
|
pub nodes: Vec<Node>,
|
||||||
|
pub edges: Vec<Edge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Graph {
|
||||||
|
pub fn new(nodes: Vec<Node>, edges: Vec<Edge>) -> Self {
|
||||||
|
Self { nodes, edges }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Node {
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Node {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
label: None,
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct Edge {
|
||||||
|
pub source: usize,
|
||||||
|
pub target: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Edge {
|
||||||
|
pub fn new(source: usize, target: usize) -> Self {
|
||||||
|
Self { source, target }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct Point {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Point {
|
||||||
|
pub fn new(x: f64, y: f64) -> Self {
|
||||||
|
Self { x, y }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum EdgeRouteKind {
|
||||||
|
Straight,
|
||||||
|
Spiral,
|
||||||
|
IntraLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct EdgeRoute {
|
||||||
|
pub original_edge_index: usize,
|
||||||
|
pub source: usize,
|
||||||
|
pub target: usize,
|
||||||
|
pub kind: EdgeRouteKind,
|
||||||
|
pub points: Vec<Point>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct RoutedNode {
|
||||||
|
pub original_index: Option<usize>,
|
||||||
|
pub level: usize,
|
||||||
|
pub point: Point,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct LayoutArtifacts {
|
||||||
|
pub node_levels: Vec<usize>,
|
||||||
|
pub edge_offsets: Vec<i32>,
|
||||||
|
pub edge_routes: Vec<EdgeRoute>,
|
||||||
|
pub routed_nodes: Vec<RoutedNode>,
|
||||||
|
pub center: Point,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SvgConfig {
|
||||||
|
pub shortest_edges: bool,
|
||||||
|
pub show_labels: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SvgConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
shortest_edges: false,
|
||||||
|
show_labels: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RingDistribution {
|
||||||
|
Packed,
|
||||||
|
Distributed,
|
||||||
|
Adaptive,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub struct LayoutConfig {
|
||||||
|
pub min_radius: f64,
|
||||||
|
pub level_distance: f64,
|
||||||
|
pub align_positive_coords: bool,
|
||||||
|
pub spiral_quality: usize,
|
||||||
|
pub left_border: f64,
|
||||||
|
pub upper_border: f64,
|
||||||
|
pub node_distance: f64,
|
||||||
|
pub ring_distribution: RingDistribution,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LayoutConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
min_radius: 1.0,
|
||||||
|
level_distance: 1.0,
|
||||||
|
align_positive_coords: true,
|
||||||
|
spiral_quality: 500,
|
||||||
|
left_border: 80.0,
|
||||||
|
upper_border: 80.0,
|
||||||
|
node_distance: 80.0,
|
||||||
|
ring_distribution: RingDistribution::Packed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
469
radial_sugiyama/src/svg_export.rs
Normal file
469
radial_sugiyama/src/svg_export.rs
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use svg::node::element::path::Data;
|
||||||
|
use svg::node::element::{Circle, Path as SvgPathElement, Rectangle, Text as SvgText};
|
||||||
|
use svg::Document;
|
||||||
|
|
||||||
|
use crate::model::{Graph, LayoutArtifacts, LayoutConfig, Point, SvgConfig};
|
||||||
|
|
||||||
|
const BACKGROUND_COLOR: &str = "#ffffff";
|
||||||
|
const RING_COLOR: &str = "#d9d9d9";
|
||||||
|
const EDGE_COLOR: &str = "#5c6773";
|
||||||
|
const NODE_FILL_COLOR: &str = "#4f81bd";
|
||||||
|
const NODE_STROKE_COLOR: &str = "#355c8a";
|
||||||
|
const LABEL_COLOR: &str = "#111111";
|
||||||
|
const NODE_RADIUS: f64 = 6.0;
|
||||||
|
const LABEL_FONT_SIZE: usize = 9;
|
||||||
|
const LABEL_X_OFFSET: f64 = NODE_RADIUS + 4.0;
|
||||||
|
const LABEL_Y_OFFSET: f64 = NODE_RADIUS + 2.0;
|
||||||
|
const LABEL_WIDTH_FACTOR: f64 = 0.56;
|
||||||
|
const EDGE_STROKE_WIDTH: f64 = 1.5;
|
||||||
|
const RING_STROKE_WIDTH: f64 = 1.0;
|
||||||
|
const VIEWBOX_HORIZONTAL_MARGIN: f64 = 72.0;
|
||||||
|
const VIEWBOX_VERTICAL_MARGIN: f64 = 36.0;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SvgExportError {
|
||||||
|
Io(std::io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for SvgExportError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SvgExportError::Io(error) => write!(f, "failed to write SVG output: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for SvgExportError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
SvgExportError::Io(error) => Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for SvgExportError {
|
||||||
|
fn from(error: std::io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_svg_path(
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
graph: &Graph,
|
||||||
|
artifacts: &LayoutArtifacts,
|
||||||
|
layout: LayoutConfig,
|
||||||
|
) -> Result<(), SvgExportError> {
|
||||||
|
write_svg_path_with_options(path, graph, artifacts, layout, SvgConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_svg_string(
|
||||||
|
graph: &Graph,
|
||||||
|
artifacts: &LayoutArtifacts,
|
||||||
|
layout: LayoutConfig,
|
||||||
|
) -> String {
|
||||||
|
render_svg_string_with_options(graph, artifacts, layout, SvgConfig::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_svg_path_with_options(
|
||||||
|
path: impl AsRef<Path>,
|
||||||
|
graph: &Graph,
|
||||||
|
artifacts: &LayoutArtifacts,
|
||||||
|
layout: LayoutConfig,
|
||||||
|
svg_config: SvgConfig,
|
||||||
|
) -> Result<(), SvgExportError> {
|
||||||
|
svg::save(path, &build_document(graph, artifacts, layout, svg_config)).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_svg_string_with_options(
|
||||||
|
graph: &Graph,
|
||||||
|
artifacts: &LayoutArtifacts,
|
||||||
|
layout: LayoutConfig,
|
||||||
|
svg_config: SvgConfig,
|
||||||
|
) -> String {
|
||||||
|
build_document(graph, artifacts, layout, svg_config).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_document(
|
||||||
|
graph: &Graph,
|
||||||
|
artifacts: &LayoutArtifacts,
|
||||||
|
layout: LayoutConfig,
|
||||||
|
svg_config: SvgConfig,
|
||||||
|
) -> Document {
|
||||||
|
let bounds = compute_bounds(graph, artifacts, layout, svg_config);
|
||||||
|
let width = (bounds.max_x - bounds.min_x).max(1.0);
|
||||||
|
let height = (bounds.max_y - bounds.min_y).max(1.0);
|
||||||
|
|
||||||
|
let mut document = Document::new()
|
||||||
|
.set("viewBox", (bounds.min_x, bounds.min_y, width, height))
|
||||||
|
.set("width", width)
|
||||||
|
.set("height", height);
|
||||||
|
|
||||||
|
document = document.add(
|
||||||
|
Rectangle::new()
|
||||||
|
.set("x", bounds.min_x)
|
||||||
|
.set("y", bounds.min_y)
|
||||||
|
.set("width", width)
|
||||||
|
.set("height", height)
|
||||||
|
.set("fill", BACKGROUND_COLOR),
|
||||||
|
);
|
||||||
|
|
||||||
|
for radius in ring_radii(artifacts, layout) {
|
||||||
|
document = document.add(
|
||||||
|
Circle::new()
|
||||||
|
.set("cx", artifacts.center.x)
|
||||||
|
.set("cy", artifacts.center.y)
|
||||||
|
.set("r", radius)
|
||||||
|
.set("fill", "none")
|
||||||
|
.set("stroke", RING_COLOR)
|
||||||
|
.set("stroke-width", RING_STROKE_WIDTH),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for data in edge_paths(graph, artifacts, svg_config) {
|
||||||
|
document = document.add(
|
||||||
|
SvgPathElement::new()
|
||||||
|
.set("fill", "none")
|
||||||
|
.set("stroke", EDGE_COLOR)
|
||||||
|
.set("stroke-width", EDGE_STROKE_WIDTH)
|
||||||
|
.set("stroke-linecap", "round")
|
||||||
|
.set("stroke-linejoin", "round")
|
||||||
|
.set("d", data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in &graph.nodes {
|
||||||
|
document = document.add(
|
||||||
|
Circle::new()
|
||||||
|
.set("cx", node.x)
|
||||||
|
.set("cy", node.y)
|
||||||
|
.set("r", NODE_RADIUS)
|
||||||
|
.set("fill", NODE_FILL_COLOR)
|
||||||
|
.set("stroke", NODE_STROKE_COLOR)
|
||||||
|
.set("stroke-width", 1.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if svg_config.show_labels {
|
||||||
|
for node in &graph.nodes {
|
||||||
|
if let Some(label) = &node.label {
|
||||||
|
document = document.add(
|
||||||
|
SvgText::new(label.clone())
|
||||||
|
.set("x", node.x + LABEL_X_OFFSET)
|
||||||
|
.set("y", node.y - LABEL_Y_OFFSET)
|
||||||
|
.set("fill", LABEL_COLOR)
|
||||||
|
.set("font-size", LABEL_FONT_SIZE)
|
||||||
|
.set("font-family", "Arial, Helvetica, sans-serif"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edge_paths(graph: &Graph, artifacts: &LayoutArtifacts, svg_config: SvgConfig) -> Vec<Data> {
|
||||||
|
if svg_config.shortest_edges {
|
||||||
|
graph
|
||||||
|
.edges
|
||||||
|
.iter()
|
||||||
|
.map(|edge| {
|
||||||
|
let source = &graph.nodes[edge.source];
|
||||||
|
let target = &graph.nodes[edge.target];
|
||||||
|
Data::new()
|
||||||
|
.move_to((source.x, source.y))
|
||||||
|
.line_to((target.x, target.y))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
artifacts
|
||||||
|
.edge_routes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|route| {
|
||||||
|
if route.points.len() < 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut data = Data::new().move_to((route.points[0].x, route.points[0].y));
|
||||||
|
for point in route.points.iter().skip(1) {
|
||||||
|
data = data.line_to((point.x, point.y));
|
||||||
|
}
|
||||||
|
Some(data)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ring_radii(artifacts: &LayoutArtifacts, layout: LayoutConfig) -> Vec<f64> {
|
||||||
|
let Some(max_level) = artifacts.node_levels.iter().copied().max() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
let center_only_level = artifacts
|
||||||
|
.node_levels
|
||||||
|
.iter()
|
||||||
|
.filter(|&&level| level == 0)
|
||||||
|
.count()
|
||||||
|
== 1;
|
||||||
|
let start_level = if center_only_level { 1 } else { 0 };
|
||||||
|
|
||||||
|
if start_level > max_level {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
(start_level..=max_level)
|
||||||
|
.map(|level| {
|
||||||
|
let radial_units = layout.min_radius
|
||||||
|
+ (level.saturating_sub(start_level) as f64 * layout.level_distance);
|
||||||
|
radial_units * layout.node_distance
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Bounds {
|
||||||
|
min_x: f64,
|
||||||
|
min_y: f64,
|
||||||
|
max_x: f64,
|
||||||
|
max_y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bounds {
|
||||||
|
fn around(point: Point) -> Self {
|
||||||
|
Self {
|
||||||
|
min_x: point.x,
|
||||||
|
min_y: point.y,
|
||||||
|
max_x: point.x,
|
||||||
|
max_y: point.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn include_point(&mut self, point: Point) {
|
||||||
|
self.min_x = self.min_x.min(point.x);
|
||||||
|
self.min_y = self.min_y.min(point.y);
|
||||||
|
self.max_x = self.max_x.max(point.x);
|
||||||
|
self.max_y = self.max_y.max(point.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn include_radius(&mut self, center: Point, radius: f64) {
|
||||||
|
self.include_point(Point::new(center.x - radius, center.y - radius));
|
||||||
|
self.include_point(Point::new(center.x + radius, center.y + radius));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expand(&mut self, horizontal_margin: f64, vertical_margin: f64) {
|
||||||
|
self.min_x -= horizontal_margin;
|
||||||
|
self.min_y -= vertical_margin;
|
||||||
|
self.max_x += horizontal_margin;
|
||||||
|
self.max_y += vertical_margin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_bounds(
|
||||||
|
graph: &Graph,
|
||||||
|
artifacts: &LayoutArtifacts,
|
||||||
|
layout: LayoutConfig,
|
||||||
|
svg_config: SvgConfig,
|
||||||
|
) -> Bounds {
|
||||||
|
let mut bounds = Bounds::around(artifacts.center);
|
||||||
|
|
||||||
|
for radius in ring_radii(artifacts, layout) {
|
||||||
|
bounds.include_radius(artifacts.center, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in &graph.nodes {
|
||||||
|
bounds.include_radius(Point::new(node.x, node.y), NODE_RADIUS);
|
||||||
|
if svg_config.show_labels {
|
||||||
|
if let Some(label) = &node.label {
|
||||||
|
include_label_bounds(&mut bounds, node, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for route in &artifacts.edge_routes {
|
||||||
|
for &point in &route.points {
|
||||||
|
bounds.include_point(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bounds.expand(VIEWBOX_HORIZONTAL_MARGIN, VIEWBOX_VERTICAL_MARGIN);
|
||||||
|
bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
fn include_label_bounds(bounds: &mut Bounds, node: &crate::model::Node, label: &str) {
|
||||||
|
let start_x = node.x + LABEL_X_OFFSET;
|
||||||
|
let baseline_y = node.y - LABEL_Y_OFFSET;
|
||||||
|
let width = estimate_label_width(label);
|
||||||
|
let ascent = LABEL_FONT_SIZE as f64;
|
||||||
|
let descent = LABEL_FONT_SIZE as f64 * 0.3;
|
||||||
|
|
||||||
|
bounds.include_point(Point::new(start_x, baseline_y - ascent));
|
||||||
|
bounds.include_point(Point::new(start_x + width, baseline_y + descent));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_label_width(label: &str) -> f64 {
|
||||||
|
label.chars().count() as f64 * LABEL_FONT_SIZE as f64 * LABEL_WIDTH_FACTOR
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::fs;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::{graph_from_ttl_reader, layout_radial_hierarchy_with_artifacts, Edge, Graph, Node};
|
||||||
|
|
||||||
|
fn simple_graph() -> (Graph, LayoutArtifacts, LayoutConfig) {
|
||||||
|
let mut graph = Graph::new(
|
||||||
|
vec![
|
||||||
|
Node {
|
||||||
|
label: Some("Root".to_owned()),
|
||||||
|
..Node::default()
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
label: Some("Child".to_owned()),
|
||||||
|
..Node::default()
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
label: Some("Leaf".to_owned()),
|
||||||
|
..Node::default()
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![Edge::new(0, 1), Edge::new(1, 2)],
|
||||||
|
);
|
||||||
|
let layout = LayoutConfig::default();
|
||||||
|
let artifacts = layout_radial_hierarchy_with_artifacts(&mut graph, layout).unwrap();
|
||||||
|
(graph, artifacts, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_svg_contains_svg_root_and_paths() {
|
||||||
|
let (graph, artifacts, layout) = simple_graph();
|
||||||
|
let svg = render_svg_string(&graph, &artifacts, layout);
|
||||||
|
|
||||||
|
assert!(svg.contains("<svg"));
|
||||||
|
assert!(svg.contains("<path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_svg_includes_labels_and_rings() {
|
||||||
|
let (graph, artifacts, layout) = simple_graph();
|
||||||
|
let svg = render_svg_string(&graph, &artifacts, layout);
|
||||||
|
|
||||||
|
assert!(svg.contains("Root"));
|
||||||
|
assert!(svg.contains("Child"));
|
||||||
|
assert!(svg.contains("<circle"));
|
||||||
|
assert!(svg.contains("font-size=\"9\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_root_layout_skips_level_zero_ring() {
|
||||||
|
let (graph, artifacts, layout) = simple_graph();
|
||||||
|
let svg = render_svg_string(&graph, &artifacts, layout);
|
||||||
|
let ring_count = svg.matches("stroke=\"#d9d9d9\"").count();
|
||||||
|
|
||||||
|
assert_eq!(ring_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn writes_svg_file_to_disk() {
|
||||||
|
let (graph, artifacts, layout) = simple_graph();
|
||||||
|
let unique = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"radial_sugiyama_svg_test_{}_{}.svg",
|
||||||
|
std::process::id(),
|
||||||
|
unique
|
||||||
|
));
|
||||||
|
|
||||||
|
write_svg_path(&path, &graph, &artifacts, layout).unwrap();
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path).unwrap();
|
||||||
|
assert!(content.contains("<svg"));
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn turtle_to_svg_pipeline_contains_labels() {
|
||||||
|
let ttl = "@prefix ex: <http://example.com/> .\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\nex:A rdfs:subClassOf ex:B .\nex:B rdfs:subClassOf ex:C .\n";
|
||||||
|
let mut graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
|
||||||
|
let layout = LayoutConfig::default();
|
||||||
|
let artifacts = layout_radial_hierarchy_with_artifacts(&mut graph, layout).unwrap();
|
||||||
|
|
||||||
|
let svg = render_svg_string(&graph, &artifacts, layout);
|
||||||
|
|
||||||
|
assert!(svg.contains("http://example.com/A"));
|
||||||
|
assert!(svg.contains("http://example.com/B"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shortest_edge_option_draws_original_edges_as_direct_segments() {
|
||||||
|
let mut graph = Graph::new(
|
||||||
|
vec![Node::default(), Node::default(), Node::default()],
|
||||||
|
vec![Edge::new(0, 1), Edge::new(1, 2), Edge::new(0, 2)],
|
||||||
|
);
|
||||||
|
let layout = LayoutConfig::default();
|
||||||
|
let artifacts = layout_radial_hierarchy_with_artifacts(&mut graph, layout).unwrap();
|
||||||
|
|
||||||
|
let svg = render_svg_string_with_options(
|
||||||
|
&graph,
|
||||||
|
&artifacts,
|
||||||
|
layout,
|
||||||
|
SvgConfig {
|
||||||
|
shortest_edges: true,
|
||||||
|
show_labels: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(svg.matches("<path").count(), graph.edges.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bounds_expand_to_fit_long_labels() {
|
||||||
|
let graph = Graph::new(
|
||||||
|
vec![Node {
|
||||||
|
label: Some("http://example.com/very/long/uri/that/should/fit".to_owned()),
|
||||||
|
x: 100.0,
|
||||||
|
y: 100.0,
|
||||||
|
}],
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
let artifacts = LayoutArtifacts {
|
||||||
|
node_levels: vec![0],
|
||||||
|
edge_offsets: vec![],
|
||||||
|
edge_routes: vec![],
|
||||||
|
routed_nodes: vec![],
|
||||||
|
center: Point::new(100.0, 100.0),
|
||||||
|
};
|
||||||
|
let bounds = compute_bounds(
|
||||||
|
&graph,
|
||||||
|
&artifacts,
|
||||||
|
LayoutConfig::default(),
|
||||||
|
SvgConfig::default(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(bounds.max_x > 300.0);
|
||||||
|
assert!(bounds.max_y > 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_svg_omits_labels_when_disabled() {
|
||||||
|
let (graph, artifacts, layout) = simple_graph();
|
||||||
|
let svg = render_svg_string_with_options(
|
||||||
|
&graph,
|
||||||
|
&artifacts,
|
||||||
|
layout,
|
||||||
|
SvgConfig {
|
||||||
|
shortest_edges: false,
|
||||||
|
show_labels: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!svg.contains("Root"));
|
||||||
|
assert!(!svg.contains("<text"));
|
||||||
|
}
|
||||||
|
}
|
||||||
200
radial_sugiyama/src/ttl.rs
Normal file
200
radial_sugiyama/src/ttl.rs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fmt::{Display, Formatter};
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufReader, Read};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use oxrdf::vocab::rdfs;
|
||||||
|
use oxrdf::{NamedOrBlankNode, Term};
|
||||||
|
use oxttl::{TurtleParseError, TurtleParser};
|
||||||
|
|
||||||
|
use crate::model::{Edge, Graph, Node};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum TtlImportError {
|
||||||
|
Io(std::io::Error),
|
||||||
|
Parse(TurtleParseError),
|
||||||
|
NoSubclassTriples,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for TtlImportError {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
TtlImportError::Io(error) => write!(f, "failed to read Turtle input: {error}"),
|
||||||
|
TtlImportError::Parse(error) => write!(f, "failed to parse Turtle input: {error}"),
|
||||||
|
TtlImportError::NoSubclassTriples => {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"no usable rdfs:subClassOf triples were found in the Turtle input"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for TtlImportError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
TtlImportError::Io(error) => Some(error),
|
||||||
|
TtlImportError::Parse(error) => Some(error),
|
||||||
|
TtlImportError::NoSubclassTriples => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for TtlImportError {
|
||||||
|
fn from(error: std::io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TurtleParseError> for TtlImportError {
|
||||||
|
fn from(error: TurtleParseError) -> Self {
|
||||||
|
Self::Parse(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn graph_from_ttl_reader<R: Read>(reader: R) -> Result<Graph, TtlImportError> {
|
||||||
|
let mut nodes = Vec::new();
|
||||||
|
let mut node_indices = HashMap::new();
|
||||||
|
let mut edges = Vec::new();
|
||||||
|
let mut seen_edges = HashSet::new();
|
||||||
|
|
||||||
|
for triple in TurtleParser::new().for_reader(reader) {
|
||||||
|
let triple = triple?;
|
||||||
|
if triple.predicate.as_ref() != rdfs::SUB_CLASS_OF {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let NamedOrBlankNode::NamedNode(subject) = triple.subject else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Term::NamedNode(object) = triple.object else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let subclass = get_or_insert_node(&mut nodes, &mut node_indices, subject.as_str());
|
||||||
|
let superclass = get_or_insert_node(&mut nodes, &mut node_indices, object.as_str());
|
||||||
|
if seen_edges.insert((superclass, subclass)) {
|
||||||
|
edges.push(Edge::new(superclass, subclass));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if edges.is_empty() {
|
||||||
|
return Err(TtlImportError::NoSubclassTriples);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Graph::new(nodes, edges))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn graph_from_ttl_path(path: impl AsRef<Path>) -> Result<Graph, TtlImportError> {
|
||||||
|
let file = File::open(path)?;
|
||||||
|
graph_from_ttl_reader(BufReader::new(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_or_insert_node(
|
||||||
|
nodes: &mut Vec<Node>,
|
||||||
|
node_indices: &mut HashMap<String, usize>,
|
||||||
|
iri: &str,
|
||||||
|
) -> usize {
|
||||||
|
if let Some(&index) = node_indices.get(iri) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = nodes.len();
|
||||||
|
nodes.push(Node {
|
||||||
|
label: Some(iri.to_owned()),
|
||||||
|
..Node::default()
|
||||||
|
});
|
||||||
|
node_indices.insert(iri.to_owned(), index);
|
||||||
|
index
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{layout_radial_hierarchy, LayoutConfig};
|
||||||
|
|
||||||
|
const TTL_PREFIXES: &str = "@prefix ex: <http://example.com/> .\n@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .\n@prefix schema: <http://schema.org/> .\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imports_only_subclass_triples() {
|
||||||
|
let ttl = format!(
|
||||||
|
"{TTL_PREFIXES}ex:A rdfs:subClassOf ex:B .\nex:A schema:name \"Alpha\" .\nex:B schema:name \"Beta\" .\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(graph.nodes.len(), 2);
|
||||||
|
assert_eq!(graph.edges, vec![Edge::new(1, 0)]);
|
||||||
|
assert_eq!(
|
||||||
|
graph.nodes[0].label.as_deref(),
|
||||||
|
Some("http://example.com/A")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
graph.nodes[1].label.as_deref(),
|
||||||
|
Some("http://example.com/B")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deduplicates_repeated_subclass_triples() {
|
||||||
|
let ttl =
|
||||||
|
format!("{TTL_PREFIXES}ex:A rdfs:subClassOf ex:B .\nex:A rdfs:subClassOf ex:B .\n");
|
||||||
|
|
||||||
|
let graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(graph.edges, vec![Edge::new(1, 0)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ignores_blank_node_and_literal_targets() {
|
||||||
|
let ttl = format!(
|
||||||
|
"{TTL_PREFIXES}ex:A rdfs:subClassOf [ a ex:Anonymous ] .\nex:B rdfs:subClassOf \"Literal\" .\nex:C rdfs:subClassOf ex:D .\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(graph.nodes.len(), 2);
|
||||||
|
assert_eq!(graph.edges, vec![Edge::new(1, 0)]);
|
||||||
|
assert_eq!(
|
||||||
|
graph.nodes[0].label.as_deref(),
|
||||||
|
Some("http://example.com/C")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
graph.nodes[1].label.as_deref(),
|
||||||
|
Some("http://example.com/D")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn imports_can_flow_into_layout() {
|
||||||
|
let ttl =
|
||||||
|
format!("{TTL_PREFIXES}ex:A rdfs:subClassOf ex:B .\nex:B rdfs:subClassOf ex:C .\n");
|
||||||
|
|
||||||
|
let mut graph = graph_from_ttl_reader(ttl.as_bytes()).unwrap();
|
||||||
|
layout_radial_hierarchy(&mut graph, LayoutConfig::default()).unwrap();
|
||||||
|
|
||||||
|
assert!(graph
|
||||||
|
.nodes
|
||||||
|
.iter()
|
||||||
|
.all(|node| node.x.is_finite() && node.y.is_finite()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_clear_error_for_invalid_turtle() {
|
||||||
|
let ttl = "@prefix ex: <http://example.com/> .\nex:A rdfs:subClassOf .\n";
|
||||||
|
let error = graph_from_ttl_reader(ttl.as_bytes()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, TtlImportError::Parse(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn returns_clear_error_when_no_subclass_triples_exist() {
|
||||||
|
let ttl = format!("{TTL_PREFIXES}ex:A schema:name \"Alpha\" .\n");
|
||||||
|
let error = graph_from_ttl_reader(ttl.as_bytes()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, TtlImportError::NoSubclassTriples));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user