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 { useNavigate } from "react-router-dom";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
|
||||||
import { Canvas, CanvasStyleProvider, registerBuiltinCommands } from "@blinksgg/canvas";
|
|
||||||
import { useVault } from "../App";
|
import { useVault } from "../App";
|
||||||
import { buildGraph, type GraphData } from "../lib/commands";
|
import { buildGraph, type GraphData, type GraphEdge } from "../lib/commands";
|
||||||
|
|
||||||
registerBuiltinCommands();
|
|
||||||
|
|
||||||
const NODE_COLORS = [
|
const NODE_COLORS = [
|
||||||
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#f43f5e",
|
"#8b5cf6", "#3b82f6", "#10b981", "#f59e0b", "#f43f5e",
|
||||||
"#06b6d4", "#a855f7", "#ec4899", "#14b8a6", "#ef4444",
|
"#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() {
|
export function GraphView() {
|
||||||
const { vaultPath } = useVault();
|
const { vaultPath } = useVault();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [graphData, setGraphData] = useState<GraphData | null>(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(() => {
|
useEffect(() => {
|
||||||
if (!vaultPath) return;
|
if (!vaultPath) return;
|
||||||
buildGraph(vaultPath).then(data => {
|
buildGraph(vaultPath).then(data => {
|
||||||
setGraphData(data);
|
setGraphData(data);
|
||||||
// Create initial nodes with random positions
|
setNodeCount(data.nodes.length);
|
||||||
const nodes = data.nodes.map((node, i) => ({
|
setEdgeCount(data.edges.length);
|
||||||
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);
|
|
||||||
}).catch(() => { });
|
}).catch(() => { });
|
||||||
}, [vaultPath]);
|
}, [vaultPath]);
|
||||||
|
|
||||||
const renderNode = useCallback(({ node, isSelected }: any) => (
|
// Initialize simulation when data arrives
|
||||||
<div
|
useEffect(() => {
|
||||||
className={`graph-canvas-node ${isSelected ? "selected" : ""}`}
|
if (!graphData) return;
|
||||||
style={{
|
|
||||||
background: node.color || "#8b5cf6",
|
|
||||||
borderColor: isSelected ? "#fff" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="graph-canvas-label">{node.label || node.dbData?.label}</span>
|
|
||||||
</div>
|
|
||||||
), []);
|
|
||||||
|
|
||||||
// Navigate to selected node
|
const { nodes, edges } = graphData;
|
||||||
const handleSelectionChange = useCallback((selectedNodeIds: Set<string>) => {
|
const simNodes: SimNode[] = nodes.map((n, i) => ({
|
||||||
if (selectedNodeIds.size === 1) {
|
id: n.id,
|
||||||
const [nodeId] = selectedNodeIds;
|
label: n.label,
|
||||||
navigate(`/note/${encodeURIComponent(nodeId)}`);
|
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 (
|
return (
|
||||||
<JotaiProvider>
|
<div className="graph-canvas-wrapper" ref={containerRef}>
|
||||||
<CanvasStyleProvider isDark={true}>
|
<div className="graph-header" style={{ position: "absolute", top: 12, left: 16, zIndex: 10, display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
<div className="graph-canvas-wrapper">
|
<span style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||||
<Canvas
|
{nodeCount} notes · {edgeCount} links
|
||||||
renderNode={renderNode}
|
</span>
|
||||||
onSelectionChange={handleSelectionChange}
|
</div>
|
||||||
minZoom={0.1}
|
<canvas
|
||||||
maxZoom={5}
|
ref={canvasRef}
|
||||||
/>
|
style={{ width: "100%", height: "100%", cursor: "grab" }}
|
||||||
</div>
|
onMouseDown={handleMouseDown}
|
||||||
</CanvasStyleProvider>
|
onMouseMove={handleMouseMove}
|
||||||
</JotaiProvider>
|
onMouseUp={handleMouseUp}
|
||||||
|
onMouseLeave={handleMouseUp}
|
||||||
|
onClick={handleClick}
|
||||||
|
onWheel={handleWheel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue