From 6fa547802c0bbc960cf68d59dc1972076342f510 Mon Sep 17 00:00:00 2001 From: enzotar Date: Tue, 10 Mar 2026 17:10:45 -0700 Subject: [PATCH] fix: replace broken graph view with HTML5 Canvas force-directed graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation used @blinksgg/canvas which is a Supabase-backed whiteboard component — nodes were computed but never passed to the Canvas (no such prop exists). Replaced with a self-contained force-directed graph using HTML5 Canvas API: drag nodes, pan, zoom, click to navigate. Co-Authored-By: Claude Opus 4.6 --- src/components/GraphView.tsx | 355 +++++++++++++++++++++++++++++------ 1 file changed, 298 insertions(+), 57 deletions(-) diff --git a/src/components/GraphView.tsx b/src/components/GraphView.tsx index a196665..7d57865 100644 --- a/src/components/GraphView.tsx +++ b/src/components/GraphView.tsx @@ -1,85 +1,326 @@ -import { useEffect, useState, useCallback, useRef } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import { useNavigate } from "react-router-dom"; -import { Provider as JotaiProvider } from "jotai"; -import { Canvas, CanvasStyleProvider, registerBuiltinCommands } from "@blinksgg/canvas"; import { useVault } from "../App"; -import { buildGraph, type GraphData } from "../lib/commands"; - -registerBuiltinCommands(); +import { buildGraph, type GraphData, type GraphEdge } from "../lib/commands"; const NODE_COLORS = [ "#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#f43f5e", "#06b6d4", "#a855f7", "#ec4899", "#14b8a6", "#ef4444", ]; +/* ── Force simulation types ─────────────────────────────── */ +interface SimNode { + id: string; + label: string; + path: string; + x: number; + y: number; + vx: number; + vy: number; + radius: number; + color: string; + linkCount: number; +} + /** - * GraphView — Force-directed graph powered by @blinksgg/canvas. + * GraphView — Force-directed graph rendered with HTML5 Canvas. + * Nodes represent notes, edges represent wikilinks between them. */ export function GraphView() { const { vaultPath } = useVault(); const navigate = useNavigate(); + const canvasRef = useRef(null); + const containerRef = useRef(null); const [graphData, setGraphData] = useState(null); - const [initialNodes, setInitialNodes] = useState([]); + const nodesRef = useRef([]); + const edgesRef = useRef([]); + const animRef = useRef(0); + const panRef = useRef({ x: 0, y: 0 }); + const zoomRef = useRef(1); + const dragRef = useRef<{ node: SimNode; offsetX: number; offsetY: number } | null>(null); + const isPanningRef = useRef(false); + const lastMouseRef = useRef({ x: 0, y: 0 }); + const hoveredRef = useRef(null); + const [nodeCount, setNodeCount] = useState(0); + const [edgeCount, setEdgeCount] = useState(0); - // Load graph data + // Load graph data from backend useEffect(() => { if (!vaultPath) return; buildGraph(vaultPath).then(data => { setGraphData(data); - // Create initial nodes with random positions - const nodes = data.nodes.map((node, i) => ({ - id: node.id, - graph_id: "vault-graph", - label: node.label, - node_type: "note", - configuration: null, - ui_properties: { - x: (Math.random() - 0.5) * 800, - y: (Math.random() - 0.5) * 600, - width: 160, - height: 60, - color: NODE_COLORS[i % NODE_COLORS.length], - }, - data: null, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - })); - setInitialNodes(nodes); + setNodeCount(data.nodes.length); + setEdgeCount(data.edges.length); }).catch(() => { }); }, [vaultPath]); - const renderNode = useCallback(({ node, isSelected }: any) => ( -
- {node.label || node.dbData?.label} -
- ), []); + // Initialize simulation when data arrives + useEffect(() => { + if (!graphData) return; - // Navigate to selected node - const handleSelectionChange = useCallback((selectedNodeIds: Set) => { - if (selectedNodeIds.size === 1) { - const [nodeId] = selectedNodeIds; - navigate(`/note/${encodeURIComponent(nodeId)}`); + const { nodes, edges } = graphData; + const simNodes: SimNode[] = nodes.map((n, i) => ({ + id: n.id, + label: n.label, + path: n.path, + x: (Math.random() - 0.5) * 400, + y: (Math.random() - 0.5) * 400, + vx: 0, + vy: 0, + radius: Math.max(6, Math.min(20, 6 + n.link_count * 2)), + color: NODE_COLORS[i % NODE_COLORS.length], + linkCount: n.link_count, + })); + + nodesRef.current = simNodes; + edgesRef.current = edges; + + // Center the view + panRef.current = { x: 0, y: 0 }; + zoomRef.current = 1; + }, [graphData]); + + // Convert screen coords to world coords + const screenToWorld = useCallback((sx: number, sy: number, canvas: HTMLCanvasElement) => { + const rect = canvas.getBoundingClientRect(); + const cx = rect.width / 2; + const cy = rect.height / 2; + return { + x: (sx - rect.left - cx - panRef.current.x) / zoomRef.current, + y: (sy - rect.top - cy - panRef.current.y) / zoomRef.current, + }; + }, []); + + // Find node at world position + const hitTest = useCallback((wx: number, wy: number): SimNode | null => { + // Iterate in reverse so topmost nodes are hit first + for (let i = nodesRef.current.length - 1; i >= 0; i--) { + const n = nodesRef.current[i]; + const dx = wx - n.x; + const dy = wy - n.y; + if (dx * dx + dy * dy <= (n.radius + 4) * (n.radius + 4)) { + return n; + } } - }, [navigate]); + return null; + }, []); + + // Animation loop — force simulation + rendering + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + let running = true; + let coolingFactor = 1; + + const tick = () => { + if (!running) return; + + const nodes = nodesRef.current; + const edges = edgesRef.current; + if (nodes.length === 0) { + animRef.current = requestAnimationFrame(tick); + return; + } + + // ── Force simulation step ── + const alpha = 0.3 * coolingFactor; + if (coolingFactor > 0.001) coolingFactor *= 0.995; + + // Repulsion (charge) + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const a = nodes[i], b = nodes[j]; + let dx = b.x - a.x; + let dy = b.y - a.y; + let dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = (150 * 150) / dist; + const fx = (dx / dist) * force * alpha; + const fy = (dy / dist) * force * alpha; + a.vx -= fx; + a.vy -= fy; + b.vx += fx; + b.vy += fy; + } + } + + // Build node index for edge lookup + const nodeMap = new Map(); + for (const n of nodes) nodeMap.set(n.id, n); + + // Attraction (springs) + for (const edge of edges) { + const a = nodeMap.get(edge.source); + const b = nodeMap.get(edge.target); + if (!a || !b) continue; + let dx = b.x - a.x; + let dy = b.y - a.y; + let dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = (dist - 100) * 0.05 * alpha; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + a.vx += fx; + a.vy += fy; + b.vx -= fx; + b.vy -= fy; + } + + // Center gravity + for (const n of nodes) { + n.vx -= n.x * 0.01 * alpha; + n.vy -= n.y * 0.01 * alpha; + } + + // Apply velocity + damping + for (const n of nodes) { + if (dragRef.current?.node === n) continue; + n.vx *= 0.6; + n.vy *= 0.6; + n.x += n.vx; + n.y += n.vy; + } + + // ── Render ── + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + ctx.clearRect(0, 0, w, h); + ctx.save(); + ctx.translate(w / 2 + panRef.current.x, h / 2 + panRef.current.y); + ctx.scale(zoomRef.current, zoomRef.current); + + // Draw edges + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.lineWidth = 1; + for (const edge of edges) { + const a = nodeMap.get(edge.source); + const b = nodeMap.get(edge.target); + if (!a || !b) continue; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + + // Draw nodes + const hovered = hoveredRef.current; + for (const n of nodes) { + const isHovered = n === hovered; + ctx.beginPath(); + ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); + ctx.fillStyle = isHovered ? "#fff" : n.color; + ctx.fill(); + + if (isHovered) { + ctx.strokeStyle = n.color; + ctx.lineWidth = 2; + ctx.stroke(); + } + + // Label + ctx.fillStyle = isHovered ? "#fff" : "rgba(255,255,255,0.7)"; + ctx.font = `${isHovered ? "bold " : ""}11px system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + ctx.fillText(n.label, n.x, n.y + n.radius + 4, 120); + } + + ctx.restore(); + animRef.current = requestAnimationFrame(tick); + }; + + animRef.current = requestAnimationFrame(tick); + + return () => { + running = false; + cancelAnimationFrame(animRef.current); + }; + }, [graphData]); + + // ── Mouse interaction handlers ── + const handleMouseDown = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + const world = screenToWorld(e.clientX, e.clientY, canvas); + const node = hitTest(world.x, world.y); + + if (node) { + dragRef.current = { node, offsetX: world.x - node.x, offsetY: world.y - node.y }; + } else { + isPanningRef.current = true; + } + lastMouseRef.current = { x: e.clientX, y: e.clientY }; + }, [screenToWorld, hitTest]); + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + if (dragRef.current) { + const world = screenToWorld(e.clientX, e.clientY, canvas); + dragRef.current.node.x = world.x - dragRef.current.offsetX; + dragRef.current.node.y = world.y - dragRef.current.offsetY; + dragRef.current.node.vx = 0; + dragRef.current.node.vy = 0; + } else if (isPanningRef.current) { + panRef.current.x += e.clientX - lastMouseRef.current.x; + panRef.current.y += e.clientY - lastMouseRef.current.y; + } else { + // Hover detection + const world = screenToWorld(e.clientX, e.clientY, canvas); + const node = hitTest(world.x, world.y); + hoveredRef.current = node; + canvas.style.cursor = node ? "pointer" : "grab"; + } + lastMouseRef.current = { x: e.clientX, y: e.clientY }; + }, [screenToWorld, hitTest]); + + const handleMouseUp = useCallback(() => { + if (dragRef.current) { + dragRef.current = null; + } + isPanningRef.current = false; + }, []); + + const handleClick = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + const world = screenToWorld(e.clientX, e.clientY, canvas); + const node = hitTest(world.x, world.y); + if (node) { + navigate(`/note/${encodeURIComponent(node.path)}`); + } + }, [screenToWorld, hitTest, navigate]); + + const handleWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const factor = e.deltaY > 0 ? 0.9 : 1.1; + zoomRef.current = Math.max(0.1, Math.min(5, zoomRef.current * factor)); + }, []); return ( - - -
- -
-
-
+
+
+ + {nodeCount} notes · {edgeCount} links + +
+ +
); }