radial sugiyama positioning integration
This commit is contained in:
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
data
|
||||
.git
|
||||
.gitignore
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
radial_sugiyama/target
|
||||
14
.env.example
14
.env.example
@@ -39,6 +39,20 @@ SPARQL_READY_TIMEOUT_S=10
|
||||
CORS_ORIGINS=http://localhost:5173
|
||||
VITE_BACKEND_URL=http://backend:8000
|
||||
|
||||
# Frontend right-pane cosmos.gl layout
|
||||
VITE_COSMOS_ENABLE_SIMULATION=true
|
||||
VITE_COSMOS_DEBUG_LAYOUT=false
|
||||
VITE_COSMOS_SPACE_SIZE=4096
|
||||
VITE_COSMOS_CURVED_LINKS=true
|
||||
VITE_COSMOS_FIT_VIEW_PADDING=0.12
|
||||
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_LINK_SPRING=1
|
||||
VITE_COSMOS_SIMULATION_LINK_DISTANCE=10
|
||||
VITE_COSMOS_SIMULATION_FRICTION=0.1
|
||||
|
||||
# Debugging
|
||||
LOG_SNAPSHOT_TIMINGS=false
|
||||
FREE_OS_MEMORY_AFTER_SNAPSHOT=false
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ frontend/dist/
|
||||
.npm/
|
||||
.vite/
|
||||
data/
|
||||
target/
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
ARG GO_VERSION=1.24
|
||||
FROM golang:${GO_VERSION}-alpine AS builder
|
||||
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 cargo build --release --bin radial_sugiyama_go_bridge
|
||||
|
||||
FROM golang:${GO_VERSION}-alpine AS go-builder
|
||||
|
||||
WORKDIR /src/backend_go
|
||||
|
||||
COPY backend_go/go.mod /src/backend_go/go.mod
|
||||
|
||||
RUN go mod download
|
||||
|
||||
COPY . /src
|
||||
COPY backend_go /src/backend_go
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@ type Config struct {
|
||||
SparqlReadyDelay time.Duration
|
||||
SparqlReadyTimeout time.Duration
|
||||
|
||||
HierarchyLayoutEngine string
|
||||
HierarchyLayoutBridgeBin string
|
||||
HierarchyLayoutBridgeWorkdir string
|
||||
HierarchyLayoutTimeout time.Duration
|
||||
HierarchyLayoutRootIRI string
|
||||
|
||||
ListenAddr string
|
||||
}
|
||||
|
||||
@@ -64,6 +70,11 @@ func LoadConfig() (Config, error) {
|
||||
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),
|
||||
ListenAddr: envString("LISTEN_ADDR", ":8000"),
|
||||
}
|
||||
@@ -81,6 +92,10 @@ func LoadConfig() (Config, error) {
|
||||
if err != nil {
|
||||
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) == "" {
|
||||
return Config{}, fmt.Errorf("SPARQL_LOAD_ON_START=true but SPARQL_DATA_FILE is not set")
|
||||
@@ -110,6 +125,24 @@ func LoadConfig() (Config, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -168,40 +168,56 @@ func fetchGraphSnapshot(
|
||||
|
||||
nodes := acc.nodes
|
||||
edges := acc.edges
|
||||
routeSegments := []RouteSegment(nil)
|
||||
layoutEngine := "go"
|
||||
var layoutRootIRI *string
|
||||
|
||||
// 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)
|
||||
}
|
||||
if shouldUseRustHierarchyLayout(cfg, graphQueryID) {
|
||||
layoutResult, err := layoutHierarchyWithRust(ctx, cfg, nodes, edges, preds)
|
||||
if err != nil {
|
||||
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)})
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
xs, ys := radialPositionsFromLayers(len(nodes), layers, 5000.0)
|
||||
for i := range nodes {
|
||||
nodes[i].X = xs[i]
|
||||
nodes[i].Y = ys[i]
|
||||
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.
|
||||
@@ -240,9 +256,11 @@ func fetchGraphSnapshot(
|
||||
EdgeLimit: edgeLimit,
|
||||
Nodes: len(nodes),
|
||||
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 {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package main
|
||||
|
||||
import selectionqueries "visualizador_instanciados/backend_go/selection_queries"
|
||||
|
||||
type ErrorResponse struct {
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
@@ -18,28 +20,42 @@ type Node struct {
|
||||
}
|
||||
|
||||
type Edge struct {
|
||||
Source uint32 `json:"source"`
|
||||
Target uint32 `json:"target"`
|
||||
Source uint32 `json:"source"`
|
||||
Target uint32 `json:"target"`
|
||||
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 {
|
||||
Backend string `json:"backend"`
|
||||
TTLPath *string `json:"ttl_path"`
|
||||
SparqlEndpoint string `json:"sparql_endpoint"`
|
||||
IncludeBNodes bool `json:"include_bnodes"`
|
||||
GraphQueryID string `json:"graph_query_id"`
|
||||
Backend string `json:"backend"`
|
||||
TTLPath *string `json:"ttl_path"`
|
||||
SparqlEndpoint string `json:"sparql_endpoint"`
|
||||
IncludeBNodes bool `json:"include_bnodes"`
|
||||
GraphQueryID string `json:"graph_query_id"`
|
||||
Predicates []string `json:"predicates,omitempty"` // index = predicate_id
|
||||
NodeLimit int `json:"node_limit"`
|
||||
EdgeLimit int `json:"edge_limit"`
|
||||
Nodes int `json:"nodes"`
|
||||
Edges int `json:"edges"`
|
||||
NodeLimit int `json:"node_limit"`
|
||||
EdgeLimit int `json:"edge_limit"`
|
||||
Nodes int `json:"nodes"`
|
||||
Edges int `json:"edges"`
|
||||
LayoutEngine string `json:"layout_engine,omitempty"`
|
||||
LayoutRootIRI *string `json:"layout_root_iri,omitempty"`
|
||||
}
|
||||
|
||||
type GraphResponse struct {
|
||||
Nodes []Node `json:"nodes"`
|
||||
Edges []Edge `json:"edges"`
|
||||
Meta *GraphMeta `json:"meta"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
Edges []Edge `json:"edges"`
|
||||
RouteSegments []RouteSegment `json:"route_segments,omitempty"`
|
||||
Meta *GraphMeta `json:"meta"`
|
||||
}
|
||||
|
||||
type StatsResponse struct {
|
||||
@@ -80,3 +96,9 @@ type SelectionQueryResponse struct {
|
||||
SelectedIDs []uint32 `json:"selected_ids"`
|
||||
NeighborIDs []uint32 `json:"neighbor_ids"`
|
||||
}
|
||||
|
||||
type SelectionTriplesResponse struct {
|
||||
QueryID string `json:"query_id"`
|
||||
SelectedIDs []uint32 `json:"selected_ids"`
|
||||
Triples []selectionqueries.Triple `json:"triples"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package selection_queries
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -66,30 +67,83 @@ func selectedNodesFromIDs(idx Index, selectedIDs []uint32, includeBNodes bool) (
|
||||
return out, set
|
||||
}
|
||||
|
||||
func idsFromBindings(raw []byte, varName string, idx Index, selectedSet map[uint32]struct{}, includeBNodes bool) ([]uint32, 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
|
||||
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[uint32]struct{})
|
||||
triples := make([]Triple, 0, len(res.Results.Bindings))
|
||||
for _, b := range res.Results.Bindings {
|
||||
term, ok := b[varName]
|
||||
if !ok {
|
||||
sTerm, okS := b["s"]
|
||||
pTerm, okP := b["p"]
|
||||
oTerm, okO := b["o"]
|
||||
if !okS || !okP || !okO {
|
||||
continue
|
||||
}
|
||||
key, ok := termKeyFromSparqlTerm(term, includeBNodes)
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
triple := Triple{
|
||||
S: tripleTermFromSparqlTerm(sTerm),
|
||||
P: tripleTermFromSparqlTerm(pTerm),
|
||||
O: tripleTermFromSparqlTerm(oTerm),
|
||||
}
|
||||
nid, ok := idx.KeyToID[key]
|
||||
if !ok {
|
||||
continue
|
||||
|
||||
subjID, subjOK := idFromSparqlTerm(sTerm, idx, includeBNodes)
|
||||
if subjOK {
|
||||
id := subjID
|
||||
triple.SubjectID = &id
|
||||
}
|
||||
if _, sel := selectedSet[nid]; sel {
|
||||
continue
|
||||
objID, objOK := idFromSparqlTerm(oTerm, idx, includeBNodes)
|
||||
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([]uint32, 0, len(neighborSet))
|
||||
@@ -97,5 +151,5 @@ func idsFromBindings(raw []byte, varName string, idx Index, selectedSet map[uint
|
||||
ids = append(ids, nid)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids, nil
|
||||
return Result{NeighborIDs: ids, Triples: triples}, nil
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@ func neighborsQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
||||
}
|
||||
|
||||
if len(valuesTerms) == 0 {
|
||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
||||
return "SELECT ?s ?p ?o WHERE { FILTER(false) }"
|
||||
}
|
||||
|
||||
bnodeFilter := ""
|
||||
if !includeBNodes {
|
||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||
}
|
||||
|
||||
values := strings.Join(valuesTerms, " ")
|
||||
@@ -31,46 +31,55 @@ PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
PREFIX owl: <http://www.w3.org/2002/07/owl#>
|
||||
|
||||
SELECT DISTINCT ?nbr
|
||||
SELECT DISTINCT ?s ?p ?o
|
||||
WHERE {
|
||||
VALUES ?sel { %s }
|
||||
{
|
||||
?sel rdf:type ?o .
|
||||
VALUES ?sel { %s }
|
||||
BIND(?sel AS ?s)
|
||||
VALUES ?p { rdf:type }
|
||||
?s ?p ?o .
|
||||
?o rdf:type owl:Class .
|
||||
BIND(?o AS ?nbr)
|
||||
}
|
||||
UNION
|
||||
{
|
||||
?s rdf:type ?sel .
|
||||
VALUES ?sel { %s }
|
||||
VALUES ?p { rdf:type }
|
||||
?s ?p ?sel .
|
||||
?sel rdf:type owl:Class .
|
||||
BIND(?s AS ?nbr)
|
||||
BIND(?sel AS ?o)
|
||||
}
|
||||
UNION
|
||||
{
|
||||
?sel rdfs:subClassOf ?o .
|
||||
BIND(?o AS ?nbr)
|
||||
VALUES ?sel { %s }
|
||||
BIND(?sel AS ?s)
|
||||
VALUES ?p { rdfs:subClassOf }
|
||||
?s ?p ?o .
|
||||
}
|
||||
UNION
|
||||
{
|
||||
?s rdfs:subClassOf ?sel .
|
||||
BIND(?s AS ?nbr)
|
||||
VALUES ?sel { %s }
|
||||
VALUES ?p { rdfs:subClassOf }
|
||||
?s ?p ?sel .
|
||||
BIND(?sel AS ?o)
|
||||
}
|
||||
FILTER(!isLiteral(?nbr))
|
||||
FILTER(?nbr != ?sel)
|
||||
FILTER(!isLiteral(?o))
|
||||
FILTER(?s != ?o)
|
||||
%s
|
||||
}
|
||||
`, values, bnodeFilter)
|
||||
`, values, values, values, values, bnodeFilter)
|
||||
}
|
||||
|
||||
func runNeighbors(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error) {
|
||||
func runNeighbors(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error) {
|
||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
||||
if len(selectedNodes) == 0 {
|
||||
return []uint32{}, 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -17,38 +17,42 @@ func subclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
||||
}
|
||||
|
||||
if len(valuesTerms) == 0 {
|
||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
||||
return "SELECT ?s ?p ?o WHERE { FILTER(false) }"
|
||||
}
|
||||
|
||||
bnodeFilter := ""
|
||||
if !includeBNodes {
|
||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||
}
|
||||
|
||||
values := strings.Join(valuesTerms, " ")
|
||||
return fmt.Sprintf(`
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
|
||||
SELECT DISTINCT ?nbr
|
||||
SELECT DISTINCT ?s ?p ?o
|
||||
WHERE {
|
||||
VALUES ?sel { %s }
|
||||
?nbr rdfs:subClassOf ?sel .
|
||||
FILTER(!isLiteral(?nbr))
|
||||
FILTER(?nbr != ?sel)
|
||||
VALUES ?p { rdfs:subClassOf }
|
||||
?s ?p ?sel .
|
||||
BIND(?sel AS ?o)
|
||||
FILTER(!isLiteral(?o))
|
||||
FILTER(?s != ?o)
|
||||
%s
|
||||
}
|
||||
`, values, bnodeFilter)
|
||||
}
|
||||
|
||||
func runSubclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error) {
|
||||
func runSubclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error) {
|
||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
||||
if len(selectedNodes) == 0 {
|
||||
return []uint32{}, 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -17,38 +17,42 @@ func superclassesQuery(selectedNodes []NodeRef, includeBNodes bool) string {
|
||||
}
|
||||
|
||||
if len(valuesTerms) == 0 {
|
||||
return "SELECT ?nbr WHERE { FILTER(false) }"
|
||||
return "SELECT ?s ?p ?o WHERE { FILTER(false) }"
|
||||
}
|
||||
|
||||
bnodeFilter := ""
|
||||
if !includeBNodes {
|
||||
bnodeFilter = "FILTER(!isBlank(?nbr))"
|
||||
bnodeFilter = "FILTER(!isBlank(?s) && !isBlank(?o))"
|
||||
}
|
||||
|
||||
values := strings.Join(valuesTerms, " ")
|
||||
return fmt.Sprintf(`
|
||||
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
|
||||
|
||||
SELECT DISTINCT ?nbr
|
||||
SELECT DISTINCT ?s ?p ?o
|
||||
WHERE {
|
||||
VALUES ?sel { %s }
|
||||
?sel rdfs:subClassOf ?nbr .
|
||||
FILTER(!isLiteral(?nbr))
|
||||
FILTER(?nbr != ?sel)
|
||||
BIND(?sel AS ?s)
|
||||
VALUES ?p { rdfs:subClassOf }
|
||||
?s ?p ?o .
|
||||
FILTER(!isLiteral(?o))
|
||||
FILTER(?s != ?o)
|
||||
%s
|
||||
}
|
||||
`, values, bnodeFilter)
|
||||
}
|
||||
|
||||
func runSuperclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) ([]uint32, error) {
|
||||
func runSuperclasses(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error) {
|
||||
selectedNodes, selectedSet := selectedNodesFromIDs(idx, selectedIDs, includeBNodes)
|
||||
if len(selectedNodes) == 0 {
|
||||
return []uint32{}, 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 {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,9 @@ type NodeRef struct {
|
||||
}
|
||||
|
||||
type Index struct {
|
||||
IDToNode map[uint32]NodeRef
|
||||
KeyToID map[string]uint32
|
||||
IDToNode map[uint32]NodeRef
|
||||
KeyToID map[string]uint32
|
||||
PredicateIDByIRI map[string]uint32
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
@@ -22,7 +23,27 @@ type Meta struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type TripleTerm struct {
|
||||
Type string `json:"type"`
|
||||
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) ([]uint32, error)
|
||||
Run func(ctx context.Context, q Querier, idx Index, selectedIDs []uint32, includeBNodes bool) (Result, error)
|
||||
}
|
||||
|
||||
@@ -14,19 +14,32 @@ func runSelectionQuery(
|
||||
queryID string,
|
||||
selectedIDs []uint32,
|
||||
includeBNodes bool,
|
||||
) ([]uint32, error) {
|
||||
) (selectionqueries.Result, error) {
|
||||
def, ok := selectionqueries.Get(queryID)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown query_id: %s", queryID)
|
||||
return selectionqueries.Result{}, fmt.Errorf("unknown query_id: %s", queryID)
|
||||
}
|
||||
|
||||
idToNode := make(map[uint32]selectionqueries.NodeRef, len(snapshot.Nodes))
|
||||
keyToID := make(map[string]uint32, len(snapshot.Nodes))
|
||||
predicateIDByIRI := make(map[string]uint32)
|
||||
for _, n := range snapshot.Nodes {
|
||||
nr := selectionqueries.NodeRef{ID: n.ID, TermType: n.TermType, IRI: n.IRI}
|
||||
idToNode[n.ID] = nr
|
||||
keyToID[n.TermType+"\x00"+n.IRI] = n.ID
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ func (s *APIServer) handler() http.Handler {
|
||||
mux.HandleFunc("/api/graph_queries", s.handleGraphQueries)
|
||||
mux.HandleFunc("/api/selection_queries", s.handleSelectionQueries)
|
||||
mux.HandleFunc("/api/selection_query", s.handleSelectionQuery)
|
||||
mux.HandleFunc("/api/selection_triples", s.handleSelectionTriples)
|
||||
mux.HandleFunc("/api/neighbors", s.handleNeighbors)
|
||||
|
||||
return s.corsMiddleware(mux)
|
||||
@@ -134,14 +135,14 @@ func (s *APIServer) handleGraph(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
graphQueryID := strings.TrimSpace(r.URL.Query().Get("graph_query_id"))
|
||||
if graphQueryID == "" {
|
||||
graphQueryID = graphqueries.DefaultID
|
||||
}
|
||||
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
||||
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
||||
return
|
||||
}
|
||||
graphQueryID := strings.TrimSpace(r.URL.Query().Get("graph_query_id"))
|
||||
if graphQueryID == "" {
|
||||
graphQueryID = graphqueries.DefaultID
|
||||
}
|
||||
if _, ok := graphqueries.Get(graphQueryID); !ok {
|
||||
writeError(w, http.StatusUnprocessableEntity, "unknown graph_query_id")
|
||||
return
|
||||
}
|
||||
|
||||
snap, err := s.snapshots.Get(r.Context(), nodeLimit, edgeLimit, graphQueryID)
|
||||
if err != nil {
|
||||
@@ -225,8 +226,18 @@ func (s *APIServer) handleSelectionQuery(w http.ResponseWriter, r *http.Request)
|
||||
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 {
|
||||
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())
|
||||
return
|
||||
}
|
||||
@@ -234,23 +245,31 @@ func (s *APIServer) handleSelectionQuery(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, SelectionQueryResponse{
|
||||
QueryID: req.QueryID,
|
||||
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 {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req NeighborsRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
var req SelectionQueryRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil || strings.TrimSpace(req.QueryID) == "" {
|
||||
writeError(w, http.StatusUnprocessableEntity, "invalid request body")
|
||||
return
|
||||
}
|
||||
if _, ok := selectionqueries.Get(req.QueryID); !ok {
|
||||
writeError(w, http.StatusUnprocessableEntity, "unknown query_id")
|
||||
return
|
||||
}
|
||||
if len(req.SelectedIDs) == 0 {
|
||||
writeJSON(w, http.StatusOK, NeighborsResponse{SelectedIDs: req.SelectedIDs, NeighborIDs: []uint32{}})
|
||||
writeJSON(w, http.StatusOK, SelectionTriplesResponse{
|
||||
QueryID: req.QueryID,
|
||||
SelectedIDs: req.SelectedIDs,
|
||||
Triples: []selectionqueries.Triple{},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -282,13 +301,96 @@ func (s *APIServer) handleNeighbors(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
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())
|
||||
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) {
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
)
|
||||
|
||||
type snapshotKey struct {
|
||||
NodeLimit int
|
||||
EdgeLimit int
|
||||
IncludeBNodes bool
|
||||
GraphQueryID string
|
||||
NodeLimit int
|
||||
EdgeLimit int
|
||||
IncludeBNodes bool
|
||||
GraphQueryID string
|
||||
LayoutEngine string
|
||||
LayoutRootIRI string
|
||||
}
|
||||
|
||||
type snapshotInflight struct {
|
||||
@@ -37,7 +39,14 @@ func NewGraphSnapshotService(sparql *AnzoGraphClient, cfg Config) *GraphSnapshot
|
||||
}
|
||||
|
||||
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()
|
||||
if snap, ok := s.cache[key]; ok {
|
||||
|
||||
@@ -11,8 +11,22 @@ services:
|
||||
volumes:
|
||||
- ./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:
|
||||
build: ./backend_go
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend_go/Dockerfile
|
||||
env_file:
|
||||
- ./radial_sugiyama/.env
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
@@ -37,6 +51,11 @@ services:
|
||||
- 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:
|
||||
owl_imports_combiner:
|
||||
condition: service_completed_successfully
|
||||
@@ -44,6 +63,7 @@ services:
|
||||
condition: service_started
|
||||
volumes:
|
||||
- ./data:/data:Z
|
||||
- ./radial_sugiyama:/workspace/radial_sugiyama:Z
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8000/api/health"]
|
||||
interval: 5s
|
||||
@@ -56,6 +76,18 @@ services:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
- 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:
|
||||
- ./frontend:/app
|
||||
- /app/node_modules
|
||||
@@ -75,4 +107,4 @@ services:
|
||||
- ./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
|
||||
- ./data/internal:/opt/anzograph/internal:Z
|
||||
|
||||
@@ -19,6 +19,23 @@ Open: `http://localhost:5173`
|
||||
## Configuration
|
||||
|
||||
- `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
|
||||
|
||||
|
||||
781
frontend/package-lock.json
generated
781
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "react-vite-tailwind",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cosmos.gl/graph": "^2.6.4",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"clsx": "2.1.1",
|
||||
"react": "19.2.3",
|
||||
@@ -21,6 +22,7 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"tailwindcss": "4.1.17",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.2.4",
|
||||
"vite-plugin-singlefile": "2.3.0"
|
||||
@@ -308,6 +310,31 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@cosmos.gl/graph": {
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@cosmos.gl/graph/-/graph-2.6.4.tgz",
|
||||
"integrity": "sha512-i+N9lSpAjGLTUPelo/bKNbQnKPDqt3k2UnRlfIWe2Lrambc4J3QFgOfpR8AalQ/1tgLRoeNtVBZ1GPpsNqae5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-array": "^3.2.0",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-transition": "^3.0.1",
|
||||
"d3-zoom": "^3.0.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"gl-bench": "^1.0.42",
|
||||
"gl-matrix": "^3.4.3",
|
||||
"random": "^4.1.0",
|
||||
"regl": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.2.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
@@ -1511,6 +1538,13 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.1.tgz",
|
||||
@@ -1639,6 +1673,172 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1667,6 +1867,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.286",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz",
|
||||
@@ -1796,6 +2005,31 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-bench": {
|
||||
"version": "1.0.42",
|
||||
"resolved": "https://registry.npmjs.org/gl-bench/-/gl-bench-1.0.42.tgz",
|
||||
"integrity": "sha512-zuMsA/NCPmI8dPy6q3zTUH8OUM5cqKg7uVWwqzrtXJPBqoypM0XeFWEc8iFOqbf/1qtXieWOrbmgFEByKTQt4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -1803,6 +2037,15 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@@ -2246,6 +2489,18 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/random": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/random/-/random-4.1.0.tgz",
|
||||
"integrity": "sha512-6Ajb7XmMSE9EFAMGC3kg9mvE7fGlBip25mYYuSMzw/uUSrmGilvZo2qwX3RnTRjwXkwkS+4swse9otZ92VjAtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"seedrandom": "^3.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
@@ -2277,6 +2532,22 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regl": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz",
|
||||
"integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
@@ -2328,6 +2599,12 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/seedrandom": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
|
||||
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -2409,6 +2686,510 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx/node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"layout": "tsx scripts/compute_layout.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cosmos.gl/graph": "^2.6.4",
|
||||
"@webgpu/types": "^0.1.69",
|
||||
"clsx": "2.1.1",
|
||||
"react": "19.2.3",
|
||||
@@ -23,9 +24,9 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"tailwindcss": "4.1.17",
|
||||
"typescript": "5.9.3",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.2.4",
|
||||
"vite-plugin-singlefile": "2.3.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,96 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Renderer } from "./renderer";
|
||||
import { fetchGraphQueries } from "./graph_queries";
|
||||
import type { GraphQueryMeta } from "./graph_queries";
|
||||
import { fetchSelectionQueries, runSelectionQuery } from "./selection_queries";
|
||||
import type { GraphMeta, SelectionQueryMeta } from "./selection_queries";
|
||||
import { fetchSelectionQueries, runSelectionQuery, runSelectionTripleQuery } from "./selection_queries";
|
||||
import { cosmosRuntimeConfig } from "./cosmos_config";
|
||||
import type { GraphMeta, GraphRoutePoint, GraphRouteSegment, SelectionQueryMeta, SelectionTriple } from "./selection_queries";
|
||||
import { TripleGraphView } from "./TripleGraphView";
|
||||
import { buildTripleGraphModel, type TripleGraphModel } from "./triple_graph";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
type GraphNodeMeta = {
|
||||
id?: number;
|
||||
iri?: string;
|
||||
label?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
function graphRoutePoint(value: unknown): GraphRoutePoint | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.x !== "number" || typeof record.y !== "number") return null;
|
||||
return {
|
||||
x: record.x,
|
||||
y: record.y,
|
||||
};
|
||||
}
|
||||
|
||||
function graphRouteSegmentArray(value: unknown): GraphRouteSegment[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: GraphRouteSegment[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.edge_index !== "number" || typeof record.kind !== "string") continue;
|
||||
if (!Array.isArray(record.points)) continue;
|
||||
const points: GraphRoutePoint[] = [];
|
||||
for (const point of record.points) {
|
||||
const parsed = graphRoutePoint(point);
|
||||
if (!parsed) continue;
|
||||
points.push(parsed);
|
||||
}
|
||||
if (points.length < 2) continue;
|
||||
out.push({
|
||||
edge_index: record.edge_index,
|
||||
kind: record.kind,
|
||||
points,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildRouteLineVertices(routeSegments: GraphRouteSegment[]): Float32Array {
|
||||
let lineCount = 0;
|
||||
for (const route of routeSegments) {
|
||||
lineCount += Math.max(0, route.points.length - 1);
|
||||
}
|
||||
|
||||
const out = new Float32Array(lineCount * 4);
|
||||
let offset = 0;
|
||||
for (const route of routeSegments) {
|
||||
for (let i = 1; i < route.points.length; i++) {
|
||||
const previous = route.points[i - 1];
|
||||
const current = route.points[i];
|
||||
out[offset++] = previous.x;
|
||||
out[offset++] = previous.y;
|
||||
out[offset++] = current.x;
|
||||
out[offset++] = current.y;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rendererRef = useRef<Renderer | null>(null);
|
||||
@@ -28,14 +111,17 @@ export default function App() {
|
||||
const [activeGraphQueryId, setActiveGraphQueryId] = useState<string>("default");
|
||||
const [selectionQueries, setSelectionQueries] = useState<SelectionQueryMeta[]>([]);
|
||||
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 graphMetaRef = useRef<GraphMeta | null>(null);
|
||||
const selectionReqIdRef = useRef(0);
|
||||
const tripleReqIdRef = useRef(0);
|
||||
const graphInitializedRef = useRef(false);
|
||||
|
||||
// Store mouse position in a ref so it can be accessed in render loop without re-renders
|
||||
const mousePos = useRef({ x: 0, y: 0 });
|
||||
const nodesRef = useRef<any[]>([]);
|
||||
const nodesRef = useRef<GraphNodeMeta[]>([]);
|
||||
|
||||
async function loadGraph(graphQueryId: string, signal: AbortSignal): Promise<void> {
|
||||
const renderer = rendererRef.current;
|
||||
@@ -60,11 +146,13 @@ export default function App() {
|
||||
|
||||
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
|
||||
const edges = Array.isArray(graph.edges) ? graph.edges : [];
|
||||
const routeSegments = graphRouteSegmentArray(graph.route_segments);
|
||||
const meta = graph.meta || null;
|
||||
const count = nodes.length;
|
||||
|
||||
nodesRef.current = nodes;
|
||||
graphMetaRef.current = meta && typeof meta === "object" ? (meta as GraphMeta) : null;
|
||||
setTripleResult(idleTripleResult(activeSelectionQueryId));
|
||||
|
||||
// Build positions from backend-provided node coordinates.
|
||||
setStatus("Preparing buffers…");
|
||||
@@ -90,6 +178,7 @@ export default function App() {
|
||||
edgeData[i * 2] = typeof s === "number" ? s >>> 0 : 0;
|
||||
edgeData[i * 2 + 1] = typeof t === "number" ? t >>> 0 : 0;
|
||||
}
|
||||
const routeLineVertices = buildRouteLineVertices(routeSegments);
|
||||
|
||||
// Use /api/graph meta; don't do a second expensive backend call.
|
||||
if (meta && typeof meta.nodes === "number" && typeof meta.edges === "number") {
|
||||
@@ -106,13 +195,32 @@ export default function App() {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
if (signal.aborted) return;
|
||||
|
||||
const buildMs = renderer.init(xs, ys, vertexIds, edgeData);
|
||||
const buildMs = renderer.init(
|
||||
xs,
|
||||
ys,
|
||||
vertexIds,
|
||||
edgeData,
|
||||
routeLineVertices.length > 0 ? routeLineVertices : null
|
||||
);
|
||||
setNodeCount(renderer.getNodeCount());
|
||||
setSelectedNodes(new Set());
|
||||
setStatus("");
|
||||
console.log(`Init complete: ${count.toLocaleString()} nodes, ${edges.length.toLocaleString()} edges in ${buildMs.toFixed(0)}ms`);
|
||||
}
|
||||
|
||||
function getSelectedIds(renderer: Renderer, selected: Set<number>): number[] {
|
||||
const selectedIds: number[] = [];
|
||||
for (const sortedIdx of selected) {
|
||||
const origIdx = renderer.sortedIndexToOriginalIndex(sortedIdx);
|
||||
if (origIdx === null) continue;
|
||||
const node = nodesRef.current?.[origIdx];
|
||||
const nodeId = node?.id;
|
||||
if (typeof nodeId !== "number") continue;
|
||||
selectedIds.push(nodeId);
|
||||
}
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
@@ -186,14 +294,14 @@ export default function App() {
|
||||
}
|
||||
})();
|
||||
|
||||
// ── Input handling ──
|
||||
// Input handling
|
||||
let dragging = false;
|
||||
let didDrag = false; // true if mouse moved significantly during drag
|
||||
let didDrag = false;
|
||||
let downX = 0;
|
||||
let downY = 0;
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
const DRAG_THRESHOLD = 5; // pixels
|
||||
const DRAG_THRESHOLD = 5;
|
||||
|
||||
const onDown = (e: MouseEvent) => {
|
||||
dragging = true;
|
||||
@@ -207,7 +315,6 @@ export default function App() {
|
||||
mousePos.current = { x: e.clientX, y: e.clientY };
|
||||
if (!dragging) return;
|
||||
|
||||
// Check if we've moved enough to consider it a drag
|
||||
const dx = e.clientX - downX;
|
||||
const dy = e.clientY - downY;
|
||||
if (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD) {
|
||||
@@ -220,15 +327,14 @@ export default function App() {
|
||||
};
|
||||
const onUp = (e: MouseEvent) => {
|
||||
if (dragging && !didDrag) {
|
||||
// This was a click, not a drag - handle selection
|
||||
const node = renderer.findNodeIndexAt(e.clientX, e.clientY);
|
||||
if (node) {
|
||||
setSelectedNodes((prev: Set<number>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(node.index)) {
|
||||
next.delete(node.index); // Deselect if already selected
|
||||
next.delete(node.index);
|
||||
} else {
|
||||
next.add(node.index); // Select
|
||||
next.add(node.index);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
@@ -252,7 +358,7 @@ export default function App() {
|
||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
canvas.addEventListener("mouseleave", onMouseLeave);
|
||||
|
||||
// ── Render loop ──
|
||||
// Render loop
|
||||
let frameCount = 0;
|
||||
let lastTime = performance.now();
|
||||
let raf = 0;
|
||||
@@ -261,7 +367,6 @@ export default function App() {
|
||||
const result = renderer.render();
|
||||
frameCount++;
|
||||
|
||||
// Find hovered node using quadtree
|
||||
const hit = renderer.findNodeIndexAt(mousePos.current.x, mousePos.current.y);
|
||||
if (hit) {
|
||||
const origIdx = renderer.sortedIndexToOriginalIndex(hit.index);
|
||||
@@ -328,44 +433,30 @@ export default function App() {
|
||||
return () => ctrl.abort();
|
||||
}, [activeGraphQueryId]);
|
||||
|
||||
// Sync selection state to renderer
|
||||
// Left-side selection highlighting path
|
||||
useEffect(() => {
|
||||
const renderer = rendererRef.current;
|
||||
if (!renderer) return;
|
||||
|
||||
// Optimistically reflect selection immediately; highlights will be filled in by backend.
|
||||
renderer.updateSelection(selectedNodes, new Set());
|
||||
|
||||
// Invalidate any in-flight request for the previous selection/mode.
|
||||
const reqId = ++selectionReqIdRef.current;
|
||||
|
||||
// Convert selected sorted indices to backend node IDs (graph-export dense IDs).
|
||||
const selectedIds: number[] = [];
|
||||
for (const sortedIdx of selectedNodes) {
|
||||
const origIdx = renderer.sortedIndexToOriginalIndex(sortedIdx);
|
||||
if (origIdx === null) continue;
|
||||
const n = nodesRef.current?.[origIdx];
|
||||
const nodeId = n?.id;
|
||||
if (typeof nodeId !== "number") continue;
|
||||
selectedIds.push(nodeId);
|
||||
}
|
||||
const selectedIds = getSelectedIds(renderer, selectedNodes);
|
||||
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queryId = (activeSelectionQueryId || selectionQueries[0]?.id || "neighbors").trim();
|
||||
|
||||
const ctrl = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const neighborIds = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
||||
const result = await runSelectionQuery(queryId, selectedIds, graphMetaRef.current, ctrl.signal);
|
||||
if (ctrl.signal.aborted) return;
|
||||
if (reqId !== selectionReqIdRef.current) return;
|
||||
|
||||
const neighborSorted = new Set<number>();
|
||||
for (const id of neighborIds) {
|
||||
for (const id of result.neighborIds) {
|
||||
if (typeof id !== "number") continue;
|
||||
const sorted = renderer.vertexIdToSortedIndexOrNull(id);
|
||||
if (sorted === null) continue;
|
||||
@@ -375,8 +466,8 @@ export default function App() {
|
||||
renderer.updateSelection(selectedNodes, neighborSorted);
|
||||
} catch (e) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
if (reqId !== selectionReqIdRef.current) return;
|
||||
console.warn(e);
|
||||
// Keep the UI usable even if neighbors fail to load.
|
||||
renderer.updateSelection(selectedNodes, new Set());
|
||||
}
|
||||
})();
|
||||
@@ -384,213 +475,369 @@ export default function App() {
|
||||
return () => ctrl.abort();
|
||||
}, [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 (
|
||||
<div style={{ width: "100vw", height: "100vh", overflow: "hidden", background: "#000" }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: "block", width: "100%", height: "100%" }}
|
||||
/>
|
||||
<div style={{ width: "100vw", height: "100vh", display: "flex", overflow: "hidden", background: "#000" }}>
|
||||
<div style={{ position: "relative", flex: "1 1 50%", minWidth: 0, background: "#000" }}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ display: "block", width: "100%", height: "100%" }}
|
||||
/>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{status && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
color: "#0f0",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error overlay */}
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
color: "#f44",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HUD */}
|
||||
{!status && !error && (
|
||||
<>
|
||||
{status && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
left: 10,
|
||||
background: "rgba(0,0,0,0.75)",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
color: "#0f0",
|
||||
fontFamily: "monospace",
|
||||
padding: "8px 12px",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.6",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
<div>FPS: {stats.fps}</div>
|
||||
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
|
||||
<div>Mode: {stats.mode}</div>
|
||||
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
||||
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
||||
<div style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
|
||||
{backendStats && (
|
||||
<div style={{ color: "#8f8" }}>
|
||||
Backend{backendStats.backend ? ` (${backendStats.backend})` : ""}: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges
|
||||
</div>
|
||||
)}
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
background: "rgba(0,0,0,0.75)",
|
||||
color: "#888",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0,0,0,0.9)",
|
||||
color: "#f44",
|
||||
fontFamily: "monospace",
|
||||
padding: "6px 10px",
|
||||
fontSize: "11px",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
fontSize: "16px",
|
||||
}}
|
||||
>
|
||||
Drag to pan · Scroll to zoom · Click to select
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection query buttons */}
|
||||
{selectionQueries.length > 0 && (
|
||||
{!status && !error && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
pointerEvents: "auto",
|
||||
left: 10,
|
||||
background: "rgba(0,0,0,0.75)",
|
||||
color: "#0f0",
|
||||
fontFamily: "monospace",
|
||||
padding: "8px 12px",
|
||||
fontSize: "12px",
|
||||
lineHeight: "1.6",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{selectionQueries.map((q) => {
|
||||
const active = q.id === activeSelectionQueryId;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setActiveSelectionQueryId(q.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: active ? "1px solid rgba(0,255,255,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
||||
background: active ? "rgba(0,255,255,0.12)" : "rgba(255,255,255,0.04)",
|
||||
color: active ? "#0ff" : "#bbb",
|
||||
textAlign: "left",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div>FPS: {stats.fps}</div>
|
||||
<div>Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}</div>
|
||||
<div>Mode: {stats.mode}</div>
|
||||
<div>Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit</div>
|
||||
<div>Pt Size: {stats.ptSize.toFixed(1)}px</div>
|
||||
<div style={{ color: "#f80" }}>Selected: {selectedNodes.size}</div>
|
||||
{backendStats && (
|
||||
<div style={{ color: "#8f8" }}>
|
||||
Backend{backendStats.backend ? ` (${backendStats.backend})` : ""}: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Graph query buttons */}
|
||||
{graphQueries.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
pointerEvents: "auto",
|
||||
left: 10,
|
||||
background: "rgba(0,0,0,0.75)",
|
||||
color: "#888",
|
||||
fontFamily: "monospace",
|
||||
padding: "6px 10px",
|
||||
fontSize: "11px",
|
||||
borderRadius: "4px",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
Drag to pan · Scroll to zoom · Click to select
|
||||
</div>
|
||||
|
||||
{selectionQueries.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 10,
|
||||
right: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "6px",
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
padding: "8px",
|
||||
borderRadius: "6px",
|
||||
border: "1px solid rgba(255,255,255,0.08)",
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{selectionQueries.map((q) => {
|
||||
const active = q.id === activeSelectionQueryId;
|
||||
return (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setActiveSelectionQueryId(q.id)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
fontFamily: "monospace",
|
||||
fontSize: "12px",
|
||||
padding: "6px 10px",
|
||||
borderRadius: "4px",
|
||||
border: active ? "1px solid rgba(0,255,255,0.8)" : "1px solid rgba(255,255,255,0.12)",
|
||||
background: active ? "rgba(0,255,255,0.12)" : "rgba(255,255,255,0.04)",
|
||||
color: active ? "#0ff" : "#bbb",
|
||||
textAlign: "left",
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Hover tooltip */}
|
||||
{hoveredNode && (
|
||||
{tripleResult.status === "loading" && (
|
||||
<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)",
|
||||
color: "#8ab",
|
||||
fontSize: "13px",
|
||||
lineHeight: 1.6,
|
||||
padding: "14px",
|
||||
}}
|
||||
>
|
||||
<div style={{ color: "#0ff" }}>
|
||||
{hoveredNode.label || hoveredNode.iri || "(unknown)"}
|
||||
</div>
|
||||
<div style={{ color: "#688" }}>
|
||||
({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)})
|
||||
</div>
|
||||
Running triple query…
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
484
frontend/src/TripleGraphView.tsx
Normal file
484
frontend/src/TripleGraphView.tsx
Normal file
@@ -0,0 +1,484 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Graph, type GraphConfig } from "@cosmos.gl/graph";
|
||||
import { 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: "#05070a",
|
||||
spaceSize: cosmosRuntimeConfig.spaceSize,
|
||||
enableSimulation: cosmosRuntimeConfig.enableSimulation,
|
||||
enableDrag: true,
|
||||
enableZoom: true,
|
||||
fitViewOnInit: false,
|
||||
fitViewPadding: cosmosRuntimeConfig.fitViewPadding,
|
||||
rescalePositions: false,
|
||||
curvedLinks: cosmosRuntimeConfig.curvedLinks,
|
||||
simulationDecay: cosmosRuntimeConfig.simulationDecay,
|
||||
simulationGravity: cosmosRuntimeConfig.simulationGravity,
|
||||
simulationCenter: cosmosRuntimeConfig.simulationCenter,
|
||||
simulationRepulsion: cosmosRuntimeConfig.simulationRepulsion,
|
||||
simulationLinkSpring: cosmosRuntimeConfig.simulationLinkSpring,
|
||||
simulationLinkDistance: cosmosRuntimeConfig.simulationLinkDistance,
|
||||
simulationFriction: cosmosRuntimeConfig.simulationFriction,
|
||||
renderHoveredPointRing: true,
|
||||
hoveredPointRingColor: "#35d6ff",
|
||||
hoveredPointCursor: "pointer",
|
||||
hoveredLinkCursor: "pointer",
|
||||
hoveredLinkColor: "#ffd166",
|
||||
hoveredLinkWidthIncrease: 2.5,
|
||||
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: "#05070a" }}>
|
||||
<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(() => {
|
||||
graph.fitViewByPointPositions(Array.from(model.pointPositions), 0, 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);
|
||||
}
|
||||
28
frontend/src/cosmos_config.ts
Normal file
28
frontend/src/cosmos_config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const cosmosRuntimeConfig = {
|
||||
enableSimulation: parseBoolean(import.meta.env.VITE_COSMOS_ENABLE_SIMULATION, true),
|
||||
debugLayout: parseBoolean(import.meta.env.VITE_COSMOS_DEBUG_LAYOUT, false),
|
||||
spaceSize: parseNumber(import.meta.env.VITE_COSMOS_SPACE_SIZE, 4096),
|
||||
curvedLinks: parseBoolean(import.meta.env.VITE_COSMOS_CURVED_LINKS, true),
|
||||
fitViewPadding: parseNumber(import.meta.env.VITE_COSMOS_FIT_VIEW_PADDING, 0.12),
|
||||
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),
|
||||
simulationLinkSpring: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_LINK_SPRING, 1),
|
||||
simulationLinkDistance: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_LINK_DISTANCE, 10),
|
||||
simulationFriction: parseNumber(import.meta.env.VITE_COSMOS_SIMULATION_FRICTION, 0.1),
|
||||
} as const;
|
||||
@@ -76,6 +76,9 @@ export class Renderer {
|
||||
private selectedProgram: WebGLProgram;
|
||||
private neighborProgram: WebGLProgram;
|
||||
private vao: WebGLVertexArrayObject;
|
||||
private nodeVbo: WebGLBuffer;
|
||||
private lineVao: WebGLVertexArrayObject;
|
||||
private lineVbo: WebGLBuffer;
|
||||
|
||||
// Data
|
||||
private leaves: Leaf[] = [];
|
||||
@@ -88,6 +91,8 @@ export class Renderer {
|
||||
private leafEdgeStarts: Uint32Array = new Uint32Array(0);
|
||||
private leafEdgeCounts: Uint32Array = new Uint32Array(0);
|
||||
private maxPtSize = 256;
|
||||
private useRawLineSegments = false;
|
||||
private rawLineVertexCount = 0;
|
||||
|
||||
// Multi-draw extension
|
||||
private multiDrawExt: any = null;
|
||||
@@ -163,15 +168,23 @@ export class Renderer {
|
||||
|
||||
// Create VAO + VBO (empty for now)
|
||||
this.vao = gl.createVertexArray()!;
|
||||
this.nodeVbo = gl.createBuffer()!;
|
||||
gl.bindVertexArray(this.vao);
|
||||
const vbo = gl.createBuffer()!;
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeVbo);
|
||||
|
||||
// We forced a_pos to location 0 in compileProgram
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||
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.selectionIbo = gl.createBuffer()!;
|
||||
this.neighborIbo = gl.createBuffer()!;
|
||||
@@ -192,7 +205,8 @@ export class Renderer {
|
||||
xs: Float32Array,
|
||||
ys: Float32Array,
|
||||
vertexIds: Uint32Array,
|
||||
edges: Uint32Array
|
||||
edges: Uint32Array,
|
||||
routeLineVertices: Float32Array | null = null
|
||||
): number {
|
||||
const t0 = performance.now();
|
||||
const gl = this.gl;
|
||||
@@ -213,6 +227,7 @@ export class Renderer {
|
||||
|
||||
// Upload sorted particles to GPU as STATIC VBO (never changes)
|
||||
gl.bindVertexArray(this.vao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeVbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, sorted, gl.STATIC_DRAW);
|
||||
gl.bindVertexArray(null);
|
||||
|
||||
@@ -236,6 +251,19 @@ export class Renderer {
|
||||
}
|
||||
this.vertexIdToSortedIndex = vertexIdToSortedIndex;
|
||||
|
||||
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);
|
||||
gl.bindVertexArray(this.lineVao);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this.lineVbo);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, routeLineVertices, gl.STATIC_DRAW);
|
||||
gl.bindVertexArray(null);
|
||||
return performance.now() - t0;
|
||||
}
|
||||
|
||||
// Remap edges from vertex IDs to sorted indices
|
||||
const lineIndices = new Uint32Array(edgeCount * 2);
|
||||
let validEdges = 0;
|
||||
@@ -572,24 +600,30 @@ export class Renderer {
|
||||
}
|
||||
|
||||
// 5. Draw Lines if deeply zoomed in (< 20k total visible particles)
|
||||
if (totalVisibleParticles < 20000 && visibleCount > 0) {
|
||||
if (totalVisibleParticles < 20000) {
|
||||
gl.useProgram(this.lineProgram);
|
||||
gl.uniform2f(this.uCenterLine, this.cx, this.cy);
|
||||
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++) {
|
||||
const leafIdx = this.visibleLeafIndices[i];
|
||||
const edgeCount = this.leafEdgeCounts[leafIdx];
|
||||
if (edgeCount === 0) continue;
|
||||
// Each edge is 2 indices (1 line segment)
|
||||
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
|
||||
const edgeStart = this.leafEdgeStarts[leafIdx];
|
||||
gl.drawElements(gl.LINES, edgeCount * 2, gl.UNSIGNED_INT, edgeStart * 2 * 4);
|
||||
for (let i = 0; i < visibleCount; i++) {
|
||||
const leafIdx = this.visibleLeafIndices[i];
|
||||
const edgeCount = this.leafEdgeCounts[leafIdx];
|
||||
if (edgeCount === 0) continue;
|
||||
// Each edge is 2 indices (1 line segment)
|
||||
// Offset is in bytes: edgeStart * 2 (indices per edge) * 4 (bytes per uint32)
|
||||
const edgeStart = this.leafEdgeStarts[leafIdx];
|
||||
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
|
||||
|
||||
@@ -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[]> {
|
||||
const res = await fetch("/api/selection_queries", { signal });
|
||||
@@ -12,7 +61,7 @@ export async function runSelectionQuery(
|
||||
selectedIds: number[],
|
||||
graphMeta: GraphMeta | null,
|
||||
signal: AbortSignal
|
||||
): Promise<number[]> {
|
||||
): Promise<SelectionQueryResult> {
|
||||
const body = {
|
||||
query_id: queryId,
|
||||
selected_ids: selectedIds,
|
||||
@@ -29,9 +78,40 @@ export async function runSelectionQuery(
|
||||
});
|
||||
if (!res.ok) throw new Error(`POST /api/selection_query failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
const ids: unknown = data?.neighbor_ids;
|
||||
if (!Array.isArray(ids)) return [];
|
||||
const out: number[] = [];
|
||||
for (const id of ids) if (typeof id === "number") out.push(id);
|
||||
return out;
|
||||
|
||||
return {
|
||||
queryId: typeof data?.query_id === "string" ? data.query_id : queryId,
|
||||
selectedIds: numberArray(data?.selected_ids),
|
||||
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,9 @@
|
||||
export { fetchSelectionQueries, runSelectionQuery } from "./api";
|
||||
export type { GraphMeta, SelectionQueryMeta } from "./types";
|
||||
|
||||
export { fetchSelectionQueries, runSelectionQuery, runSelectionTripleQuery } from "./api";
|
||||
export type {
|
||||
GraphMeta,
|
||||
GraphRoutePoint,
|
||||
GraphRouteSegment,
|
||||
SelectionQueryMeta,
|
||||
SelectionTriple,
|
||||
SelectionTripleResult,
|
||||
} from "./types";
|
||||
|
||||
@@ -8,9 +8,49 @@ export type GraphMeta = {
|
||||
edge_limit?: number;
|
||||
nodes?: number;
|
||||
edges?: number;
|
||||
layout_engine?: string;
|
||||
layout_root_iri?: string | null;
|
||||
};
|
||||
|
||||
export type GraphRoutePoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type GraphRouteSegment = {
|
||||
edge_index: number;
|
||||
kind: string;
|
||||
points: GraphRoutePoint[];
|
||||
};
|
||||
|
||||
export type SelectionQueryMeta = {
|
||||
id: 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;
|
||||
}
|
||||
21
frontend/src/vite-env.d.ts
vendored
Normal file
21
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
/// <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_SPACE_SIZE?: string;
|
||||
readonly VITE_COSMOS_CURVED_LINKS?: 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_LINK_SPRING?: string;
|
||||
readonly VITE_COSMOS_SIMULATION_LINK_DISTANCE?: string;
|
||||
readonly VITE_COSMOS_SIMULATION_FRICTION?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
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"]
|
||||
144
radial_sugiyama/GO_PIPELINE_INTERSECTION.md
Normal file
144
radial_sugiyama/GO_PIPELINE_INTERSECTION.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Radial Sugiyama vs Go Snapshot Pipeline
|
||||
|
||||
This note delimits the algorithmic intersection between the Rust pipeline in `radial_sugiyama/` and the Go snapshot/export path in:
|
||||
|
||||
- `backend_go/graph_export.go`
|
||||
- `backend_go/graph_snapshot.go`
|
||||
|
||||
The goal is not to describe integration mechanics yet, but to mark where the two implementations solve the same problem, where they only touch indirectly, and where they are solving different problems.
|
||||
|
||||
## Scope
|
||||
|
||||
The Rust pipeline is a hierarchy-specific layout pipeline:
|
||||
|
||||
1. import ontology hierarchy from Turtle
|
||||
2. optionally filter to a rooted descendant subtree
|
||||
3. validate DAG structure
|
||||
4. assign hierarchy levels
|
||||
5. insert dummy nodes for long edges
|
||||
6. reduce crossings
|
||||
7. assign coordinates
|
||||
8. project to radial space
|
||||
9. generate routed edge artifacts
|
||||
10. export SVG
|
||||
|
||||
The Go path is a snapshot/materialization pipeline:
|
||||
|
||||
1. query predicates and edges from SPARQL
|
||||
2. accumulate nodes and edges
|
||||
3. build a graph response
|
||||
4. run a lightweight hierarchy layering + radial placement
|
||||
5. attach labels
|
||||
6. return JSON to the frontend
|
||||
|
||||
Because of that, the true intersection is narrow in `graph_export.go` and broader in the layout section of `graph_snapshot.go`.
|
||||
|
||||
## Legend
|
||||
|
||||
- `Direct overlap`: both sides implement essentially the same algorithmic concern
|
||||
- `Adjacent overlap`: one side prepares or consumes the same kind of structure, but the algorithm differs materially
|
||||
- `No overlap`: the stage exists only on one side
|
||||
|
||||
## Intersection with `graph_export.go`
|
||||
|
||||
`graph_export.go` overlaps with the Rust pipeline only at graph materialization time.
|
||||
|
||||
| Algorithmic stage | Rust pipeline | `graph_export.go` | Intersection | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Node identity and deduplication | `ttl.rs` maps class IRIs to stable node indices | `graphAccumulator.getOrAddNode` maps SPARQL terms to stable node IDs | Direct overlap | Both build a unique node set from repeated source records. |
|
||||
| Edge materialization | `ttl.rs` emits `superclass -> subclass` edges and deduplicates repeats | `graphAccumulator.addBindings` emits `source -> target` edges from SPARQL bindings | Adjacent overlap | Both convert raw triples/bindings into an in-memory graph, but Rust is specialized to `rdfs:subClassOf` while Go is predicate-agnostic. |
|
||||
| Literal / blank-node filtering | `ttl.rs` ignores blank/literal hierarchy endpoints | `getOrAddNode` skips literals and optionally keeps blank nodes | Adjacent overlap | Similar sanitation step, but not identical semantics. |
|
||||
| Predicate preservation | Rust discards all predicates except `rdfs:subClassOf` | Go preserves predicate IDs through `PredicateDict` | No overlap | This is Go-only in the compared files. |
|
||||
| Graph limits / capacity management | Rust does not enforce snapshot-style node and edge caps here | Go enforces `nodeLimit` and preallocates with edge hints | No overlap | This is an operational concern of the Go snapshot path. |
|
||||
|
||||
### Boundary for `graph_export.go`
|
||||
|
||||
The clean algorithmic seam is:
|
||||
|
||||
- Go owns generic SPARQL binding ingestion and generic graph materialization.
|
||||
- Rust owns hierarchy-specialized interpretation once a hierarchy graph has already been isolated.
|
||||
|
||||
That means `graph_export.go` is not competing with the Rust layout pipeline. It is only producing the kind of node/edge structure that Rust would eventually need as input.
|
||||
|
||||
## Intersection with `graph_snapshot.go`
|
||||
|
||||
`graph_snapshot.go` intersects with the Rust pipeline in two different regions:
|
||||
|
||||
1. graph acquisition and hierarchy preparation
|
||||
2. lightweight layout assignment
|
||||
|
||||
## Stage-by-stage comparison
|
||||
|
||||
| Algorithmic stage | Rust pipeline | `graph_snapshot.go` | Intersection | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Source acquisition | `graph_from_ttl_path` parses Turtle directly | `fetchGraphSnapshot` queries SPARQL in batches | Adjacent overlap | Both acquire a graph, but from different upstream sources. |
|
||||
| Hierarchy graph extraction | Rust keeps only `rdfs:subClassOf` during import | Go accepts a `graph_query_id` and accumulates whatever that query returns | Adjacent overlap | The overlap is meaningful only when the Go query is hierarchy-like. |
|
||||
| Rooted subtree filtering | `filter_graph_to_descendants` keeps one configured root and its descendants | No equivalent in these two Go files | No overlap | This is currently Rust-only. |
|
||||
| Cycle detection / DAG validation | `compute_hierarchy_levels` rejects cyclic graphs | `levelSynchronousKahnLayers` returns a `CycleError` if not all nodes are processed | Direct overlap | Both need a DAG to continue with hierarchy layout. |
|
||||
| Level assignment | Rust computes longest-path hierarchy levels | Go computes level-synchronous Kahn layers | Direct overlap | Same problem, different algorithm. Both assign ring depth from DAG structure. |
|
||||
| Per-level ordering | Rust later optimizes order for crossings | Go sorts each layer lexicographically by IRI | Adjacent overlap | Both define an order inside a level, but Go is a simple deterministic ordering while Rust is layout-driven. |
|
||||
| Radial node placement | Rust projects coordinates to rings after Sugiyama coordinate assignment | Go uses `radialPositionsFromLayers` to place each layer on a ring | Direct overlap | Same output shape, very different sophistication. |
|
||||
| Coordinate shifting / scaling controls | Rust has configurable radius, spacing, borders, and positive-coordinate shifting | Go uses a fixed `maxR = 5000.0` radial envelope | Adjacent overlap | Both map levels to 2D coordinates, but only Rust exposes tuned geometry controls. |
|
||||
| Label enrichment | Rust keeps node labels as imported IRIs | Go fetches `rdfs:label` after layout | Adjacent overlap | Both carry node naming, but the enrichment algorithm is currently Go-only. |
|
||||
| Response packaging | Rust writes SVG and layout artifacts | Go returns `GraphResponse` JSON plus metadata | No overlap | Same graph, different downstream consumers. |
|
||||
|
||||
## Rust-only algorithms with no counterpart in the compared Go files
|
||||
|
||||
These parts of the Rust pipeline do not currently intersect with `graph_export.go` or `graph_snapshot.go`:
|
||||
|
||||
- rooted descendant filtering
|
||||
- dummy-node insertion for long edges
|
||||
- crossing reduction / sifting
|
||||
- coordinate assignment before radial projection
|
||||
- adaptive / packed / distributed ring projection modes
|
||||
- routed edge generation
|
||||
- layout artifact generation
|
||||
- SVG rendering and export
|
||||
|
||||
These are the parts that make the Rust pipeline a true Sugiyama-style layout engine rather than a simple radial snapshot placer.
|
||||
|
||||
## Go-only algorithms with no counterpart in the Rust pipeline
|
||||
|
||||
These parts of the compared Go files do not currently exist in Rust:
|
||||
|
||||
- predicate dictionary construction from SPARQL results
|
||||
- batched SPARQL edge fetching with memory management
|
||||
- snapshot limits and backend metadata packaging
|
||||
- `rdfs:label` lookup through SPARQL
|
||||
- generic graph export over arbitrary predicate sets
|
||||
|
||||
These are acquisition and serving concerns rather than layout concerns.
|
||||
|
||||
## Algorithmic ownership boundary
|
||||
|
||||
If the future integration wants a clean division of responsibility, the strongest ownership boundary is:
|
||||
|
||||
### Go-owned stages
|
||||
|
||||
- query execution against AnzoGraph / SPARQL
|
||||
- predicate-aware graph accumulation
|
||||
- generic graph snapshot materialization
|
||||
- label fetching and API response orchestration
|
||||
|
||||
### Rust-owned stages
|
||||
|
||||
- hierarchy-specific filtering
|
||||
- hierarchy-level assignment
|
||||
- Sugiyama expansion with dummy nodes
|
||||
- crossing minimization
|
||||
- coordinate assignment
|
||||
- radial projection and route generation
|
||||
- layout artifact production
|
||||
|
||||
## Most important practical conclusion
|
||||
|
||||
At algorithm granularity, the Rust pipeline intersects only lightly with `graph_export.go`, but it intersects substantially with the hierarchy-layout portion of `graph_snapshot.go`.
|
||||
|
||||
The main replacement candidates in a future integration are therefore not the generic export/materialization routines in `graph_export.go`, but these hierarchy-layout steps currently performed by `graph_snapshot.go`:
|
||||
|
||||
1. DAG validation / cycle detection
|
||||
2. layer assignment
|
||||
3. per-layer ordering
|
||||
4. radial coordinate generation
|
||||
|
||||
Everything after that depends on how much of the Rust layout artifact model the future integration wants to expose to the frontend.
|
||||
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