// Cinematic 7-act hero visualization.
// One Three.js voxel-city scene drives Acts 1, 2, 5, 6, 7 (camera choreography).
// Acts 3 (Best loading + Best routing) and 4 (Worker↔Truck assignment + checklist)
// are HTML/SVG overlays that fly in over a dimmed city background.
// Act 7 reveals Logo + tagline + CTAs. Final state runs as a calm ambient loop.
// `prefers-reduced-motion` → skips animation, renders end state directly.

// ── Timeline (ms from animation start) ────────────────────────────────
const T = {
  ACT1_START: 0,
  ACT1_END:   1500,   // depot close-up + first demand markers (1.5s)
  ACT2_END:   3000,   // zoomed out + bowed demand arrows (1.5s)
  ACT3_END:   6300,   // best loading + best routing overlay (3.3s)
  ACT4_END:   9800,   // worker↔truck bipartite + checklist (3.5s)
  ACT5_END:   15300,  // trucks drive multi-stop routes & return to depot (5.5s)
  ACT6_END:   16100,  // final delivery markers settle (0.8s)
  ACT7_END:   17600,  // pull-back + logo/tagline/CTAs reveal (1.5s)
};
// Scroll indicator fades in ~1s after Act 7 completes. The React tick loop
// keeps emitting state updates until this point so the indicator can transition.
const SCROLL_REVEAL_DONE = T.ACT7_END + 1700;
const TOTAL_MS = SCROLL_REVEAL_DONE;

// ── City layout ────────────────────────────────────────────────────────
// Procedurally generated voxel grid. Center is depot. Buildings randomly placed
// avoiding the depot area and the road corridors that connect depot to demands.
const CITY_HALF = 9;                       // city extends from -9 to +9 in world units
const DEPOT_CLEAR_RADIUS = 1.8;

// 7 demand markers spread around the city — used in acts 1, 2, 5, 6.
const DEMANDS = [
  { x: -7.5, z: -6.5 },
  { x:  7.0, z: -7.5 },
  { x:  8.0, z:  0.2 },
  { x:  6.5, z:  6.8 },
  { x: -1.5, z:  8.0 },
  { x: -7.8, z:  5.0 },
  { x: -8.5, z: -0.5 },
];

// Helpers
const _parseColor = (str) => {
  const s = (str || '').trim();
  if (s.startsWith('#')) {
    const hex = s.length === 4
      ? s[1] + s[1] + s[2] + s[2] + s[3] + s[3]
      : s.slice(1);
    return parseInt(hex, 16);
  }
  if (s.startsWith('rgb')) {
    const m = s.match(/[\d.]+/g);
    if (m && m.length >= 3) {
      return (Math.round(+m[0]) << 16) | (Math.round(+m[1]) << 8) | Math.round(+m[2]);
    }
  }
  return 0xcccccc;
};
const _clamp = (n, a, b) => Math.max(a, Math.min(b, n));
const _lerp = (a, b, t) => a + (b - a) * t;
const _easeOut = (t) => 1 - Math.pow(1 - t, 3);
const _easeInOut = (t) => t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2;
const _smoothstep = (t) => t * t * (3 - 2 * t);

function useReducedMotion() {
  const [reduced, setReduced] = React.useState(() =>
    typeof window !== 'undefined' &&
    window.matchMedia('(prefers-reduced-motion: reduce)').matches
  );
  React.useEffect(() => {
    const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
    const onChange = (e) => setReduced(e.matches);
    if (mql.addEventListener) mql.addEventListener('change', onChange);
    else mql.addListener(onChange);
    return () => {
      if (mql.removeEventListener) mql.removeEventListener('change', onChange);
      else mql.removeListener(onChange);
    };
  }, []);
  return reduced;
}

// Seeded RNG so the city layout is identical every page load.
function mulberry32(seed) {
  return function () {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

// Distance from point (x,z) to a segment (ax,az)→(bx,bz)
function distToSegment(x, z, ax, az, bx, bz) {
  const dx = bx - ax, dz = bz - az;
  const len2 = dx * dx + dz * dz;
  if (len2 < 1e-6) return Math.hypot(x - ax, z - az);
  const t = _clamp(((x - ax) * dx + (z - az) * dz) / len2, 0, 1);
  return Math.hypot(x - (ax + dx * t), z - (az + dz * t));
}

// Build a sparse set of buildings: random grid avoiding the depot disc and the
// corridors used by the road network. Lower density than before so the streets
// dominate the visual.
function buildCityLayout() {
  const rng = mulberry32(0x1337);
  const out = [];
  const CELL = 1.1;
  const ROAD_CLEAR = 0.85;
  for (let x = -CITY_HALF; x <= CITY_HALF; x += CELL) {
    for (let z = -CITY_HALF; z <= CITY_HALF; z += CELL) {
      const r = Math.hypot(x, z);
      if (r < DEPOT_CLEAR_RADIUS + 0.6) continue;
      // Avoid roads (carve corridors so streets stay clear)
      let onRoad = false;
      for (const [ai, bi] of ROAD_SEGMENTS) {
        const a = pointFor(ai), b = pointFor(bi);
        if (distToSegment(x, z, a.x, a.z, b.x, b.z) < ROAD_CLEAR) {
          onRoad = true; break;
        }
      }
      if (onRoad) continue;
      // Probabilistic placement — sparse density
      if (rng() > 0.34) continue;
      const jx = (rng() - 0.5) * 0.22;
      const jz = (rng() - 0.5) * 0.22;
      const w = 0.55 + rng() * 0.40;
      const d = 0.55 + rng() * 0.40;
      const h = 0.4 + Math.pow(rng(), 1.6) * 2.6;
      out.push({ x: x + jx, z: z + jz, w, d, h });
    }
  }
  return out;
}

// Bezier control point for an arc above the line from depot→demand
function bowControl(ax, az, bx, bz, height = 1.6) {
  return [(ax + bx) / 2, height, (az + bz) / 2];
}
function bowPoint(t, ax, az, bx, bz, cy) {
  const cx = (ax + bx) / 2;
  const cz = (az + bz) / 2;
  const u = 1 - t;
  return {
    x: u * u * ax + 2 * u * t * cx + t * t * bx,
    y: u * u * 0 + 2 * u * t * cy + t * t * 0,
    z: u * u * az + 2 * u * t * cz + t * t * bz,
  };
}

// Truck plans for Acts 5–6. Three trucks, each makes 2–3 stops then returns
// to the depot. Together they cover all 7 demands.
//   Truck 0: depot → d0 → d6 → d5 → depot   (west side)
//   Truck 1: depot → d1 → d2 → depot         (east side)
//   Truck 2: depot → d3 → d4 → depot         (south-east → south)
const TRUCK_ROUTES = [
  [0, 6, 5],
  [1, 2],
  [3, 4],
];

// All unique road segments needed by the truck plans, expressed as point pairs.
// "-1" denotes the depot (origin). Used both for visible road geometry and for
// carving building corridors so streets stay clear.
function buildRoadSegments() {
  const segs = [];
  const key = (a, b) => a < b ? `${a}|${b}` : `${b}|${a}`;
  const seen = new Set();
  TRUCK_ROUTES.forEach((route) => {
    const waypoints = [-1, ...route, -1];
    for (let i = 0; i < waypoints.length - 1; i++) {
      const a = waypoints[i], b = waypoints[i + 1];
      const k = key(a, b);
      if (seen.has(k)) continue;
      seen.add(k);
      segs.push([a, b]);
    }
  });
  return segs;
}

function pointFor(idx) {
  return idx === -1 ? { x: 0, z: 0 } : DEMANDS[idx];
}
const ROAD_SEGMENTS = buildRoadSegments();

// ─────────────────────────────────────────────────────────────────────
// 3D Canvas — owns the master timeline and renders the city scene.
// Calls `onTick(elapsedMs)` every frame so React overlays can sync.
// ─────────────────────────────────────────────────────────────────────
function HeroCinematicCanvas({ onTick, paused }) {
  const mountRef = React.useRef(null);
  const onTickRef = React.useRef(onTick);
  onTickRef.current = onTick;

  React.useEffect(() => {
    if (!mountRef.current || typeof THREE === 'undefined') return;
    const mount = mountRef.current;

    const cs = getComputedStyle(document.body);
    const accentHex = _parseColor(cs.getPropertyValue('--accent'));
    const bgHex     = _parseColor(cs.getPropertyValue('--bg'));
    const bg2Hex    = _parseColor(cs.getPropertyValue('--bg-2'));
    const fg2Hex    = _parseColor(cs.getPropertyValue('--fg-2'));
    const DEMAND_HEX = 0xff4d4d;
    const DELIVERED_HEX = 0x3dd576;

    let w = mount.clientWidth || 1200;
    let h = mount.clientHeight || 800;

    const scene = new THREE.Scene();
    // Fog distances scale with the mobile zoom-out factor in renderFrame —
    // otherwise pulled-back portrait viewports lose the whole scene to fog.
    const FOG_BASE_NEAR = 21;
    const FOG_BASE_FAR  = 45;
    scene.fog = new THREE.Fog(bgHex, FOG_BASE_NEAR, FOG_BASE_FAR);

    const camera = new THREE.PerspectiveCamera(35, w / h, 0.1, 100);
    const camLook = new THREE.Vector3(0, 0, 0);

    const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.75));
    renderer.setSize(w, h, false);
    renderer.domElement.style.width = '100%';
    renderer.domElement.style.height = '100%';
    renderer.domElement.style.display = 'block';
    mount.appendChild(renderer.domElement);

    // Lights — soft, near-flat. Edge lines do most of the visual work.
    scene.add(new THREE.AmbientLight(0xffffff, 0.55));
    const key = new THREE.DirectionalLight(0xffffff, 0.45);
    key.position.set(6, 12, 4);
    scene.add(key);
    const rim = new THREE.DirectionalLight(0xfff0d0, 0.18);
    rim.position.set(-8, 6, -4);
    scene.add(rim);

    // Ground — subtle dark plane to anchor everything
    const groundMat = new THREE.MeshBasicMaterial({
      color: bgHex, transparent: true, opacity: 0.9,
    });
    const ground = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), groundMat);
    ground.rotation.x = -Math.PI / 2;
    ground.position.y = -0.01;
    scene.add(ground);

    // Roads are *not* rendered as visible geometry — buildings carve corridors
    // along ROAD_SEGMENTS so trucks have a clean path through the city.

    // City buildings — instanced for performance
    const layout = buildCityLayout();
    const buildingGeo = new THREE.BoxGeometry(1, 1, 1);
    const buildingMat = new THREE.MeshStandardMaterial({
      color: bg2Hex, roughness: 0.92, metalness: 0.0,
    });
    const buildings = new THREE.InstancedMesh(buildingGeo, buildingMat, layout.length);
    const dummy = new THREE.Object3D();
    layout.forEach((b, i) => {
      dummy.position.set(b.x, b.h / 2, b.z);
      dummy.scale.set(b.w, b.h, b.d);
      dummy.updateMatrix();
      buildings.setMatrixAt(i, dummy.matrix);
    });
    buildings.instanceMatrix.needsUpdate = true;
    scene.add(buildings);

    // Building edge outlines — single line geometry built from all box wireframes
    const edgePositions = [];
    layout.forEach((b) => {
      const hw = b.w / 2, hh = b.h, hd = b.d / 2;
      const c = [b.x, 0, b.z];
      const corners = [
        [c[0]-hw, 0,    c[2]-hd], [c[0]+hw, 0,    c[2]-hd],
        [c[0]+hw, 0,    c[2]+hd], [c[0]-hw, 0,    c[2]+hd],
        [c[0]-hw, hh,   c[2]-hd], [c[0]+hw, hh,   c[2]-hd],
        [c[0]+hw, hh,   c[2]+hd], [c[0]-hw, hh,   c[2]+hd],
      ];
      const edges = [
        [0,1],[1,2],[2,3],[3,0],
        [4,5],[5,6],[6,7],[7,4],
        [0,4],[1,5],[2,6],[3,7],
      ];
      edges.forEach(([a, b]) => {
        edgePositions.push(...corners[a], ...corners[b]);
      });
    });
    const edgeGeo = new THREE.BufferGeometry();
    edgeGeo.setAttribute('position', new THREE.Float32BufferAttribute(edgePositions, 3));
    const edgeMat = new THREE.LineBasicMaterial({
      color: fg2Hex, transparent: true, opacity: 0.22,
    });
    scene.add(new THREE.LineSegments(edgeGeo, edgeMat));

    // Depot — central glowing pad with stacked amber packages
    const depotGroup = new THREE.Group();
    scene.add(depotGroup);

    const depotBase = new THREE.Mesh(
      new THREE.BoxGeometry(2.6, 0.18, 2.6),
      new THREE.MeshStandardMaterial({ color: bg2Hex, roughness: 0.7 })
    );
    depotBase.position.y = 0.09;
    depotGroup.add(depotBase);

    const depotEdges = new THREE.LineSegments(
      new THREE.EdgesGeometry(new THREE.BoxGeometry(2.6, 0.18, 2.6)),
      new THREE.LineBasicMaterial({ color: accentHex, transparent: true, opacity: 0.85 })
    );
    depotEdges.position.y = 0.09;
    depotGroup.add(depotEdges);

    // Amber packages stacked in depot — these pulse in Act 1
    const pkgMat = new THREE.MeshStandardMaterial({
      color: accentHex, roughness: 0.5, metalness: 0.0,
      emissive: accentHex, emissiveIntensity: 0.25,
    });
    const pkgGeo = new THREE.BoxGeometry(0.42, 0.42, 0.42);
    const packagePositions = [
      [-0.5, 0.4, -0.5], [0.0, 0.4, -0.5], [0.5, 0.4, -0.5],
      [-0.5, 0.4,  0.0], [0.0, 0.4,  0.0], [0.5, 0.4,  0.0],
      [-0.5, 0.4,  0.5], [0.0, 0.4,  0.5], [0.5, 0.4,  0.5],
    ];
    const packages = packagePositions.map(([px, py, pz]) => {
      const pkg = new THREE.Mesh(pkgGeo, pkgMat.clone());
      pkg.position.set(px, py, pz);
      depotGroup.add(pkg);
      const pkgEdge = new THREE.LineSegments(
        new THREE.EdgesGeometry(pkgGeo),
        new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.18 })
      );
      pkgEdge.position.copy(pkg.position);
      depotGroup.add(pkgEdge);
      return pkg;
    });

    // Demand markers — red glowing pillars + ring under each
    const demandGroup = new THREE.Group();
    scene.add(demandGroup);
    const demandRingMat = new THREE.MeshBasicMaterial({
      color: DEMAND_HEX, transparent: true, opacity: 0,
    });
    const demandRingGeo = new THREE.RingGeometry(0.45, 0.65, 32);
    const demandPillarMat = new THREE.MeshBasicMaterial({
      color: DEMAND_HEX, transparent: true, opacity: 0,
    });
    const demandPillarGeo = new THREE.BoxGeometry(0.18, 1.1, 0.18);
    const demandCubeMat = new THREE.LineBasicMaterial({
      color: DEMAND_HEX, transparent: true, opacity: 0,
    });
    const demandCubeGeo = new THREE.EdgesGeometry(new THREE.BoxGeometry(0.55, 0.55, 0.55));

    const demands = DEMANDS.map((d) => {
      const g = new THREE.Group();
      g.position.set(d.x, 0, d.z);

      const ring = new THREE.Mesh(demandRingGeo, demandRingMat.clone());
      ring.rotation.x = -Math.PI / 2;
      ring.position.y = 0.02;
      g.add(ring);

      const pillar = new THREE.Mesh(demandPillarGeo, demandPillarMat.clone());
      pillar.position.y = 0.55;
      g.add(pillar);

      const cube = new THREE.LineSegments(demandCubeGeo, demandCubeMat.clone());
      cube.position.y = 1.35;
      g.add(cube);

      demandGroup.add(g);
      return { g, ring, pillar, cube,
        deliveredRingMat: null, deliveredCubeMat: null };
    });

    // Bowed arrows (Act 2) — animated curves from depot to each demand
    const bowCurves = [];
    const BOW_SEGMENTS = 24;
    DEMANDS.forEach((d, i) => {
      const cy = 1.4 + (i % 3) * 0.4;
      const points = [];
      for (let s = 0; s <= BOW_SEGMENTS; s++) {
        const t = s / BOW_SEGMENTS;
        const p = bowPoint(t, 0, 0, d.x, d.z, cy);
        points.push(new THREE.Vector3(p.x, p.y, p.z));
      }
      const geo = new THREE.BufferGeometry().setFromPoints(points);
      geo.setDrawRange(0, 0);
      const mat = new THREE.LineBasicMaterial({
        color: accentHex, transparent: true, opacity: 0,
      });
      const line = new THREE.Line(geo, mat);
      scene.add(line);
      bowCurves.push({ line, geo, mat, points, cy });
    });

    // ── Truck plans (Acts 5–6) ─────────────────────────────────────────
    // Each truck visits 2–3 demands then returns to depot. We compute a list of
    // legs with start/end times so any frame can sample position + heading.
    const TRUCK_SPEED = 0.0085;     // world units per ms
    const STOP_PAUSE = 280;          // ms held at each delivery
    const STAGGER = 350;             // ms between truck departures
    const truckPlans = TRUCK_ROUTES.map((stops, ti) => {
      const waypoints = [
        { x: 0, z: 0, demandIdx: -1 },
        ...stops.map((i) => ({ x: DEMANDS[i].x, z: DEMANDS[i].z, demandIdx: i })),
        { x: 0, z: 0, demandIdx: -1 },
      ];
      const legs = [];
      let cumT = 0;
      for (let i = 0; i < waypoints.length - 1; i++) {
        const a = waypoints[i], b = waypoints[i + 1];
        const dist = Math.hypot(b.x - a.x, b.z - a.z);
        const dur = dist / TRUCK_SPEED;
        legs.push({ a, b, startT: cumT, endT: cumT + dur, dur, demandIdx: b.demandIdx });
        cumT += dur;
        // Pause at each delivery (not at final depot return)
        if (i < waypoints.length - 2) cumT += STOP_PAUSE;
      }
      return { startOffset: ti * STAGGER, legs, totalT: cumT, waypoints };
    });

    // Precompute arrival time for each demand: when does its truck reach it?
    const demandArrivalAbsT = new Array(DEMANDS.length).fill(Infinity);
    truckPlans.forEach((plan) => {
      plan.legs.forEach((leg) => {
        if (leg.demandIdx >= 0) {
          demandArrivalAbsT[leg.demandIdx] = T.ACT4_END + 100 + plan.startOffset + leg.endT;
        }
      });
    });

    function truckSampleAt(plan, absT) {
      const localT = absT - (T.ACT4_END + 100) - plan.startOffset;
      if (localT < 0) return { visible: false };
      if (localT >= plan.totalT) return { visible: false, returned: true };
      // Find current leg or pause
      for (let i = 0; i < plan.legs.length; i++) {
        const leg = plan.legs[i];
        if (localT < leg.startT) {
          // We're in the pause before this leg → sit at previous leg's endpoint
          const prev = plan.legs[i - 1];
          return {
            visible: true, paused: true,
            x: prev.b.x, z: prev.b.z,
            ang: Math.atan2(-(prev.b.z - prev.a.z), prev.b.x - prev.a.x),
          };
        }
        if (localT < leg.endT) {
          const u = (localT - leg.startT) / leg.dur;
          return {
            visible: true, paused: false,
            x: leg.a.x + (leg.b.x - leg.a.x) * u,
            z: leg.a.z + (leg.b.z - leg.a.z) * u,
            ang: Math.atan2(-(leg.b.z - leg.a.z), leg.b.x - leg.a.x),
          };
        }
      }
      return { visible: false, returned: true };
    }

    const truckGroup = new THREE.Group();
    scene.add(truckGroup);
    function makeTruck() {
      const g = new THREE.Group();
      const cargo = new THREE.Mesh(
        new THREE.BoxGeometry(0.78, 0.5, 0.5),
        new THREE.MeshStandardMaterial({
          color: accentHex, roughness: 0.55,
          transparent: true, opacity: 1,
        })
      );
      cargo.position.set(-0.05, 0.3, 0);
      g.add(cargo);
      const cargoEdge = new THREE.LineSegments(
        new THREE.EdgesGeometry(cargo.geometry),
        new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.3 })
      );
      cargoEdge.position.copy(cargo.position);
      g.add(cargoEdge);
      const cab = new THREE.Mesh(
        new THREE.BoxGeometry(0.32, 0.4, 0.5),
        new THREE.MeshStandardMaterial({
          color: bg2Hex, roughness: 0.55,
          transparent: true, opacity: 1,
        })
      );
      cab.position.set(0.5, 0.25, 0);
      g.add(cab);
      const cabEdge = new THREE.LineSegments(
        new THREE.EdgesGeometry(cab.geometry),
        new THREE.LineBasicMaterial({ color: accentHex, transparent: true, opacity: 0.45 })
      );
      cabEdge.position.copy(cab.position);
      g.add(cabEdge);
      g.visible = false;
      return g;
    }
    const trucks = truckPlans.map((plan) => {
      const truck = makeTruck();
      truckGroup.add(truck);
      return { mesh: truck, plan };
    });

    // Green "delivered" rings — flash briefly in Act 6 at each delivery, then morph
    // into the persistent amber light points used in the ambient end-state.
    const deliveredRings = DEMANDS.map((d) => {
      const ringMat = new THREE.MeshBasicMaterial({
        color: DELIVERED_HEX, transparent: true, opacity: 0,
        side: THREE.DoubleSide,
      });
      const ring = new THREE.Mesh(
        new THREE.RingGeometry(0.42, 0.62, 32),
        ringMat
      );
      ring.rotation.x = -Math.PI / 2;
      ring.position.set(d.x, 0.04, d.z);
      scene.add(ring);

      // A small floating check-cube above each delivery point
      const checkMat = new THREE.MeshBasicMaterial({
        color: DELIVERED_HEX, transparent: true, opacity: 0,
      });
      const check = new THREE.Mesh(
        new THREE.BoxGeometry(0.32, 0.32, 0.32),
        checkMat
      );
      check.position.set(d.x, 0.5, d.z);
      scene.add(check);
      const checkEdge = new THREE.LineSegments(
        new THREE.EdgesGeometry(check.geometry),
        new THREE.LineBasicMaterial({ color: DELIVERED_HEX, transparent: true, opacity: 0 })
      );
      checkEdge.position.copy(check.position);
      scene.add(checkEdge);

      return { ring, ringMat, check, checkMat, checkEdge };
    });

    // Delivered light points — emerge in Act 7 from green checks, persist in ambient
    const lightPointMat = new THREE.PointsMaterial({
      color: accentHex,
      size: 0.34,
      sizeAttenuation: true,
      transparent: true,
      opacity: 0,
      blending: THREE.AdditiveBlending,
      depthWrite: false,
    });
    const lightPointGeo = new THREE.BufferGeometry();
    const lightPositions = new Float32Array(DEMANDS.length * 3);
    DEMANDS.forEach((d, i) => {
      lightPositions[i*3] = d.x;
      lightPositions[i*3+1] = 0.7;
      lightPositions[i*3+2] = d.z;
    });
    lightPointGeo.setAttribute('position', new THREE.Float32BufferAttribute(lightPositions, 3));
    const lightPoints = new THREE.Points(lightPointGeo, lightPointMat);
    scene.add(lightPoints);

    // ── Camera choreography ────────────────────────────────────────────
    // Three phases:
    //  Phase 1 (0 → ACT2_END):    close on depot → zoom out to isometric overview
    //  Phase 2 (ACT2_END → ACT4_END): hold (subtle drift)
    //  Phase 3 (ACT4_END → ACT7_END): pull back + tilt up for reveal
    // Wide-view keys are scaled by a mobile-zoom factor (computed per-frame from
    // camera.aspect) so portrait viewports can still see the whole city +
    // demand markers + trucks. The depot close-up (act1Start) stays unscaled.
    const CAM_KEYS = {
      act1Start: { pos: [1.5, 2.0, 3.2], look: [0, 0.3, 0], wide: false },
      act2End:   { pos: [11.5, 11.5, 14.5], look: [0, 0, 0], wide: true },
      act3Hold:  { pos: [11.5, 11.7, 14.7], look: [0, 0, 0], wide: true },
      act4Hold:  { pos: [11.7, 11.9, 14.9], look: [0, 0, 0], wide: true },
      act7End:   { pos: [15.5, 15.5, 19.5], look: [0, -0.4, 0], wide: true },
    };
    function mobileZoomFactor() {
      const a = camera.aspect || 1;
      if (a >= 1) return 1;
      // Pull the camera back so the horizontal field-of-view matches what a
      // landscape viewport would see at the default position.
      return Math.min(1.85, Math.max(1, 0.78 / a));
    }
    function keyPos(key) {
      if (!key.wide) return key.pos;
      const z = mobileZoomFactor();
      return [key.pos[0] * z, key.pos[1] * z, key.pos[2] * z];
    }
    function setCamera(pos, look) {
      camera.position.set(pos[0], pos[1], pos[2]);
      camLook.set(look[0], look[1], look[2]);
      camera.lookAt(camLook);
    }
    function lerp3(a, b, t) {
      return [_lerp(a[0], b[0], t), _lerp(a[1], b[1], t), _lerp(a[2], b[2], t)];
    }

    // ── Render frame for current timeline state ────────────────────────
    function renderFrame(elapsedMs) {
      const t = elapsedMs;

      // Keep fog distances proportional to the camera pull-back on mobile
      // viewports — otherwise the whole city dissolves into background.
      const fogZoom = mobileZoomFactor();
      scene.fog.near = FOG_BASE_NEAR * fogZoom;
      scene.fog.far  = FOG_BASE_FAR  * fogZoom;

      // ── Camera ────────────────────────────────────────────────────────
      let camPos, camLook2;
      if (t <= T.ACT2_END) {
        const u = _easeInOut(_clamp(t / T.ACT2_END, 0, 1));
        camPos  = lerp3(keyPos(CAM_KEYS.act1Start),  keyPos(CAM_KEYS.act2End),  u);
        camLook2 = lerp3(CAM_KEYS.act1Start.look,    CAM_KEYS.act2End.look,    u);
      } else if (t <= T.ACT4_END) {
        const u = _smoothstep(_clamp((t - T.ACT2_END) / (T.ACT4_END - T.ACT2_END), 0, 1));
        camPos  = lerp3(keyPos(CAM_KEYS.act2End),  keyPos(CAM_KEYS.act4Hold),  u);
        camLook2 = lerp3(CAM_KEYS.act2End.look, CAM_KEYS.act4Hold.look, u);
      } else if (t <= T.ACT7_END) {
        const u = _easeInOut(_clamp((t - T.ACT4_END) / (T.ACT7_END - T.ACT4_END), 0, 1));
        camPos  = lerp3(keyPos(CAM_KEYS.act4Hold), keyPos(CAM_KEYS.act7End), u);
        camLook2 = lerp3(CAM_KEYS.act4Hold.look, CAM_KEYS.act7End.look, u);
      } else {
        // Ambient: slow rotation around the look target, starting with zero
        // angular velocity and easing into a constant cruising speed so the
        // transition from Act 7's pull-back feels seamless (no velocity jump).
        const dt = t - T.ACT7_END;
        const ROT_VEL = 0.00011;   // peak radians/ms (~6.3°/sec)
        const RAMP = 2500;          // ms to reach cruising speed
        let angDelta;
        if (dt < RAMP) {
          // Velocity ramps linearly 0→ROT_VEL. Position is integral: 0.5·v·t²/RAMP.
          angDelta = 0.5 * ROT_VEL * dt * dt / RAMP;
        } else {
          // Post-ramp: constant velocity, offset so positions stay continuous.
          angDelta = ROT_VEL * (dt - RAMP / 2);
        }
        const endPos = keyPos(CAM_KEYS.act7End);
        const r = Math.hypot(endPos[0], endPos[2]);
        const baseAng = Math.atan2(endPos[0], endPos[2]);
        const ang = baseAng + angDelta;
        camPos = [Math.sin(ang) * r, endPos[1], Math.cos(ang) * r];
        camLook2 = CAM_KEYS.act7End.look;
      }
      setCamera(camPos, camLook2);

      // ── Depot packages pulse continuously, brightest during Act 1 ────
      const pulse = 0.65 + 0.35 * Math.sin(t * 0.004);
      const act1Intensity = t < T.ACT2_END ? _clamp(1 - (t / T.ACT2_END) * 0.5, 0.5, 1) : 0.5;
      packages.forEach((pkg, i) => {
        const phase = i * 0.4;
        const localPulse = 0.55 + 0.45 * Math.sin(t * 0.004 + phase);
        pkg.material.emissiveIntensity = 0.18 + 0.4 * localPulse * act1Intensity;
      });

      // ── Demand markers fade in during Act 1, fully visible by Act 2 ──
      demands.forEach((d, i) => {
        // Each demand stagger-emerges between t=200ms and t=ACT1_END
        const startT = 200 + i * 140;
        const inT = _clamp((t - startT) / 500, 0, 1);
        const op = _easeOut(inT);
        // After the truck arrives at this demand, the red marker fades out
        const arriveAt = demandArrivalAbsT[i];
        let deliveredT = 0;
        if (isFinite(arriveAt)) {
          deliveredT = _clamp((t - arriveAt) / 400, 0, 1);
        }
        const demandOp = op * (1 - deliveredT);
        d.ring.material.opacity = 0.85 * demandOp;
        d.pillar.material.opacity = 0.7 * demandOp;
        d.cube.material.opacity = 0.7 * demandOp;
      });

      // ── Bowed demand arrows: animate during Act 2 ────────────────────
      bowCurves.forEach((bc, i) => {
        const start = T.ACT1_END + 100 + i * 60;
        const drawDur = 700;
        const drawT = _clamp((t - start) / drawDur, 0, 1);
        const count = Math.floor(BOW_SEGMENTS * _easeOut(drawT));
        bc.geo.setDrawRange(0, Math.max(1, count + 1));
        // Visible only during Act 2
        let opacity = 0;
        if (t >= T.ACT1_END && t <= T.ACT2_END + 200) {
          opacity = _easeOut(drawT);
        } else if (t > T.ACT2_END + 200 && t < T.ACT3_END) {
          // fade out gracefully at start of Act 3
          opacity = 1 - _easeOut(_clamp((t - T.ACT2_END - 200) / 500, 0, 1));
        }
        bc.mat.opacity = opacity * 0.85;
      });

      // ── Trucks: drive along city roads, stopping at each demand ──────
      // Position is sampled from the precomputed plan. Trucks fade out once
      // they return to the depot (totalT for that plan is reached).
      trucks.forEach((tr) => {
        const s = truckSampleAt(tr.plan, t);
        if (!s.visible) {
          tr.mesh.visible = false;
          return;
        }
        tr.mesh.visible = true;
        tr.mesh.position.set(s.x, 0.05, s.z);
        tr.mesh.rotation.y = s.ang;
        // Fade out once the truck has returned to the depot
        const returnAbsT = (T.ACT4_END + 100) + tr.plan.startOffset + tr.plan.totalT;
        const fadeT = _clamp((t - (returnAbsT - 600)) / 600, 0, 1);
        tr.mesh.children.forEach((c) => {
          if (c.material) c.material.opacity = 1 - fadeT;
        });
      });

      // ── Green delivered checks: appear at truck arrival, fade out before Act 7 ──
      deliveredRings.forEach((dr, demandIdx) => {
        const arriveAt = demandArrivalAbsT[demandIdx];
        if (!isFinite(arriveAt)) return;
        const sinceArrive = t - arriveAt;
        if (sinceArrive < 0) {
          dr.ringMat.opacity = 0;
          dr.checkMat.opacity = 0;
          dr.checkEdge.material.opacity = 0;
          return;
        }
        // Fade in (0→250ms), hold green until reveal begins (T.ACT6_END + 300),
        // then crossfade out as the amber light points take over for Act 7.
        const revealStart = T.ACT6_END + 300;
        let op;
        if (sinceArrive < 250) op = sinceArrive / 250;
        else if (t < revealStart) op = 1;
        else op = _clamp(1 - (t - revealStart) / 600, 0, 1);
        op = _clamp(op, 0, 1);
        const pulse = 0.85 + 0.15 * Math.sin(t * 0.012 + demandIdx);
        dr.ringMat.opacity = op * 0.9 * pulse;
        dr.checkMat.opacity = op * 0.85 * pulse;
        dr.checkEdge.material.opacity = op * 0.95;
        // Gentle float on the check cube
        dr.check.position.y = 0.5 + Math.sin(t * 0.003 + demandIdx) * 0.05;
        dr.checkEdge.position.y = dr.check.position.y;
      });

      // ── Delivered light points: emerge in Act 7, persist ─────────────
      const lpStart = T.ACT6_END;
      const lpT = _clamp((t - lpStart) / 800, 0, 1);
      const lpAmbient = t > T.ACT7_END
        ? 0.6 + 0.4 * Math.sin(t * 0.0018)
        : 1;
      lightPointMat.opacity = _easeOut(lpT) * 0.85 * lpAmbient;

      // Building edge opacity dims slightly during Acts 3/4 to focus on overlays
      let edgeOp = 0.22;
      if (t >= T.ACT2_END && t <= T.ACT4_END) {
        edgeOp = 0.10;
      }
      edgeMat.opacity = edgeOp;

      renderer.render(scene, camera);
    }

    // ── Frame loop ────────────────────────────────────────────────────
    // 3D scene renders at full 60 fps, but the React state (consumed by HTML
    // overlays) is throttled to ~30 fps and stopped entirely once we've
    // entered the ambient end state — overlays read tMs > TOTAL_MS to render
    // their final state, so we don't need further re-renders.
    let raf = null;
    let t0 = null;
    let lastTickMs = -100;
    let postRevealNotified = false;

    const tick = (now) => {
      if (t0 === null) t0 = now;
      const elapsed = now - t0;
      renderFrame(elapsed);
      if (onTickRef.current) {
        if (elapsed < TOTAL_MS + 200) {
          // Inside the cinematic — emit ~30 fps so overlays stay in sync
          if (elapsed - lastTickMs > 32) {
            lastTickMs = elapsed;
            onTickRef.current(elapsed);
          }
        } else if (!postRevealNotified) {
          // One last emit to lock overlays into their final state
          postRevealNotified = true;
          onTickRef.current(elapsed);
        }
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);

    const onResize = () => {
      const w2 = mount.clientWidth;
      const h2 = mount.clientHeight;
      if (!w2 || !h2) return;
      camera.aspect = w2 / h2;
      camera.updateProjectionMatrix();
      renderer.setSize(w2, h2, false);
    };
    window.addEventListener('resize', onResize);

    return () => {
      if (raf) cancelAnimationFrame(raf);
      window.removeEventListener('resize', onResize);
      scene.traverse((obj) => {
        if (obj.geometry) obj.geometry.dispose();
        if (obj.material) {
          if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
          else obj.material.dispose();
        }
      });
      renderer.dispose();
      if (mount.contains(renderer.domElement)) mount.removeChild(renderer.domElement);
    };
  }, []);

  return <div className="hero-cine-canvas" ref={mountRef}></div>;
}

// ─────────────────────────────────────────────────────────────────────
// Act 3 overlay — Best loading + Best routing cards (HTML).
// "Best loading" appears at Act 3 start; "Best routing" follows 500ms later.
// Both fly out together before Act 4.
// ─────────────────────────────────────────────────────────────────────
function Act3Overlay({ tMs }) {
  const showStart = T.ACT2_END;
  const card1In  = showStart;
  const card2In  = showStart + 500;
  const cardsOut = T.ACT3_END - 500;
  const visible = tMs > showStart - 200 && tMs < T.ACT3_END + 200;
  if (!visible) return null;

  const c1Op = _clamp((tMs - card1In) / 400, 0, 1) * (1 - _clamp((tMs - cardsOut) / 400, 0, 1));
  const c2Op = _clamp((tMs - card2In) / 400, 0, 1) * (1 - _clamp((tMs - cardsOut) / 400, 0, 1));

  // Inner animations run for 2.2s, giving a long "hold" on the solved result.
  const loadT = _clamp((tMs - card1In - 200) / 2200, 0, 1);
  const routeT = _clamp((tMs - card2In - 200) / 2200, 0, 1);

  const volume = Math.round(_easeOut(loadT) * 91);
  const wasted = Math.max(0, Math.round((1 - _easeOut(loadT)) * 28));
  const distance = Math.round(_easeOut(routeT) * 312);
  const stops = Math.round(_easeOut(routeT) * 18);

  // Truck filling visualization — 11 boxes filling up
  const filledBoxes = Math.floor(_easeOut(loadT) * 11);

  return (
    <div className="hero-cine-overlay hero-cine-act3">
      <div className="cine-card cine-card-load" style={{
        opacity: c1Op,
        transform: `translateY(${(1 - c1Op) * 18}px)`,
      }}>
        <div className="cine-card-tag"><i></i>OPTIMAL LOADING</div>
        <div className="cine-card-title">Best loading</div>
        <div className="cine-card-visual">
          <div className="cine-truck">
            <div className="cine-truck-cab"/>
            <div className="cine-truck-cargo">
              {Array.from({ length: 11 }).map((_, i) => (
                <span key={i} className={i < filledBoxes ? 'filled' : ''}/>
              ))}
            </div>
          </div>
        </div>
        <div className="cine-card-stats">
          <div className="cine-stat">
            <span>VOLUME</span>
            <strong>{volume}%</strong>
          </div>
          <div className="cine-stat">
            <span>WASTED</span>
            <strong>{wasted}%</strong>
          </div>
          <div className="cine-stat">
            <span>BOXES</span>
            <strong>{filledBoxes}/11</strong>
          </div>
        </div>
      </div>

      <div className="cine-card cine-card-route" style={{
        opacity: c2Op,
        transform: `translateY(${(1 - c2Op) * 18}px)`,
      }}>
        <div className="cine-card-tag"><i></i>OPTIMAL ROUTE</div>
        <div className="cine-card-title">Best routing</div>
        <div className="cine-card-visual">
          <svg viewBox="0 0 220 130" preserveAspectRatio="xMidYMid meet">
            {/* Candidate edges */}
            <g stroke="currentColor" strokeWidth="0.6" strokeDasharray="2 2" opacity="0.4">
              <line x1="110" y1="65" x2="40" y2="30"/>
              <line x1="110" y1="65" x2="180" y2="20"/>
              <line x1="110" y1="65" x2="200" y2="80"/>
              <line x1="110" y1="65" x2="160" y2="115"/>
              <line x1="110" y1="65" x2="60" y2="110"/>
              <line x1="110" y1="65" x2="20" y2="70"/>
              <line x1="40" y1="30" x2="180" y2="20"/>
              <line x1="180" y1="20" x2="200" y2="80"/>
              <line x1="200" y1="80" x2="160" y2="115"/>
              <line x1="160" y1="115" x2="60" y2="110"/>
              <line x1="60" y1="110" x2="20" y2="70"/>
              <line x1="20" y1="70" x2="40" y2="30"/>
            </g>
            {/* Solved route — animated draw */}
            <polyline
              fill="none"
              stroke="var(--accent)"
              strokeWidth="1.6"
              strokeLinecap="round"
              strokeLinejoin="round"
              points="110,65 40,30 180,20 200,80 160,115 60,110 20,70 110,65"
              style={{
                strokeDasharray: 600,
                strokeDashoffset: 600 * (1 - _easeOut(routeT)),
                filter: 'drop-shadow(0 0 2px var(--accent))',
              }}
            />
            {/* Nodes */}
            {[[110,65,'D'],[40,30],[180,20],[200,80],[160,115],[60,110],[20,70]].map((p, i) => (
              <circle key={i} cx={p[0]} cy={p[1]} r={p[2] === 'D' ? 4 : 2.5}
                fill={p[2] === 'D' ? 'var(--accent)' : 'var(--bg-2)'}
                stroke="var(--accent)" strokeWidth="0.8"/>
            ))}
          </svg>
        </div>
        <div className="cine-card-stats">
          <div className="cine-stat">
            <span>DISTANCE</span>
            <strong>{distance} km</strong>
          </div>
          <div className="cine-stat">
            <span>STOPS</span>
            <strong>{stops}</strong>
          </div>
          <div className="cine-stat">
            <span>EST. TIME</span>
            <strong>13.4 h</strong>
          </div>
        </div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────
// Act 4 overlay — Bipartite Worker↔Truck assignment + checklist (HTML/SVG).
// Search-and-solve pattern: candidate lines flicker, then one converges
// to amber; checklist items tick off in parallel.
// ─────────────────────────────────────────────────────────────────────
function Act4Overlay({ tMs }) {
  const showStart = T.ACT3_END;
  const cardIn  = showStart;
  const cardOut = T.ACT4_END - 500;
  const visible = tMs > showStart - 200 && tMs < T.ACT4_END + 200;
  if (!visible) return null;

  const cardOp = _clamp((tMs - cardIn) / 350, 0, 1) * (1 - _clamp((tMs - cardOut) / 400, 0, 1));
  const localT = tMs - cardIn;

  // 3 operators × 3 trucks → 9 candidate lines
  const operators = [0, 1, 2];   // y positions: 0.2, 0.5, 0.8 (normalized)
  const truckLines = [0, 1, 2];
  const opY = [30, 75, 120];
  const trY = [30, 75, 120];

  // Search phase: which candidate is "currently exploring"
  // 0-500ms: lines draw in
  // 500-2000ms: random flicker (long enough to feel like exploration)
  // 2000-2400ms: converge to solution
  const drawT = _clamp(localT / 500, 0, 1);
  const flickerActive = localT > 500 && localT < 2000;
  const solvedT = _clamp((localT - 2000) / 400, 0, 1);

  // Solved assignment (OP-01→TRUCK-02, OP-02→TRUCK-03, OP-03→TRUCK-01 for variety)
  const solution = [[0,1],[1,2],[2,0]];
  const isSolutionLine = (oi, ti) => solution.some(([a, b]) => a === oi && b === ti);

  // Flicker random index — change every 90ms for "search" feel
  const flickerIdx = Math.floor(localT / 90) % 9;

  // Checklist (5 items, 3 pre-checked, 4 ticks at t=2000ms, 5 ticks at t=2500ms)
  const checks = [
    true,                          // Forecast demand
    true,                          // Allocate teams
    true,                          // Assign routes
    localT > 2000,                 // Validate capacity
    localT > 2500,                 // Confirm schedule
  ];
  const checkedCount = checks.filter(Boolean).length;

  return (
    <div className="hero-cine-overlay hero-cine-act4" style={{ opacity: cardOp }}>
      <div className="cine-act4-grid">
        <div className="cine-act4-side">
          <div className="cine-card-tag"><i></i>BEST SCHEDULING</div>
          <div className="cine-card-title">Best scheduling</div>
          <div className="cine-card-sub">Assign the right team to the right route.</div>

          <div className="cine-checklist">
            <div className="cine-checklist-head">SCHEDULE OVERVIEW</div>
            {[
              'Forecast demand',
              'Allocate teams',
              'Assign routes',
              'Validate capacity',
              'Confirm schedule',
            ].map((label, i) => (
              <div key={i} className={'cine-check' + (checks[i] ? ' on' : '')}>
                <span className="cine-check-box">{checks[i] ? '✓' : ''}</span>
                <span>{label}</span>
              </div>
            ))}
            <div className="cine-checklist-foot">
              {checkedCount}/5 completed
              <div className="cine-checklist-bar">
                <div style={{ width: `${(checkedCount / 5) * 100}%` }}/>
              </div>
            </div>
          </div>
        </div>

        <div className="cine-act4-graph">
          <svg viewBox="0 0 280 150" preserveAspectRatio="xMidYMid meet">
            {/* Columns: operators left (x=30), trucks right (x=250) */}
            {/* Candidate lines */}
            {operators.flatMap((oi) =>
              truckLines.map((ti) => {
                const idx = oi * 3 + ti;
                const isSol = isSolutionLine(oi, ti);
                let opacity = 0.35 * _easeOut(drawT);
                let color = 'currentColor';
                let strokeW = 0.8;
                if (flickerActive && idx === flickerIdx) {
                  opacity = 0.95;
                  color = 'var(--accent)';
                }
                if (solvedT > 0 && isSol) {
                  opacity = _easeOut(solvedT) * 1;
                  color = 'var(--accent)';
                  strokeW = 1.4;
                } else if (solvedT > 0 && !isSol) {
                  opacity = (1 - _easeOut(solvedT)) * 0.3 + 0.08;
                }
                return (
                  <line key={`${oi}-${ti}`}
                    x1={45} y1={opY[oi]} x2={235} y2={trY[ti]}
                    stroke={color}
                    strokeWidth={strokeW}
                    strokeDasharray={isSol && solvedT > 0.5 ? null : '3 3'}
                    opacity={opacity}
                  />
                );
              })
            )}

            {/* Operator nodes */}
            {operators.map((oi) => (
              <g key={`op${oi}`} transform={`translate(35, ${opY[oi]})`}>
                <circle r="13" fill="var(--bg-2)" stroke="var(--line)" strokeWidth="1"/>
                {/* Worker silhouette: head + shoulders */}
                <circle cx="0" cy="-3" r="3.5" fill="var(--fg-2)"/>
                <path d="M -6,4 Q -6,-1 0,-1 Q 6,-1 6,4 Z" fill="var(--fg-2)"/>
                <text x="-21" y="3.5" fontFamily="var(--mono)" fontSize="6"
                  fill="var(--fg-3)" textAnchor="end" letterSpacing="0.5">
                  OP-0{oi+1}
                </text>
              </g>
            ))}

            {/* Truck nodes */}
            {truckLines.map((ti) => (
              <g key={`tr${ti}`} transform={`translate(245, ${trY[ti]})`}>
                <rect x="-12" y="-7" width="24" height="14" rx="2"
                  fill="var(--bg-2)" stroke="var(--accent)" strokeWidth="0.8" opacity="0.85"/>
                <rect x="-10" y="-5" width="9" height="10" fill="var(--accent)" opacity="0.7"/>
                <text x="20" y="3" fontFamily="var(--mono)" fontSize="6"
                  fill="var(--fg-3)" letterSpacing="0.5">
                  TRUCK-0{ti+1}
                </text>
              </g>
            ))}
          </svg>
        </div>
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────
// Final reveal — Logo + tagline + CTAs (Act 7).
// "Planning solved" enters with the logo; "with Math" italic-amber follows;
// CTA buttons fade in last (0.3s after tagline).
// ─────────────────────────────────────────────────────────────────────
function HeroReveal({ tMs, ctaLabel, snapToEnd }) {
  const start = T.ACT6_END + 300;  // begin reveal at Act 6 end + small beat
  const logoIn = start;
  const taglineIn = start + 400;
  const ctaIn = taglineIn + 300;
  const scrollIn = T.ACT7_END + 1000;  // ~1s after the full reveal is done

  const op = (k, dur) => snapToEnd ? 1 : _clamp((tMs - k) / dur, 0, 1);
  const logoOp    = _easeOut(op(logoIn,    400));
  const lead1Op   = _easeOut(op(logoIn,    400));
  const lead2Op   = _easeOut(op(taglineIn, 350));
  const ctaOp     = _easeOut(op(ctaIn,     350));
  const scrollOp  = _easeOut(op(scrollIn,  500));

  return (
    <React.Fragment>
      <div className="hero-cine-reveal">
        <img
          src="assets/banner_white_transparent.png"
          alt="JPJ Solutions"
          className="hero-cine-logo"
          style={{ opacity: logoOp, transform: `translateY(${(1 - logoOp) * 10}px)` }}
        />
        <h1 className="hero-cine-headline">
          <span style={{ opacity: lead1Op, transform: `translateY(${(1 - lead1Op) * 10}px)` }}>
            Planning&nbsp;solved
          </span>
          <em style={{ opacity: lead2Op, transform: `translateY(${(1 - lead2Op) * 10}px)` }}>
            with Math
          </em>
        </h1>
        <div className="hero-cine-cta" style={{ opacity: ctaOp, transform: `translateY(${(1 - ctaOp) * 10}px)` }}>
          <a href="#contact" className="btn btn-primary">{ctaLabel} <span className="arr">↗</span></a>
          <a href="#proof" className="btn btn-ghost">See client results</a>
        </div>
      </div>
      <a href="#problem" className="hero-cine-scroll" aria-label="Scroll down"
         style={{ opacity: scrollOp }}>
        <span>Scroll</span>
        <svg viewBox="0 0 24 24" width="22" height="22" fill="none"
          stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
          <path d="M6 9l6 6 6-6"/>
        </svg>
      </a>
    </React.Fragment>
  );
}

// ─────────────────────────────────────────────────────────────────────
// Reduced-motion fallback — static end state, no Three.js, no overlays.
// ─────────────────────────────────────────────────────────────────────
function HeroStaticReveal({ ctaLabel }) {
  return (
    <div className="hero-cine-root hero-cine-static">
      <div className="hero-cine-static-bg" aria-hidden="true"/>
      <div className="hero-cine-reveal">
        <img src="assets/banner_white_transparent.png" alt="JPJ Solutions" className="hero-cine-logo"/>
        <h1 className="hero-cine-headline">
          <span>Planning&nbsp;solved</span>
          <em>with Math</em>
        </h1>
        <div className="hero-cine-cta">
          <a href="#contact" className="btn btn-primary">{ctaLabel} <span className="arr">↗</span></a>
          <a href="#proof" className="btn btn-ghost">See client results</a>
        </div>
      </div>
      <a href="#problem" className="hero-cine-scroll" aria-label="Scroll down" style={{ opacity: 1 }}>
        <span>Scroll</span>
        <svg viewBox="0 0 24 24" width="22" height="22" fill="none"
          stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
          <path d="M6 9l6 6 6-6"/>
        </svg>
      </a>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────
// Main hero — orchestrates 3D canvas + overlays + reveal.
// ─────────────────────────────────────────────────────────────────────
function HeroVisual({ ctaLabel = 'Book a discovery call' } = {}) {
  const reduced = useReducedMotion();
  const [tMs, setT] = React.useState(0);

  if (reduced) {
    return <HeroStaticReveal ctaLabel={ctaLabel}/>;
  }

  return (
    <div className="hero-cine-root">
      <HeroCinematicCanvas onTick={setT}/>
      {/* Dimming layer — eases up during overlay acts, plateaus, then eases out */}
      <div className="hero-cine-dim" style={{
        opacity: (() => {
          if (tMs <= T.ACT2_END || tMs >= T.ACT4_END) return 0;
          const fadeIn  = _clamp((tMs - T.ACT2_END) / 500, 0, 1);
          const fadeOut = 1 - _clamp((tMs - (T.ACT4_END - 500)) / 500, 0, 1);
          return 0.55 * Math.min(fadeIn, fadeOut);
        })(),
      }}/>
      <Act3Overlay tMs={tMs}/>
      <Act4Overlay tMs={tMs}/>
      <HeroReveal tMs={tMs} ctaLabel={ctaLabel} snapToEnd={false}/>
    </div>
  );
}

window.HeroVisual = HeroVisual;
