Glitch Signal Map

A Canvas 2D signal grid with a per-cell state machine, pulsing accents and glitchy recalibration — throttled to 30fps.

Canvas 2Dstate machineLCG noise30fpsHUD · 3 files · 1,033 lines
Canvas 2D

<GlitchSignalMap isAnimating={true} tickMs={250} updateRatio={0.04} seed={0x5e2d91af} />

man glitch-signal-map

A line ref opens it in the source below

Name

glitch-signal-map — a Canvas 2D signal grid driven by one seeded generator.

How it works

  1. One generator, no Math.random. Every value comes from lcg(seed) = (seed × 1664525 + 1013904223) >>> 0, started at 0x5e2d91af: the grid is the same on every load.

  2. A scattered walk. Every 250 ms, 4 % of the cells are rewritten (8 of the 222 on this screen), picked at (tick × 7 + i × 19 + 5) mod n, so the updates land apart instead of sweeping down the grid.

  3. Three states from one number. Bits of the new seed decide: seed & 0x7f ≤ 4 recalibrates (a 120 ms cyan jitter, then it settles), otherwise 5 in 16 turn active, and 1 in 16 carry the pink accent.

  4. Breathing, not blinking. Active and accent cells pulse on a 3.4 s sine, each shifted by (i × 11 mod 240) / 80 s, so the pulse ripples across the grid.

Cost

  1. Capped. At most 30 draws a second (1000 / 30 ms apart), devicePixelRatio held at 1.5, one fillRect per cell.

  2. Idle when still. Paused, or under prefers-reduced-motion: one last frame, then no requestAnimationFrame at all.

See also

import { memo, useEffect, useRef } from "react";
import * as styles from "./glitch-signal-map.css";

/** What a cell is showing: its state, or the accent flag drawn over it. */
export type GlitchCellKind = "idle" | "active" | "accent" | "recal";

/** The grid after a tick, for an xray readout. */
export interface GlitchSignalMapStats {
  tick: number;
  /** Cells rewritten by this tick. */
  rewritten: number;
  cells: number;
  counts: Record<GlitchCellKind, number>;
}

export interface GlitchSignalMapCell {
  index: number;
  row: number;
  col: number;
  seed: number;
  kind: GlitchCellKind;
}

export interface GlitchSignalMapProps {
  isAnimating: boolean;
  className?: string;
  /** Milliseconds between two rewrites of the grid. */
  tickMs?: number;
  /** Share of the cells rewritten per tick (0–1). */
  updateRatio?: number;
  /** Start of the generator every value comes from. */
  seed?: number;
  /** Draw the mechanism: cells by state, the ones this tick rewrote ringed in gold. */
  xray?: boolean;
  /** Changing it while paused advances the grid by one tick. */
  stepToken?: number;
  onTick?: (stats: GlitchSignalMapStats) => void;
  onHoverCell?: (cell: GlitchSignalMapCell | null) => void;
}

const BASE_SEED = 0x5e2d91af;
const LCG_A = 1664525;
const LCG_C = 1013904223;

const PAD_PX = 4;
const GAP_PX = 2;

const MAX_DPR = 1.5;
const TARGET_FRAME_MS = 1000 / 30;

const TICK_MS = 250;
const UPDATE_RATIO = 0.04;
const RECALIBRATE_DURATION_MS = 120;

const DOT_COUNT = 12;

/** The tuning the home runs with: what the props default to. */
export const GLITCH_SIGNAL_MAP_DEFAULTS = {
  tickMs: TICK_MS,
  updateRatio: UPDATE_RATIO,
  seed: BASE_SEED,
} as const;

const PULSE_PERIOD_S = 3.4;
const PULSE_RADIANS_PER_SECOND = (Math.PI * 2) / PULSE_PERIOD_S;

const STATE_IDLE = 0;
const STATE_ACTIVE = 1;
const STATE_RECALIBRATING = 2;

type BlockState = typeof STATE_IDLE | typeof STATE_ACTIVE | typeof STATE_RECALIBRATING;

interface BlockCell {
  state: BlockState;
  seed: number;
  accent: boolean;
  recalUntil: number;
  settleState: BlockState;
  settleSeed: number;
  pulseDelay: number;
}

interface DotMeta {
  topPct: number;
  leftPct: number;
  accent: boolean;
}

interface DotCell {
  x: number;
  y: number;
  accent: boolean;
}

interface RectCell {
  x: number;
  y: number;
}

interface GridLayout {
  cols: number;
  rows: number;
  bx: number;
  by: number;
  bw: number;
  bh: number;
}

const lcg = (seed: number): number => (seed * LCG_A + LCG_C) >>> 0;

const buildBlocks = (count: number, base: number = BASE_SEED): BlockCell[] => {
  const cells: BlockCell[] = [];
  let current = base;

  for (let index = 0; index < count; index += 1) {
    current = lcg(current ^ (index * 2654435761));

    cells[index] = {
      state: (current & 0x0f) < 2 ? STATE_ACTIVE : STATE_IDLE,
      seed: current,
      accent: ((current >>> 8) & 0x0f) === 0,
      recalUntil: 0,
      settleState: STATE_IDLE,
      settleSeed: current,
      pulseDelay: ((index * 11) % 240) / 80,
    };
  }

  return cells;
};

const buildDots = (count: number, base: number = BASE_SEED): DotMeta[] => {
  const dots: DotMeta[] = [];
  let seed = base ^ 0x00abc123;

  for (let index = 0; index < count; index += 1) {
    seed = lcg(seed);
    const topPct = 8 + (seed % 84);

    seed = lcg(seed);
    const leftPct = 10 + (seed % 78);

    dots[index] = {
      topPct,
      leftPct,
      accent: ((seed >>> 5) & 0x07) === 0,
    };
  }

  return dots;
};

const DOT_META = buildDots(DOT_COUNT);

const computeLayout = (width: number, height: number): GridLayout => {
  const innerW = Math.max(1, width - PAD_PX * 2);
  const innerH = Math.max(1, height - PAD_PX * 2);

  const cols = Math.max(3, Math.min(6, Math.floor(innerW / 16)));
  const rows = Math.max(14, Math.floor(innerH / 10));

  const bw = (innerW - GAP_PX * (cols - 1)) / cols;
  const bh = (innerH - GAP_PX * (rows - 1)) / rows;

  return {
    cols,
    rows,
    bx: PAD_PX,
    by: PAD_PX,
    bw,
    bh,
  };
};

const buildRects = ({ cols, rows, bx, by, bw, bh }: GridLayout): RectCell[] => {
  const rects: RectCell[] = [];

  for (let row = 0; row < rows; row += 1) {
    for (let col = 0; col < cols; col += 1) {
      rects[row * cols + col] = {
        x: bx + col * (bw + GAP_PX),
        y: by + row * (bh + GAP_PX),
      };
    }
  }

  return rects;
};

const buildDotCells = (width: number, height: number, meta: DotMeta[]): DotCell[] => {
  const innerW = Math.max(1, width - PAD_PX * 2);
  const innerH = Math.max(1, height - PAD_PX * 2);

  return meta.map((dot) => ({
    x: PAD_PX + (innerW * dot.leftPct) / 100,
    y: PAD_PX + (innerH * dot.topPct) / 100,
    accent: dot.accent,
  }));
};

const cellKind = (block: BlockCell): GlitchCellKind => {
  if (block.recalUntil > 0) return "recal";
  if (block.accent) return "accent";
  return block.state === STATE_ACTIVE ? "active" : "idle";
};

const countKinds = (blocks: BlockCell[]): Record<GlitchCellKind, number> => {
  const counts = { idle: 0, active: 0, accent: 0, recal: 0 };
  for (const block of blocks) counts[cellKind(block)] += 1;
  return counts;
};

const XRAY_FILL: Record<GlitchCellKind, string> = {
  idle: "oklch(0.79 0.16 167)",
  active: "oklch(0.79 0.16 167)",
  accent: "oklch(0.71 0.17 335)",
  recal: "oklch(0.76 0.21 178)",
};

/** Placeholder grid until the first measure; replaced, never mutated. */
const INITIAL_LAYOUT: GridLayout = { cols: 4, rows: 18, bx: PAD_PX, by: PAD_PX, bw: 10, bh: 6 };

export const GlitchSignalMap = memo(function GlitchSignalMap({
  isAnimating,
  className,
  tickMs = TICK_MS,
  updateRatio = UPDATE_RATIO,
  seed = BASE_SEED,
  xray = false,
  stepToken = 0,
  onTick,
  onHoverCell,
}: GlitchSignalMapProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const ctxRef = useRef<CanvasRenderingContext2D | null>(null);

  const isAnimatingRef = useRef(isAnimating);
  const runningRef = useRef(false);
  const reducedMotionRef = useRef(false);

  const layoutRef = useRef<GridLayout>(INITIAL_LAYOUT);

  const blocksRef = useRef<BlockCell[]>(buildBlocks(INITIAL_LAYOUT.cols * INITIAL_LAYOUT.rows));
  const rectsRef = useRef<RectCell[]>(buildRects(INITIAL_LAYOUT));
  const dotsRef = useRef<DotCell[]>([]);

  const cssWidthRef = useRef(0);
  const cssHeightRef = useRef(0);
  const dprRef = useRef(1);

  const rafRef = useRef<number | null>(null);
  const resizeRafRef = useRef<number | null>(null);

  const animStartRef = useRef(0);
  const lastFrameRef = useRef(0);
  const lastTickRef = useRef(0);
  const tickStepRef = useRef(0);

  const hoverIdxRef = useRef(-1);
  const needsDrawRef = useRef(true);

  const tickMsRef = useRef(tickMs);
  const updateRatioRef = useRef(updateRatio);
  const seedRef = useRef(seed);
  const xrayRef = useRef(xray);
  const onTickRef = useRef(onTick);
  const onHoverCellRef = useRef(onHoverCell);
  const dotMetaRef = useRef<DotMeta[]>(DOT_META);
  const touchedRef = useRef<Set<number>>(new Set());

  const startLoopRef = useRef<() => void>(() => {});
  const stepOnceRef = useRef<() => void>(() => {});
  const reseedRef = useRef<() => void>(() => {});
  const reportStatsRef = useRef<() => void>(() => {});
  const stopLoopRef = useRef<() => void>(() => {});
  const drawOnceRef = useRef<() => void>(() => {});

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;

    if (!root || !canvas) return;

    const ctx = canvas.getContext("2d", {
      alpha: true,
      desynchronized: true,
    } as CanvasRenderingContext2DSettings);

    if (!ctx) return;

    ctxRef.current = ctx;

    const cancelMainRaf = () => {
      if (rafRef.current !== null) {
        cancelAnimationFrame(rafRef.current);
        rafRef.current = null;
      }
    };

    const cancelResizeRaf = () => {
      if (resizeRafRef.current !== null) {
        cancelAnimationFrame(resizeRafRef.current);
        resizeRafRef.current = null;
      }
    };

    const shouldRun = () => isAnimatingRef.current && !reducedMotionRef.current;

    const computeHoverIndex = (mx: number, my: number) => {
      const { cols, rows, bx, by, bw, bh } = layoutRef.current;

      const innerX = mx - bx;
      const innerY = my - by;

      const col = Math.floor(innerX / (bw + GAP_PX));
      const row = Math.floor(innerY / (bh + GAP_PX));

      if (col < 0 || col >= cols || row < 0 || row >= rows) {
        return -1;
      }

      const localX = innerX - col * (bw + GAP_PX);
      const localY = innerY - row * (bh + GAP_PX);

      if (localX < 0 || localX > bw || localY < 0 || localY > bh) {
        return -1;
      }

      return row * cols + col;
    };

    const resizeCanvas = () => {
      const rect = root.getBoundingClientRect();
      const width = rect.width;
      const height = rect.height;

      if (width <= 0 || height <= 0) return;

      const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);

      cssWidthRef.current = width;
      cssHeightRef.current = height;
      dprRef.current = dpr;

      const pixelWidth = Math.max(1, Math.round(width * dpr));
      const pixelHeight = Math.max(1, Math.round(height * dpr));

      if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
        canvas.width = pixelWidth;
        canvas.height = pixelHeight;
      }

      const layout = computeLayout(width, height);
      layoutRef.current = layout;

      const count = layout.cols * layout.rows;

      if (blocksRef.current.length !== count) {
        blocksRef.current = buildBlocks(count, seedRef.current);
      }

      rectsRef.current = buildRects(layout);
      dotsRef.current = buildDotCells(width, height, dotMetaRef.current);

      needsDrawRef.current = true;
      drawOnceRef.current();
    };

    const updateBlocks = (now: number, force = false) => {
      if (!force && now - lastTickRef.current < tickMsRef.current) return;

      lastTickRef.current = now;
      tickStepRef.current += 1;

      const blocks = blocksRef.current;
      const count = blocks.length;

      if (count === 0) return;

      const batch = Math.max(1, Math.floor(count * updateRatioRef.current));
      const step = tickStepRef.current;
      const touched = new Set<number>();

      for (let index = 0; index < batch; index += 1) {
        const blockIndex = (step * 7 + index * 19 + 5) % count;
        const block = blocks[blockIndex];

        if (!block) continue;
        touched.add(blockIndex);

        const nextSeed = lcg(block.seed);
        const shouldRecalibrate = (nextSeed & 0x7f) <= 4;
        const isActive = !shouldRecalibrate && ((nextSeed >>> 4) & 0x0f) < 5;

        block.seed = nextSeed;
        block.state = shouldRecalibrate
          ? STATE_RECALIBRATING
          : isActive
            ? STATE_ACTIVE
            : STATE_IDLE;

        block.accent = ((nextSeed >>> 8) & 0x0f) === 0;

        if (shouldRecalibrate) {
          const settleSeed = lcg(nextSeed);

          block.recalUntil = now + RECALIBRATE_DURATION_MS;
          block.settleState = ((settleSeed >>> 3) & 0x0f) < 4 ? STATE_ACTIVE : STATE_IDLE;
          block.settleSeed = settleSeed;
        } else {
          block.recalUntil = 0;
        }
      }

      touchedRef.current = touched;
      needsDrawRef.current = true;
      onTickRef.current?.({
        tick: step,
        rewritten: touched.size,
        cells: count,
        counts: countKinds(blocks),
      });
    };

    // The mechanism, legible: every cell at full strength in its state's colour (idle as an
    // outline), the cells this tick rewrote ringed in gold, no pulse, no jitter, no dots.
    const drawXray = (now: number) => {
      const { bw, bh } = layoutRef.current;
      const blocks = blocksRef.current;
      const rects = rectsRef.current;
      const touched = touchedRef.current;
      const hoverIdx = hoverIdxRef.current;

      for (let index = 0; index < blocks.length; index += 1) {
        const block = blocks[index];
        const rect = rects[index];
        if (!block || !rect) continue;

        if (block.recalUntil > 0 && now >= block.recalUntil) {
          block.state = block.settleState;
          block.seed = block.settleSeed;
          block.recalUntil = 0;
        }

        const kind = cellKind(block);
        ctx.globalAlpha = 1;
        if (kind === "idle") {
          ctx.globalAlpha = 0.45;
          ctx.strokeStyle = XRAY_FILL.idle;
          ctx.lineWidth = 1;
          ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, bw - 1, bh - 1);
        } else {
          ctx.fillStyle = XRAY_FILL[kind];
          ctx.fillRect(rect.x, rect.y, bw, bh);
        }

        if (touched.has(index) || index === hoverIdx) {
          ctx.globalAlpha = 1;
          ctx.strokeStyle =
            index === hoverIdx ? "oklch(0.93 0.03 80)" : "oklch(0.8858 0.182 95.69)";
          ctx.lineWidth = index === hoverIdx ? 2 : 1.5;
          ctx.strokeRect(rect.x - 1, rect.y - 1, bw + 2, bh + 2);
        }
      }

      ctx.globalAlpha = 1;
    };

    const draw = (now: number) => {
      const width = cssWidthRef.current;
      const height = cssHeightRef.current;
      const dpr = dprRef.current;

      if (width <= 0 || height <= 0) return;

      const { bw, bh } = layoutRef.current;
      const blocks = blocksRef.current;
      const rects = rectsRef.current;
      const dots = dotsRef.current;

      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, width, height);

      ctx.globalAlpha = 1;
      ctx.fillStyle = "oklch(0.105 0.022 225 / 0.96)";
      ctx.fillRect(0, 0, width, height);

      if (xrayRef.current) {
        drawXray(now);
        needsDrawRef.current = false;
        return;
      }

      const running = shouldRun();
      const elapsed = running ? (now - animStartRef.current) / 1000 : 0;
      const hoverIdx = hoverIdxRef.current;

      for (let index = 0; index < blocks.length; index += 1) {
        const block = blocks[index];
        const rect = rects[index];

        if (!block || !rect) continue;

        if (block.recalUntil > 0 && now >= block.recalUntil) {
          block.state = block.settleState;
          block.seed = block.settleSeed;
          block.recalUntil = 0;
        }

        const isHovered = index === hoverIdx;
        const isRecalibrating = block.recalUntil > 0;

        let alpha = 0.4;
        let ox = 0;

        if (isHovered) {
          alpha = 0.95;
          ctx.fillStyle = "oklch(0.84 0.17 128)";
        } else if (isRecalibrating) {
          const phase = (now * 0.0043 + index * 0.617) % 1;

          alpha = 0.72 + 0.2 * Math.abs(Math.sin(phase * Math.PI));
          ox = Math.sin(phase * 9.3) * 0.45;

          ctx.fillStyle = "oklch(0.76 0.21 178)";
        } else if (block.accent) {
          const pulse = running
            ? Math.sin((elapsed + block.pulseDelay) * PULSE_RADIANS_PER_SECOND - Math.PI / 2) *
                0.5 +
              0.5
            : 0.5;

          alpha = 0.48 + 0.24 * pulse;
          ctx.fillStyle = "oklch(0.71 0.17 335)";
        } else if (block.state === STATE_ACTIVE) {
          const pulse = running
            ? Math.sin((elapsed + block.pulseDelay) * PULSE_RADIANS_PER_SECOND - Math.PI / 2) *
                0.5 +
              0.5
            : 0.5;

          alpha = 0.48 + 0.2 * pulse;
          ctx.fillStyle = "oklch(0.79 0.16 167)";
        } else {
          alpha = 0.34;
          ctx.fillStyle = "oklch(0.79 0.16 167)";
        }

        ctx.globalAlpha = alpha;
        ctx.fillRect(rect.x + ox, rect.y, bw, bh);
      }

      if (hoverIdx >= 0) {
        const hoverRect = rects[hoverIdx];

        if (hoverRect) {
          ctx.globalAlpha = 0.75;
          ctx.strokeStyle = "oklch(0.8858 0.182 95.69 / 0.75)";
          ctx.lineWidth = 1;
          ctx.strokeRect(hoverRect.x - 1, hoverRect.y - 1, bw + 2, bh + 2);
        }
      }

      ctx.globalAlpha = 1;

      for (let index = 0; index < dots.length; index += 1) {
        const dot = dots[index];

        if (!dot) continue;

        ctx.beginPath();
        ctx.arc(dot.x, dot.y, 1.35, 0, Math.PI * 2);
        ctx.globalAlpha = dot.accent ? 0.66 : 0.3;
        ctx.fillStyle = dot.accent ? "oklch(0.68 0.19 331)" : "oklch(0.47 0.09 167)";
        ctx.fill();
      }

      ctx.globalAlpha = 1;
      needsDrawRef.current = false;
    };

    const loop = (now: number) => {
      rafRef.current = null;

      if (!runningRef.current) return;

      if (!shouldRun()) {
        runningRef.current = false;
        draw(now);
        return;
      }

      const shouldDrawFrame = now - lastFrameRef.current >= TARGET_FRAME_MS;

      if (shouldDrawFrame) {
        lastFrameRef.current = now;

        updateBlocks(now);
        draw(now);
      }

      rafRef.current = requestAnimationFrame(loop);
    };

    const startLoop = () => {
      if (!shouldRun()) {
        runningRef.current = false;
        cancelMainRaf();
        draw(performance.now());
        return;
      }

      if (runningRef.current) return;

      runningRef.current = true;
      animStartRef.current = performance.now();
      lastFrameRef.current = 0;
      lastTickRef.current = 0;

      cancelMainRaf();
      rafRef.current = requestAnimationFrame(loop);
    };

    const stopLoop = () => {
      runningRef.current = false;
      cancelMainRaf();
      draw(performance.now());
    };

    const drawOnce = () => {
      if (runningRef.current) return;

      cancelMainRaf();
      rafRef.current = requestAnimationFrame((now) => {
        rafRef.current = null;
        draw(now);
      });
    };

    let settleTimer = 0;
    const stepOnce = () => {
      const now = performance.now();
      updateBlocks(now, true);
      lastTickRef.current = now;
      draw(now);
      // A recalibrating cell settles 120 ms later: draw it settled.
      window.clearTimeout(settleTimer);
      settleTimer = window.setTimeout(drawOnce, RECALIBRATE_DURATION_MS + 10);
    };

    const reseed = () => {
      blocksRef.current = buildBlocks(blocksRef.current.length, seedRef.current);
      dotMetaRef.current =
        seedRef.current === BASE_SEED ? DOT_META : buildDots(DOT_COUNT, seedRef.current);
      dotsRef.current = buildDotCells(
        cssWidthRef.current,
        cssHeightRef.current,
        dotMetaRef.current,
      );
      touchedRef.current = new Set();
      tickStepRef.current = 0;
      needsDrawRef.current = true;
      if (!runningRef.current) drawOnce();
    };

    const reportStats = () => {
      const blocks = blocksRef.current;
      onTickRef.current?.({
        tick: tickStepRef.current,
        rewritten: touchedRef.current.size,
        cells: blocks.length,
        counts: countKinds(blocks),
      });
    };

    startLoopRef.current = startLoop;
    stopLoopRef.current = stopLoop;
    drawOnceRef.current = drawOnce;
    stepOnceRef.current = stepOnce;
    reseedRef.current = reseed;
    reportStatsRef.current = reportStats;

    const resizeObserver = new ResizeObserver(() => {
      cancelResizeRaf();

      resizeRafRef.current = requestAnimationFrame(() => {
        resizeRafRef.current = null;
        resizeCanvas();
      });
    });

    const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");

    const updateReducedMotion = () => {
      reducedMotionRef.current = motionQuery.matches;
      needsDrawRef.current = true;

      if (motionQuery.matches) {
        stopLoop();
      } else {
        startLoop();
      }
    };

    const reportHover = (index: number) => {
      const report = onHoverCellRef.current;
      if (!report) return;
      const block = blocksRef.current[index];
      if (index < 0 || !block) {
        report(null);
        return;
      }
      const { cols } = layoutRef.current;
      report({
        index,
        row: Math.floor(index / cols),
        col: index % cols,
        seed: block.seed,
        kind: cellKind(block),
      });
    };

    const handleMouseMove = (event: MouseEvent) => {
      const rect = canvas.getBoundingClientRect();
      const mx = event.clientX - rect.left;
      const my = event.clientY - rect.top;
      const hoverIndex = computeHoverIndex(mx, my);

      if (hoverIndex === hoverIdxRef.current) return;

      hoverIdxRef.current = hoverIndex;
      needsDrawRef.current = true;
      reportHover(hoverIndex);

      if (!runningRef.current) {
        drawOnce();
      }
    };

    const handleMouseLeave = () => {
      if (hoverIdxRef.current === -1) return;

      hoverIdxRef.current = -1;
      needsDrawRef.current = true;
      reportHover(-1);

      if (!runningRef.current) {
        drawOnce();
      }
    };

    resizeCanvas();

    resizeObserver.observe(root);

    motionQuery.addEventListener("change", updateReducedMotion);
    canvas.addEventListener("mousemove", handleMouseMove, { passive: true });
    canvas.addEventListener("mouseleave", handleMouseLeave);

    updateReducedMotion();

    return () => {
      startLoopRef.current = () => {};
      stopLoopRef.current = () => {};
      drawOnceRef.current = () => {};

      runningRef.current = false;

      resizeObserver.disconnect();
      motionQuery.removeEventListener("change", updateReducedMotion);
      canvas.removeEventListener("mousemove", handleMouseMove);
      canvas.removeEventListener("mouseleave", handleMouseLeave);

      cancelMainRaf();
      cancelResizeRaf();
      window.clearTimeout(settleTimer);

      ctxRef.current = null;
    };
  }, []);

  useEffect(() => {
    isAnimatingRef.current = isAnimating;
    if (!isAnimating) return;
    startLoopRef.current();
    return () => {
      stopLoopRef.current();
    };
  }, [isAnimating]);

  // Tuning props are read by the loop through refs: no restart when they change.
  useEffect(() => {
    tickMsRef.current = tickMs;
    updateRatioRef.current = updateRatio;
    onTickRef.current = onTick;
    onHoverCellRef.current = onHoverCell;
  });

  useEffect(() => {
    if (seedRef.current === seed) return;
    seedRef.current = seed;
    reseedRef.current();
  }, [seed]);

  useEffect(() => {
    xrayRef.current = xray;
    needsDrawRef.current = true;
    if (!runningRef.current) drawOnceRef.current();
    // An xray readout opening mid-run gets the grid as it is, not after the next tick.
    if (xray) reportStatsRef.current();
  }, [xray]);

  useEffect(() => {
    if (stepToken === 0 || runningRef.current) return;
    stepOnceRef.current();
  }, [stepToken]);

  const rootClassName = className ? `${styles.rootStyles} ${className}` : styles.rootStyles;

  return (
    <div ref={rootRef} className={rootClassName} aria-hidden="true">
      <canvas ref={canvasRef} className={styles.canvasStyles} />
      <div className={styles.textureStyles} />
    </div>
  );
});
glitch-signal-map.component.tsx · 827 lines · 22K · tsx_Source_