repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
The Living OS cockpit
stars
latest
clone command
git clone gitlawb://did:key:z6Mku78K...XywC/living-os-cockp...git clone gitlawb://did:key:z6Mku78K.../living-os-cockp...59751530feat: surface worker supervisor health in live work5h ago| #1 | 'use client'; |
| #2 | |
| #3 | import dynamic from 'next/dynamic'; |
| #4 | import { RotateCcw } from 'lucide-react'; |
| #5 | import { type PointerEvent, type WheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; |
| #6 | import { Vector3 } from 'three'; |
| #7 | |
| #8 | const ForceGraph3D = dynamic(() => import('react-force-graph-3d'), { |
| #9 | ssr: false, |
| #10 | loading: () => <div className="vault-graph-loading" />, |
| #11 | }); |
| #12 | |
| #13 | type VaultNode = { |
| #14 | id: string; |
| #15 | title: string; |
| #16 | path: string; |
| #17 | folder?: string; |
| #18 | tags?: string[]; |
| #19 | preview?: string; |
| #20 | }; |
| #21 | |
| #22 | type VaultEdge = { |
| #23 | id: string; |
| #24 | source: string; |
| #25 | target: string; |
| #26 | type: string; |
| #27 | label?: string; |
| #28 | context?: string; |
| #29 | }; |
| #30 | |
| #31 | type Props = { |
| #32 | nodes: VaultNode[]; |
| #33 | edges: VaultEdge[]; |
| #34 | onPrompt: (value: string) => void; |
| #35 | }; |
| #36 | |
| #37 | type CameraSnapshot = { |
| #38 | position: { x: number; y: number; z: number }; |
| #39 | target: { x: number; y: number; z: number }; |
| #40 | }; |
| #41 | |
| #42 | declare global { |
| #43 | interface Window { |
| #44 | __vaultGraph3DDebug?: { |
| #45 | ready: boolean; |
| #46 | nodeCount: number; |
| #47 | snapshotCamera: () => CameraSnapshot | null; |
| #48 | setCamera: (position: CameraSnapshot['position'], target: CameraSnapshot['target']) => void; |
| #49 | firstNodeScreenPoint: () => { x: number; y: number; id: string } | null; |
| #50 | nodeScreenPoints: () => Array<{ x: number; y: number; id: string }>; |
| #51 | }; |
| #52 | } |
| #53 | } |
| #54 | |
| #55 | const EDGE_COLORS: Record<string, string> = { |
| #56 | wiki: '#d9b15f', |
| #57 | backlink: '#f4d488', |
| #58 | tag: '#7da5d8', |
| #59 | folder: '#6ec07a', |
| #60 | related: '#b8923f', |
| #61 | }; |
| #62 | |
| #63 | function edgeColor(type?: string) { |
| #64 | return EDGE_COLORS[type || ''] || '#837a6a'; |
| #65 | } |
| #66 | |
| #67 | function edgeLabel(type?: string) { |
| #68 | if (type === 'wiki') return 'wiki-link'; |
| #69 | return type || 'edge'; |
| #70 | } |
| #71 | |
| #72 | export default function VaultGraph3D({ nodes, edges, onPrompt }: Props) { |
| #73 | const frameRef = useRef<HTMLDivElement | null>(null); |
| #74 | const graphRef = useRef<any>(null); |
| #75 | const graphDataCacheRef = useRef<{ signature: string; data: { nodes: any[]; links: any[] } } | null>(null); |
| #76 | const graphDataRef = useRef<{ nodes: any[]; links: any[] }>({ nodes: [], links: [] }); |
| #77 | const cameraSnapshotRef = useRef<CameraSnapshot | null>(null); |
| #78 | const restoreFrameRef = useRef<number | null>(null); |
| #79 | const pointerStartRef = useRef<{ x: number; y: number } | null>(null); |
| #80 | const [graphReadyTick, setGraphReadyTick] = useState(0); |
| #81 | const [width, setWidth] = useState(640); |
| #82 | const [selectedNode, setSelectedNode] = useState<VaultNode | null>(null); |
| #83 | const [selectedEdge, setSelectedEdge] = useState<VaultEdge | null>(null); |
| #84 | |
| #85 | useEffect(() => { |
| #86 | const update = () => { |
| #87 | const rect = frameRef.current?.getBoundingClientRect(); |
| #88 | if (rect?.width) setWidth(Math.max(280, Math.floor(rect.width))); |
| #89 | }; |
| #90 | update(); |
| #91 | const observer = new ResizeObserver(update); |
| #92 | if (frameRef.current) observer.observe(frameRef.current); |
| #93 | return () => observer.disconnect(); |
| #94 | }, []); |
| #95 | |
| #96 | const graphData = useMemo(() => { |
| #97 | const signature = JSON.stringify({ |
| #98 | nodes: nodes.map(node => ({ |
| #99 | id: node.id, |
| #100 | title: node.title, |
| #101 | path: node.path, |
| #102 | folder: node.folder ?? '', |
| #103 | tags: node.tags ?? [], |
| #104 | preview: node.preview ?? '', |
| #105 | })), |
| #106 | edges: edges.map(edge => ({ |
| #107 | id: edge.id, |
| #108 | source: edge.source, |
| #109 | target: edge.target, |
| #110 | type: edge.type, |
| #111 | label: edge.label ?? '', |
| #112 | context: edge.context ?? '', |
| #113 | })), |
| #114 | }); |
| #115 | if (graphDataCacheRef.current?.signature === signature) return graphDataCacheRef.current.data; |
| #116 | const data = { |
| #117 | nodes: nodes.map(node => ({ ...node })), |
| #118 | links: edges.map(edge => ({ ...edge })), |
| #119 | }; |
| #120 | graphDataCacheRef.current = { signature, data }; |
| #121 | return data; |
| #122 | }, [nodes, edges]); |
| #123 | graphDataRef.current = graphData; |
| #124 | |
| #125 | const snapshotCamera = useCallback((): CameraSnapshot | null => { |
| #126 | const camera = graphRef.current?.camera?.(); |
| #127 | if (!camera?.position) return cameraSnapshotRef.current; |
| #128 | const controls = graphRef.current?.controls?.(); |
| #129 | const target = controls?.target ?? { x: 0, y: 0, z: 0 }; |
| #130 | const snapshot = { |
| #131 | position: { |
| #132 | x: Number(camera.position.x), |
| #133 | y: Number(camera.position.y), |
| #134 | z: Number(camera.position.z), |
| #135 | }, |
| #136 | target: { |
| #137 | x: Number(target.x), |
| #138 | y: Number(target.y), |
| #139 | z: Number(target.z), |
| #140 | }, |
| #141 | }; |
| #142 | cameraSnapshotRef.current = snapshot; |
| #143 | return snapshot; |
| #144 | }, []); |
| #145 | |
| #146 | const restoreCamera = useCallback((snapshot = cameraSnapshotRef.current) => { |
| #147 | if (!snapshot || !graphRef.current) return; |
| #148 | if (restoreFrameRef.current) cancelAnimationFrame(restoreFrameRef.current); |
| #149 | restoreFrameRef.current = requestAnimationFrame(() => { |
| #150 | graphRef.current?.cameraPosition?.(snapshot.position, snapshot.target, 0); |
| #151 | const controls = graphRef.current?.controls?.(); |
| #152 | if (controls?.target?.set) { |
| #153 | controls.target.set(snapshot.target.x, snapshot.target.y, snapshot.target.z); |
| #154 | controls.update?.(); |
| #155 | } |
| #156 | }); |
| #157 | }, []); |
| #158 | |
| #159 | const selectNode = useCallback((node: any) => { |
| #160 | const snapshot = snapshotCamera(); |
| #161 | setSelectedNode(node as VaultNode); |
| #162 | setSelectedEdge(null); |
| #163 | restoreCamera(snapshot); |
| #164 | }, [restoreCamera, snapshotCamera]); |
| #165 | |
| #166 | const selectEdge = useCallback((link: any) => { |
| #167 | const snapshot = snapshotCamera(); |
| #168 | setSelectedEdge(link as VaultEdge); |
| #169 | setSelectedNode(null); |
| #170 | restoreCamera(snapshot); |
| #171 | }, [restoreCamera, snapshotCamera]); |
| #172 | |
| #173 | const resetView = useCallback(() => { |
| #174 | graphRef.current?.zoomToFit?.(650, 52); |
| #175 | window.setTimeout(() => snapshotCamera(), 700); |
| #176 | }, [snapshotCamera]); |
| #177 | |
| #178 | const nodeScreenPoints = useCallback(() => { |
| #179 | const camera = graphRef.current?.camera?.(); |
| #180 | const rect = frameRef.current?.getBoundingClientRect(); |
| #181 | if (!camera || !rect) return []; |
| #182 | camera.updateMatrixWorld?.(); |
| #183 | camera.updateProjectionMatrix?.(); |
| #184 | const points: Array<{ x: number; y: number; id: string }> = []; |
| #185 | for (const node of graphDataRef.current.nodes) { |
| #186 | if (![node.x, node.y, node.z].every(Number.isFinite)) continue; |
| #187 | const point = new Vector3(Number(node.x), Number(node.y), Number(node.z)).project(camera); |
| #188 | if (point.z < -1 || point.z > 1) continue; |
| #189 | const x = rect.left + ((point.x + 1) / 2) * rect.width; |
| #190 | const y = rect.top + ((-point.y + 1) / 2) * rect.height; |
| #191 | if (x >= rect.left + 8 && x <= rect.right - 8 && y >= rect.top + 8 && y <= rect.bottom - 8) { |
| #192 | points.push({ x, y, id: String(node.id) }); |
| #193 | } |
| #194 | } |
| #195 | return points.sort((a, b) => { |
| #196 | const ax = Math.abs(a.x - (rect.left + rect.width / 2)) + Math.abs(a.y - (rect.top + rect.height / 2)); |
| #197 | const bx = Math.abs(b.x - (rect.left + rect.width / 2)) + Math.abs(b.y - (rect.top + rect.height / 2)); |
| #198 | return ax - bx; |
| #199 | }); |
| #200 | }, []); |
| #201 | |
| #202 | const nearestNodeAt = useCallback((x: number, y: number, maxDistance = 24) => { |
| #203 | let best: { id: string; distance: number } | null = null; |
| #204 | for (const point of nodeScreenPoints()) { |
| #205 | const distance = Math.hypot(point.x - x, point.y - y); |
| #206 | if (distance <= maxDistance && (!best || distance < best.distance)) { |
| #207 | best = { id: point.id, distance }; |
| #208 | } |
| #209 | } |
| #210 | if (!best) return null; |
| #211 | return graphDataRef.current.nodes.find(node => String(node.id) === best?.id) ?? null; |
| #212 | }, [nodeScreenPoints]); |
| #213 | |
| #214 | const handleFramePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => { |
| #215 | const start = pointerStartRef.current; |
| #216 | pointerStartRef.current = null; |
| #217 | if (!start) return; |
| #218 | if (Math.hypot(event.clientX - start.x, event.clientY - start.y) > 6) return; |
| #219 | const node = nearestNodeAt(event.clientX, event.clientY); |
| #220 | if (node) selectNode(node); |
| #221 | }, [nearestNodeAt, selectNode]); |
| #222 | |
| #223 | const handleWheelCapture = useCallback((_event: WheelEvent<HTMLDivElement>) => { |
| #224 | snapshotCamera(); |
| #225 | requestAnimationFrame(() => { |
| #226 | snapshotCamera(); |
| #227 | requestAnimationFrame(() => snapshotCamera()); |
| #228 | }); |
| #229 | }, [snapshotCamera]); |
| #230 | |
| #231 | useEffect(() => { |
| #232 | const controls = graphRef.current?.controls?.(); |
| #233 | if (!controls?.addEventListener) return; |
| #234 | const onChange = () => snapshotCamera(); |
| #235 | controls.addEventListener('change', onChange); |
| #236 | return () => controls.removeEventListener?.('change', onChange); |
| #237 | }, [graphReadyTick, snapshotCamera, width]); |
| #238 | |
| #239 | useLayoutEffect(() => { |
| #240 | restoreCamera(); |
| #241 | return () => { |
| #242 | if (restoreFrameRef.current) cancelAnimationFrame(restoreFrameRef.current); |
| #243 | }; |
| #244 | }, [graphData, graphReadyTick, restoreCamera, selectedEdge, selectedNode, width]); |
| #245 | |
| #246 | useEffect(() => { |
| #247 | let frame = 0; |
| #248 | let settled = false; |
| #249 | const waitForGraph = () => { |
| #250 | if (graphRef.current && !settled) { |
| #251 | settled = true; |
| #252 | setGraphReadyTick(tick => tick + 1); |
| #253 | snapshotCamera(); |
| #254 | return; |
| #255 | } |
| #256 | if (!settled) frame = requestAnimationFrame(waitForGraph); |
| #257 | }; |
| #258 | frame = requestAnimationFrame(waitForGraph); |
| #259 | return () => cancelAnimationFrame(frame); |
| #260 | }, [snapshotCamera]); |
| #261 | |
| #262 | useEffect(() => { |
| #263 | window.__vaultGraph3DDebug = { |
| #264 | ready: Boolean(graphRef.current), |
| #265 | nodeCount: graphDataRef.current.nodes.length, |
| #266 | snapshotCamera, |
| #267 | setCamera: (position, target) => { |
| #268 | cameraSnapshotRef.current = { position, target }; |
| #269 | restoreCamera(cameraSnapshotRef.current); |
| #270 | }, |
| #271 | nodeScreenPoints, |
| #272 | firstNodeScreenPoint: () => window.__vaultGraph3DDebug?.nodeScreenPoints()[0] ?? null, |
| #273 | }; |
| #274 | return () => { |
| #275 | if (window.__vaultGraph3DDebug?.snapshotCamera === snapshotCamera) { |
| #276 | delete window.__vaultGraph3DDebug; |
| #277 | } |
| #278 | }; |
| #279 | }, [graphReadyTick, nodeScreenPoints, restoreCamera, snapshotCamera]); |
| #280 | |
| #281 | return ( |
| #282 | <div className="vault-graph-3d-shell"> |
| #283 | <div className="vault-graph-toolbar"> |
| #284 | <button type="button" onClick={resetView} title="Reset view" aria-label="Reset vault graph view"> |
| #285 | <RotateCcw size={13} /> |
| #286 | <span>Reset view</span> |
| #287 | </button> |
| #288 | </div> |
| #289 | <div |
| #290 | ref={frameRef} |
| #291 | className="vault-graph-3d-frame" |
| #292 | data-testid="vault-graph-3d" |
| #293 | onWheelCapture={handleWheelCapture} |
| #294 | onPointerDown={(event) => { pointerStartRef.current = { x: event.clientX, y: event.clientY }; }} |
| #295 | onPointerUp={handleFramePointerUp} |
| #296 | > |
| #297 | <ForceGraph3D |
| #298 | ref={graphRef} |
| #299 | graphData={graphData} |
| #300 | width={width} |
| #301 | height={320} |
| #302 | backgroundColor="rgba(0,0,0,0)" |
| #303 | showNavInfo={false} |
| #304 | enableNodeDrag={false} |
| #305 | nodeLabel={(node: any) => node.title || node.id} |
| #306 | nodeColor={(node: any) => node.tags?.length ? '#d9b15f' : '#7da5d8'} |
| #307 | nodeVal={(node: any) => node.tags?.length ? 1.8 : 1.25} |
| #308 | nodeRelSize={7} |
| #309 | linkColor={(link: any) => edgeColor(link.type)} |
| #310 | linkOpacity={0.46} |
| #311 | linkWidth={(link: any) => link.type === 'wiki' ? 1.35 : 0.85} |
| #312 | linkDirectionalParticles={(link: any) => link.type === 'wiki' || link.type === 'backlink' ? 1 : 0} |
| #313 | linkDirectionalParticleSpeed={0.003} |
| #314 | linkDirectionalParticleWidth={1.4} |
| #315 | onNodeClick={selectNode} |
| #316 | onLinkClick={selectEdge} |
| #317 | /> |
| #318 | </div> |
| #319 | |
| #320 | <div className="vault-graph-legend" aria-label="Vault graph edge types"> |
| #321 | {(['wiki', 'backlink', 'tag', 'folder'] as const).map(type => ( |
| #322 | <span key={type}><i style={{ backgroundColor: edgeColor(type) }} />{edgeLabel(type)}</span> |
| #323 | ))} |
| #324 | </div> |
| #325 | |
| #326 | {selectedNode && ( |
| #327 | <div className="vault-note-view" data-testid="vault-note-view"> |
| #328 | <div> |
| #329 | <strong>{selectedNode.title}</strong> |
| #330 | <span>{selectedNode.path}</span> |
| #331 | </div> |
| #332 | {selectedNode.tags?.length ? <p>#{selectedNode.tags.join(' #')}</p> : null} |
| #333 | {selectedNode.preview ? <p>{selectedNode.preview}</p> : null} |
| #334 | <button onClick={() => onPrompt(`what does my note on ${selectedNode.title} say?`)}>Open in chat</button> |
| #335 | </div> |
| #336 | )} |
| #337 | |
| #338 | {selectedEdge && ( |
| #339 | <div className="vault-note-view" data-testid="vault-note-view"> |
| #340 | <div> |
| #341 | <strong>{edgeLabel(selectedEdge.type)}</strong> |
| #342 | <span>{selectedEdge.source} {'->'} {selectedEdge.target}</span> |
| #343 | </div> |
| #344 | <p>{selectedEdge.context || selectedEdge.label || 'Vault relationship'}</p> |
| #345 | <button onClick={() => onPrompt(`show me the connection between ${selectedEdge.source} and ${selectedEdge.target}`)}>Open in chat</button> |
| #346 | </div> |
| #347 | )} |
| #348 | </div> |
| #349 | ); |
| #350 | } |
| #351 |