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:
parent
9dcfece5bf
commit
6fa547802c
1 changed files with 298 additions and 57 deletions
|
|
@ -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 · {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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue