/* Dexilion — the dark-hero erosion field.
   Ported from the design system's website UI kit (Site.jsx), with the tweak
   panel's shipped defaults baked in.

   The dots are BOIDS: one per grid cell (capped), flocking with classic
   separation / alignment / cohesion plus a light wander, drifting through a
   STATIC precomputed erosion mask (periodic value-noise fBm, quantized to N
   flat levels). Per frame: integrate boids on a spatial hash grid (O(n)),
   sprite-blit them, multiply alpha by the noise pattern + a cached ramp
   gradient, composite. The loop runs only while animating, visible and
   focused, and never starts under prefers-reduced-motion. */

(function () {

const DITHER_DEFAULTS = {
  dotCell: 16,
  dotRadius: 1.5,
  dotOpacity: 0.4,
  noiseFreq: 1 / 280,
  noiseContrast: 2.1,
  noiseSteps: 7,
  animate: true,
  driftSpeed: 1.1,
  tileSize: 1200,
  alphaCutoff: 0.56,
  fieldReach: 180,
  glowStrength: 0.48
};

function DitherField({ t = {} }) {
  const hostRef = React.useRef(null), cvRef = React.useRef(null);
  const o = { ...DITHER_DEFAULTS, ...t };
  const cell = o.dotCell;
  const ink = o.dotOpacity, dot = o.dotRadius;
  const freq = o.noiseFreq, slope = o.noiseContrast, steps = Math.max(2, Math.round(o.noiseSteps));
  const T = Math.max(600, Math.round(o.tileSize));
  const reach = o.fieldReach, glow = o.glowStrength;
  const animate = o.animate !== false, speed = Math.max(0.05, o.driftSpeed);
  const cutoff = o.alphaCutoff;

  React.useEffect(() => {
    const host = hostRef.current, cv = cvRef.current; if (!host || !cv) return;
    const ctx = cv.getContext('2d');
    const scratch = document.createElement('canvas'), sctx = scratch.getContext('2d');
    const dpr = Math.min(2, window.devicePixelRatio || 1);
    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    const hash = (x, y, s) => { let h = (x * 374761393 + y * 668265263 + s * 974634211) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); h ^= h >>> 16; return (h >>> 0) / 4294967296; };
    // Seamless quantized noise tile: lattice periods are integer divisors of
    // the tile, so it wraps exactly — drift can run forever with no seam.
    // Rendered at ~1/3 scale (blotches are huge) and upsampled by the pattern.
    const makeNoise = (f, seed, sl, ic) => {
      const res = Math.min(512, Math.max(96, Math.round(T / 3)));
      const N0 = Math.max(1, Math.round(T * f));
      const c = document.createElement('canvas'); c.width = c.height = res;
      const g = c.getContext('2d'), img = g.createImageData(res, res), d = img.data;
      for (let y = 0; y < res; y++) for (let x = 0; x < res; x++) {
        let sum = 0, amp = 1, tot = 0;
        for (let oct = 0; oct < 4; oct++) {
          const n = N0 << oct, u = x / res * n, v = y / res * n;
          const x0 = Math.floor(u), y0 = Math.floor(v), fx = u - x0, fy = v - y0;
          const sx = fx * fx * (3 - 2 * fx), sy = fy * fy * (3 - 2 * fy);
          const xm = x0 % n, ym = y0 % n, x1 = (x0 + 1) % n, y1 = (y0 + 1) % n, sd = seed + oct * 101;
          const a = hash(xm, ym, sd), b = hash(x1, ym, sd), cc = hash(xm, y1, sd), dd = hash(x1, y1, sd);
          sum += (a + (b - a) * sx + (cc - a) * sy + (a - b - cc + dd) * sx * sy) * amp; tot += amp; amp *= 0.5;
        }
        let al = sl * (sum / tot) + ic; al = al < 0 ? 0 : al > 1 ? 1 : al;
        let q = Math.min(steps - 1, Math.floor(al * steps)) / (steps - 1);
        if (q < cutoff) q = 0; // hard cutoff: faint levels drop out entirely
        const p = (y * res + x) * 4; d[p] = 255; d[p + 1] = 255; d[p + 2] = 255; d[p + 3] = Math.round(q * 255);
      }
      g.putImageData(img, 0, 0);
      return { c, res };
    };
    const noise = makeNoise(freq, 7, slope, -0.16);
    const noisePat = sctx.createPattern(noise.c, 'repeat');
    // Dot sprite, blitted per boid (cheaper than per-dot arc fills).
    const PAD = 2, sprCss = dot * 2 + PAD * 2, sprPx = Math.max(2, Math.ceil(sprCss * dpr));
    const spr = document.createElement('canvas'); spr.width = spr.height = sprPx;
    {
      const g = spr.getContext('2d'); g.scale(sprPx / sprCss, sprPx / sprCss);
      g.fillStyle = 'rgba(182,194,217,' + ink + ')'; g.beginPath(); g.arc(sprCss / 2, sprCss / 2, dot, 0, Math.PI * 2); g.fill();
    }
    let W = 0, H = 0, ramp = null, raf = 0, visible = true, prevMs = 0, tSec = 0;
    let n = 0, bx, by, bvx, bvy, ph, head, next, cols, rows;
    const per = Math.max(24, cell * 2.8), per2 = per * per, sepR = cell * 1.15, sepR2 = sepR * sepR;
    const maxV = 14 * speed, maxF = 60 * speed;
    const initBoids = () => {
      // Density eases off linearly below the 1280px design width so narrow
      // canvases don't read busier than desktop.
      const density = Math.min(1, W / 1280);
      n = Math.min(3500, Math.round(W * H / (cell * cell) * density));
      const sp = Math.sqrt(W * H / Math.max(1, n));
      const gc = Math.max(1, Math.round(W / sp)), gr = Math.max(1, Math.ceil(n / gc));
      bx = new Float32Array(n); by = new Float32Array(n); bvx = new Float32Array(n); bvy = new Float32Array(n); ph = new Float32Array(n);
      for (let i = 0; i < n; i++) { // jittered grid start — the static frame still reads as dither
        const gx = i % gc, gy = (i / gc) | 0;
        bx[i] = (gx + 0.5) * (W / gc) + (hash(gx, gy, 5) - 0.5) * sp * 0.9;
        by[i] = (gy + 0.5) * (H / gr) + (hash(gx, gy, 41) - 0.5) * sp * 0.9;
        const a = hash(gx, gy, 77) * Math.PI * 2, v = maxV * 0.3 * hash(gx, gy, 91);
        bvx[i] = Math.cos(a) * v; bvy[i] = Math.sin(a) * v; ph[i] = hash(gx, gy, 113) * Math.PI * 2;
      }
      cols = Math.max(1, Math.ceil(W / per)); rows = Math.max(1, Math.ceil(H / per));
      head = new Int32Array(cols * rows); next = new Int32Array(n);
    };
    const step = (dt) => {
      head.fill(-1); // rebuild spatial hash — O(n), no allocation
      for (let i = 0; i < n; i++) {
        const cx = Math.min(cols - 1, Math.max(0, (bx[i] / per) | 0)), cy = Math.min(rows - 1, Math.max(0, (by[i] / per) | 0));
        const k = cy * cols + cx; next[i] = head[k]; head[k] = i;
      }
      for (let i = 0; i < n; i++) {
        const cx = Math.min(cols - 1, Math.max(0, (bx[i] / per) | 0)), cy = Math.min(rows - 1, Math.max(0, (by[i] / per) | 0));
        let cnt = 0, avx = 0, avy = 0, ccx = 0, ccy = 0, sx = 0, sy = 0;
        for (let oy = -1; oy <= 1; oy++) for (let ox = -1; ox <= 1; ox++) {
          const nx = cx + ox, ny = cy + oy; if (nx < 0 || ny < 0 || nx >= cols || ny >= rows) continue;
          for (let j = head[ny * cols + nx]; j !== -1 && cnt < 9; j = next[j]) { // cap neighbours per boid
            if (j === i) continue;
            const dx = bx[j] - bx[i], dy = by[j] - by[i], d2 = dx * dx + dy * dy;
            if (d2 > per2) continue;
            cnt++; avx += bvx[j]; avy += bvy[j]; ccx += bx[j]; ccy += by[j];
            if (d2 < sepR2) { const inv = 1 / (d2 + 1); sx -= dx * inv; sy -= dy * inv; }
          }
        }
        let ax = 0, ay = 0;
        const steer = (dx, dy, w) => {
          const m = Math.hypot(dx, dy); if (m < 1e-4) return;
          let fx = dx / m * maxV - bvx[i], fy = dy / m * maxV - bvy[i];
          const f = Math.hypot(fx, fy); if (f > maxF) { fx *= maxF / f; fy *= maxF / f; }
          ax += fx * w; ay += fy * w;
        };
        // Brownian-dominant: flocking pull is barely there, motion is mostly
        // random accelerations with strong velocity damping (jittery, local).
        if (cnt) { steer(sx, sy, 0.5); steer(avx, avy, 0.1); steer(ccx / cnt - bx[i], ccy / cnt - by[i], 0.06); }
        ax += (Math.random() - 0.5) * 2 * maxF * 1.6; ay += (Math.random() - 0.5) * 2 * maxF * 1.6;
        bvx[i] += ax * dt; bvy[i] += ay * dt;
        const dmp = Math.max(0, 1 - 3.5 * dt); bvx[i] *= dmp; bvy[i] *= dmp;
        const v = Math.hypot(bvx[i], bvy[i]);
        if (v > maxV) { bvx[i] *= maxV / v; bvy[i] *= maxV / v; }
        bx[i] += bvx[i] * dt; by[i] += bvy[i] * dt;
        if (bx[i] < -8) bx[i] += W + 16; else if (bx[i] > W + 8) bx[i] -= W + 16;
        if (by[i] < -8) by[i] += H + 16; else if (by[i] > H + 8) by[i] -= H + 16;
      }
    };
    const draw = () => {
      if (!W || !H) return;
      sctx.setTransform(1, 0, 0, 1, 0, 0); sctx.clearRect(0, 0, scratch.width, scratch.height);
      sctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      sctx.globalCompositeOperation = 'source-over';
      const hs = sprCss / 2;
      for (let i = 0; i < n; i++) sctx.drawImage(spr, bx[i] - hs, by[i] - hs, sprCss, sprCss);
      sctx.globalCompositeOperation = 'destination-in';
      // Noise mask scrolls along a fixed vector (-2,+1)/tile-cycle, seamless wrap.
      const mx = (((-2 * T * tSec / (90 / speed)) % T) + T) % T, my = (((T * tSec / (90 / speed)) % T) + T) % T;
      noisePat.setTransform(new DOMMatrix().translate(mx, my).scale(T / noise.res));
      sctx.fillStyle = noisePat; sctx.fillRect(0, 0, W, H);
      sctx.fillStyle = ramp; sctx.fillRect(0, 0, W, H);
      ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, cv.width, cv.height);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.drawImage(scratch, 0, 0, W, H);
    };
    const loop = (ms) => {
      const dt = Math.min(0.05, (ms - prevMs) / 1000 || 0.016); prevMs = ms; tSec = ms / 1000;
      step(dt); draw(); raf = requestAnimationFrame(loop);
    };
    const sync = () => {
      const want = animate && !reduced && visible && !document.hidden;
      if (want && !raf) { prevMs = performance.now(); raf = requestAnimationFrame(loop); }
      else if (!want && raf) { cancelAnimationFrame(raf); raf = 0; }
    };
    const resize = () => {
      W = host.clientWidth; H = host.clientHeight; if (!W || !H) return;
      cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
      scratch.width = cv.width; scratch.height = cv.height;
      const a = 232 * Math.PI / 180, gdx = Math.sin(a), gdy = -Math.cos(a); // CSS linear-gradient(232deg,…)
      const half = (Math.abs(W * gdx) + Math.abs(H * gdy)) / 2;
      ramp = sctx.createLinearGradient(W / 2 - gdx * half, H / 2 - gdy * half, W / 2 + gdx * half, H / 2 + gdy * half);
      ramp.addColorStop(0, 'rgba(0,0,0,1)'); ramp.addColorStop(0.62, 'rgba(0,0,0,0.5)'); ramp.addColorStop(0.96, 'rgba(0,0,0,0)');
      initBoids(); draw();
    };
    const ro = new ResizeObserver(resize); ro.observe(host);
    const io = new IntersectionObserver(en => { visible = en[0].isIntersecting; sync(); }); io.observe(host);
    const onVis = () => sync(); document.addEventListener('visibilitychange', onVis);
    resize(); sync();
    return () => { if (raf) cancelAnimationFrame(raf); ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis); };
  }, [cell, ink, dot, freq, slope, steps, T, animate, speed, cutoff]);

  const keepClear = 'radial-gradient(' + reach + '% ' + (reach * 1.5) + '% at 100% 0%, #000 20%, rgba(0,0,0,.6) 56%, transparent 90%)';
  return (
    <div aria-hidden="true" ref={hostRef} style={{
      position: 'absolute', inset: 0, pointerEvents: 'none',
      maskImage: keepClear, WebkitMaskImage: keepClear, contain: 'strict', opacity: 0, animation: 'dxFadeIn 3s linear forwards'
    }}>
      <div style={{
        position: 'absolute', inset: 0,
        background: 'radial-gradient(72% 84% at 92% -6%, rgba(26,54,189,' + glow + '), transparent 70%)'
      }} />
      {/* width/height 100% are load-bearing: a canvas is a replaced element, so
          with inset:0 alone it takes its intrinsic (DPR-scaled) backing-store
          width and renders 2x zoomed on high-DPI screens. */}
      <canvas ref={cvRef} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}></canvas>
    </div>);
}

Object.assign(window, { DitherField, DITHER_DEFAULTS });

})();
