diff --git a/CLAUDE.md b/CLAUDE.md index 579a988..2cf6e50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ Knowledge graph explorer frontend. Rebuilds the core of sphinx-nav-fiber with Ne - Uses `sphinx-bridge` npm package (postMessage-based, not window.sphinx) for Sphinx app auth - Custom Dialog component using React createPortal (base-ui Dialog had portal issues with Next.js) - `.noise-bg` class uses `isolation: isolate` not `position: relative` (breaks fixed positioning on modals) -- Schema/ontology visualization uses dagre for layout, rendered as SVG +- Ontology page (`/admin/ontology`) has two views: the default network view reuses the main graph's `Neo4jCanvas` in standalone mode (`layoutKey`), fed by `src/lib/ontology-graph-data.ts`; the hierarchy view is a dagre tree rendered as SVG - All API requests go through `src/lib/api.ts` which appends signed message params and handles L402 402 retries - `NEXT_PUBLIC_USE_MOCKS=true` enables mock mode — skips all API calls, uses local fixtures diff --git a/src/app/admin/ontology/ontology-graph-3d.tsx b/src/app/admin/ontology/ontology-graph-3d.tsx deleted file mode 100644 index 941f8cb..0000000 --- a/src/app/admin/ontology/ontology-graph-3d.tsx +++ /dev/null @@ -1,439 +0,0 @@ -"use client" - -import { useState, useMemo, useCallback, useRef, useEffect } from "react" -import { Canvas } from "@react-three/fiber" -import { CameraControls, Html } 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" -import type { SchemaNode, SchemaEdge } from "@/lib/schema-types" - -interface Props { - schemas: SchemaNode[] - edges: SchemaEdge[] - selectedId: string | null - onSelect: (id: string) => void - selectedEdgeType?: string | null -} - -function schemasToGraph( - schemas: SchemaNode[], - edges: SchemaEdge[] -): { graph: Graph; indexMap: Map } { - const rawNodes: RawNode[] = schemas.map((s) => ({ - id: s.ref_id, - label: s.type, - })) - - const rawEdges: RawEdge[] = [] - const edgeSet = new Set() - - for (const e of edges) { - const key = `${e.source}-${e.target}` - if (!edgeSet.has(key)) { - edgeSet.add(key) - const isChildOf = e.edge_type === "CHILD_OF" - rawEdges.push({ - source: isChildOf ? e.target : e.source, - target: isChildOf ? e.source : e.target, - label: e.edge_type, - displayReverse: isChildOf, - }) - } - } - - const hasChildOf = edges.some((e) => e.edge_type === "CHILD_OF") - if (!hasChildOf) { - for (const s of schemas) { - if (s.parent) { - const parent = schemas.find((p) => p.type === s.parent) - if (parent) { - const key = `${parent.ref_id}-${s.ref_id}` - if (!edgeSet.has(key)) { - edgeSet.add(key) - rawEdges.push({ source: parent.ref_id, target: s.ref_id }) - } - } - } - } - } - - const graph = buildGraph(rawNodes, rawEdges) - - const indexMap = new Map() - for (let i = 0; i < schemas.length; i++) { - indexMap.set(i, schemas[i].ref_id) - } - - return { graph, indexMap } -} - -function applyInitialLayout(graph: Graph, rootIndex?: number) { - const sub = rootIndex !== undefined - ? extractSubgraph(graph, rootIndex, 30, { useAdj: "undirected" }) - : 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 -} - -function moveCameraToNode( - cam: CameraControlsImpl, - graph: Graph, - nodeId: number -) { - const p = graph.nodes[nodeId].position - const treeKids = graph.childrenOf?.get(nodeId) ?? [] - const allPts = [p, ...treeKids.map((nid) => graph.nodes[nid]?.position).filter(Boolean)] - const avgX = allPts.reduce((s, pt) => s + pt.x, 0) / allPts.length - const avgZ = allPts.reduce((s, pt) => s + pt.z, 0) / allPts.length - let maxRadius = 0 - for (const pt of allPts) { - const dx = pt.x - avgX - const dz = pt.z - avgZ - maxRadius = Math.max(maxRadius, Math.sqrt(dx * dx + dz * dz)) - } - const fovRad = (50 / 2) * (Math.PI / 180) - const cameraHeight = Math.max(5, (maxRadius * 1.05) / Math.tan(fovRad)) - cam.setLookAt(avgX, p.y + cameraHeight, avgZ + 0.1, avgX, p.y, avgZ, true) -} - -export function OntologyGraph3D({ schemas, edges, selectedId, onSelect, selectedEdgeType }: Props) { - const cameraRef = useRef(null) - - const { graph: baseGraph, indexMap } = useMemo(() => { - const result = schemasToGraph(schemas, edges) - // Find the hierarchy root (schema with no parent) so the layout - // places it at the center instead of picking highest-degree node. - const rootSchemaIdx = schemas.findIndex((s) => !s.parent) - applyInitialLayout(result.graph, rootSchemaIdx !== -1 ? rootSchemaIdx : undefined) - return result - }, [schemas, edges]) - - const [viewState, setViewState] = useState({ mode: "overview" }) - const [pinStack, setPinStack] = useState([]) - const [hoveredId, setHoveredId] = useState(null) - const pinnedNodeId = pinStack.length > 0 ? pinStack[pinStack.length - 1] : null - - // Pinned view: build a chain of radial layouts - const pinnedGraph = useMemo(() => { - if (pinStack.length === 0) return null - - let result = baseGraph - - for (const pid of pinStack) { - const node = result.nodes[pid] - if (!node) return null - - const sub = extractSubgraph(result, pid, 4, { useAdj: "undirected" }) - - if (sub.neighborsByDepth[0]) { - sub.neighborsByDepth[0].sort((a, b) => { - const ta = result.nodes[a]?.label || "" - const tb = result.nodes[b]?.label || "" - return ta.localeCompare(tb) - }) - } - - const layoutEdges = sub.edges.map((e) => ({ src: e.src, dst: e.dst })) - const layout = computeRadialLayout(pid, sub.neighborsByDepth, layoutEdges, { - parentId: sub.parentId, - }) - - const cx = node.position.x - const cy = node.position.y - const cz = node.position.z - - const clonedNodes = result.nodes.map((n, i) => { - const layoutPos = layout.positions.get(i) - if (layoutPos) { - return { ...n, position: { x: cx + layoutPos.x, y: cy + layoutPos.y, z: cz + layoutPos.z } } - } - return n - }) - - const subNodeSet = new Set(sub.nodeIds) - const filteredAdj = result.adj.map((neighbors, i) => - subNodeSet.has(i) ? neighbors.filter((n) => subNodeSet.has(n)) : [] - ) - const filteredEdges = result.edges.filter( - (e) => subNodeSet.has(e.src) && subNodeSet.has(e.dst) - ) - - result = { - ...result, - nodes: clonedNodes, - adj: filteredAdj, - edges: filteredEdges, - childrenOf: layout.childrenOf, - treeEdgeSet: layout.treeEdgeSet, - initialDepthMap: sub.depthMap, - } - } - - return result - }, [pinStack, baseGraph]) - - // When a node is pinned, set viewState for the pinned graph. Also drives - // an imperative camera move, so the React-compiler-friendly alternative - // (move setViewState into the click handler that sets pinnedNodeId) would - // require lifting the subgraph extraction out of here too. - useEffect(() => { - if (pinnedNodeId === null || !pinnedGraph) return - const sub = extractSubgraph(pinnedGraph, pinnedNodeId, 30, { useAdj: "undirected" }) - // eslint-disable-next-line react-hooks/set-state-in-effect -- paired with imperative camera move; refactor into pin handler is out of scope - setViewState({ - mode: "subgraph", - selectedNodeId: pinnedNodeId, - navigationHistory: [pinnedNodeId], - depthMap: sub.depthMap, - neighborsByDepth: sub.neighborsByDepth, - parentId: sub.parentId, - visibleNodeIds: sub.nodeIds, - }) - - const cam = cameraRef.current - if (cam) moveCameraToNode(cam, pinnedGraph, pinnedNodeId) - }, [pinStack, pinnedGraph, pinnedNodeId]) - - const graph = pinnedGraph ?? baseGraph - - const handleNodeClick = useCallback( - (nodeId: number) => { - const refId = indexMap.get(nodeId) - if (refId) onSelect(refId) - - 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] - - // Ensure all nodes in navigation history stay visible - const allVisible = [...prevVisible, ...newNodes] - const visibleSet = new Set(allVisible) - for (const hid of newHistory) { - if (!visibleSet.has(hid)) { - allVisible.push(hid) - visibleSet.add(hid) - } - } - - // Ensure previous node has a depth entry so it's not invisible - 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) // depth -1 = parent-like visibility - } - - return { - mode: "subgraph" as const, - selectedNodeId: nodeId, - navigationHistory: newHistory, - depthMap, - neighborsByDepth: sub.neighborsByDepth, - parentId: sub.parentId, - visibleNodeIds: allVisible, - } - }) - - const cam = cameraRef.current - if (cam) moveCameraToNode(cam, graph, nodeId) - }, - [graph, indexMap, onSelect, viewState] - ) - - const handlePin = useCallback( - (nodeId: number) => { - if (nodeId === pinnedNodeId) return - setPinStack((prev) => [...prev, nodeId]) - }, - [pinnedNodeId] - ) - - const handleReset = useCallback(() => { - setPinStack([]) - setViewState({ mode: "overview" }) - const cam = cameraRef.current - if (cam) cam.setLookAt(0, 80, 0.1, 0, 0, 0, true) - }, []) - - // Escape key to reset - useEffect(() => { - const onKeyDown = (e: KeyboardEvent) => { - if (e.key !== "Escape") return - if (pinStack.length > 0) { - // Unpin last - setPinStack((prev) => prev.slice(0, -1)) - } else if (viewState.mode === "subgraph") { - handleReset() - } - } - window.addEventListener("keydown", onKeyDown) - return () => window.removeEventListener("keydown", onKeyDown) - }, [viewState.mode, pinStack.length, handleReset]) - - if (schemas.length === 0) { - return ( -
-

No schema data

-
- ) - } - - // Selected node for pin button - const selectedNodeId = viewState.mode === "subgraph" ? viewState.selectedNodeId : null - - return ( -
- - - - - - - {/* Pin button on selected node */} - {selectedNodeId !== null && graph.nodes[selectedNodeId] && ( - - - - )} - - - - - - - - {/* Controls overlay */} - {(viewState.mode === "subgraph" || pinStack.length > 0) && ( - - )} - - {pinStack.length > 0 && ( - - )} -
- ) -} diff --git a/src/app/admin/ontology/ontology-neo4j-graph.tsx b/src/app/admin/ontology/ontology-neo4j-graph.tsx new file mode 100644 index 0000000..e7633e7 --- /dev/null +++ b/src/app/admin/ontology/ontology-neo4j-graph.tsx @@ -0,0 +1,66 @@ +"use client" + +import { memo, useCallback, useEffect, useMemo } from "react" +import { Neo4jCanvas } from "@/components/universe/neo4j-canvas" +import type { GraphNode } from "@/lib/graph-api" +import { HIERARCHY_EDGE_TYPE, ontologyGraphData } from "@/lib/ontology-graph-data" +import type { SchemaNode, SchemaEdge } from "@/lib/schema-types" + +interface Props { + schemas: SchemaNode[] + edges: SchemaEdge[] + selectedId: string | null + onSelect: (id: string) => void + /** Clear the selection (Esc) to return to the full view. */ + onClear?: () => void + selectedEdgeType?: string | null +} + +// The ontology drawn with the main graph's 2D canvas (Neo4jCanvas): each +// schema type is a node, schema relationships are edges, CHILD_OF is a quiet +// dashed skeleton, and types are coloured by their top-level branch. +// Selecting a type swaps in that type and its direct neighbors, centered on +// it; selecting an edge type shows only the types that relationship connects. +// +// Memoized so page re-renders for unrelated reasons (chat busy-state, panel +// toggles) don't re-reconcile the SVG. Keep parent-supplied handlers stable. +export const OntologyNeo4jGraph = memo(function OntologyNeo4jGraph({ + schemas, + edges, + selectedId, + onSelect, + onClear, + selectedEdgeType = null, +}: Props) { + const data = useMemo( + () => ontologyGraphData(schemas, edges, selectedId, selectedEdgeType), + [schemas, edges, selectedId, selectedEdgeType] + ) + + const handleNodeSelect = useCallback((node: GraphNode) => onSelect(node.ref_id), [onSelect]) + + // Esc clears the selection and returns to the full ontology view. + useEffect(() => { + if (!selectedId) return + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClear?.() + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [selectedId, onClear]) + + return ( + + ) +}) diff --git a/src/app/admin/ontology/page.tsx b/src/app/admin/ontology/page.tsx index 6b39e1d..377b775 100644 --- a/src/app/admin/ontology/page.tsx +++ b/src/app/admin/ontology/page.tsx @@ -1,20 +1,15 @@ "use client" import { useCallback, useEffect, useMemo, useState } from "react" -import dynamic from "next/dynamic" import { useRouter } from "next/navigation" import { OntologyGraph } from "./ontology-graph" +import { OntologyNeo4jGraph } from "./ontology-neo4j-graph" import { TypeEditor } from "./type-editor" import { EdgeTypePanel } from "./edge-type-panel" import { EdgeCreatePanel, type NewEdgeParams } from "./edge-create-panel" import { OntologyAgentPanel } from "./ontology-agent-panel" -import { Plus, ArrowLeft, Box, Grid2x2, Search, ArrowRight, HelpCircle, Sparkles } from "lucide-react" +import { Plus, ArrowLeft, Network, Share2, Search, ArrowRight, HelpCircle, Sparkles } from "lucide-react" import { useUserStore } from "@/stores/user-store" - -const OntologyGraph3D = dynamic( - () => import("./ontology-graph-3d").then((m) => ({ default: m.OntologyGraph3D })), - { ssr: false, loading: () =>

Loading 3D...

} -) import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { useSchemaStore, serializeAttributes } from "@/stores/schema-store" @@ -27,7 +22,8 @@ export default function OntologyPage() { const isAdmin = useUserStore((s) => s.isAdmin) const store = useSchemaStore() const [selectedId, setSelectedId] = useState(null) - const [view3D, setView3D] = useState(false) + // "network" (the main graph's 2D canvas) is the default; "hierarchy" is the dagre tree. + const [graphView, setGraphView] = useState<"network" | "hierarchy">("network") const [search, setSearch] = useState("") const [schemaError, setSchemaError] = useState(null) const [sidebarTab, setSidebarTab] = useState<"nodes" | "edges">("nodes") @@ -307,11 +303,11 @@ export default function OntologyPage() {