~ / labs/infinite-canvas

infinite canvas.

a canvas2d pan + zoom surface backed by a spatial-hash grid (for culling) and an offscreen canvas cache (for view memoization). scale clamps from 0.1× to 10×; nodes outside the view are skipped entirely.

drag pan⌘ / ctrl + scroll zoompinch touch zoomnodes 481
(0, 0) · origin top-left of viewport
canvas2d · offscreen cache · spatial-hash
src / labs / infinite-canvas / InfiniteCanvas.tsx
import { useEffect, useRef, useState } from 'react';
import { CanvasCache } from '../../../lib/canvas-cache';
import { SpatialHashGrid } from '../../../lib/spatial-hash';

export type Node =
  | { type: 'rect'; x: number; y: number; width: number; height: number; color: string }
  | { type: 'circle'; x: number; y: number; radius: number; color: string }
  | { type: 'text'; x: number; y: number; text: string; color: string };

type Offset = { x: number; y: number };

export function InfiniteCanvas({ nodes }: { nodes: Node[] }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const cache = useRef(new CanvasCache(50));
  const hash = useRef(new SpatialHashGrid<Node>(500));
  const [dims, setDims] = useState({ w: 0, h: 0 });
  const [offset, setOffset] = useState<Offset>({ x: 0, y: 0 });
  const [scale, setScale] = useState(1);
  const dragging = useRef(false);
  const lastPt = useRef({ x: 0, y: 0 });

  // size the backing canvas to its parent
  useEffect(() => {
    const el = canvasRef.current;
    if (!el) return;
    const fit = () => {
      const p = el.parentElement;
      if (!p) return;
      const w = p.clientWidth;
      const h = p.clientHeight;
      el.width = w;
      el.height = h;
      setDims({ w, h });
      cache.current.clear();
    };
    fit();
    const ro = new ResizeObserver(fit);
    if (el.parentElement) ro.observe(el.parentElement);
    return () => ro.disconnect();
  }, []);

  // reindex on node change
  useEffect(() => {
    hash.current.clear();
    for (const n of nodes) {
      const w = n.type === 'circle' ? n.radius * 2 : n.type === 'text' ? 100 : n.width;
      const h = n.type === 'circle' ? n.radius * 2 : n.type === 'text' ? 20 : n.height;
      hash.current.insert({ bounds: { x: n.x, y: n.y, width: w, height: h }, data: n });
    }
  }, [nodes]);

  // paint
  useEffect(() => {
    const ctx = canvasRef.current?.getContext('2d');
    if (!ctx) return;
    if (dims.w === 0 || dims.h === 0) return;

    ctx.clearRect(0, 0, dims.w, dims.h);

    const view = {
      x: offset.x,
      y: offset.y,
      width: dims.w / scale,
      height: dims.h / scale,
    };

    const cached = cache.current.getCachedCanvas(view.x, view.y, view.width, view.height, scale);
    if (cached) {
      ctx.drawImage(cached, 0, 0);
      return;
    }

    const visible = Array.from(hash.current.query(view)).map((i) => i.data);
    if (visible.length === 0) return;

    const off = document.createElement('canvas');
    off.width = dims.w;
    off.height = dims.h;
    const octx = off.getContext('2d');
    if (!octx) return;

    for (const n of visible) {
      const x = (n.x - offset.x) * scale;
      const y = (n.y - offset.y) * scale;
      if (n.type === 'rect') {
        octx.fillStyle = n.color;
        octx.fillRect(x, y, n.width * scale, n.height * scale);
      } else if (n.type === 'circle') {
        octx.beginPath();
        octx.fillStyle = n.color;
        octx.arc(x, y, n.radius * scale, 0, Math.PI * 2);
        octx.fill();
      } else if (n.type === 'text') {
        octx.fillStyle = n.color;
        octx.font = `${12 * scale}px ui-monospace, Menlo, monospace`;
        octx.fillText(n.text, x, y);
      }
    }

    cache.current.cacheCanvas(view.x, view.y, view.width, view.height, scale, off);
    ctx.drawImage(off, 0, 0);
  }, [nodes, dims.w, dims.h, offset, scale]);

  // pan + zoom
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    let activeTouches: Touch[] = [];
    let initialDistance = 0;
    let initialScale = 1;

    const dist = (a: Touch, b: Touch) => Math.hypot(b.clientX - a.clientX, b.clientY - a.clientY);
    const mid = (a: Touch, b: Touch) => {
      const r = canvas.getBoundingClientRect();
      return { x: (a.clientX + b.clientX) / 2 - r.left, y: (a.clientY + b.clientY) / 2 - r.top };
    };

    const onPtDown = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      dragging.current = true;
      lastPt.current = { x: e.clientX, y: e.clientY };
      canvas.setPointerCapture(e.pointerId);
    };
    const onPtMove = (e: PointerEvent) => {
      if (e.pointerType === 'touch' || !dragging.current) return;
      const dx = e.clientX - lastPt.current.x;
      const dy = e.clientY - lastPt.current.y;
      setOffset((p) => ({ x: p.x - dx / scale, y: p.y - dy / scale }));
      lastPt.current = { x: e.clientX, y: e.clientY };
    };
    const onPtUp = (e: PointerEvent) => {
      if (e.pointerType === 'touch') return;
      dragging.current = false;
      canvas.releasePointerCapture(e.pointerId);
    };
    const onWheel = (e: WheelEvent) => {
      if (e.ctrlKey || e.metaKey) {
        e.preventDefault();
        const factor = Math.pow(1.005, -e.deltaY);
        const r = canvas.getBoundingClientRect();
        const mx = e.clientX - r.left;
        const my = e.clientY - r.top;
        const ns = Math.min(Math.max(scale * factor, 0.1), 10);
        setOffset((p) => ({ x: p.x + (mx / scale - mx / ns), y: p.y + (my / scale - my / ns) }));
        setScale(ns);
      } else {
        setOffset((p) => ({ x: p.x + e.deltaX / scale, y: p.y + e.deltaY / scale }));
      }
    };
    const onTouchStart = (e: TouchEvent) => {
      e.preventDefault();
      if (e.touches.length === 2) {
        initialDistance = dist(e.touches[0], e.touches[1]);
        initialScale = scale;
      } else if (e.touches.length === 1) {
        lastPt.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
      }
      activeTouches = Array.from(e.touches);
    };
    const onTouchMove = (e: TouchEvent) => {
      e.preventDefault();
      if (e.touches.length === 2 && activeTouches.length === 2) {
        const d = dist(e.touches[0], e.touches[1]);
        const c = mid(e.touches[0], e.touches[1]);
        const ratio = d / initialDistance;
        const damp = ratio > 1 ? 0.1 : 0.2;
        const sf = 1 + (ratio - 1) * damp;
        const ns = Math.min(Math.max(initialScale * sf, 0.1), 10);
        setOffset((p) => ({ x: p.x + (c.x / scale - c.x / ns), y: p.y + (c.y / scale - c.y / ns) }));
        setScale(ns);
      } else if (e.touches.length === 1 && activeTouches.length >= 1) {
        const t = e.touches[0];
        const dx = t.clientX - lastPt.current.x;
        const dy = t.clientY - lastPt.current.y;
        setOffset((p) => ({ x: p.x - dx / scale, y: p.y - dy / scale }));
        lastPt.current = { x: t.clientX, y: t.clientY };
      }
      activeTouches = Array.from(e.touches);
    };
    const onTouchEnd = (e: TouchEvent) => {
      activeTouches = Array.from(e.touches);
    };

    canvas.addEventListener('pointerdown', onPtDown);
    canvas.addEventListener('pointermove', onPtMove);
    canvas.addEventListener('pointerup', onPtUp);
    canvas.addEventListener('pointercancel', onPtUp);
    canvas.addEventListener('wheel', onWheel, { passive: false });
    canvas.addEventListener('touchstart', onTouchStart, { passive: false });
    canvas.addEventListener('touchmove', onTouchMove, { passive: false });
    canvas.addEventListener('touchend', onTouchEnd);
    canvas.addEventListener('touchcancel', onTouchEnd);

    return () => {
      canvas.removeEventListener('pointerdown', onPtDown);
      canvas.removeEventListener('pointermove', onPtMove);
      canvas.removeEventListener('pointerup', onPtUp);
      canvas.removeEventListener('pointercancel', onPtUp);
      canvas.removeEventListener('wheel', onWheel);
      canvas.removeEventListener('touchstart', onTouchStart);
      canvas.removeEventListener('touchmove', onTouchMove);
      canvas.removeEventListener('touchend', onTouchEnd);
      canvas.removeEventListener('touchcancel', onTouchEnd);
    };
  }, [scale]);

  return (
    <canvas
      ref={canvasRef}
      style={{
        display: 'block',
        width: '100%',
        height: '100%',
        cursor: dragging.current ? 'grabbing' : 'grab',
        touchAction: 'none',
      }}
    />
  );
}

// deterministic demo scene — phosphor-themed shapes + coord labels
export function buildDemoScene(count = 400): Node[] {
  const rng = mulberry32(0xcafe);
  const nodes: Node[] = [];
  const palette = [
    'oklch(0.86 0.19 145)', // accent
    'oklch(0.55 0.13 145)', // accent dim
    'oklch(0.78 0.16 315)', // magenta
    'oklch(0.82 0.13 85)', // amber
    'oklch(0.78 0.11 210)', // cyan
    '#2a2a2a',
  ];

  // coord grid labels
  for (let gx = -4; gx <= 4; gx++) {
    for (let gy = -4; gy <= 4; gy++) {
      nodes.push({
        type: 'text',
        x: gx * 400,
        y: gy * 400,
        text: `${gx * 400}, ${gy * 400}`,
        color: '#3a3a3a',
      });
    }
  }

  // scattered shapes
  for (let i = 0; i < count; i++) {
    const x = (rng() - 0.5) * 4000;
    const y = (rng() - 0.5) * 4000;
    const color = palette[Math.floor(rng() * palette.length)];
    if (rng() > 0.5) {
      nodes.push({ type: 'rect', x, y, width: 20 + rng() * 80, height: 20 + rng() * 80, color });
    } else {
      nodes.push({ type: 'circle', x, y, radius: 10 + rng() * 40, color });
    }
  }
  return nodes;
}

// deterministic rng
function mulberry32(seed: number) {
  let a = seed;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}