Baktiyar Ablaikhan

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).