Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
160 changes: 160 additions & 0 deletions src/components/universe/UniverseGraph.tsx
Original file line number Diff line number Diff line change
@@ -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<CameraControlsImpl>(null)
const [hoveredId, setHoveredId] = useState<number | null>(null)

const graph = useMemo(() => {
const g = buildGraph(rawNodes, rawEdges)
applyInitialLayout(g)
return g
}, [rawNodes, rawEdges])

const [viewState, setViewState] = useState<ViewState>({ 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 (
<div className="relative h-full w-full">
<Canvas
camera={{ position: [0, 80, 0.1], fov: 50 }}
style={{ background: "oklch(0.06 0.02 260)" }}
>
<ambientLight intensity={0.3} />
<GraphView
graph={graph}
viewState={viewState}
onNodeClick={handleNodeClick}
onHoverChange={setHoveredId}
/>
<OffscreenIndicators
graph={graph}
viewState={viewState}
onNodeClick={handleNodeClick}
hovered={hoveredId}
/>
<PrevNodeIndicator
graph={graph}
viewState={viewState}
onNodeClick={handleNodeClick}
/>
<CameraControls
ref={cameraRef}
makeDefault
dollySpeed={0.5}
truckSpeed={1}
dollyToCursor
/>
<EffectComposer>
<Bloom
luminanceThreshold={0.2}
luminanceSmoothing={0.9}
intensity={0.6}
/>
</EffectComposer>
</Canvas>
{viewState.mode === "subgraph" && (
<button
onClick={handleReset}
className="absolute top-4 left-4 rounded-md bg-background/80 px-3 py-1.5 text-xs text-foreground backdrop-blur hover:bg-background"
>
Reset view
</button>
)}
</div>
)
}
105 changes: 83 additions & 22 deletions src/components/universe/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
<div
Expand All @@ -36,10 +50,68 @@ function StarLayer({

export function Universe() {
const searchTerm = useAppStore((s) => s.searchTerm)
const { nodes, edges, loading } = useGraphStore()
const { nodes, edges, loading, setGraphData, setLoading } = useGraphStore()
const schemas = useSchemaStore((s) => s.schemas)
const [error, setError] = useState<string | null>(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 (
<div className="relative h-full w-full">
{/* Corner decorations */}
<div className="absolute top-4 left-4 z-10 flex items-center gap-2 opacity-40 pointer-events-none">
<div className="h-px w-8 bg-primary/40" />
<span className="text-[9px] font-mono text-primary/60 uppercase">viewport</span>
</div>
<div className="absolute bottom-4 right-4 z-10 flex items-center gap-2 opacity-40 pointer-events-none">
<span className="text-[9px] font-mono text-primary/60">
{nodes.length}n {edges.length}e
</span>
<div className="h-px w-8 bg-primary/40" />
</div>
<UniverseGraph rawNodes={rawNodes} rawEdges={rawEdges} />
</div>
)
}

return (
<div className="relative flex h-full w-full items-center justify-center overflow-hidden">
{/* Deep background */}
Expand All @@ -64,25 +136,14 @@ export function Universe() {
<div className="space-y-3">
<div className="mx-auto h-8 w-8 rounded-full border-2 border-primary/30 border-t-primary animate-spin" />
<p className="text-xs font-heading font-semibold uppercase tracking-[0.2em] text-primary/60">
Searching
{searchTerm ? "Searching" : "Loading"}
</p>
</div>
) : hasResults ? (
) : error ? (
<div className="space-y-3">
<p className="text-xs font-heading font-semibold uppercase tracking-[0.2em] text-primary/60">
Results
</p>
<p className="text-3xl font-heading font-bold text-foreground">
{nodes.length}
<span className="text-sm font-normal text-muted-foreground ml-2">
nodes
</span>
</p>
<p className="text-sm text-muted-foreground">
{edges.length} edges &middot; &ldquo;{searchTerm}&rdquo;
</p>
<p className="text-xs text-muted-foreground/60 mt-4">
3D graph visualization will render here
<p className="text-sm text-destructive/80">{error}</p>
<p className="text-xs text-muted-foreground">
Check your connection and try again
</p>
</div>
) : (
Expand Down
38 changes: 38 additions & 0 deletions src/lib/graph-transform.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, unknown> | 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,
}))
}
Loading