diff --git a/package.json b/package.json index f93fcda..a44998f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev", - "build": "next build", + "build": "NODE_ENV=production next build", "start": "next start", "lint": "eslint" }, diff --git a/src/components/universe/UniverseGraph.tsx b/src/components/universe/UniverseGraph.tsx new file mode 100644 index 0000000..9694762 --- /dev/null +++ b/src/components/universe/UniverseGraph.tsx @@ -0,0 +1,160 @@ +"use client" + +import { useState, useMemo, useCallback, useRef, useEffect } from "react" +import { Canvas } from "@react-three/fiber" +import { CameraControls } from "@react-three/drei" +import { EffectComposer, Bloom } from "@react-three/postprocessing" +import type CameraControlsImpl from "camera-controls" +import { + buildGraph, + computeRadialLayout, + extractInitialSubgraph, + extractSubgraph, + VIRTUAL_CENTER, + GraphView, + OffscreenIndicators, + PrevNodeIndicator, +} from "@/graph-viz-kit" +import type { Graph, ViewState, RawNode, RawEdge } from "@/graph-viz-kit" + +interface Props { + rawNodes: RawNode[] + rawEdges: RawEdge[] +} + +function applyInitialLayout(graph: Graph) { + const sub = extractInitialSubgraph(graph) + const { positions, treeEdgeSet, childrenOf } = computeRadialLayout( + sub.centerId, + sub.neighborsByDepth, + graph.edges, + { parentId: sub.parentId } + ) + for (const [id, pos] of positions) { + if (id !== VIRTUAL_CENTER && id < graph.nodes.length) { + graph.nodes[id].position = pos + } + } + graph.initialDepthMap = sub.depthMap + graph.treeEdgeSet = treeEdgeSet + graph.childrenOf = childrenOf +} + +export function UniverseGraph({ rawNodes, rawEdges }: Props) { + const cameraRef = useRef(null) + const [hoveredId, setHoveredId] = useState(null) + + const graph = useMemo(() => { + const g = buildGraph(rawNodes, rawEdges) + applyInitialLayout(g) + return g + }, [rawNodes, rawEdges]) + + const [viewState, setViewState] = useState({ mode: "overview" }) + + // Reset camera + view when graph rebuilds (new search or initial load) + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setViewState({ mode: "overview" }) + const cam = cameraRef.current + if (cam) cam.setLookAt(0, 80, 0.1, 0, 0, 0, true) + }, [graph]) + + const handleNodeClick = useCallback( + (nodeId: number) => { + if (viewState.mode === "subgraph" && viewState.selectedNodeId === nodeId) return + const sub = extractSubgraph(graph, nodeId, 30, { useAdj: "undirected" }) + setViewState((prev) => { + const prevVisible = prev.mode === "subgraph" ? prev.visibleNodeIds : [] + const prevSet = new Set(prevVisible) + const newNodes = sub.nodeIds.filter((n) => !prevSet.has(n)) + const prevHistory = prev.mode === "subgraph" ? prev.navigationHistory : [] + const existingIdx = prevHistory.indexOf(nodeId) + const newHistory = + existingIdx !== -1 + ? prevHistory.slice(0, existingIdx + 1) + : [...prevHistory, nodeId] + const allVisible = [...prevVisible, ...newNodes] + const visibleSet = new Set(allVisible) + for (const hid of newHistory) { + if (!visibleSet.has(hid)) { + allVisible.push(hid) + visibleSet.add(hid) + } + } + const depthMap = new Map(sub.depthMap) + const prevNodeId = + newHistory.length >= 2 ? newHistory[newHistory.length - 2] : null + if (prevNodeId !== null && !depthMap.has(prevNodeId)) { + depthMap.set(prevNodeId, -1) + } + return { + mode: "subgraph" as const, + selectedNodeId: nodeId, + navigationHistory: newHistory, + depthMap, + neighborsByDepth: sub.neighborsByDepth, + parentId: sub.parentId, + visibleNodeIds: allVisible, + } + }) + }, + [graph, viewState] + ) + + const handleReset = useCallback(() => { + setViewState({ mode: "overview" }) + const cam = cameraRef.current + if (cam) cam.setLookAt(0, 80, 0.1, 0, 0, 0, true) + }, []) + + return ( +
+ + + + + + + + + + + {viewState.mode === "subgraph" && ( + + )} +
+ ) +} diff --git a/src/components/universe/index.tsx b/src/components/universe/index.tsx index fd241e5..6b24d4a 100644 --- a/src/components/universe/index.tsx +++ b/src/components/universe/index.tsx @@ -1,7 +1,25 @@ "use client" +import { useEffect, useState, useMemo, useRef } from "react" +import dynamic from "next/dynamic" import { useAppStore } from "@/stores/app-store" import { useGraphStore } from "@/stores/graph-store" +import { useSchemaStore } from "@/stores/schema-store" +import { listNodes, listEdges } from "@/lib/graph-api" +import { apiNodesToRawNodes, apiEdgesToRawEdges } from "@/lib/graph-transform" + +const UniverseGraph = dynamic( + () => import("./UniverseGraph").then((m) => ({ default: m.UniverseGraph })), + { ssr: false } +) + +function buildStarShadows(count: number, opacity: number): string { + return Array.from({ length: count }, () => { + const x = Math.floor(Math.random() * 2000) + const y = Math.floor(Math.random() * 2000) + return `${x}px ${y}px oklch(0.85 0.01 260 / ${opacity})` + }).join(", ") +} function StarLayer({ count, @@ -14,11 +32,7 @@ function StarLayer({ duration: number opacity: number }) { - const shadows = Array.from({ length: count }, () => { - const x = Math.floor(Math.random() * 2000) - const y = Math.floor(Math.random() * 2000) - return `${x}px ${y}px oklch(0.85 0.01 260 / ${opacity})` - }).join(", ") + const shadows = useMemo(() => buildStarShadows(count, opacity), [count, opacity]) return (
s.searchTerm) - const { nodes, edges, loading } = useGraphStore() + const { nodes, edges, loading, setGraphData, setLoading } = useGraphStore() + const schemas = useSchemaStore((s) => s.schemas) + const [error, setError] = useState(null) + const initialLoaded = useRef(false) + + // Load initial graph on mount + useEffect(() => { + if (initialLoaded.current) return + initialLoaded.current = true + + const controller = new AbortController() + setLoading(true) + // eslint-disable-next-line react-hooks/set-state-in-effect + setError(null) + + Promise.all([ + listNodes({ limit: 50 }, controller.signal), + listEdges({ limit: 200 }, controller.signal), + ]) + .then(([nodesRes, edgesRes]) => { + setGraphData(nodesRes.nodes, edgesRes.edges ?? []) + }) + .catch((err) => { + if (err?.name !== "AbortError") { + setError("Failed to load graph") + } + }) + .finally(() => { + setLoading(false) + }) + + return () => controller.abort() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const rawNodes = useMemo( + () => apiNodesToRawNodes(nodes, schemas), + [nodes, schemas] + ) + const rawEdges = useMemo(() => apiEdgesToRawEdges(edges), [edges]) const hasResults = nodes.length > 0 + // Show 3D graph when nodes are available + if (!loading && !error && hasResults) { + return ( +
+ {/* Corner decorations */} +
+
+ viewport +
+
+ + {nodes.length}n {edges.length}e + +
+
+ +
+ ) + } + return (
{/* Deep background */} @@ -64,25 +136,14 @@ export function Universe() {

- Searching + {searchTerm ? "Searching" : "Loading"}

- ) : hasResults ? ( + ) : error ? (
-

- Results -

-

- {nodes.length} - - nodes - -

-

- {edges.length} edges · “{searchTerm}” -

-

- 3D graph visualization will render here +

{error}

+

+ Check your connection and try again

) : ( diff --git a/src/lib/graph-transform.ts b/src/lib/graph-transform.ts new file mode 100644 index 0000000..3e76e6c --- /dev/null +++ b/src/lib/graph-transform.ts @@ -0,0 +1,38 @@ +import type { GraphNode, GraphEdge } from "@/lib/graph-api" +import type { RawNode, RawEdge } from "@/graph-viz-kit" +import type { SchemaNode } from "@/app/ontology/page" + +const DISPLAY_KEY_FALLBACKS = ["name", "title", "label", "text", "content", "body"] + +function pickString( + props: Record | undefined, + key: string | undefined +): string | undefined { + if (!props || !key) return undefined + const v = props[key] + return typeof v === "string" && v.length > 0 ? v : undefined +} + +export function apiNodesToRawNodes(nodes: GraphNode[], schemas: SchemaNode[]): RawNode[] { + return nodes.map((node) => { + const schema = schemas.find((s) => s.type === node.node_type) + const props = node.properties as Record | undefined + let label = + pickString(props, schema?.title_key) ?? pickString(props, schema?.index) + if (!label) { + for (const key of DISPLAY_KEY_FALLBACKS) { + label = pickString(props, key) + if (label) break + } + } + return { id: node.ref_id, label: label ?? node.ref_id } + }) +} + +export function apiEdgesToRawEdges(edges: GraphEdge[]): RawEdge[] { + return edges.map((e) => ({ + source: e.source, + target: e.target, + label: e.edge_type, + })) +}