From 696844f341d38654c94386bf64cc834810ed53ed Mon Sep 17 00:00:00 2001 From: Oxy8 <34687508+Oxy8@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:13:27 -0300 Subject: [PATCH] radial sugiyama positioning integration --- .dockerignore | 6 + .env.example | 14 + .gitignore | 1 + Requisitos.md | 23 + backend_go/Dockerfile | 25 +- backend_go/config.go | 33 + backend_go/graph_snapshot.go | 80 +- backend_go/hierarchy_layout_bridge.go | 268 ++ backend_go/hierarchy_layout_bridge_test.go | 158 + backend_go/models.go | 50 +- backend_go/selection_queries/helpers.go | 82 +- backend_go/selection_queries/neighbors.go | 49 +- backend_go/selection_queries/subclasses.go | 26 +- backend_go/selection_queries/superclasses.go | 26 +- backend_go/selection_queries/types.go | 27 +- backend_go/selection_query.go | 19 +- backend_go/server.go | 134 +- backend_go/snapshot_service.go | 19 +- docker-compose.yml | 36 +- frontend/README.md | 17 + frontend/package-lock.json | 781 +++++ frontend/package.json | 5 +- frontend/src/App.tsx | 647 ++-- frontend/src/TripleGraphView.tsx | 484 +++ frontend/src/cosmos_config.ts | 28 + frontend/src/renderer.ts | 64 +- frontend/src/selection_queries/api.ts | 94 +- frontend/src/selection_queries/index.ts | 12 +- frontend/src/selection_queries/types.ts | 40 + frontend/src/triple_graph.ts | 363 +++ frontend/src/vite-env.d.ts | 21 + radial_sugiyama/.dockerignore | 6 + ...r_Visualizing_Hierarchical_Information.pdf | Bin 0 -> 2858504 bytes radial_sugiyama/Cargo.lock | 286 ++ radial_sugiyama/Cargo.toml | 15 + radial_sugiyama/Dockerfile | 20 + radial_sugiyama/GO_PIPELINE_INTERSECTION.md | 144 + radial_sugiyama/VISUALIZATION_TIMELINE.md | 141 + radial_sugiyama/out/layout.svg | 985 ++++++ .../src/bin/radial_sugiyama_go_bridge.rs | 38 + radial_sugiyama/src/bridge.rs | 784 +++++ radial_sugiyama/src/env_config.rs | 324 ++ radial_sugiyama/src/error.rs | 70 + radial_sugiyama/src/filter.rs | 159 + radial_sugiyama/src/layering.rs | 88 + radial_sugiyama/src/layout.rs | 2888 +++++++++++++++++ radial_sugiyama/src/lib.rs | 42 + radial_sugiyama/src/main.rs | 29 + radial_sugiyama/src/model.rs | 133 + radial_sugiyama/src/svg_export.rs | 469 +++ radial_sugiyama/src/ttl.rs | 200 ++ 51 files changed, 10089 insertions(+), 364 deletions(-) create mode 100644 .dockerignore create mode 100644 backend_go/hierarchy_layout_bridge.go create mode 100644 backend_go/hierarchy_layout_bridge_test.go create mode 100644 frontend/src/TripleGraphView.tsx create mode 100644 frontend/src/cosmos_config.ts create mode 100644 frontend/src/triple_graph.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 radial_sugiyama/.dockerignore create mode 100644 radial_sugiyama/A_Radial_Adaptation_of_the_Sugiyama_Framework_for_Visualizing_Hierarchical_Information.pdf create mode 100644 radial_sugiyama/Cargo.lock create mode 100644 radial_sugiyama/Cargo.toml create mode 100644 radial_sugiyama/Dockerfile create mode 100644 radial_sugiyama/GO_PIPELINE_INTERSECTION.md create mode 100644 radial_sugiyama/VISUALIZATION_TIMELINE.md create mode 100644 radial_sugiyama/out/layout.svg create mode 100644 radial_sugiyama/src/bin/radial_sugiyama_go_bridge.rs create mode 100644 radial_sugiyama/src/bridge.rs create mode 100644 radial_sugiyama/src/env_config.rs create mode 100644 radial_sugiyama/src/error.rs create mode 100644 radial_sugiyama/src/filter.rs create mode 100644 radial_sugiyama/src/layering.rs create mode 100644 radial_sugiyama/src/layout.rs create mode 100644 radial_sugiyama/src/lib.rs create mode 100644 radial_sugiyama/src/main.rs create mode 100644 radial_sugiyama/src/model.rs create mode 100644 radial_sugiyama/src/svg_export.rs create mode 100644 radial_sugiyama/src/ttl.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..328cb61 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +data +.git +.gitignore +frontend/node_modules +frontend/dist +radial_sugiyama/target diff --git a/.env.example b/.env.example index e6a45f9..6eb25ea 100644 --- a/.env.example +++ b/.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 diff --git a/.gitignore b/.gitignore index f8c9630..2caa30e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ frontend/dist/ .npm/ .vite/ data/ +target/ \ No newline at end of file diff --git a/Requisitos.md b/Requisitos.md index e69de29..7e0bd61 100644 --- a/Requisitos.md +++ b/Requisitos.md @@ -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 + diff --git a/backend_go/Dockerfile b/backend_go/Dockerfile index f71fe82..53ce558 100644 --- a/backend_go/Dockerfile +++ b/backend_go/Dockerfile @@ -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 diff --git a/backend_go/config.go b/backend_go/config.go index 98753af..c60a961 100644 --- a/backend_go/config.go +++ b/backend_go/config.go @@ -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 } diff --git a/backend_go/graph_snapshot.go b/backend_go/graph_snapshot.go index 4e97036..a35b302 100644 --- a/backend_go/graph_snapshot.go +++ b/backend_go/graph_snapshot.go @@ -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 { diff --git a/backend_go/hierarchy_layout_bridge.go b/backend_go/hierarchy_layout_bridge.go new file mode 100644 index 0000000..7c77433 --- /dev/null +++ b/backend_go/hierarchy_layout_bridge.go @@ -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) +} diff --git a/backend_go/hierarchy_layout_bridge_test.go b/backend_go/hierarchy_layout_bridge_test.go new file mode 100644 index 0000000..7c4b2fe --- /dev/null +++ b/backend_go/hierarchy_layout_bridge_test.go @@ -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) + } +} diff --git a/backend_go/models.go b/backend_go/models.go index 77a0dbc..8a701eb 100644 --- a/backend_go/models.go +++ b/backend_go/models.go @@ -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"` +} diff --git a/backend_go/selection_queries/helpers.go b/backend_go/selection_queries/helpers.go index 4563cde..4c1f6f3 100644 --- a/backend_go/selection_queries/helpers.go +++ b/backend_go/selection_queries/helpers.go @@ -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 } diff --git a/backend_go/selection_queries/neighbors.go b/backend_go/selection_queries/neighbors.go index 60ccd91..4722466 100644 --- a/backend_go/selection_queries/neighbors.go +++ b/backend_go/selection_queries/neighbors.go @@ -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: PREFIX rdfs: PREFIX 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) } diff --git a/backend_go/selection_queries/subclasses.go b/backend_go/selection_queries/subclasses.go index 9de4151..fba0b1a 100644 --- a/backend_go/selection_queries/subclasses.go +++ b/backend_go/selection_queries/subclasses.go @@ -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: -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) } diff --git a/backend_go/selection_queries/superclasses.go b/backend_go/selection_queries/superclasses.go index 8841941..c4c2220 100644 --- a/backend_go/selection_queries/superclasses.go +++ b/backend_go/selection_queries/superclasses.go @@ -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: -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) } diff --git a/backend_go/selection_queries/types.go b/backend_go/selection_queries/types.go index 62467bb..c0699b0 100644 --- a/backend_go/selection_queries/types.go +++ b/backend_go/selection_queries/types.go @@ -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) } diff --git a/backend_go/selection_query.go b/backend_go/selection_query.go index 825296d..929ab7c 100644 --- a/backend_go/selection_query.go +++ b/backend_go/selection_query.go @@ -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) } diff --git a/backend_go/server.go b/backend_go/server.go index 5a042ec..69e014e 100644 --- a/backend_go/server.go +++ b/backend_go/server.go @@ -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) { diff --git a/backend_go/snapshot_service.go b/backend_go/snapshot_service.go index 3a112b0..99e2896 100644 --- a/backend_go/snapshot_service.go +++ b/backend_go/snapshot_service.go @@ -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 { diff --git a/docker-compose.yml b/docker-compose.yml index 957c254..18c8aa4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 \ No newline at end of file + - ./data/internal:/opt/anzograph/internal:Z diff --git a/frontend/README.md b/frontend/README.md index 1ba0847..9591584 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f5871ab..af5f4d3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 46eb7ee..b681701 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a91e57..e6021e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 { 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; + 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; + 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(null); const rendererRef = useRef(null); @@ -28,14 +111,17 @@ export default function App() { const [activeGraphQueryId, setActiveGraphQueryId] = useState("default"); const [selectionQueries, setSelectionQueries] = useState([]); const [activeSelectionQueryId, setActiveSelectionQueryId] = useState("neighbors"); + const [tripleResult, setTripleResult] = useState(() => idleTripleResult("neighbors")); + const [tripleGraphModel, setTripleGraphModel] = useState(null); const [backendStats, setBackendStats] = useState<{ nodes: number; edges: number; backend?: string } | null>(null); const graphMetaRef = useRef(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([]); + const nodesRef = useRef([]); async function loadGraph(graphQueryId: string, signal: AbortSignal): Promise { 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[] { + 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) => { 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(); - 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 ( -
- +
+
+ - {/* Loading overlay */} - {status && ( -
- {status} -
- )} - - {/* Error overlay */} - {error && ( -
- Error: {error} -
- )} - - {/* HUD */} - {!status && !error && ( - <> + {status && (
-
FPS: {stats.fps}
-
Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}
-
Mode: {stats.mode}
-
Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit
-
Pt Size: {stats.ptSize.toFixed(1)}px
-
Selected: {selectedNodes.size}
- {backendStats && ( -
- Backend{backendStats.backend ? ` (${backendStats.backend})` : ""}: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges -
- )} + {status}
+ )} + + {error && (
- Drag to pan · Scroll to zoom · Click to select + Error: {error}
+ )} - {/* Selection query buttons */} - {selectionQueries.length > 0 && ( + {!status && !error && ( + <>
- {selectionQueries.map((q) => { - const active = q.id === activeSelectionQueryId; - return ( - - ); - })} +
FPS: {stats.fps}
+
Drawn: {stats.drawn.toLocaleString()} / {nodeCount.toLocaleString()}
+
Mode: {stats.mode}
+
Zoom: {stats.zoom < 0.01 ? stats.zoom.toExponential(2) : stats.zoom.toFixed(2)} px/unit
+
Pt Size: {stats.ptSize.toFixed(1)}px
+
Selected: {selectedNodes.size}
+ {backendStats && ( +
+ Backend{backendStats.backend ? ` (${backendStats.backend})` : ""}: {backendStats.nodes.toLocaleString()} nodes, {backendStats.edges.toLocaleString()} edges +
+ )}
- )} - - {/* Graph query buttons */} - {graphQueries.length > 0 && (
- {graphQueries.map((q) => { - const active = q.id === activeGraphQueryId; - return ( - - ); - })} + Drag to pan · Scroll to zoom · Click to select +
+ + {selectionQueries.length > 0 && ( +
+ {selectionQueries.map((q) => { + const active = q.id === activeSelectionQueryId; + return ( + + ); + })} +
+ )} + + {graphQueries.length > 0 && ( +
+ {graphQueries.map((q) => { + const active = q.id === activeGraphQueryId; + return ( + + ); + })} +
+ )} + + {hoveredNode && ( +
+
+ {hoveredNode.label || hoveredNode.iri || "(unknown)"} +
+
+ ({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)}) +
+
+ )} + + )} +
+ +
+
+
+ {resultQueryLabel} +
+
Selection Graph
+
+ Nodes: {(tripleGraphModel?.nodeCount ?? 0).toLocaleString()} · Edges: {(tripleGraphModel?.edgeCount ?? 0).toLocaleString()} +
+
+ Layout: {cosmosRuntimeConfig.enableSimulation ? "force-directed" : "static"} · Camera: static · Center force: {cosmosRuntimeConfig.simulationCenter} · Repulsion: {cosmosRuntimeConfig.simulationRepulsion} · Link spring: {cosmosRuntimeConfig.simulationLinkSpring} · Friction: {cosmosRuntimeConfig.simulationFriction} +
+
+ +
+ {tripleResult.status === "idle" && ( +
+ Select nodes on the left to view returned triples
)} - {/* Hover tooltip */} - {hoveredNode && ( + {tripleResult.status === "loading" && (
-
- {hoveredNode.label || hoveredNode.iri || "(unknown)"} -
-
- ({hoveredNode.x.toFixed(2)}, {hoveredNode.y.toFixed(2)}) -
+ Running triple query…
)} - - )} + + {tripleResult.status === "error" && ( +
+ {tripleResult.errorMessage || "Triple query failed"} +
+ )} + + {tripleResult.status === "ready" && (!tripleGraphModel || tripleGraphModel.edgeCount === 0) && ( +
+ No returned graph +
+ )} + + {tripleResult.status === "ready" && tripleGraphModel && tripleGraphModel.edgeCount > 0 && ( + + )} +
+
); } diff --git a/frontend/src/TripleGraphView.tsx b/frontend/src/TripleGraphView.tsx new file mode 100644 index 0000000..1c5d61e --- /dev/null +++ b/frontend/src/TripleGraphView.tsx @@ -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(null); + const graphRef = useRef(null); + const modelRef = useRef(model); + const debugLogTimeRef = useRef(0); + const [hovered, setHovered] = useState(null); + const [pinned, setPinned] = useState(null); + const [layoutDebug, setLayoutDebug] = useState({ + 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 ( +
+
+ + {cosmosRuntimeConfig.debugLayout && ( +
+
+ Layout Debug +
+
+ phase: {layoutDebug.phase} · event: {layoutDebug.lastEvent} +
+
+ alpha: {formatMaybeNumber(layoutDebug.alpha)} · progress: {formatNumber(layoutDebug.progress)} +
+
zoom: {formatNumber(layoutDebug.zoomLevel)}
+
seed centroid
+
+ ({formatNumber(model.seedMetrics.centroidX)}, {formatNumber(model.seedMetrics.centroidY)}) +
+
+ bounds: {formatNumber(model.seedMetrics.width)} × {formatNumber(model.seedMetrics.height)} · r={formatNumber(model.seedMetrics.maxRadius)} +
+
current centroid
+
+ ({formatNumber(layoutDebug.currentMetrics.centroidX)}, {formatNumber(layoutDebug.currentMetrics.centroidY)}) +
+
+ bounds: {formatNumber(layoutDebug.currentMetrics.width)} × {formatNumber(layoutDebug.currentMetrics.height)} · r={formatNumber(layoutDebug.currentMetrics.maxRadius)} +
+
screen center
+
+ ({formatNumber(layoutDebug.screenCenter.x)}, {formatNumber(layoutDebug.screenCenter.y)}) +
+
screen origin
+
+ ({formatNumber(layoutDebug.screenOrigin.x)}, {formatNumber(layoutDebug.screenOrigin.y)}) d=({formatNumber(layoutDebug.originDelta.x)}, {formatNumber(layoutDebug.originDelta.y)}) +
+
screen centroid
+
+ ({formatNumber(layoutDebug.screenCentroid.x)}, {formatNumber(layoutDebug.screenCentroid.y)}) d=({formatNumber(layoutDebug.centroidDelta.x)}, {formatNumber(layoutDebug.centroidDelta.y)}) +
+
+ near space boundary: {layoutDebug.nearSpaceBoundary ? "yes" : "no"} +
+
+ )} + +
+
+ {pinned ? "Pinned details" : activeDetail ? "Hovered details" : "Inspector"} +
+ + {!activeDetail && ( +
+ Hover a node or edge to inspect it. Click a node or edge to pin its details. +
+ )} + + {activeDetail?.kind === "node" && ( + <> +
+ Node +
+
{activeDetail.node.text}
+ {typeof activeDetail.node.backendId === "number" && ( +
+ backend id: {activeDetail.node.backendId} +
+ )} + {activeDetail.node.isSelectedSource && ( +
+ selected source node +
+ )} + + )} + + {activeDetail?.kind === "link" && ( + <> +
+ Edge +
+
{activeDetail.link.predicateText}
+
from
+
{activeDetail.link.sourceText}
+
to
+
{activeDetail.link.targetText}
+ {typeof activeDetail.link.predicateId === "number" && ( +
+ predicate id: {activeDetail.link.predicateId} +
+ )} + + )} +
+
+ ); +}); + +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); +} diff --git a/frontend/src/cosmos_config.ts b/frontend/src/cosmos_config.ts new file mode 100644 index 0000000..65df336 --- /dev/null +++ b/frontend/src/cosmos_config.ts @@ -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; diff --git a/frontend/src/renderer.ts b/frontend/src/renderer.ts index 2c74169..b0d4465 100644 --- a/frontend/src/renderer.ts +++ b/frontend/src/renderer.ts @@ -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 diff --git a/frontend/src/selection_queries/api.ts b/frontend/src/selection_queries/api.ts index 47503e4..c8422ef 100644 --- a/frontend/src/selection_queries/api.ts +++ b/frontend/src/selection_queries/api.ts @@ -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; + 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; + 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 { const res = await fetch("/api/selection_queries", { signal }); @@ -12,7 +61,7 @@ export async function runSelectionQuery( selectedIds: number[], graphMeta: GraphMeta | null, signal: AbortSignal -): Promise { +): Promise { 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 { + 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), + }; } diff --git a/frontend/src/selection_queries/index.ts b/frontend/src/selection_queries/index.ts index b40a0d2..a89f097 100644 --- a/frontend/src/selection_queries/index.ts +++ b/frontend/src/selection_queries/index.ts @@ -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"; diff --git a/frontend/src/selection_queries/types.ts b/frontend/src/selection_queries/types.ts index d7eb77b..a85a998 100644 --- a/frontend/src/selection_queries/types.ts +++ b/frontend/src/selection_queries/types.ts @@ -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[]; +}; diff --git a/frontend/src/triple_graph.ts b/frontend/src/triple_graph.ts new file mode 100644 index 0000000..f316666 --- /dev/null +++ b/frontend/src/triple_graph.ts @@ -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(selectedIds); + const nodeMap = new Map(); + + 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(); + 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, + term: TripleGraphTerm, + backendId: number | undefined, + selectedSet: Set +): 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): 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; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..99f4f74 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,21 @@ +/// + +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; +} diff --git a/radial_sugiyama/.dockerignore b/radial_sugiyama/.dockerignore new file mode 100644 index 0000000..a4caf20 --- /dev/null +++ b/radial_sugiyama/.dockerignore @@ -0,0 +1,6 @@ +target +out +data +.env +*.pdf +VISUALIZATION_TIMELINE.md diff --git a/radial_sugiyama/A_Radial_Adaptation_of_the_Sugiyama_Framework_for_Visualizing_Hierarchical_Information.pdf b/radial_sugiyama/A_Radial_Adaptation_of_the_Sugiyama_Framework_for_Visualizing_Hierarchical_Information.pdf new file mode 100644 index 0000000000000000000000000000000000000000..665a14d820019178c3bd2ef91c1b5103886b9a84 GIT binary patch literal 2858504 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