fix: replace broken graph view with HTML5 Canvas force-directed graph

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 <noreply@anthropic.com>
This commit is contained in:
enzotar 2026-03-10 17:10:45 -07:00
parent 9dcfece5bf
commit 6fa547802c

View file

@ -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<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [graphData, setGraphData] = useState<GraphData | null>(null);
const [initialNodes, setInitialNodes] = useState<any[]>([]);
const nodesRef = useRef<SimNode[]>([]);
const edgesRef = useRef<GraphEdge[]>([]);
const animRef = useRef<number>(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<SimNode | null>(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) => (
<div
className={`graph-canvas-node ${isSelected ? "selected" : ""}`}
style={{
background: node.color || "#8b5cf6",
borderColor: isSelected ? "#fff" : "transparent",
}}
>
<span className="graph-canvas-label">{node.label || node.dbData?.label}</span>
</div>
), []);
// Initialize simulation when data arrives
useEffect(() => {
if (!graphData) return;
// Navigate to selected node
const handleSelectionChange = useCallback((selectedNodeIds: Set<string>) => {
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<string, SimNode>();
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
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<HTMLCanvasElement>) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
zoomRef.current = Math.max(0.1, Math.min(5, zoomRef.current * factor));
}, []);
return (
<JotaiProvider>
<CanvasStyleProvider isDark={true}>
<div className="graph-canvas-wrapper">
<Canvas
renderNode={renderNode}
onSelectionChange={handleSelectionChange}
minZoom={0.1}
maxZoom={5}
/>
</div>
</CanvasStyleProvider>
</JotaiProvider>
<div className="graph-canvas-wrapper" ref={containerRef}>
<div className="graph-header" style={{ position: "absolute", top: 12, left: 16, zIndex: 10, display: "flex", gap: 12, alignItems: "center" }}>
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>
{nodeCount} notes &middot; {edgeCount} links
</span>
</div>
<canvas
ref={canvasRef}
style={{ width: "100%", height: "100%", cursor: "grab" }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onClick={handleClick}
onWheel={handleWheel}
/>
</div>
);
}