/* Ported from ../../prt.ts (a Figma WGSL "particle poke" shader) to a
   Canvas2D per-dot spring simulation. The monogram is a sparse shape (a
   thin border stroke plus two glyphs on transparent ground), so at normal
   dot density it resolves to a few hundred/thousand active dots — a full
   WebGL2 compute/storage-buffer pipeline (as in the source) is overkill at
   that count; a plain per-dot JS physics loop hits 60fps easily and keeps
   the code readable. Every formula below (the five falloff shapes, the
   spring/damping constants, the substep-on-fast-mouse-travel anti-
   tunneling trick, the fake-Z "poke" projection, the OKLab gradient blend,
   nearest-particle-wins depth ordering) is carried over from prt.ts's
   velWgsl/particleGenWgsl/scatterRenderWgsl verbatim — only the render
   backend itself (WebGPU passes -> Canvas2D arcs, sorted back-to-front
   since Canvas2D has no per-fragment depth test to do it for us) changed.
   NOTE: intentionally does NOT destructure `const { useRef, useEffect } =
   React` at module scope — MovingGradientSphere.jsx (loaded earlier as its
   own classic <script>, not a module) already does exactly that, and
   separate <script> tags share one global lexical scope, so a second
   top-level `const useRef` here would throw "Identifier has already been
   declared" and silently break this whole file. React.useRef/useEffect
   are called directly below instead. */

const MOUSE_SHAPE_IDS = { Plateau: 0, Dome: 1, Pinch: 2, Ring: 3, Wave: 4 };

// physicsGridSize in prt.ts is a fixed calibration reference, NOT the
// actual dot spacing — spring stiffness/timing stay constant regardless of
// the dotDensity the user picks; only dot placement/topology changes.
const PHYSICS_GRID_SIZE = 8;
const BASE_STIFFNESS = 300 / (14 * PHYSICS_GRID_SIZE);

function clamp(value, lo, hi) { return Math.max(lo, Math.min(hi, value)); }

function smoothstep(edge0, edge1, x) {
  const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
  return t * t * (3 - 2 * t);
}

// prt.ts shapeMouseFalloff() — five falloff profiles, each reaching zero
// with a smooth slope at the mouse-radius boundary.
function mouseFalloff(distance, radius, shapeId) {
  const t = clamp(distance / Math.max(radius, 1), 0, 1);
  switch (shapeId) {
    case 0: // Plateau: broad, nearly flat centre with a rounded outer rim
      return 1 - smoothstep(0.65, 1, t);
    case 2: { // Pinch: response concentrated tightly at the cursor
      const base = 1 - smoothstep(0, 1, t);
      return base * base * base * base;
    }
    case 3: { // Ring: no response at the centre, strongest halfway out
      const ring = Math.sin(Math.PI * t);
      return ring * ring;
    }
    case 4: { // Wave: concentric non-negative pulses inside a soft envelope
      const envelope = 1 - smoothstep(0, 1, t);
      const rings = 0.15 + 0.85 * (0.5 + 0.5 * Math.cos(18.8495559 * t));
      return envelope * rings;
    }
    default: { // Dome: spherical profile
      const v = Math.max(0, 1 - t * t);
      return Math.pow(v, 1.5);
    }
  }
}

// prt.ts dragFollowAmount() — only engages when the mouseSpring multiplier
// drops below 100% (springMix < 1); kept for full parameter-range fidelity
// even though this component's default spring value never reaches it.
function dragFollowAmount(velocityRetention, dt) {
  if (dt <= 0) return 0;
  const drag = clamp((1 - velocityRetention) * 4, 0, 1.5);
  if (drag <= 0.0001) return 1;
  const perFrameFollow = Math.pow(2, -4 * drag);
  return 1 - Math.pow(Math.max(0, 1 - perFrameFollow), dt * 60);
}

function closestPointOnSegment(px, py, ax, ay, bx, by) {
  const sx = bx - ax;
  const sy = by - ay;
  const lenSq = sx * sx + sy * sy;
  const amount = lenSq > 0.0001 ? clamp(((px - ax) * sx + (py - ay) * sy) / lenSq, 0, 1) : 0;
  return [ax + sx * amount, ay + sy * amount];
}

function hash2(x, y) {
  const a = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
  const b = Math.sin(x * 269.5 + y * 183.3) * 43758.5453;
  return [a - Math.floor(a), b - Math.floor(b)];
}

// prt.ts srgbToLinear/linearToSrgb/linearToOklab/oklabToLinear — ported
// verbatim (same matrices/constants) so the gradient blends in OKLab
// exactly like the source's sampleParticleGradient(), instead of the
// muddier grey-ish midpoints a plain sRGB lerp would produce between a
// near-black and a saturated blue.
function srgbChannelToLinear(c) { return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function linearChannelToSrgb(c) { return c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; }
function srgbToLinear([r, g, b]) { return [srgbChannelToLinear(r), srgbChannelToLinear(g), srgbChannelToLinear(b)]; }
function linearToSrgb([r, g, b]) { return [linearChannelToSrgb(r), linearChannelToSrgb(g), linearChannelToSrgb(b)]; }

function linearToOklab([r, g, b]) {
  const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
  const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
  const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
  const lc = Math.cbrt(Math.max(l, 0));
  const mc = Math.cbrt(Math.max(m, 0));
  const sc = Math.cbrt(Math.max(s, 0));
  return [
    0.2104542553 * lc + 0.7936177850 * mc - 0.0040720468 * sc,
    1.9779984951 * lc - 2.4285922050 * mc + 0.4505937099 * sc,
    0.0259040371 * lc + 0.7827717662 * mc - 0.8086757660 * sc,
  ];
}
function oklabToLinear([L, a, b]) {
  const lc = L + 0.3963377774 * a + 0.2158037573 * b;
  const mc = L - 0.1055613458 * a - 0.0638541728 * b;
  const sc = L - 0.0894841775 * a - 1.2914855480 * b;
  const l = lc * lc * lc;
  const m = mc * mc * mc;
  const s = sc * sc * sc;
  return [
    4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
  ];
}

// prt.ts sampleParticleGradient()/gradientAt() — locate the bracketing pair
// of stops, ease the blend fraction, then interpolate in OKLab rather than
// sRGB. Stop colors are stored pre-converted to linear (see gradientStops
// construction below) so this doesn't redo srgbToLinear every frame.
function sampleGradient(stops, t) {
  const clamped = clamp(t, 0, 1);
  for (let i = 1; i < stops.length; i++) {
    const a = stops[i - 1];
    const b = stops[i];
    if (clamped <= b.t || i === stops.length - 1) {
      const span = Math.max(0.0001, b.t - a.t);
      let amount = clamp((clamped - a.t) / span, 0, 1);
      amount = amount * amount * (3 - 2 * amount);
      const mixedLab = [
        a.lab[0] + (b.lab[0] - a.lab[0]) * amount,
        a.lab[1] + (b.lab[1] - a.lab[1]) * amount,
        a.lab[2] + (b.lab[2] - a.lab[2]) * amount,
      ];
      const blended = linearToSrgb(oklabToLinear(mixedLab));
      return [blended[0] * 255, blended[1] * 255, blended[2] * 255];
    }
  }
  const last = stops[stops.length - 1];
  const blended = linearToSrgb(oklabToLinear(last.lab));
  return [blended[0] * 255, blended[1] * 255, blended[2] * 255];
}

// Named distinctly (not hexToRgb01) even though MovingGradientSphere.jsx
// has an identical helper — same reason useRef/useEffect aren't
// destructured above: plain `function` names are shared globals across
// these classic <script> tags, and reusing the exact name would make this
// file's correctness depend on staying byte-identical to that other one.
function parseHexColor01(hex) {
  const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex || "");
  if (!m) return [1, 1, 1];
  return [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255];
}

// ---- percent -> physical-unit mapping, ported from prt.ts render() ----
function mapDotDensity(pct) {
  const dotDensity = 10 + clamp(pct, 0, 100) * 1.9;
  return clamp(400 / dotDensity, 2, 32);
}
function mapMouseStrength(pct) { return clamp(pct, 0, 100) * 2 * 3; }
function mapMouseSpringMultiplier(pct) {
  const p = clamp(pct, 0, 100) * 3;
  return p <= 100 ? p * 0.01 : 1 + (p - 100) * 0.02;
}
function mapMouseDamping(pct) { return clamp(pct, 0, 100) * 0.00375; }

// Builds the dot field once from the source image's alpha (or luma) at the
// chosen grid resolution — prt.ts's particleGenWgsl inclusion test.
function buildDots(image, width, height, opts) {
  const sample = document.createElement("canvas");
  sample.width = Math.max(1, Math.round(width));
  sample.height = Math.max(1, Math.round(height));
  const sctx = sample.getContext("2d");
  sctx.clearRect(0, 0, sample.width, sample.height);
  sctx.drawImage(image, 0, 0, sample.width, sample.height);
  const { data } = sctx.getImageData(0, 0, sample.width, sample.height);

  const gridSize = mapDotDensity(opts.dotDensity);
  const dotScale = clamp(opts.dotScale * 0.01, 0.1, 5);
  const maxRadius = gridSize * dotScale * 0.5;
  const cellsX = Math.max(1, Math.ceil(sample.width / gridSize));
  const cellsY = Math.max(1, Math.ceil(sample.height / gridSize));
  const isNoise = opts.arrangement === "Noise";
  const invert = opts.mode === "Inverse Alpha" || opts.mode === "Inverse Luma";
  const useLuma = opts.mode === "Luma" || opts.mode === "Inverse Luma";
  const threshold = clamp(opts.threshold, 0, 100) * 0.01;

  const dots = [];
  if (maxRadius < 0.5) return dots;

  for (let cy = 0; cy < cellsY; cy++) {
    for (let cx = 0; cx < cellsX; cx++) {
      let restX;
      let restY;
      if (isNoise) {
        const h = hash2(cx, cy);
        restX = (cx + 0.5 + (h[0] - 0.5)) * gridSize;
        restY = (cy + 0.5 + (h[1] - 0.5)) * gridSize;
      } else {
        restX = (cx + 0.5) * gridSize;
        restY = (cy + 0.5) * gridSize;
      }
      if (restX < 0 || restY < 0 || restX >= sample.width || restY >= sample.height) continue;

      const px = Math.min(sample.width - 1, Math.floor(restX));
      const py = Math.min(sample.height - 1, Math.floor(restY));
      const idx = (py * sample.width + px) * 4;
      // Canvas2D ImageData is straight (non-premultiplied) alpha, unlike
      // the WGSL texture the source divides by alpha to un-premultiply —
      // so luma reads rgb directly with no extra un-premultiply step.
      let signal = data[idx + 3] / 255;
      if (useLuma) {
        signal = (data[idx] * 0.2126 + data[idx + 1] * 0.7152 + data[idx + 2] * 0.0722) / 255;
      }
      if (invert) signal = 1 - signal;
      if (signal < threshold) continue;

      dots.push({
        restX, restY,
        radius: maxRadius,
        velX: 0, velY: 0, dispX: 0, dispY: 0,
        zVel: 0, zDisp: 0,
        responseVel: 0, response: 0,
      });
    }
  }
  return dots;
}

// One physics tick — prt.ts's velWgsl fs_main, translated per-dot.
function stepDots(dots, dt, mouse, prevMouse, p) {
  const stiffness = BASE_STIFFNESS * p.mouseSpringMultiplier;
  const springMix = clamp(p.mouseSpringMultiplier, 0, 1);
  const velocityRetention = 1 - p.mouseDamping;

  const mdx = mouse.x - prevMouse.x;
  const mdy = mouse.y - prevMouse.y;
  const travelSq = mdx * mdx + mdy * mdy;

  for (let i = 0; i < dots.length; i++) {
    const d = dots[i];
    const [cxp, cyp] = closestPointOnSegment(d.restX, d.restY, prevMouse.x, prevMouse.y, mouse.x, mouse.y);
    const pdx = d.restX - cxp;
    const pdy = d.restY - cyp;
    const pathDistSq = pdx * pdx + pdy * pdy;

    // Same anti-tunneling trick as the source: dots whose rest position
    // sits close to this frame's mouse travel path get extra substeps so a
    // fast flick across the radius can't skip over them entirely.
    const substeps = (travelSq > 0.25 && p.mouseRadius > 0 && pathDistSq < p.mouseRadius * p.mouseRadius) ? 8 : 1;
    const subDt = dt / substeps;
    const substepRetention = Math.pow(clamp(velocityRetention, 0, 1), 1 / substeps);
    const directFollow = dragFollowAmount(velocityRetention, subDt);

    let { velX, velY, dispX, dispY, zVel, zDisp, responseVel, response } = d;

    for (let s = 0; s < substeps; s++) {
      const amount = (s + 1) / substeps;
      const sx = prevMouse.x + mdx * amount;
      const sy = prevMouse.y + mdy * amount;
      const tx = d.restX - sx;
      const ty = d.restY - sy;
      const dist = Math.sqrt(tx * tx + ty * ty);

      let targetResponse = 0;
      let targetDispX = 0;
      let targetDispY = 0;
      if (dist < p.mouseRadius) {
        const falloff = mouseFalloff(dist, p.mouseRadius, p.mouseShapeId);
        targetResponse = falloff;
        if (dist > 0.001) {
          const forceX = (tx / dist) * falloff * p.mouseStrength * p.radiusScale;
          const forceY = (ty / dist) * falloff * p.mouseStrength * p.radiusScale;
          targetDispX = forceX / BASE_STIFFNESS;
          targetDispY = forceY / BASE_STIFFNESS;
        }
      }

      velX += (targetDispX - dispX) * stiffness * subDt * 60;
      velY += (targetDispY - dispY) * stiffness * subDt * 60;
      velX *= substepRetention;
      velY *= substepRetention;
      const springDispX = dispX + velX * subDt;
      const springDispY = dispY + velY * subDt;
      const draggedDispX = dispX + (targetDispX - dispX) * directFollow;
      const draggedDispY = dispY + (targetDispY - dispY) * directFollow;
      dispX = draggedDispX + (springDispX - draggedDispX) * springMix;
      dispY = draggedDispY + (springDispY - draggedDispY) * springMix;
      velX *= springMix;
      velY *= springMix;

      zVel += -stiffness * zDisp * subDt * 60;
      const zTarget = -p.mouseZOffsetSim * targetResponse;
      const zDiff = zTarget - zDisp;
      zVel += zDiff * 8 * p.mouseStrengthScale * p.mouseSpringMultiplier * subDt * 60 * targetResponse;
      zVel *= substepRetention;
      const springZDisp = zDisp + zVel * subDt;

      responseVel += (targetResponse - response) * stiffness * subDt * 60;
      responseVel *= substepRetention;
      const springResponse = response + responseVel * subDt;

      const directZTarget = -p.mouseZOffsetSim * targetResponse * p.mouseStrengthScale;
      const draggedZDisp = zDisp + (directZTarget - zDisp) * directFollow;
      const draggedResponse = response + (targetResponse - response) * directFollow;
      zDisp = draggedZDisp + (springZDisp - draggedZDisp) * springMix;
      response = draggedResponse + (springResponse - draggedResponse) * springMix;
      zVel *= springMix;
      responseVel *= springMix;
    }

    d.velX = velX; d.velY = velY; d.dispX = dispX; d.dispY = dispY;
    d.zVel = zVel; d.zDisp = zDisp; d.responseVel = responseVel; d.response = response;
  }
}

function dotsSettled(dots) {
  for (let i = 0; i < dots.length; i++) {
    const d = dots[i];
    if (Math.abs(d.dispX) > 0.05 || Math.abs(d.dispY) > 0.05 ||
        Math.abs(d.zDisp) > 0.05 || d.response > 0.002 ||
        Math.abs(d.velX) > 0.01 || Math.abs(d.velY) > 0.01) {
      return false;
    }
  }
  return true;
}

// Fills one dot. particleSoftness 0 (the default, matching the shader's
// own default) takes the plain solid-circle fast path; a higher softness
// feathers the edge via a radial gradient, mirroring the scatter shader's
// `edge = 1 - smoothstep(radius - feather, radius + 1, d)` falloff.
function fillDot(ctx, x, y, radius, r, g, b, softness) {
  const ri = r | 0, gi = g | 0, bi = b | 0;
  if (softness > 0.001) {
    const feather = Math.max(1, radius * clamp(softness, 0, 1));
    const inner = Math.max(0, (radius - feather) / (radius + 1));
    const gradient = ctx.createRadialGradient(x, y, inner * (radius + 1), x, y, radius + 1);
    gradient.addColorStop(0, `rgb(${ri}, ${gi}, ${bi})`);
    gradient.addColorStop(1, `rgba(${ri}, ${gi}, ${bi}, 0)`);
    ctx.fillStyle = gradient;
    ctx.beginPath();
    ctx.arc(x, y, radius + 1, 0, Math.PI * 2);
    ctx.fill();
  } else {
    ctx.fillStyle = `rgb(${ri}, ${gi}, ${bi})`;
    ctx.beginPath();
    ctx.arc(x, y, radius, 0, Math.PI * 2);
    ctx.fill();
  }
}

// Draws each dot's fake-Z "poke" projection — prt.ts's particleGenWgsl,
// minus the WebGPU-specific perspective-camera plumbing it doesn't need.
function drawDots(ctx, dots, width, height, p, gradientStops) {
  ctx.clearRect(0, 0, width, height);
  const centerX = width / 2;
  const centerY = height / 2;
  const renderList = [];

  for (let i = 0; i < dots.length; i++) {
    const d = dots[i];
    const influence = clamp(d.response, 0, 1);
    const effectiveZ = d.zDisp - p.mouseZOffset * influence * p.mouseStrengthScale * 0.15;
    const denom = Math.max(p.perspective + effectiveZ, 1);
    const wx = (d.restX - centerX) + d.dispX;
    const wy = (d.restY - centerY) + d.dispY;
    const projX = (p.perspective * wx) / denom + centerX;
    const projY = (p.perspective * wy) / denom + centerY;
    const directMouseScale = Math.max(0.01, 1 + influence * p.mouseScale * 0.01);
    const radius = d.radius * (p.perspective / denom) * directMouseScale;
    if (radius < 0.5) continue;

    const [r, g, b] = sampleGradient(gradientStops, influence);
    renderList.push({ x: projX, y: projY, radius, effectiveZ, r, g, b });
  }

  // Canvas2D has no per-fragment depth test, so nothing stops a farther-
  // back dot from painting over a nearer one that happens to be drawn
  // later in grid order. prt.ts's depthPrepassWgsl/nearestDepthTex exist
  // specifically to make the nearest particle win at each pixel; the
  // cheap CPU-side equivalent here is to paint back-to-front by
  // effectiveZ, so dots pushed toward the viewer (more negative
  // effectiveZ, i.e. closer) always land on top — most visible exactly
  // where dots crowd and overlap near the cursor.
  renderList.sort((a, b) => b.effectiveZ - a.effectiveZ);

  const prevComposite = ctx.globalCompositeOperation;
  ctx.globalCompositeOperation = p.particleBlend === "Add" ? "lighter" : "source-over";
  for (let i = 0; i < renderList.length; i++) {
    const item = renderList[i];
    fillDot(ctx, item.x, item.y, item.radius, item.r, item.g, item.b, p.particleSoftness);
  }
  ctx.globalCompositeOperation = prevComposite;
}

function drawRestFrame(ctx, dots, width, height, gradientStops, softness) {
  ctx.clearRect(0, 0, width, height);
  const [r, g, b] = sampleGradient(gradientStops, 0);
  if (softness > 0.001) {
    for (let i = 0; i < dots.length; i++) {
      const d = dots[i];
      fillDot(ctx, d.restX, d.restY, d.radius, r, g, b, softness);
    }
    return;
  }
  // Fast path: every resting dot shares the same base-gradient color, so a
  // single path/fill covers the whole static frame in one draw call.
  ctx.fillStyle = `rgb(${r | 0}, ${g | 0}, ${b | 0})`;
  ctx.beginPath();
  for (let i = 0; i < dots.length; i++) {
    const d = dots[i];
    ctx.moveTo(d.restX + d.radius, d.restY);
    ctx.arc(d.restX, d.restY, d.radius, 0, Math.PI * 2);
  }
  ctx.fill();
}

function MonogramParticles({
  className,
  src = "../../assets/monogram.svg",
  mode = "Alpha",
  threshold = 5,
  arrangement = "Grid",
  dotDensity = 50,
  dotScale = 100,
  mouseShape = "Dome",
  mouseRadius = 150,
  mouseStrength = 50,
  mouseSpring = 35,
  mouseDamping = 50,
  mouseScale = 100,
  mouseZOffset = 80,
  particleSoftness = 0,
  particleBlend = "Normal",
  colors = ["#0B1220", "#2563EB", "#60A5FA"],
  stops = [0, 0.5, 1],
}) {
  const canvasRef = React.useRef(null);
  const fallbackRef = React.useRef(null);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const fallbackImg = fallbackRef.current;
    if (!canvas) return undefined;

    const ctx = canvas.getContext("2d");
    if (!ctx) {
      canvas.style.display = "none";
      if (fallbackImg) fallbackImg.style.display = "block";
      return undefined;
    }

    let destroyed = false;
    let dots = [];
    let boxWidth = 0;
    let boxHeight = 0;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);

    // Pre-convert each stop to OKLab once (srgbToLinear -> linearToOklab)
    // so sampleGradient() only has to lerp + convert back, every frame.
    const gradientStops = colors.slice(0, stops.length).map((hex, i) => ({
      t: stops[i],
      lab: linearToOklab(srgbToLinear(parseHexColor01(hex))),
    }));

    const mouseZOffsetClamped = clamp(mouseZOffset, 0, 300);
    const physics = {
      mouseRadius,
      mouseShapeId: MOUSE_SHAPE_IDS[mouseShape] ?? 1,
      mouseStrength: mapMouseStrength(mouseStrength),
      mouseSpringMultiplier: mapMouseSpringMultiplier(mouseSpring),
      mouseDamping: mapMouseDamping(mouseDamping),
      radiusScale: clamp(mouseRadius / 400, 0, 1),
      mouseZOffset: mouseZOffsetClamped,
      mouseZOffsetSim: mouseZOffsetClamped * 0.85,
      mouseScale,
      perspective: Math.max(350, 1200 - mouseZOffsetClamped * 4),
      particleSoftness: clamp(particleSoftness, 0, 100) * 0.01,
      particleBlend,
    };
    physics.mouseStrengthScale = physics.mouseStrength / 300;

    const mouse = { x: -9999, y: -9999 };
    let prevMouse = { x: -9999, y: -9999 };
    let rafId = null;
    let isVisible = false;
    let lastFrameTime = null;
    let io = null;
    let ro = null;

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

    function resize() {
      const rect = canvas.getBoundingClientRect();
      const w = Math.max(1, rect.width);
      const h = Math.max(1, rect.height);
      const changed = Math.round(w) !== Math.round(boxWidth) || Math.round(h) !== Math.round(boxHeight);
      boxWidth = w;
      boxHeight = h;
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      return changed;
    }

    function paintRest() {
      drawRestFrame(ctx, dots, boxWidth, boxHeight, gradientStops, physics.particleSoftness);
    }

    function frame(now) {
      const dt = lastFrameTime == null ? 0 : clamp((now - lastFrameTime) / 1000, 0, 0.1);
      lastFrameTime = now;
      stepDots(dots, dt, mouse, prevMouse, physics);
      prevMouse = { x: mouse.x, y: mouse.y };
      drawDots(ctx, dots, boxWidth, boxHeight, physics, gradientStops);

      // Idle-pause: once every dot has relaxed back to (near) rest and the
      // cursor's influence has fully decayed, stop re-simulating entirely
      // rather than redrawing an unchanging frame 60x/sec. A later
      // pointermove (see handlePointerMove) restarts the loop.
      if (isVisible && !reducedMotionQuery.matches && !dotsSettled(dots)) {
        rafId = requestAnimationFrame(frame);
      } else {
        rafId = null;
      }
    }

    function startLoop() {
      if (rafId !== null || reducedMotionQuery.matches || !isVisible) return;
      // Deliberately NOT resetting lastFrameTime here: frame()'s own
      // clamp((now-lastFrameTime)/1000, 0, 0.1) already caps however long
      // the loop was paused at a sane single-frame dt. Resetting it to
      // null would force dt=0 on this resuming frame — and since every
      // spring/response term below is scaled by dt, a dt=0 frame makes
      // zero progress, which the settle-check then reads as "nothing
      // changed" and immediately re-stops the loop, permanently stuck.
      //
      // Resuming from idle: treat this instant as a fresh baseline instead
      // of reusing a stale prevMouse, so a big jump while paused can't read
      // as one long, fast mouse sweep across the whole radius.
      prevMouse = { x: mouse.x, y: mouse.y };
      rafId = requestAnimationFrame(frame);
    }

    function stopLoop() {
      if (rafId !== null) {
        cancelAnimationFrame(rafId);
        rafId = null;
      }
    }

    function handlePointerMove(e) {
      const rect = canvas.getBoundingClientRect();
      mouse.x = e.clientX - rect.left;
      mouse.y = e.clientY - rect.top;
      startLoop();
    }

    function handlePointerLeaveWindow(e) {
      if (e.relatedTarget || e.toElement) return;
      mouse.x = -9999;
      mouse.y = -9999;
      startLoop();
    }

    function handleReducedMotionChange() {
      stopLoop();
      if (reducedMotionQuery.matches) {
        paintRest();
      } else if (isVisible) {
        startLoop();
      }
    }

    const sourceImage = new Image();
    sourceImage.onerror = () => {
      if (destroyed) return;
      canvas.style.display = "none";
      if (fallbackImg) fallbackImg.style.display = "block";
    };
    sourceImage.onload = () => {
      if (destroyed) return;
      resize();
      dots = buildDots(sourceImage, boxWidth, boxHeight, { mode, threshold, arrangement, dotDensity, dotScale });
      paintRest();
      // Seed a real timestamp now rather than leaving lastFrameTime null:
      // otherwise the very first frame() call ever (whenever the loop
      // first starts) computes dt=0, makes zero physics progress, and its
      // own settle-check would then stop the loop right back — silently
      // eating one deliberate hover-and-hold. Any later resume already
      // has a real prior timestamp regardless.
      lastFrameTime = performance.now();

      // prefers-reduced-motion: render the static dot field once and never
      // attach pointer tracking or start the simulation loop at all.
      if (!reducedMotionQuery.matches) {
        window.addEventListener("pointermove", handlePointerMove, { passive: true });
        window.addEventListener("pointerout", handlePointerLeaveWindow, { passive: true });
      }
      reducedMotionQuery.addEventListener("change", handleReducedMotionChange);

      io = new IntersectionObserver((entries) => {
        isVisible = entries[0] && entries[0].isIntersecting;
        if (isVisible) startLoop();
        else stopLoop();
      }, { threshold: 0.01 });
      io.observe(canvas);

      ro = new ResizeObserver(() => {
        const changed = resize();
        if (changed) {
          dots = buildDots(sourceImage, boxWidth, boxHeight, { mode, threshold, arrangement, dotDensity, dotScale });
        }
        if (reducedMotionQuery.matches || rafId === null) paintRest();
      });
      ro.observe(canvas);
    };
    sourceImage.src = src;

    return () => {
      destroyed = true;
      stopLoop();
      if (io) io.disconnect();
      if (ro) ro.disconnect();
      window.removeEventListener("pointermove", handlePointerMove);
      window.removeEventListener("pointerout", handlePointerLeaveWindow);
      reducedMotionQuery.removeEventListener("change", handleReducedMotionChange);
    };
    // Config is treated as fixed for the lifetime of the instance, same
    // convention as MovingGradientSphere — Hero mounts this once with
    // static props.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div className={className} aria-hidden="true">
      <canvas ref={canvasRef} className="hero-monogram-canvas" />
      <img ref={fallbackRef} src={src} className="hero-monogram-fallback" alt="" />
    </div>
  );
}

window.MonogramParticles = MonogramParticles;
