/* global React */
/* ============================================================
   HeroBlock110V3 — "The Living Block" scroll-driven hero (v3).
   Port of uploads/Revolute-Hero-LivingBlock.html into React.
   Built on REAL Block 110 data (window.BLOCK110_V3):
     00 INTRO   — bare terrain (Ouplaas DEM)
     01 SOIL    — EMI scan bar sweeps 3×: EC 90 / 50 / 25 cm
     02 CANOPY  — trees grow along the 40 real rows, then morph
                  through 8 satellite dates (Dec→Mar)
     03 FRUIT   — whole orchard recolours white→purple by measured
                  fruit size, morphing 2 dates (25 Feb → 5 Mar);
                  terrain shows the same map
     04 YIELD   — top-down; orchard recolours into the yield map
   Vanilla Three.js (r160 global) mounted inside React.
   All chrome namespaced .b110-* (only one hero mounts per page).
   ============================================================ */
const { useRef, useEffect } = React;

window.HeroBlock110V3 = function HeroBlock110V3({ setRoute, title, sub, meta, skin }) {
  const rootRef = useRef(null);
  const canvasRef = useRef(null);

  useEffect(() => {
    const T = window.THREE;
    const DATA = window.BLOCK110_V3;
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!T || !DATA || !root || !canvas) {
      const fb0 = root && root.querySelector('.b110-fallback');
      if (fb0) fb0.style.display = 'grid';
      return;
    }
    const $ = (sel) => root.querySelector(sel);
    const $$ = (sel) => Array.prototype.slice.call(root.querySelectorAll(sel));
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    /* Night skin — opt-in via prop (Mythos night mode); default stays light. */
    const isDark = skin === 'dark';
    const disposables = [];

    /* ---------- data ---------- */
    const W = DATA.grid.w, H = DATA.grid.h, N = W * H;
    const MASK = DATA.mask, Q = DATA.meta.quant;
    const DATES = DATA.meta.dates, NDATES = DATES.length;
    const ELEVA = DATA.layers.elevation;
    const ROWS = DATA.rows || [];
    const VIG = DATA.layers.vigour;
    const FS2 = DATA.layers.fruitsize;          /* [feb, mar] — 2 measured dates */
    const FS_DATES = DATA.meta.fsDates || ['2026-02-25', '2026-03-05'];
    const FS_MEANS = DATA.meta.fsMeans || [60.8, 62.8];
    const YL = DATA.layers['yield'];

    const ELEV_SCALE = 7;
    const SPAN = 72, maxd = Math.max(W, H);
    const spanX = SPAN * W / maxd, spanZ = SPAN * H / maxd;

    /* ---------- colour ramps ---------- */
    const RAMP = {
      ec:  ['#FFF7EC', '#F6C68A', '#E08A3C', '#B5651D', '#6B3410'],
      vig: ['#F2F8EC', '#B7D89A', '#6FA84C', '#2F7A2E', '#14532D'],
      vir: ['#FFFFFF', '#E9DCF4', '#C9A4DF', '#9B59C0', '#5E1A8E'], /* white → purple (fruit size) */
      ryg: ['#A50026', '#F46D43', '#FEE08B', '#A6D96A', '#1A9850']
    };
    const hexToRgb = (h) => [parseInt(h.slice(1, 3), 16) / 255, parseInt(h.slice(3, 5), 16) / 255, parseInt(h.slice(5, 7), 16) / 255];
    const s2l = (c) => c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    const l2s = (c) => c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
    const LIN = {}; for (const rk in RAMP) LIN[rk] = RAMP[rk].map((h) => hexToRgb(h).map(s2l));
    function rampAt(key, t, out) {
      const st = LIN[key]; if (t < 0) t = 0; if (t > 1) t = 1;
      const seg = t * (st.length - 1); let i = Math.floor(seg), f = seg - i;
      if (i >= st.length - 1) { i = st.length - 2; f = 1; }
      const a = st[i], b = st[i + 1];
      out[0] = l2s(a[0] + (b[0] - a[0]) * f); out[1] = l2s(a[1] + (b[1] - a[1]) * f); out[2] = l2s(a[2] + (b[2] - a[2]) * f);
      return out;
    }
    const STEPS = 6;
    function rampBand(key, t, out) { if (t < 0) t = 0; if (t > 1) t = 1; let b = Math.floor(t * STEPS); if (b >= STEPS) b = STEPS - 1; return rampAt(key, (b + 0.5) / STEPS, out); }
    function cssRamp(k) {
      const segs = [], tmp = [0, 0, 0];
      for (let b = 0; b < STEPS; b++) {
        rampAt(k, (b + 0.5) / STEPS, tmp);
        const c = 'rgb(' + Math.round(tmp[0] * 255) + ',' + Math.round(tmp[1] * 255) + ',' + Math.round(tmp[2] * 255) + ')';
        segs.push(c + ' ' + (b / STEPS * 100).toFixed(2) + '% ' + ((b + 1) / STEPS * 100).toFixed(2) + '%');
      }
      return 'linear-gradient(90deg,' + segs.join(',') + ')';
    }

    /* ---------- grid helpers ---------- */
    const bX = new Float32Array(N), bZ = new Float32Array(N), elv = new Float32Array(N);
    for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
      const ii = r * W + c;
      bX[ii] = (c / (W - 1) - 0.5) * spanX; bZ[ii] = (r / (H - 1) - 0.5) * spanZ; elv[ii] = ELEVA[ii] / Q;
    }
    function worldXZ(u, v) { const fc = u * W - 0.5, fr = v * H - 0.5; return [(fc / (W - 1) - 0.5) * spanX, (fr / (H - 1) - 0.5) * spanZ]; }
    function sampleGrid(arr, u, v) {
      const fc = u * W - 0.5, fr = v * H - 0.5;
      const c0 = Math.floor(fc), r0 = Math.floor(fr), tx = fc - c0, ty = fr - r0;
      function g(cc, rr) { if (cc < 0) cc = 0; if (cc > W - 1) cc = W - 1; if (rr < 0) rr = 0; if (rr > H - 1) rr = H - 1; return arr[rr * W + cc]; }
      const A = g(c0, r0), B = g(c0 + 1, r0), C = g(c0, r0 + 1), D = g(c0 + 1, r0 + 1);
      return ((A * (1 - tx) + B * tx) * (1 - ty) + (C * (1 - tx) + D * tx) * ty) / Q;
    }

    /* ---------- renderer / scene ---------- */
    let renderer;
    try { renderer = new T.WebGLRenderer({ canvas: canvas, antialias: true, alpha: true }); }
    catch (err) { const fb = $('.b110-fallback'); if (fb) fb.style.display = 'grid'; return; }
    renderer.setClearColor(0x000000, 0);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));

    const scene = new T.Scene();
    scene.fog = new T.FogExp2(isDark ? 0x12180D : 0xE7F0D2, isDark ? 0.0016 : 0.0022);
    const camera = new T.PerspectiveCamera(40, 1, 1, 2000);
    const TARGET = new T.Vector3(0, 6, 0);

    if (isDark) {
      /* Night rig — warm key, cool faint rim, low ambient so layer colours glow */
      scene.add(new T.HemisphereLight(0xF6F2E2, 0x10150B, 0.72));
      const sun = new T.DirectionalLight(0xFFEFD0, 0.95); sun.position.set(90, 150, 50); scene.add(sun);
      const rim = new T.DirectionalLight(0x9FB8D8, 0.20); rim.position.set(-80, 60, -70); scene.add(rim);
      scene.add(new T.AmbientLight(0x39422C, 0.55));
    } else {
      scene.add(new T.HemisphereLight(0xFFFFFF, 0xCFD9B0, 0.9));
      const sun = new T.DirectionalLight(0xFFF3DD, 1.05); sun.position.set(90, 150, 50); scene.add(sun);
      const rim = new T.DirectionalLight(0xCBE4FF, 0.3); rim.position.set(-80, 60, -70); scene.add(rim);
      scene.add(new T.AmbientLight(0xEAF0DC, 0.45));
    }

    /* ---------- terrain ---------- */
    const tIndex = [];
    for (let r = 0; r < H - 1; r++) for (let c = 0; c < W - 1; c++) {
      const a0 = r * W + c, b0 = a0 + 1, d0 = a0 + W, e0 = d0 + 1;
      if (MASK[a0] && MASK[b0] && MASK[d0] && MASK[e0]) tIndex.push(a0, d0, b0, b0, d0, e0);
    }
    const tPos = new Float32Array(N * 3), tCol = new Float32Array(N * 3);
    for (let i3 = 0; i3 < N; i3++) { tPos[3 * i3] = bX[i3]; tPos[3 * i3 + 1] = elv[i3] * ELEV_SCALE; tPos[3 * i3 + 2] = bZ[i3]; }
    const tGeo = new T.BufferGeometry();
    tGeo.setAttribute('position', new T.BufferAttribute(tPos, 3));
    tGeo.setAttribute('color', new T.BufferAttribute(tCol, 3));
    tGeo.setIndex(tIndex); tGeo.computeVertexNormals();
    const tMat = new T.MeshStandardMaterial({ vertexColors: true, side: T.DoubleSide, roughness: 0.92, metalness: 0.06 });
    scene.add(new T.Mesh(tGeo, tMat));
    disposables.push(tGeo, tMat);

    /* skirt — dark edge so the block reads as a solid slab */
    (function buildSkirt() {
      const pos = [], idx = []; let n = 0;
      function edge(a, b) {
        pos.push(bX[a], elv[a] * ELEV_SCALE, bZ[a], bX[a], elv[a] * ELEV_SCALE - 2.6, bZ[a],
                 bX[b], elv[b] * ELEV_SCALE, bZ[b], bX[b], elv[b] * ELEV_SCALE - 2.6, bZ[b]);
        idx.push(n, n + 1, n + 2, n + 2, n + 1, n + 3); n += 4;
      }
      for (let r = 0; r < H - 1; r++) for (let c = 0; c < W - 1; c++) {
        const a = r * W + c, b = a + 1, d = a + W;
        if (!(MASK[a] && MASK[b] && MASK[d] && MASK[a + W + 1])) continue;
        if (r === 0 || !(MASK[a - W] && MASK[b - W])) edge(a, b);
        if (c === 0 || !(MASK[a - 1] && MASK[d - 1])) edge(d, a);
        if (r === H - 2 || !(MASK[d + W] && MASK[d + W + 1])) edge(d + 1, d);
        if (c === W - 2 || !(MASK[b + 1] && MASK[b + W + 1])) edge(b, b + W + 1);
      }
      const g = new T.BufferGeometry();
      g.setAttribute('position', new T.BufferAttribute(new Float32Array(pos), 3));
      g.setIndex(idx); g.computeVertexNormals();
      const m = new T.MeshStandardMaterial({ color: 0x8A8458, roughness: 1, metalness: 0, side: T.DoubleSide });
      scene.add(new T.Mesh(g, m));
      disposables.push(g, m);
    })();

    /* per-vertex EC, normalised */
    const ECV = ['ec90', 'ec50', 'ec25'].map((k) => {
      const src = DATA.layers[k], out = new Float32Array(N);
      for (let i = 0; i < N; i++) out[i] = src[i] / Q;
      return out;
    });
    /* per-vertex fruit size, both dates, normalised */
    const FSV = FS2.map((src) => {
      const out = new Float32Array(N);
      for (let i = 0; i < N; i++) out[i] = src[i] / Q;
      return out;
    });
    const NEUT_A = hexToRgb('#EFF4E0'), NEUT_B = hexToRgb('#D8E6BA');
    const SOIL_A = hexToRgb('#E6E0C8'), SOIL_B = hexToRgb('#C8BC95');
    function neutralCol(i, out) { const e = elv[i]; out[0] = NEUT_A[0] + (NEUT_B[0] - NEUT_A[0]) * e; out[1] = NEUT_A[1] + (NEUT_B[1] - NEUT_A[1]) * e; out[2] = NEUT_A[2] + (NEUT_B[2] - NEUT_A[2]) * e; }
    function soilCol(i, out) { const e = ECV[2][i]; out[0] = SOIL_A[0] + (SOIL_B[0] - SOIL_A[0]) * e; out[1] = SOIL_A[1] + (SOIL_B[1] - SOIL_A[1]) * e; out[2] = SOIL_A[2] + (SOIL_B[2] - SOIL_A[2]) * e; }

    let lastSkinKey = '';
    const c0a = [0, 0, 0], c1a = [0, 0, 0];
    /* modes: 0 neutral · 1 EC sweep(d, front) · 2 ec25→soil blend(t)
       3 fruit-size MAP: soil → white→purple map (blend t), morphing 2 dates (front = fsT) */
    function skinTerrain(mode, d, front, t) {
      const key = mode + ':' + d + ':' + Math.round(front * 120) + ':' + Math.round(t * 120);
      if (key === lastSkinKey) return; lastSkinKey = key;
      for (let i = 0; i < N; i++) {
        if (mode === 0) neutralCol(i, c0a);
        else if (mode === 1) {
          if (bX[i] < front) rampBand('ec', ECV[d][i], c0a);
          else if (d === 0) neutralCol(i, c0a);
          else rampBand('ec', ECV[d - 1][i], c0a);
        } else if (mode === 2) {
          rampBand('ec', ECV[2][i], c0a); soilCol(i, c1a);
          c0a[0] += (c1a[0] - c0a[0]) * t; c0a[1] += (c1a[1] - c0a[1]) * t; c0a[2] += (c1a[2] - c0a[2]) * t;
        } else {
          soilCol(i, c0a);
          const fv = FSV[0][i] + (FSV[1][i] - FSV[0][i]) * front;
          rampBand('vir', fv, c1a);
          c0a[0] += (c1a[0] - c0a[0]) * t; c0a[1] += (c1a[1] - c0a[1]) * t; c0a[2] += (c1a[2] - c0a[2]) * t;
        }
        tCol[3 * i] = c0a[0]; tCol[3 * i + 1] = c0a[1]; tCol[3 * i + 2] = c0a[2];
      }
      tGeo.attributes.color.needsUpdate = true;
    }

    /* ---------- EMI scan bar ---------- */
    const sweepGrp = new T.Group();
    const sbGeo = new T.BoxGeometry(0.25, 8, spanZ * 1.02);
    const sbMat = new T.MeshBasicMaterial({ color: 0x83B535, transparent: true, opacity: 0 });
    const sweepBar = new T.Mesh(sbGeo, sbMat);
    sweepBar.position.y = 4.5; sweepGrp.add(sweepBar);
    const sgGeo = new T.PlaneGeometry(4, spanZ * 1.02);
    const sgMat = new T.MeshBasicMaterial({ color: 0xB8DC73, transparent: true, opacity: 0, side: T.DoubleSide, depthWrite: false });
    const sweepGlow = new T.Mesh(sgGeo, sgMat);
    sweepGlow.rotation.x = -Math.PI / 2; sweepGlow.position.x = -3.2; sweepGlow.position.y = 7.6;
    sweepGrp.add(sweepGlow); scene.add(sweepGrp);
    disposables.push(sbGeo, sbMat, sgGeo, sgMat);

    /* ---------- trees along the real rows ---------- */
    const trees = [];
    (function plantTrees() {
      const SPACING = 1.18;
      for (let p = 0; p < ROWS.length; p++) {
        const part = ROWS[p];
        const A0 = part[0], A1 = part[1], B0 = part[2], B1 = part[3];
        const m0 = [(A0[0] + B0[0]) / 2, (A0[1] + B0[1]) / 2], m1 = [(A1[0] + B1[0]) / 2, (A1[1] + B1[1]) / 2];
        const w0 = worldXZ(m0[0], m0[1]), w1 = worldXZ(m1[0], m1[1]);
        const L = Math.hypot(w1[0] - w0[0], w1[1] - w0[1]);
        const K = Math.max(2, Math.floor(L / SPACING));
        for (let s = 0; s <= K; s++) {
          const tt = (s + 0.5) / (K + 1);
          const u = m0[0] + (m1[0] - m0[0]) * tt, v = m0[1] + (m1[1] - m0[1]) * tt;
          const w = worldXZ(u, v);
          const vig = new Float32Array(NDATES);
          for (let d = 0; d < NDATES; d++) vig[d] = sampleGrid(VIG[d], u, v);
          trees.push({
            x: w[0], z: w[1], y0: sampleGrid(ELEVA, u, v) * ELEV_SCALE,
            vig: vig,
            fs0: sampleGrid(FS2[0], u, v), fs1: sampleGrid(FS2[1], u, v),
            yld: sampleGrid(YL, u, v),
            ph: Math.random() * 6.283
          });
        }
      }
    })();
    const NT = trees.length;

    const canGeo = new T.IcosahedronGeometry(0.62, 1);
    const canMat = new T.MeshStandardMaterial({ roughness: 0.7, metalness: 0.02 });
    const canopy = new T.InstancedMesh(canGeo, canMat, NT);
    canopy.instanceMatrix.setUsage(T.DynamicDrawUsage); scene.add(canopy);
    const trGeo = new T.CylinderGeometry(0.07, 0.1, 1, 5);
    const trMat = new T.MeshStandardMaterial({ color: 0x6B4A2E, roughness: 0.95 });
    const trunks = new T.InstancedMesh(trGeo, trMat, NT);
    trunks.instanceMatrix.setUsage(T.DynamicDrawUsage); scene.add(trunks);
    disposables.push(canGeo, canMat, trGeo, trMat, canopy, trunks);

    /* ---------- pollen drift ---------- */
    const NP = 240;
    const pPos = new Float32Array(NP * 3), pSeed = new Float32Array(NP);
    for (let ip = 0; ip < NP; ip++) {
      pPos[3 * ip] = (Math.random() - 0.5) * spanX * 1.1;
      pPos[3 * ip + 1] = 2 + Math.random() * 16;
      pPos[3 * ip + 2] = (Math.random() - 0.5) * spanZ * 1.1;
      pSeed[ip] = Math.random() * 6.283;
    }
    const pGeo = new T.BufferGeometry();
    pGeo.setAttribute('position', new T.BufferAttribute(pPos, 3));
    const pMat = new T.PointsMaterial({ color: 0xFFFDF2, size: 0.55, transparent: true, opacity: 0, depthWrite: false, sizeAttenuation: true });
    scene.add(new T.Points(pGeo, pMat));
    disposables.push(pGeo, pMat);

    /* ---------- timeline ---------- */
    const ACT = {
      intro: [0.00, 0.05],
      soil: [0.05, 0.26],
      grow: [0.26, 0.33],
      dates: [0.31, 0.60],
      fruit: [0.62, 0.79],     /* recolour 0.62–0.66, then 2-date size morph 0.66–0.775 */
      fsMorph: [0.66, 0.775],
      yield: [0.79, 0.95],
      outro: [0.95, 1.00]
    };
    function smooth(s, e, x) { if (x <= s) return 0; if (x >= e) return 1; const t = (x - s) / (e - s); return t * t * (3 - 2 * t); }

    /* ---------- tree pose + colour ----------
       colour chain: vigour green → fruit-size white→purple (fruitC)
       → yield red→green (yieldT). Fruit size is shown by COLOUR on
       the whole canopy — no fruit spheres. */
    const dummy = new T.Object3D(), colT = new T.Color(), v0 = [0, 0, 0], v1 = [0, 0, 0];
    let lastTreeKey = '';
    function setTrees(growT, dateF, fruitC, fsT, yieldT) {
      const key = Math.round(growT * 160) + ':' + Math.round(dateF * 60) + ':' + Math.round(fruitC * 140) + ':' + Math.round(fsT * 140) + ':' + Math.round(yieldT * 140);
      if (key === lastTreeKey) return; lastTreeKey = key;
      let d0 = Math.floor(dateF); if (d0 > NDATES - 2) d0 = NDATES - 2; const df = dateF - d0;
      for (let i = 0; i < NT; i++) {
        const tr = trees[i];
        const wave = (tr.x / spanX + 0.5) * 0.55 + (Math.sin(tr.ph) * 0.5 + 0.5) * 0.45;
        const g = smooth(wave * 0.55, wave * 0.55 + 0.45, growT);
        const vg = tr.vig[d0] + (tr.vig[d0 + 1] - tr.vig[d0]) * df;
        const sBase = 0.55 + vg * 0.8;
        const s = g * (sBase + (0.45 + tr.yld * 1.05 - sBase) * yieldT);
        const h = tr.y0 + 1.05 + vg * 1.9 * g;
        dummy.position.set(tr.x, h, tr.z);
        dummy.scale.set(s, s * 1.18, s);
        dummy.rotation.y = tr.ph;
        dummy.updateMatrix(); canopy.setMatrixAt(i, dummy.matrix);
        const th = (h - tr.y0) * 0.92;
        dummy.position.set(tr.x, tr.y0 + th / 2, tr.z);
        dummy.scale.set(g, Math.max(0.001, th), g);
        dummy.rotation.y = 0; dummy.updateMatrix(); trunks.setMatrixAt(i, dummy.matrix);
        /* colour chain */
        rampBand('vig', vg, v0);
        if (fruitC > 0.001) {
          const fsVal = tr.fs0 + (tr.fs1 - tr.fs0) * fsT;
          rampBand('vir', fsVal, v1);
          v0[0] += (v1[0] - v0[0]) * fruitC; v0[1] += (v1[1] - v0[1]) * fruitC; v0[2] += (v1[2] - v0[2]) * fruitC;
        }
        if (yieldT > 0.001) {
          rampBand('ryg', tr.yld, v1);
          v0[0] += (v1[0] - v0[0]) * yieldT; v0[1] += (v1[1] - v0[1]) * yieldT; v0[2] += (v1[2] - v0[2]) * yieldT;
        }
        colT.setRGB(v0[0], v0[1], v0[2]); canopy.setColorAt(i, colT);
      }
      canopy.instanceMatrix.needsUpdate = true;
      trunks.instanceMatrix.needsUpdate = true;
      if (canopy.instanceColor) canopy.instanceColor.needsUpdate = true;
    }

    /* ---------- camera ---------- */
    const WAY = [
      [0.00, -2.45, 0.56, 152, 8],
      [0.10, -2.05, 0.40, 118, 6],
      [0.26, -1.60, 0.30, 100, 6],
      [0.45, -1.10, 0.15, 78, 5],
      [0.62, -0.78, 0.30, 86, 5],
      [0.79, -0.50, 0.52, 100, 4],
      [0.95, -0.32, 1.28, 124, 0],
      [1.00, -0.28, 1.34, 130, 0]
    ];
    function easeIO(x) { return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; }
    function updateCamera(p, time) {
      let i = 0; while (i < WAY.length - 2 && p > WAY[i + 1][0]) i++;
      const a = WAY[i], b = WAY[i + 1];
      const t = easeIO(Math.min(1, Math.max(0, (p - a[0]) / (b[0] - a[0]))));
      let az = a[1] + (b[1] - a[1]) * t, elv2 = a[2] + (b[2] - a[2]) * t;
      let rad = a[3] + (b[3] - a[3]) * t, ty = a[4] + (b[4] - a[4]) * t;
      if (reduce) { az = -1.6; elv2 = 0.34; rad = 112; ty = 6; }
      else { az += Math.sin(time * 0.00012) * 0.012; elv2 += Math.sin(time * 0.00009) * 0.006; }
      TARGET.y = ty;
      const ce = Math.cos(elv2), se = Math.sin(elv2);
      camera.position.set(TARGET.x + rad * ce * Math.cos(az), TARGET.y + rad * se, TARGET.z + rad * ce * Math.sin(az));
      camera.lookAt(TARGET);
    }

    /* ---------- scene update ---------- */
    function updateScene(p, time) {
      const sw = smooth(ACT.soil[0], ACT.soil[1], p);
      if (p < ACT.soil[0]) { skinTerrain(0, 0, 0, 0); sbMat.opacity = 0; sgMat.opacity = 0; }
      else if (p < ACT.soil[1]) {
        const seg = Math.min(2, Math.floor(sw * 3)), st = sw * 3 - seg;
        const front = -spanX / 2 - 3 + st * (spanX + 6);
        skinTerrain(1, seg, front, 0);
        sweepGrp.position.x = front;
        const vis = Math.sin(st * Math.PI);
        sbMat.opacity = 0.32 * vis; sgMat.opacity = 0.10 * vis;
      } else {
        /* fruit act: the ground shows the white→purple fruit-size map too */
        const fsT0 = smooth(ACT.fsMorph[0], ACT.fsMorph[1], p);
        const fsMapB = smooth(ACT.fruit[0], ACT.fruit[0] + 0.04, p) * (1 - smooth(ACT.yield[0], ACT.yield[0] + 0.07, p));
        if (fsMapB > 0.001) skinTerrain(3, 0, fsT0, fsMapB);
        else skinTerrain(2, 0, 0, smooth(ACT.grow[0], ACT.grow[0] + 0.07, p));
        sbMat.opacity = 0; sgMat.opacity = 0;
      }
      const growT = smooth(ACT.grow[0], ACT.grow[1], p);
      const dateT = smooth(ACT.dates[0], ACT.dates[1], p);
      const fruitC = smooth(ACT.fruit[0], ACT.fruit[0] + 0.04, p) * (1 - smooth(ACT.yield[0], ACT.yield[0] + 0.08, p));
      const fsT = smooth(ACT.fsMorph[0], ACT.fsMorph[1], p);
      const yieldT = smooth(ACT.yield[0], ACT.yield[0] + 0.08, p);
      canopy.visible = trunks.visible = growT > 0.001;
      if (canopy.visible) setTrees(growT, dateT * (NDATES - 1), fruitC, fsT, yieldT);
      const dustOn = smooth(0.30, 0.36, p) * (1 - smooth(0.80, 0.88, p));
      pMat.opacity = reduce ? 0 : 0.30 * dustOn;
      if (pMat.opacity > 0.005) {
        for (let i = 0; i < NP; i++) {
          pPos[3 * i + 1] = 2 + ((Math.sin(time * 0.00018 + pSeed[i]) * 0.5 + 0.5) * 14);
          pPos[3 * i] += Math.sin(time * 0.0001 + pSeed[i] * 2) * 0.01;
        }
        pGeo.attributes.position.needsUpdate = true;
      }
      return { fsT: fsT };
    }

    /* ---------- HUD ---------- */
    const el = {
      title: $('.b110-title'), panel: $('.b110-panel'),
      phaseName: $('.b110-phase-name'), layerName: $('.b110-layer-name'),
      ramp: $('.b110-ramp'), rampLo: $('.b110-ramp-lo'), rampHi: $('.b110-ramp-hi'),
      key: $('.b110-readout-key'), val: $('.b110-readout-val'), note: $('.b110-readout-note'),
      hint: $('.b110-hint'), bar: $('.b110-bar'),
      phases: $$('.b110-phases span'),
      demtag: $('.b110-demtag'),
      gauge: $('.b110-gauge'), gaugeDot: $('.b110-gauge-dot'),
      gaugeTicks: $$('.b110-gauge-tick'),
      scrub: $('.b110-scrub'), scrubFill: $('.b110-scrub-fill'),
      scrubTicks: $$('.b110-scrub-tick'),
      outro: $('.b110-outro')
    };
    const eb = DATA.meta.elevBlock; if (eb && el.demtag) el.demtag.textContent = 'RELIEF · OUPLAAS DEM · ' + eb[0] + '–' + eb[1] + ' m';
    const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
    function fmtDate(iso) { const q = iso.split('-'); return parseInt(q[2], 10) + ' ' + MON[parseInt(q[1], 10) - 1] + " '" + q[0].slice(2); }
    function setPhase(i) { el.phases.forEach((s, k) => { s.classList.toggle('active', k === i); }); }
    let curRamp = '';
    function showRamp(k) { if (k !== curRamp) { el.ramp.style.background = cssRamp(k); curRamp = k; } }
    const RNG = DATA.meta.ranges;
    const mqMobile = window.matchMedia('(max-width:640px)');

    function updateHUD(p, st) {
      el.bar.style.width = (p * 100).toFixed(1) + '%';
      const labelsOn = p >= 0.012 && p < ACT.outro[0];
      el.hint.style.opacity = p < 0.015 ? 0.85 : 0;
      el.panel.classList.toggle('on', labelsOn);
      el.title.style.opacity = p < ACT.outro[0] - 0.03 ? 1 : 0;
      el.title.classList.toggle('labels-on', mqMobile.matches && labelsOn);
      el.outro.classList.toggle('on', p >= ACT.outro[0]);

      const soilOn = p >= ACT.soil[0] && p < ACT.grow[0];
      el.gauge.classList.toggle('on', soilOn);
      const datesOn = p >= ACT.grow[0] && p < ACT.fruit[0] - 0.02;
      el.scrub.classList.toggle('on', datesOn);

      if (p < ACT.soil[0]) { setPhase(-1); }
      else if (soilOn) {
        setPhase(0); showRamp('ec'); el.phaseName.textContent = 'SCANNING · SUBSURFACE';
        el.rampLo.textContent = Math.round(RNG.ec[0]); el.rampHi.textContent = RNG.ec[1] + ' mS/m'; el.key.textContent = 'DEPTH';
        const sw = smooth(ACT.soil[0], ACT.soil[1], p), seg = Math.min(2, Math.floor(sw * 3));
        const DEP = ['90 cm', '50 cm', '25 cm'];
        const NOTE = ['Deep clay & moisture signal', 'Mid-profile conductivity', 'Shallow root-zone signal'];
        el.layerName.textContent = 'Soil EC · ' + DEP[seg];
        el.val.textContent = DEP[seg]; el.note.textContent = NOTE[seg];
        el.gaugeTicks.forEach((tk, i) => { tk.classList.toggle('active', i === seg); });
        el.gaugeDot.style.top = (78 - seg * 33) + '%';
      } else if (p < ACT.fruit[0]) {
        setPhase(1); showRamp('vig'); el.phaseName.textContent = 'GROWING · CANOPY VIGOUR';
        el.layerName.textContent = 'Canopy vigour';
        el.rampLo.textContent = RNG.vigour[0]; el.rampHi.textContent = RNG.vigour[1] + ' idx'; el.key.textContent = 'DATE';
        const dt = smooth(ACT.dates[0], ACT.dates[1], p);
        const di = Math.round(dt * (NDATES - 1));
        el.val.textContent = fmtDate(DATES[di]);
        el.note.textContent = NT + ' trees · 40 rows · 8 satellite dates';
        el.scrubFill.style.width = (dt * 100).toFixed(1) + '%';
        el.scrubTicks.forEach((tk, i) => { tk.classList.toggle('active', i <= di); });
      } else if (p < ACT.yield[0]) {
        setPhase(2); showRamp('vir'); el.phaseName.textContent = 'MEASURING · FRUIT SIZE';
        el.layerName.textContent = 'Fruit size';
        el.rampLo.textContent = Math.round(RNG.fruitsize[0]); el.rampHi.textContent = Math.round(RNG.fruitsize[1]) + ' mm';
        el.key.textContent = 'MEAN DIA';
        const fsT = st && typeof st.fsT === 'number' ? st.fsT : 0;
        el.val.textContent = (FS_MEANS[0] + fsT * (FS_MEANS[1] - FS_MEANS[0])).toFixed(1) + ' mm';
        el.note.textContent = 'Sized on the tree · ' + fmtDate(FS_DATES[fsT < 0.5 ? 0 : 1]) + ' · RevScout S';
      } else {
        setPhase(3); showRamp('ryg'); el.phaseName.textContent = 'PAY-OUT · ESTIMATED YIELD';
        el.layerName.textContent = 'Estimated yield';
        el.rampLo.textContent = 'low'; el.rampHi.textContent = 'high'; el.key.textContent = 'YIELD';
        el.val.textContent = 'size × count';
        el.note.textContent = 'Tree by tree · red → green';
      }
    }

    /* ---------- seasonal photo backdrop ---------- */
    const bgEls = $$('.b110-bg');
    const BG_ON = [0, 0.13, 0.30, 0.50, 0.68, 0.86];
    function updateBG(p) {
      for (let i = 0; i < bgEls.length; i++) {
        if (i === 0) { bgEls[i].style.opacity = '1'; continue; }
        bgEls[i].style.opacity = smooth(BG_ON[i] - 0.05, BG_ON[i] + 0.05, p).toFixed(3);
      }
    }

    /* ---------- loop ---------- */
    function getProgress() { const r = root.getBoundingClientRect(); const total = r.height - window.innerHeight; return total > 0 ? Math.min(Math.max(-r.top, 0), total) / total : 0; }
    function resize() { const w = canvas.clientWidth, h = canvas.clientHeight, pr = renderer.getPixelRatio(); if (canvas.width !== Math.floor(w * pr) || canvas.height !== Math.floor(h * pr)) renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); }
    window.addEventListener('resize', resize);

    let raf, alive = true;
    function frame(time) {
      if (!alive) return;
      resize();
      const p = getProgress();
      const st = updateScene(p, time || 0);
      updateCamera(p, time || 0);
      updateHUD(p, st);
      updateBG(p);
      renderer.render(scene, camera);
      raf = requestAnimationFrame(frame);
    }
    skinTerrain(0, 0, 0, 0); setTrees(0, 0, 0, 0, 0);
    raf = requestAnimationFrame(frame);

    return () => {
      alive = false;
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', resize);
      disposables.forEach((d) => { try { d.dispose(); } catch (_) {} });
      try { renderer.dispose(); } catch (_) {}
    };
  }, [skin]);

  return (
    <div className="b110-hero" id="b110hero" ref={rootRef} data-screen-label="Home hero — The Living Block">
      <style>{`
        .b110-hero{position:relative;height:560vh;
          background:linear-gradient(180deg,#F5F8EC 0%,#E9F1D5 42%,#DCEBBE 78%,#E8F0D2 100%);
          --b-ink:#2C3326;--b-muted:rgba(35,43,24,.58);--b-accent:#1C4100;--b-accent2:#3C6B12}
        .b110-stage{position:sticky;top:0;height:100vh;width:100%;overflow:hidden}
        .b110-bg-wrap{position:absolute;inset:0;z-index:0;overflow:hidden}
        .b110-bg{position:absolute;inset:0;width:100%;height:100%;background-size:cover;background-position:center;opacity:0;will-change:opacity}
        .b110-bg-scrim{position:absolute;inset:0;width:100%;height:100%;
          background:linear-gradient(180deg,rgba(245,248,236,.78) 0%,rgba(236,243,220,.70) 46%,rgba(223,236,194,.66) 100%)}
        .b110-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;z-index:2;
          pointer-events:none;touch-action:pan-y}
        .b110-grain{position:absolute;inset:0;z-index:3;pointer-events:none;opacity:.05;mix-blend-mode:multiply;
          background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E")}
        .b110-hud{position:absolute;inset:0;pointer-events:none;z-index:5}
        .b110-hud > *{position:absolute}
        .b110-logo{top:clamp(80px,10vh,104px);left:clamp(18px,3vw,56px)}
        .b110-logo img{width:clamp(150px,19vw,250px);height:auto;display:block}
        .b110-title{top:48%;right:clamp(18px,3vw,56px);left:auto;transform:translateY(-50%);
          max-width:min(50vw,680px);text-align:right;will-change:top,transform;
          transition:opacity .5s ease,transform 1.1s cubic-bezier(.22,1,.36,1),top 1.1s cubic-bezier(.22,1,.36,1)}
        .b110-title-block{font-family:'Instrument Serif',serif;font-weight:400;
          font-size:clamp(38px,6.2vw,86px);line-height:.98;letter-spacing:-.01em;color:var(--b-accent)}
        .b110-title-sub{font-family:'Instrument Serif',serif;font-style:italic;
          font-size:clamp(16px,2.4vw,28px);color:var(--b-accent2);margin-top:.35em}
        .b110-title-meta{font-family:'JetBrains Mono',monospace;letter-spacing:.22em;
          font-size:clamp(9px,1vw,11px);color:var(--b-muted);margin-top:1.6em;text-transform:uppercase}
        .b110-panel{top:clamp(150px,20vh,210px);left:clamp(18px,3vw,56px);width:min(300px,80vw);
          text-align:left;opacity:0;transition:opacity .6s ease}
        .b110-panel.on{opacity:1}
        .b110-phase-name{font-family:'JetBrains Mono',monospace;letter-spacing:.3em;
          font-size:clamp(8px,.8vw,9.5px);color:var(--b-muted)}
        .b110-layer-name{font-family:'Instrument Serif',serif;font-size:clamp(32px,3.8vw,52px);
          font-weight:500;color:var(--b-accent);margin-top:4px;line-height:1;
          letter-spacing:-0.01em;text-shadow:0 1px 10px rgba(220,235,190,.8)}
        .b110-ramp{height:9px;width:100%;border-radius:2px;margin-top:14px;
          border:1px solid rgba(35,43,24,.18)}
        .b110-ramp-scale{display:flex;justify-content:space-between;margin-top:6px;
          font-family:'JetBrains Mono',monospace;font-size:10px;color:var(--b-muted);letter-spacing:.04em}
        .b110-readout{margin-top:16px;border-top:1px solid rgba(35,43,24,.18);padding-top:12px;
          display:flex;justify-content:flex-start;align-items:baseline;gap:14px}
        .b110-readout-key{font-family:'JetBrains Mono',monospace;letter-spacing:.22em;
          font-size:9.5px;color:var(--b-muted)}
        .b110-readout-val{font-family:'JetBrains Mono',monospace;font-size:clamp(18px,2vw,24px);
          color:var(--b-accent2);font-weight:500;font-variant-numeric:tabular-nums}
        .b110-readout-note{font-family:'JetBrains Mono',monospace;font-size:9.5px;color:var(--b-muted);
          margin-top:6px;letter-spacing:.04em}
        .b110-gauge{margin-top:18px;display:flex;gap:12px;align-items:stretch;height:84px;
          opacity:0;transition:opacity .5s ease;position:relative}
        .b110-gauge.on{opacity:1}
        .b110-gauge-rail{width:3px;border-radius:2px;background:linear-gradient(180deg,#F6C68A,#B5651D,#6B3410);position:relative}
        .b110-gauge-dot{position:absolute;left:-3.5px;top:78%;width:10px;height:10px;border-radius:50%;
          background:#1C4100;border:2px solid #F5F8EC;box-shadow:0 1px 6px rgba(28,65,0,.4);
          transition:top .6s cubic-bezier(.22,1,.36,1)}
        .b110-gauge-ticks{display:flex;flex-direction:column;justify-content:space-between;padding:2px 0}
        .b110-gauge-tick{font-family:'JetBrains Mono',monospace;font-size:9.5px;color:var(--b-muted);
          letter-spacing:.1em;transition:color .4s ease,opacity .4s ease;opacity:.55}
        .b110-gauge-tick.active{color:var(--b-accent);opacity:1;font-weight:500}
        .b110-scrub{margin-top:18px;opacity:0;transition:opacity .5s ease}
        .b110-scrub.on{opacity:1}
        .b110-scrub-track{height:3px;background:rgba(35,43,24,.14);border-radius:2px;overflow:hidden}
        .b110-scrub-fill{height:100%;width:0;background:linear-gradient(90deg,#3C6B12,#83B535)}
        .b110-scrub-ticks{display:flex;justify-content:space-between;margin-top:6px}
        .b110-scrub-tick{width:5px;height:5px;border-radius:50%;background:rgba(35,43,24,.22);
          transition:background .3s ease,transform .3s ease}
        .b110-scrub-tick.active{background:#3C6B12;transform:scale(1.25)}
        .b110-scrub-labels{display:flex;justify-content:space-between;margin-top:5px;
          font-family:'JetBrains Mono',monospace;font-size:8.5px;letter-spacing:.12em;color:var(--b-muted)}
        .b110-phases{left:clamp(18px,3vw,56px);bottom:clamp(40px,5vw,56px);display:flex;gap:18px}
        .b110-phases span{font-family:'JetBrains Mono',monospace;font-size:10px;letter-spacing:.18em;
          color:var(--b-muted);opacity:.6;transition:opacity .4s ease,color .4s ease}
        .b110-phases span.active{opacity:1;color:var(--b-accent)}
        .b110-demtag{right:clamp(18px,3vw,40px);bottom:clamp(40px,5vw,56px);text-align:right;
          font-family:'JetBrains Mono',monospace;font-size:9px;letter-spacing:.2em;color:var(--b-muted);opacity:.8}
        .b110-hint{left:50%;bottom:clamp(40px,5vw,52px);transform:translateX(-50%);
          font-family:'JetBrains Mono',monospace;letter-spacing:.30em;font-size:clamp(15px,1.7vw,19px);
          font-weight:500;color:var(--b-ink);opacity:.85;transition:opacity .5s ease;animation:b110bob 2.4s ease-in-out infinite;
          white-space:nowrap}
        @keyframes b110bob{0%,100%{transform:translateX(-50%) translateY(0)}50%{transform:translateX(-50%) translateY(6px)}}
        .b110-outro{left:50%;top:50%;transform:translate(-50%,-50%) translateY(18px);text-align:center;
          opacity:0;transition:opacity .9s ease,transform .9s cubic-bezier(.22,1,.36,1);max-width:90vw}
        .b110-outro.on{opacity:1;transform:translate(-50%,-50%) translateY(0)}
        .b110-outro-big{font-family:'Instrument Serif',serif;font-size:clamp(40px,7vw,96px);
          line-height:1;color:var(--b-accent);letter-spacing:-.01em}
        .b110-outro-sub{font-family:'Instrument Serif',serif;font-style:italic;
          font-size:clamp(16px,2.4vw,26px);color:var(--b-accent2);margin-top:.5em}
        .b110-outro-hint{font-family:'JetBrains Mono',monospace;letter-spacing:.3em;
          font-size:clamp(9px,1vw,11px);color:var(--b-muted);margin-top:2.4em}
        .b110-progress{left:0;right:0;bottom:0;height:3px;background:rgba(35,43,24,.10)}
        .b110-bar{height:100%;width:0;background:linear-gradient(90deg,#1C4100,#83B535,#B8DC73);transition:width .08s linear}
        .b110-fallback{position:absolute;inset:0;display:none;place-items:center;text-align:center;
          padding:40px;font-family:'JetBrains Mono',monospace;color:var(--b-muted);z-index:9}
        @media (max-width:640px){
          .b110-logo img{width:clamp(120px,42vw,168px)}
          .b110-panel{top:clamp(150px,21vh,200px);width:80vw}
          .b110-title{top:62%;max-width:88vw;right:clamp(12px,3vw,20px)}
          .b110-title.labels-on{top:86%;bottom:auto;transform:translateY(-100%)}
          .b110-title-block{font-size:clamp(30px,8.2vw,42px)}
          .b110-title-sub{font-size:clamp(14px,3.6vw,18px)}
          .b110-title-meta{font-size:8.5px;letter-spacing:.14em;margin-top:1.1em}
          .b110-gauge{height:66px}
          .b110-phases{gap:12px}
          .b110-phases span{font-size:8.5px;letter-spacing:.1em}
          .b110-demtag{display:none}
          .b110-hint{font-size:12px;letter-spacing:.2em}
        }
        @media (prefers-reduced-motion: reduce){ .b110-hint{animation:none} }
      `}</style>
      <div className="b110-stage">
        <div className="b110-bg-wrap" aria-hidden="true">
          <div className="b110-bg" style={{ backgroundImage: "url('assets/hero-v3-season-1.jpg')", opacity: 1 }}></div>
          <div className="b110-bg" style={{ backgroundImage: "url('assets/hero-v3-season-2.jpg')" }}></div>
          <div className="b110-bg" style={{ backgroundImage: "url('assets/hero-v3-season-3.jpg')" }}></div>
          <div className="b110-bg" style={{ backgroundImage: "url('assets/hero-v3-season-4.jpg')" }}></div>
          <div className="b110-bg" style={{ backgroundImage: "url('assets/hero-v3-season-5.jpg')" }}></div>
          <div className="b110-bg" style={{ backgroundImage: "url('assets/hero-v3-season-6.jpg')" }}></div>
          <div className="b110-bg-scrim"></div>
        </div>
        <canvas className="b110-canvas" ref={canvasRef}></canvas>
        <div className="b110-grain" aria-hidden="true"></div>
        <div className="b110-fallback">WebGL unavailable — this scene needs a hardware-accelerated browser.</div>
        <div className="b110-hud">
          <div className="b110-logo">
            <img src="assets/logo-dark-no-tagline.png" alt="Revolute Systems" />
          </div>
          <div className="b110-title">
            <div className="b110-title-block">{title || 'Orchard Mapping Technologies'}</div>
            <div className="b110-title-sub">{sub || 'Soil to fruit at tree-level data.'}</div>
            <div className="b110-title-meta">{meta || 'Western Cape, South Africa · Season 2025–26 · Pome Fruit Farm'}</div>
          </div>
          <div className="b110-panel">
            <div className="b110-phase-name">SCANNING · SUBSURFACE</div>
            <div className="b110-layer-name">Soil EC</div>
            <div className="b110-ramp"></div>
            <div className="b110-ramp-scale"><span className="b110-ramp-lo">18</span><span className="b110-ramp-hi">28 mS/m</span></div>
            <div className="b110-readout">
              <span className="b110-readout-key">DEPTH</span>
              <span className="b110-readout-val">90 cm</span>
            </div>
            <div className="b110-readout-note">Deep clay &amp; moisture signal</div>
            <div className="b110-gauge">
              <div className="b110-gauge-rail"><div className="b110-gauge-dot"></div></div>
              <div className="b110-gauge-ticks">
                <span className="b110-gauge-tick">25 cm · root zone</span>
                <span className="b110-gauge-tick">50 cm · sub-root</span>
                <span className="b110-gauge-tick">90 cm · drainage</span>
              </div>
            </div>
            <div className="b110-scrub">
              <div className="b110-scrub-track"><div className="b110-scrub-fill"></div></div>
              <div className="b110-scrub-ticks">
                <span className="b110-scrub-tick"></span><span className="b110-scrub-tick"></span>
                <span className="b110-scrub-tick"></span><span className="b110-scrub-tick"></span>
                <span className="b110-scrub-tick"></span><span className="b110-scrub-tick"></span>
                <span className="b110-scrub-tick"></span><span className="b110-scrub-tick"></span>
              </div>
              <div className="b110-scrub-labels"><span>DEC</span><span>JAN</span><span>FEB</span><span>MAR</span></div>
            </div>
          </div>
          <div className="b110-phases">
            <span>01 · SOIL</span>
            <span>02 · CANOPY</span>
            <span>03 · FRUIT</span>
            <span>04 · YIELD</span>
          </div>
          <div className="b110-demtag">RELIEF · OUPLAAS DEM</div>
          <div className="b110-hint">SCROLL · ONE BLOCK · ONE SEASON ↓</div>
          <div className="b110-outro">
            <div className="b110-outro-big">One block. Every layer.</div>
            <div className="b110-outro-sub">Soil to fruit, tree by tree — this is how we read an orchard.</div>
            <div className="b110-outro-hint">KEEP SCROLLING ↓</div>
          </div>
          <div className="b110-progress"><div className="b110-bar"></div></div>
        </div>
      </div>
    </div>
  );
};
