Network graph
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Drag a node through the graph; hover to light up its connections.
Code
"use client";
import { useEffect, useRef } from "react";
import { fitCanvas, prefersReducedMotion, rng, Surface } from "../ui";
const NODES = ["You", "Design", "Research", "Product", "Brand", "Web", "iOS", "Data", "Ops", "Sales", "Support", "Docs"];
const EDGES: [number, number][] = [
[0, 1], [0, 2], [0, 3], [1, 4], [1, 5], [1, 6], [3, 5], [3, 6], [3, 7], [2, 7],
[7, 8], [3, 9], [9, 10], [10, 11], [5, 11], [8, 10], [4, 9],
];
type N = { x: number; y: number; vx: number; vy: number; label: string; hub: boolean };
export default function NetworkGraph() {
const canvas = useRef<HTMLCanvasElement>(null);
const ui = useRef({ hover: -1, drag: -1, mx: 0, my: 0 });
const nodesRef = useRef<N[]>([]);
useEffect(() => {
const cv = canvas.current;
if (!cv) return;
const still = prefersReducedMotion();
const r = rng(5);
let ctx: CanvasRenderingContext2D | null = null;
let W = 0;
let H = 0;
let raf = 0;
let nodes: N[] = [];
const size = () => {
const b = cv.getBoundingClientRect();
const first = W === 0;
W = b.width;
H = b.height;
ctx = fitCanvas(cv, W, H);
if (first) {
nodes = NODES.map((label, i) => ({
x: W / 2 + (r() - 0.5) * W * 0.5,
y: H / 2 + (r() - 0.5) * H * 0.5,
vx: 0,
vy: 0,
label,
hub: i === 0,
}));
nodesRef.current = nodes;
}
};
size();
const ro = new ResizeObserver(size);
ro.observe(cv);
let t = 0;
const loop = () => {
t++;
const u = ui.current;
const unit = Math.min(W, H);
const rest = unit * 0.17;
// forces
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i];
const b = nodes[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const d2 = dx * dx + dy * dy + 0.01;
const f = (unit * unit * 0.0032) / d2;
const d = Math.sqrt(d2);
a.vx -= (dx / d) * f;
a.vy -= (dy / d) * f;
b.vx += (dx / d) * f;
b.vy += (dy / d) * f;
}
}
for (const [i, j] of EDGES) {
const a = nodes[i];
const b = nodes[j];
const dx = b.x - a.x;
const dy = b.y - a.y;
const d = Math.hypot(dx, dy) + 0.01;
const f = (d - rest) * 0.004;
a.vx += (dx / d) * f;
a.vy += (dy / d) * f;
b.vx -= (dx / d) * f;
b.vy -= (dy / d) * f;
}
nodes.forEach((n, i) => {
n.vx += (W / 2 - n.x) * 0.0015 + Math.sin(t * 0.01 + i) * 0.01;
n.vy += (H / 2 - n.y) * 0.004 + Math.cos(t * 0.012 + i) * 0.01;
if (i === u.drag) {
n.x = u.mx;
n.y = u.my;
n.vx = 0;
n.vy = 0;
return;
}
n.vx *= 0.86;
n.vy *= 0.86;
n.x = Math.max(20, Math.min(W - 20, n.x + n.vx));
n.y = Math.max(unit * 0.12, Math.min(H - unit * 0.1, n.y + n.vy));
});
if (ctx) {
ctx.clearRect(0, 0, W, H);
const focus = u.drag >= 0 ? u.drag : u.hover;
const linked = new Set<number>();
if (focus >= 0) {
linked.add(focus);
for (const [a, b] of EDGES) {
if (a === focus) linked.add(b);
if (b === focus) linked.add(a);
}
}
for (const [i, j] of EDGES) {
const on = focus >= 0 && (i === focus || j === focus);
ctx.strokeStyle = on ? "#2B46B8" : focus >= 0 ? "rgba(21,21,21,0.08)" : "rgba(21,21,21,0.22)";
ctx.lineWidth = on ? 1.6 : 1;
ctx.beginPath();
ctx.moveTo(nodes[i].x, nodes[i].y);
ctx.lineTo(nodes[j].x, nodes[j].y);
ctx.stroke();
}
const fs = Math.max(9, unit * 0.034);
ctx.font = `500 ${fs}px ui-sans-serif, system-ui, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
nodes.forEach((n, i) => {
const dim = focus >= 0 && !linked.has(i);
const rad = n.hub ? unit * 0.03 : unit * 0.017;
ctx!.globalAlpha = dim ? 0.3 : 1;
ctx!.beginPath();
ctx!.arc(n.x, n.y, rad + (i === focus ? 2 : 0), 0, Math.PI * 2);
ctx!.fillStyle = n.hub || i === focus ? "#151515" : "#ffffff";
ctx!.fill();
ctx!.lineWidth = 1.5;
ctx!.strokeStyle = i === focus ? "#2B46B8" : "#151515";
ctx!.stroke();
ctx!.fillStyle = "#151515";
ctx!.fillText(n.label, n.x, n.y - rad - fs * 0.8);
ctx!.globalAlpha = 1;
});
}
if (!still) raf = requestAnimationFrame(loop);
};
loop();
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
};
}, []);
const hit = (e: React.PointerEvent<HTMLCanvasElement>) => {
const b = e.currentTarget.getBoundingClientRect();
const x = e.clientX - b.left;
const y = e.clientY - b.top;
const nodes = nodesRef.current;
const rad = Math.min(b.width, b.height) * 0.05;
let best = -1;
let bd = rad;
nodes.forEach((n, i) => {
const d = Math.hypot(n.x - x, n.y - y);
if (d < bd) {
bd = d;
best = i;
}
});
return { x, y, best };
};
return (
<Surface>
<canvas
ref={canvas}
className="absolute inset-0 h-full w-full touch-none"
onPointerMove={(e) => {
const h = hit(e);
ui.current.mx = h.x;
ui.current.my = h.y;
if (ui.current.drag < 0) ui.current.hover = h.best;
e.currentTarget.style.cursor = ui.current.drag >= 0 ? "grabbing" : h.best >= 0 ? "grab" : "default";
}}
onPointerDown={(e) => {
const h = hit(e);
if (h.best < 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
Object.assign(ui.current, { drag: h.best, mx: h.x, my: h.y });
}}
onPointerUp={() => (ui.current.drag = -1)}
onPointerCancel={() => (ui.current.drag = -1)}
onPointerLeave={() => (ui.current.hover = -1)}
/>
<p className="pointer-events-none absolute bottom-[5%] font-mono text-[0.7em] text-[#8a8984]">drag a node</p>
</Surface>
);
}
Prompt
Build "Network graph", a canvas animation. What it does: Drag a node through the graph; hover to light up its connections. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).