{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stockpile-visualiser",
  "title": "Stockpile Visualiser",
  "description": "An interactive 3D soil-stockpile visualisation on a dependency-free canvas engine — a rotatable surveyed heightfield with test-location pins, live morph-animated stock levels and geotechnical overlays: angle-of-repose stability, height compliance, test coverage, cut/fill deltas, cross-sections and a volume/tonnage panel.",
  "dependencies": [
    "@untitledui/icons@^0.0.22",
    "motion@^12.42.2"
  ],
  "registryDependencies": [
    "@nx-ui/button",
    "@nx-ui/popover",
    "@nx-ui/toggle-group",
    "@nx-ui/tooltip",
    "@nx-ui/utils"
  ],
  "files": [
    {
      "path": "components/blocks/domain/stockpile-visualiser/index.ts",
      "content": "export { StockpileVisualiser } from \"./stockpile-visualiser\"\nexport { StockpileScene } from \"./stockpile-scene\"\nexport { StockpilePanel } from \"./stockpile-panel\"\n\nexport {\n  buildHeightfield,\n  computeVolumeM3,\n  computeSlopeDeg,\n  computeCutFill,\n  computeSection,\n  computeTestCoverage,\n  sampleHeight,\n  findPeak,\n  surveyCentroid,\n  resolveMaxHeightM,\n  hasSurface,\n  DEFAULT_GRID,\n} from \"./stockpile-geometry\"\nexport type { SectionProfile } from \"./stockpile-geometry\"\n\nexport {\n  useStockpileSimulation,\n  makeHeroStockpile,\n  makeSubsoilStockpile,\n  makeComplianceStockpile,\n  generatePilePoints,\n  makeRng,\n} from \"./stockpile-demo\"\nexport type {\n  StockpileSimulation,\n  GeneratePileOptions,\n} from \"./stockpile-demo\"\n\nexport type {\n  Stockpile,\n  StockpileMaterial,\n  StockpileTone,\n  SurveyPoint,\n  SurveyMethod,\n  StockpileSurvey,\n  TestStatus,\n  TestLocation,\n  Determinand,\n  StockpileClassification,\n  SoilHandlingClass,\n  StockpileEventKind,\n  StockpileEvent,\n  StockpileStatus,\n  StockpileNutrients,\n  StockpileOverlay,\n  SectionAxis,\n  Heightfield,\n  VolumeResult,\n  CutFillResult,\n  SlopeResult,\n  CompassOctant,\n  StockpileVisualiserProps,\n  StockpileSceneProps,\n} from \"./types\"\n",
      "type": "registry:component",
      "target": "components/blocks/domain/stockpile-visualiser/index.ts"
    },
    {
      "path": "components/blocks/domain/stockpile-visualiser/stockpile-demo.ts",
      "content": "/**\n * stockpile-demo.ts — deterministic demo data and the live-simulation hook.\n *\n * Point clouds are generated from overlapping gaussian mounds plus seeded\n * jitter, so the piles read as weathered heaps rather than cones — and the same\n * seed always yields the same surface, keeping SSR hydration stable (no bare\n * Math.random / Date.now at module scope). `useStockpileSimulation` drives the\n * showcase hero: timed deliveries and removals grow/shrink the pile, survey\n * points and stock events update, and test statuses advance through the lab.\n */\n\nimport * as React from \"react\"\nimport { useReducedMotion } from \"motion/react\"\n\nimport type {\n  Stockpile,\n  StockpileEvent,\n  SurveyPoint,\n  TestLocation,\n} from \"./types\"\n\n// ---------------------------------------------------------------------------\n// Seeded PRNG — mulberry32, deterministic across renders and server/client.\n// ---------------------------------------------------------------------------\n\nexport function makeRng(seed: number): () => number {\n  let a = seed >>> 0\n  return () => {\n    a |= 0\n    a = (a + 0x6d2b79f5) | 0\n    let t = Math.imul(a ^ (a >>> 15), 1 | a)\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n  }\n}\n\n/** Standard-normal sample via Box–Muller, driven by a unit-random source. */\nfunction gaussian(rng: () => number): number {\n  let u = 0\n  let v = 0\n  while (u === 0) u = rng()\n  while (v === 0) v = rng()\n  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v)\n}\n\ninterface Mound {\n  /** Centre in site-local metres. */\n  cx: number\n  cy: number\n  /** Peak height above toe, metres. */\n  peak: number\n  /** Gaussian spread (metres). */\n  sigmaX: number\n  sigmaY: number\n}\n\nexport interface GeneratePileOptions {\n  seed: number\n  /** Overall footprint radius, metres. */\n  radius: number\n  /** Target peak height, metres. */\n  peakHeight: number\n  /** Number of survey points to scatter. */\n  points?: number\n  /** Toe (base) elevation, metres — the pile sits on this datum. */\n  baseZ?: number\n  /** Height multiplier for growth/shrink animation (default 1). */\n  scale?: number\n}\n\n/**\n * Generate an organic survey point cloud from 2–3 overlapping gaussian mounds\n * with seeded jitter and a weathered toe spread. IDW downstream turns this into\n * the smooth surveyed surface.\n */\nexport function generatePilePoints(options: GeneratePileOptions): SurveyPoint[] {\n  const {\n    seed,\n    radius,\n    peakHeight,\n    points = 150,\n    baseZ = 0,\n    scale = 1,\n  } = options\n  const rng = makeRng(seed)\n\n  // Two or three lobes offset from centre, the tallest near the middle.\n  const lobeCount = 2 + Math.floor(rng() * 2)\n  const mounds: Mound[] = []\n  for (let i = 0; i < lobeCount; i++) {\n    const angle = rng() * Math.PI * 2\n    const off = i === 0 ? radius * 0.08 : radius * (0.22 + rng() * 0.34)\n    const spread = radius * (0.34 + rng() * 0.22)\n    mounds.push({\n      cx: Math.cos(angle) * off,\n      cy: Math.sin(angle) * off,\n      peak: peakHeight * (i === 0 ? 1 : 0.55 + rng() * 0.4),\n      sigmaX: spread * (0.85 + rng() * 0.3),\n      sigmaY: spread * (0.85 + rng() * 0.3),\n    })\n  }\n\n  const height = (x: number, y: number): number => {\n    let h = 0\n    for (const m of mounds) {\n      const dx = x - m.cx\n      const dy = y - m.cy\n      const e =\n        (dx * dx) / (2 * m.sigmaX * m.sigmaX) +\n        (dy * dy) / (2 * m.sigmaY * m.sigmaY)\n      h += m.peak * Math.exp(-e)\n    }\n    return h\n  }\n\n  const out: SurveyPoint[] = []\n  // A ring of toe points anchors the base datum and gives the pile a spread\n  // apron rather than a floating dome.\n  const toeCount = Math.max(16, Math.floor(points * 0.18))\n  for (let i = 0; i < toeCount; i++) {\n    const a = (i / toeCount) * Math.PI * 2 + rng() * 0.2\n    const rr = radius * (1.02 + rng() * 0.12)\n    out.push({\n      x: Math.cos(a) * rr,\n      y: Math.sin(a) * rr,\n      z: baseZ + Math.max(0, gaussian(rng) * 0.06),\n    })\n  }\n\n  // Interior points, denser toward the centre (sqrt keeps the middle sampled).\n  for (let i = 0; i < points - toeCount; i++) {\n    const a = rng() * Math.PI * 2\n    const rr = radius * Math.sqrt(rng()) * 0.98\n    const x = Math.cos(a) * rr\n    const y = Math.sin(a) * rr\n    const base = height(x, y) * scale\n    // Surface micro-relief: haul ramps and machine tracks leave shallow noise.\n    const noise = gaussian(rng) * (0.05 + base * 0.04)\n    out.push({ x, y, z: baseZ + Math.max(0, base + noise) })\n  }\n\n  return out\n}\n\n// ---------------------------------------------------------------------------\n// Demo stockpiles — realistic HS2-flavoured material-reuse piles.\n// ---------------------------------------------------------------------------\n\nconst TOPSOIL = {\n  key: \"topsoil\",\n  label: \"Topsoil (BS 3882)\",\n  shortCode: \"TS\",\n  densityTPerM3: 1.55,\n  angleOfReposeDeg: 35,\n  tone: \"brown\",\n} as const\n\nconst SUBSOIL = {\n  key: \"subsoil-upper\",\n  label: \"Upper subsoil (BS 8601)\",\n  shortCode: \"USS\",\n  densityTPerM3: 1.7,\n  angleOfReposeDeg: 32,\n  tone: \"olive\",\n} as const\n\nconst SIXF2 = {\n  key: \"6f2\",\n  label: \"6F2 capping (SHW 613)\",\n  shortCode: \"6F2\",\n  densityTPerM3: 2.1,\n  angleOfReposeDeg: 38,\n  tone: \"grey\",\n} as const\n\n/**\n * The showcase hero pile: a class-A topsoil heap of ~2,400 m³ under active\n * material-reuse tracking, with seven test locations across the lab pipeline.\n */\nexport function makeHeroStockpile(): Stockpile {\n  const points = generatePilePoints({\n    seed: 20260614,\n    radius: 15,\n    peakHeight: 2.42,\n    points: 168,\n    baseZ: 82.4,\n  })\n\n  const tests: TestLocation[] = [\n    {\n      id: \"tp-01\",\n      label: \"TP-01\",\n      x: -4.5,\n      y: 3.2,\n      depthM: 0.5,\n      status: \"pass\",\n      suite: \"Agronomy (pH/OM/PKMg)\",\n      sampledAt: \"2026-06-15\",\n      summary: \"Good structure, pH 6.8, organic matter within band.\",\n      determinands: [\n        { name: \"pH\", value: 6.8, unit: \"\", limit: 7.5 },\n        { name: \"Organic matter\", value: 6.2, unit: \"%\", limit: 3, exceeds: false },\n        { name: \"Phosphorus (P)\", value: 24, unit: \"mg/L\" },\n      ],\n    },\n    {\n      id: \"tp-02\",\n      label: \"TP-02\",\n      x: 5.1,\n      y: 4.4,\n      depthM: 0.5,\n      status: \"pass\",\n      suite: \"MCERTS chemical\",\n      sampledAt: \"2026-06-15\",\n      summary: \"No exceedances against the agricultural GAC screen.\",\n      determinands: [\n        { name: \"TPH-CWG\", value: 210, unit: \"mg/kg\", limit: 500, exceeds: false },\n        { name: \"Lead\", value: 68, unit: \"mg/kg\", limit: 200, exceeds: false },\n        { name: \"Asbestos\", value: 0, unit: \"\", limit: 0 },\n      ],\n    },\n    {\n      id: \"ws-01\",\n      label: \"WS-01\",\n      x: 0.4,\n      y: -5.6,\n      depthM: 1.0,\n      status: \"at-lab\",\n      suite: \"WAC leachate\",\n      sampledAt: \"2026-06-28\",\n      summary: \"Leachate suite in progress at the accredited laboratory.\",\n    },\n    {\n      id: \"tp-03\",\n      label: \"TP-03\",\n      x: -6.8,\n      y: -2.1,\n      depthM: 0.5,\n      status: \"attention\",\n      suite: \"MCERTS chemical\",\n      sampledAt: \"2026-06-22\",\n      summary: \"TPH marginally elevated near the former haul road — review.\",\n      determinands: [\n        { name: \"TPH-CWG\", value: 470, unit: \"mg/kg\", limit: 500, exceeds: false },\n        { name: \"Benzo(a)pyrene\", value: 0.9, unit: \"mg/kg\", limit: 1.0, exceeds: false },\n      ],\n    },\n    {\n      id: \"tp-04\",\n      label: \"TP-04\",\n      x: 7.2,\n      y: -3.4,\n      depthM: 0.5,\n      status: \"sampled\",\n      suite: \"Agronomy (pH/OM/PKMg)\",\n      sampledAt: \"2026-07-02\",\n      summary: \"Sampled; awaiting dispatch to the agronomy lab.\",\n    },\n    {\n      id: \"ws-02\",\n      label: \"WS-02\",\n      x: 2.6,\n      y: 6.8,\n      depthM: 1.2,\n      status: \"scheduled\",\n      suite: \"Asbestos ID\",\n      summary: \"Scheduled for the next sampling round.\",\n    },\n    {\n      id: \"tp-05\",\n      label: \"TP-05\",\n      x: -1.8,\n      y: 0.6,\n      depthM: 0.5,\n      status: \"pass\",\n      suite: \"Plasticity field test\",\n      sampledAt: \"2026-06-15\",\n      summary: \"Non-plastic to low plasticity; handling class confirmed.\",\n    },\n  ]\n\n  // Newest-first, to match the live simulation (which prepends new events) and\n  // the panel (which renders the array order directly). Running balances read\n  // downward as one coherent ledger and land on the surveyed volume (~2,440 m³,\n  // ~80% of the 3,000 m³ capacity): the test leaves the balance unchanged at\n  // 2440, the prior delivery brought it from 2428 to 2440.\n  const events: StockpileEvent[] = [\n    {\n      id: \"ev-3\",\n      at: \"2026-07-06T11:05:00\",\n      kind: \"test\",\n      note: \"TP-04 sampled for agronomy\",\n      balanceM3: 2440,\n    },\n    {\n      id: \"ev-2\",\n      at: \"2026-07-06T09:40:00\",\n      kind: \"delivery\",\n      volumeM3: 12.0,\n      vehicleReg: \"BX73 LPO\",\n      note: \"Stripped topsoil, north cutting\",\n      balanceM3: 2440,\n    },\n    {\n      id: \"ev-1\",\n      at: \"2026-07-06T08:12:00\",\n      kind: \"delivery\",\n      volumeM3: 11.5,\n      vehicleReg: \"YD24 HKE\",\n      note: \"Stripped topsoil, north cutting\",\n      balanceM3: 2428,\n    },\n  ]\n\n  return {\n    id: \"sp-hero\",\n    name: \"SL2A_TS_CLA_GWC_0011\",\n    material: TOPSOIL,\n    classification: \"non-hazardous\",\n    shuClass: \"A\",\n    survey: {\n      id: \"srv-2026-07-06\",\n      takenAt: \"2026-07-06\",\n      method: \"drone-photogrammetry\",\n      points,\n    },\n    previousSurvey: {\n      id: \"srv-2026-06-14\",\n      takenAt: \"2026-06-14\",\n      method: \"gps-rover\",\n      points: generatePilePoints({\n        seed: 20260614,\n        radius: 15,\n        peakHeight: 2.42,\n        points: 168,\n        baseZ: 82.4,\n        scale: 0.9,\n      }),\n    },\n    capacityM3: 3000,\n    tests,\n    events,\n    ewcCode: \"17 05 04\",\n    gridRef: \"SP 18035 76423\",\n    moisturePct: 22,\n    nutrients: { ph: 6.8, organicMatterPct: 6.2, pMgPerL: 24, kMgPerL: 180, mgMgPerL: 96 },\n    status: \"active\",\n  }\n}\n\n/**\n * A subsoil pile used for the overlay walkthrough — a clean, well-behaved heap\n * so the elevation / slope / coverage / cut-fill ramps read clearly.\n */\nexport function makeSubsoilStockpile(): Stockpile {\n  const points = generatePilePoints({\n    seed: 771903,\n    radius: 12,\n    peakHeight: 3.4,\n    points: 150,\n    baseZ: 64,\n  })\n  return {\n    id: \"sp-subsoil\",\n    name: \"SL4B_USS_HAM_STK_0027\",\n    material: SUBSOIL,\n    classification: \"inert\",\n    shuClass: \"B\",\n    survey: {\n      id: \"srv-uss-1\",\n      takenAt: \"2026-07-01\",\n      method: \"laser-scan\",\n      points,\n    },\n    previousSurvey: {\n      id: \"srv-uss-0\",\n      takenAt: \"2026-06-10\",\n      method: \"gps-rover\",\n      points: generatePilePoints({\n        seed: 771903,\n        radius: 12,\n        peakHeight: 3.4,\n        points: 150,\n        baseZ: 64,\n        scale: 1.16,\n      }),\n    },\n    capacityM3: 2200,\n    tests: [\n      { id: \"u-tp-1\", label: \"TP-11\", x: -3, y: 2, status: \"pass\", suite: \"Plasticity field test\", sampledAt: \"2026-07-01\" },\n      { id: \"u-tp-2\", label: \"TP-12\", x: 4, y: -1.5, status: \"pass\", suite: \"MCERTS chemical\", sampledAt: \"2026-07-01\" },\n      { id: \"u-tp-3\", label: \"TP-13\", x: 0.5, y: -4, status: \"at-lab\", suite: \"WAC leachate\", sampledAt: \"2026-07-02\" },\n    ],\n    ewcCode: \"17 05 04\",\n    gridRef: \"SP 17422 75980\",\n    moisturePct: 18,\n    nutrients: { ph: 7.1 },\n    status: \"reserved\",\n  }\n}\n\n/**\n * The \"everything shouting\" compliance pile: a non-hazardous 6F2 heap that\n * breaches its height limit, over-steepens on one flank, and carries a WAC\n * failure — the state where every banner and flag lights up at once.\n */\nexport function makeComplianceStockpile(): Stockpile {\n  const points = generatePilePoints({\n    seed: 5540021,\n    radius: 11,\n    peakHeight: 5.9,\n    points: 156,\n    baseZ: 71,\n  })\n  return {\n    id: \"sp-6f2\",\n    name: \"SL7C_6F2_CAP_STK_0044\",\n    material: SIXF2,\n    classification: \"non-hazardous\",\n    shuClass: \"C\",\n    maxHeightM: 4,\n    survey: {\n      id: \"srv-6f2-1\",\n      takenAt: \"2026-07-05\",\n      method: \"drone-photogrammetry\",\n      points,\n    },\n    capacityM3: 1800,\n    tests: [\n      {\n        id: \"c-ws-1\",\n        label: \"WS-01\",\n        x: -2,\n        y: 1,\n        depthM: 1.0,\n        status: \"fail\",\n        suite: \"WAC leachate\",\n        sampledAt: \"2026-07-03\",\n        summary: \"Sulphate leachate exceeds the inert WAC threshold.\",\n        determinands: [\n          { name: \"Sulphate (SO₄)\", value: 1420, unit: \"mg/kg\", limit: 1000, exceeds: true },\n          { name: \"Chloride\", value: 640, unit: \"mg/kg\", limit: 800, exceeds: false },\n          { name: \"TDS\", value: 4200, unit: \"mg/kg\", limit: 4000, exceeds: true },\n        ],\n      },\n      {\n        id: \"c-tp-1\",\n        label: \"TP-21\",\n        x: 5,\n        y: 3,\n        status: \"attention\",\n        suite: \"MCERTS chemical\",\n        sampledAt: \"2026-07-03\",\n        summary: \"Elevated readings pending confirmation.\",\n      },\n      { id: \"c-tp-2\", label: \"TP-22\", x: 3.5, y: -4, status: \"at-lab\", suite: \"WAC leachate\", sampledAt: \"2026-07-04\" },\n      { id: \"c-tp-3\", label: \"TP-23\", x: -5, y: -2.5, status: \"scheduled\", suite: \"Asbestos ID\" },\n    ],\n    events: [\n      { id: \"c-ev-1\", at: \"2026-07-05T14:22:00\", kind: \"delivery\", volumeM3: 14, vehicleReg: \"GK24 RTV\", balanceM3: 1712 },\n      { id: \"c-ev-2\", at: \"2026-07-05T10:10:00\", kind: \"test\", note: \"WS-01 returned a WAC fail\", balanceM3: 1698 },\n    ],\n    ewcCode: \"17 05 04\",\n    gridRef: \"SP 19110 77002\",\n    moisturePct: 12,\n    status: \"active\",\n  }\n}\n\n// ---------------------------------------------------------------------------\n// useStockpileSimulation — timer-driven live updates for the hero.\n// ---------------------------------------------------------------------------\n\nconst TEST_FLIPS: Record<string, TestLocation[\"status\"]> = {\n  \"ws-01\": \"pass\",\n  \"tp-04\": \"at-lab\",\n}\n\nexport interface StockpileSimulation {\n  stockpile: Stockpile\n  playing: boolean\n  setPlaying: (playing: boolean) => void\n  /** Reset to the base pile and clear simulated activity. */\n  reset: () => void\n}\n\n/**\n * Drive a stockpile through timed deliveries, removals and lab progress for the\n * showcase hero. Deterministic (seeded), SSR-safe (all timers in effects), and\n * quiet under reduced motion: it still ticks, but the caller's morph/count-up\n * animations collapse to instant, so no motion is forced on the user.\n */\nexport function useStockpileSimulation(\n  base: Stockpile\n): StockpileSimulation {\n  const reduce = useReducedMotion()\n  const [playing, setPlaying] = React.useState(true)\n  const [stockpile, setStockpile] = React.useState<Stockpile>(base)\n  const tickRef = React.useRef(0)\n  const rngRef = React.useRef(makeRng(0x51a7ee))\n\n  // Re-seed and reset whenever the base pile identity changes.\n  React.useEffect(() => {\n    tickRef.current = 0\n    rngRef.current = makeRng(0x51a7ee)\n    setStockpile(base)\n  }, [base])\n\n  const reset = React.useCallback(() => {\n    tickRef.current = 0\n    rngRef.current = makeRng(0x51a7ee)\n    setStockpile(base)\n  }, [base])\n\n  React.useEffect(() => {\n    if (!playing) return\n    // A calmer cadence under reduced motion, but still live.\n    const interval = reduce ? 4200 : 2600\n    const timer = window.setInterval(() => {\n      tickRef.current += 1\n      const tick = tickRef.current\n      const rng = rngRef.current\n      setStockpile((prev) => advance(prev, base, tick, rng))\n    }, interval)\n    return () => window.clearInterval(timer)\n  }, [playing, base, reduce])\n\n  return { stockpile, playing, setPlaying, reset }\n}\n\n/** Apply one simulation step: grow/shrink the surface and log an event. */\nfunction advance(\n  prev: Stockpile,\n  base: Stockpile,\n  tick: number,\n  rng: () => number\n): Stockpile {\n  // Alternate deliveries (grow) and the occasional removal (shrink), oscillating\n  // the pile scale within a believable band around the base survey.\n  const isRemoval = tick % 5 === 0\n  const deltaV = isRemoval\n    ? -(9 + rng() * 8)\n    : 10 + rng() * 6\n\n  const prevBalance =\n    prev.events && prev.events.length > 0\n      ? prev.events[0].balanceM3 ?? base.capacityM3 ?? 0\n      : base.capacityM3 ?? 0\n  const balance = Math.max(0, Math.round(prevBalance + deltaV))\n\n  // Scale factor derived from the running balance vs the base capacity, so the\n  // rendered surface visibly tracks the ledger. The seed pile sits at ~80% full\n  // (scale 1.0); the upper clamp is held at 1.04 so a growth run stays below the\n  // 5 m SHU-A height limit and under the angle-of-repose banner threshold — the\n  // hero is the healthy exemplar, never a self-inflicted breach.\n  const capacity = base.capacityM3 ?? 3000\n  const scale = clamp(balance / (capacity * 0.8), 0.7, 1.04)\n\n  const grownPoints = generatePilePoints({\n    seed: 20260614,\n    radius: 15,\n    peakHeight: 2.42,\n    points: 168,\n    baseZ: 82.4,\n    scale,\n  })\n\n  const hour = 8 + Math.floor(tick / 2)\n  const minute = (tick * 17) % 60\n  const at = `2026-07-06T${pad(Math.min(18, hour))}:${pad(minute)}:00`\n\n  const reg = pickReg(rng)\n  const newEvent: StockpileEvent =\n    tick % 7 === 3\n      ? {\n          id: `sim-t-${tick}`,\n          at,\n          kind: \"test\",\n          note: labProgressNote(tick),\n          balanceM3: balance,\n        }\n      : {\n          id: `sim-${tick}`,\n          at,\n          kind: isRemoval ? \"removal\" : \"delivery\",\n          volumeM3: Math.abs(Math.round(deltaV * 10) / 10),\n          vehicleReg: reg,\n          note: isRemoval ? \"Loaded out to reuse area\" : \"Stripped topsoil delivery\",\n          balanceM3: balance,\n        }\n\n  const events = [newEvent, ...(prev.events ?? [])].slice(0, 8)\n\n  // Advance the lab pipeline once, a few ticks in.\n  let tests = prev.tests\n  if (tick === 4) {\n    tests = prev.tests.map((t) =>\n      TEST_FLIPS[t.id] ? { ...t, status: TEST_FLIPS[t.id] } : t\n    )\n  }\n\n  return {\n    ...prev,\n    survey: { ...base.survey, id: `srv-sim-${tick}`, points: grownPoints },\n    events,\n    tests,\n    status: \"active\",\n  }\n}\n\nfunction labProgressNote(tick: number): string {\n  const notes = [\n    \"WS-01 leachate results returned — pass\",\n    \"TP-04 dispatched to the agronomy lab\",\n    \"Sampling round booked for tomorrow\",\n  ]\n  return notes[tick % notes.length]\n}\n\nfunction pickReg(rng: () => number): string {\n  const regs = [\"YD24 HKE\", \"BX73 LPO\", \"GK24 RTV\", \"MA71 TSC\", \"FN24 WPB\"]\n  return regs[Math.floor(rng() * regs.length)]\n}\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return Math.min(hi, Math.max(lo, v))\n}\n\nfunction pad(n: number): string {\n  return n < 10 ? `0${n}` : String(n)\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/stockpile-visualiser/stockpile-demo.ts"
    },
    {
      "path": "components/blocks/domain/stockpile-visualiser/stockpile-geometry.ts",
      "content": "/**\n * stockpile-geometry.ts — pure geometry and geotechnical analysis.\n *\n * Everything here is deterministic and framework-free: it turns a survey point\n * cloud into a regular height grid (inverse-distance weighting), integrates that\n * grid into a volume, and derives slope, cut/fill and cross-section profiles.\n * The renderer and the panel consume these results; nothing here touches the DOM.\n */\n\nimport type {\n  CompassOctant,\n  CutFillResult,\n  Heightfield,\n  SectionAxis,\n  SlopeResult,\n  SoilHandlingClass,\n  StockpileSurvey,\n  SurveyPoint,\n  VolumeResult,\n} from \"./types\"\n\n/** Default height-grid resolution (cells per side). */\nexport const DEFAULT_GRID = 56\n\n/** Padding around the point-cloud bounding box, as a fraction of its span. */\nconst EXTENT_PADDING = 0.14\n\n/** SHU-class → default permitted max height (m) for topsoil-family materials. */\nconst SHU_MAX_HEIGHT_M: Record<SoilHandlingClass, number> = {\n  A: 5,\n  B: 4,\n  C: 3,\n}\n\n/** Resolve the effective max height: explicit value wins, else the SHU limit. */\nexport function resolveMaxHeightM(\n  maxHeightM: number | undefined,\n  shuClass: SoilHandlingClass | undefined\n): number | null {\n  if (typeof maxHeightM === \"number\") return maxHeightM\n  if (shuClass) return SHU_MAX_HEIGHT_M[shuClass]\n  return null\n}\n\ninterface BuildHeightfieldOptions {\n  /** Grid resolution (cells per side). Default DEFAULT_GRID. */\n  grid?: number\n  /** IDW power. Higher = sharper local influence. Default 2.6. */\n  power?: number\n}\n\n/**\n * Build a regular height grid from a survey point cloud via inverse-distance\n * weighting. The grid extent is the padded bounding box of the points; the base\n * datum (toe elevation) is the minimum surveyed z, so heights are measured above\n * the ground the pile rests on.\n */\nexport function buildHeightfield(\n  survey: StockpileSurvey,\n  options: BuildHeightfieldOptions = {}\n): Heightfield {\n  const grid = options.grid ?? DEFAULT_GRID\n  const power = options.power ?? 2.6\n  const points = survey.points\n\n  const cols = grid\n  const rows = grid\n\n  if (points.length === 0) {\n    return {\n      cols,\n      rows,\n      minX: 0,\n      maxX: 1,\n      minY: 0,\n      maxY: 1,\n      dx: 1 / (cols - 1),\n      dy: 1 / (rows - 1),\n      z: new Float32Array(cols * rows),\n      baseZ: 0,\n      maxZ: 0,\n    }\n  }\n\n  let minX = Infinity\n  let maxX = -Infinity\n  let minY = Infinity\n  let maxY = -Infinity\n  let minZ = Infinity\n  for (const p of points) {\n    if (p.x < minX) minX = p.x\n    if (p.x > maxX) maxX = p.x\n    if (p.y < minY) minY = p.y\n    if (p.y > maxY) maxY = p.y\n    if (p.z < minZ) minZ = p.z\n  }\n\n  const spanX = maxX - minX || 1\n  const spanY = maxY - minY || 1\n  const padX = spanX * EXTENT_PADDING\n  const padY = spanY * EXTENT_PADDING\n  minX -= padX\n  maxX += padX\n  minY -= padY\n  maxY += padY\n\n  const dx = (maxX - minX) / (cols - 1)\n  const dy = (maxY - minY) / (rows - 1)\n  const baseZ = minZ\n\n  // A smoothing floor on distance keeps IDW from spiking at coincident nodes\n  // and gives the surface a naturally weathered rather than pinned look.\n  const smooth = Math.max(dx, dy) * 0.9\n  const smooth2 = smooth * smooth\n\n  const z = new Float32Array(cols * rows)\n  let maxZ = 0\n\n  for (let r = 0; r < rows; r++) {\n    const wy = minY + r * dy\n    for (let c = 0; c < cols; c++) {\n      const wx = minX + c * dx\n\n      let wsum = 0\n      let vsum = 0\n      let exact = -1\n      for (let i = 0; i < points.length; i++) {\n        const p = points[i]\n        const ddx = wx - p.x\n        const ddy = wy - p.y\n        const d2 = ddx * ddx + ddy * ddy\n        if (d2 < 1e-9) {\n          exact = i\n          break\n        }\n        // Smoothed inverse-distance weight, w = 1 / (d² + s²)^(power/2).\n        const w = 1 / Math.pow(d2 + smooth2, power * 0.5)\n        wsum += w\n        vsum += w * p.z\n      }\n\n      const height =\n        exact >= 0\n          ? points[exact].z - baseZ\n          : wsum > 0\n            ? Math.max(0, vsum / wsum - baseZ)\n            : 0\n      z[r * cols + c] = height\n      if (height > maxZ) maxZ = height\n    }\n  }\n\n  return { cols, rows, minX, maxX, minY, maxY, dx, dy, z, baseZ, maxZ }\n}\n\n/**\n * Integrate the height grid into a fill volume above the base datum. Each\n * interior cell contributes the mean of its four corner heights times the cell\n * footprint (the midpoint rule over the grid quads).\n */\nexport function computeVolumeM3(\n  field: Heightfield,\n  densityTPerM3: number\n): VolumeResult {\n  const { cols, rows, dx, dy, z } = field\n  const cellArea = Math.abs(dx * dy)\n  let volume = 0\n  for (let r = 0; r < rows - 1; r++) {\n    for (let c = 0; c < cols - 1; c++) {\n      const h0 = z[r * cols + c]\n      const h1 = z[r * cols + c + 1]\n      const h2 = z[(r + 1) * cols + c]\n      const h3 = z[(r + 1) * cols + c + 1]\n      volume += ((h0 + h1 + h2 + h3) * 0.25) * cellArea\n    }\n  }\n  return {\n    volumeM3: volume,\n    tonnes: volume * densityTPerM3,\n    maxHeightM: field.maxZ,\n  }\n}\n\n/**\n * Per-cell maximum face slope in degrees, plus the fraction of the pile face\n * exceeding a reference angle (its angle of repose) and the compass octant of\n * the worst exceedance region. The slope of a cell is derived from a central\n * gradient of its corner heights; flat ground around the toe is excluded from\n * the exceedance statistics so a wide apron does not dilute the signal.\n */\nexport function computeSlopeDeg(\n  field: Heightfield,\n  reposeDeg: number\n): SlopeResult {\n  const { cols, rows, dx, dy, z, maxZ } = field\n  const slopeDeg = new Float32Array(cols * rows)\n  const rad2deg = 180 / Math.PI\n\n  // Only cells that carry real pile height count toward the face statistics.\n  const faceFloor = Math.max(0.15, maxZ * 0.08)\n\n  let maxSlope = 0\n  let faceCells = 0\n  let exceedCells = 0\n\n  // Accumulate the centroid of exceedance (weighted by how far over repose) so\n  // we can name the worst octant honestly rather than by a single hot cell.\n  let exceedCx = 0\n  let exceedCy = 0\n  let exceedW = 0\n\n  for (let r = 0; r < rows; r++) {\n    for (let c = 0; c < cols; c++) {\n      const idx = r * cols + c\n      const cL = c > 0 ? c - 1 : c\n      const cR = c < cols - 1 ? c + 1 : c\n      const rD = r > 0 ? r - 1 : r\n      const rU = r < rows - 1 ? r + 1 : r\n      const gx = (z[r * cols + cR] - z[r * cols + cL]) / ((cR - cL) * dx || dx)\n      const gy = (z[rU * cols + c] - z[rD * cols + c]) / ((rU - rD) * dy || dy)\n      const slope = Math.atan(Math.hypot(gx, gy)) * rad2deg\n      slopeDeg[idx] = slope\n      if (slope > maxSlope) maxSlope = slope\n\n      if (z[idx] >= faceFloor) {\n        faceCells++\n        if (slope > reposeDeg) {\n          exceedCells++\n          const over = slope - reposeDeg\n          exceedCx += c * over\n          exceedCy += r * over\n          exceedW += over\n        }\n      }\n    }\n  }\n\n  const exceedFraction = faceCells > 0 ? exceedCells / faceCells : 0\n  let worstOctant: CompassOctant | null = null\n  if (exceedW > 0) {\n    const cx = exceedCx / exceedW\n    const cy = exceedCy / exceedW\n    // Grid col → east, grid row → north. Vector from centre to hot region.\n    const vx = cx - (cols - 1) / 2\n    const vy = cy - (rows - 1) / 2\n    worstOctant = octantOf(vx, vy)\n  }\n\n  return { slopeDeg, maxSlopeDeg: maxSlope, exceedFraction, worstOctant }\n}\n\n/** Compass octant of a grid-space vector (x → east, y → north). */\nfunction octantOf(vx: number, vy: number): CompassOctant {\n  // atan2 with north up: angle measured clockwise from north.\n  const angle = (Math.atan2(vx, vy) * 180) / Math.PI // −180..180, 0 = N\n  const norm = (angle + 360) % 360\n  const octants: CompassOctant[] = [\"N\", \"NE\", \"E\", \"SE\", \"S\", \"SW\", \"W\", \"NW\"]\n  const index = Math.round(norm / 45) % 8\n  return octants[index]\n}\n\n/**\n * Cut/fill against a previous survey.\n *\n * Two grids are in play here, deliberately with different extents:\n *\n *  - The per-cell `delta` grid is on the CURRENT field's extent, because that is\n *    the surface the overlay actually renders — the delta has to line up cell for\n *    cell with the drawn heightfield.\n *  - The reported net / cut / fill TOTALS are integrated over a UNION extent that\n *    covers both surveys. A lobe that existed only in the previous survey (fully\n *    removed, now outside the current footprint) sits off the current grid and\n *    would otherwise be invisible to the net figure; the union grid keeps the m³\n *    readout honest when the footprint shrank or grew. When `current` is omitted\n *    the totals fall back to the current-extent grid (the union is unavailable\n *    without the current point cloud), preserving the previous behaviour.\n *\n * The previous base datum is preserved so the comparison is like-for-like, and\n * the signed per-cell delta is integrated into net / cut / fill volumes.\n */\nexport function computeCutFill(\n  field: Heightfield,\n  previous: StockpileSurvey,\n  current?: StockpileSurvey\n): CutFillResult {\n  const { cols, rows, z, baseZ } = field\n\n  // Per-cell delta on the current extent — this is what the overlay renders.\n  const prev = resampleOnto(previous, field)\n  const delta = new Float32Array(cols * rows)\n\n  // The previous field is expressed above its own toe; realign both to the\n  // current base datum so only genuine surface movement shows as cut/fill.\n  const prevBaseShift = prev.baseZ - baseZ\n\n  for (let i = 0; i < delta.length; i++) {\n    delta[i] = z[i] - (prev.z[i] + prevBaseShift)\n  }\n\n  // Totals: prefer a union-extent grid so material outside the current footprint\n  // still counts; fall back to the current-extent delta when we cannot build it.\n  const totalsField = current ? unionField(current, previous, field) : field\n  const totals =\n    totalsField === field\n      ? integrateDelta(delta, field.cols, field.rows, Math.abs(field.dx * field.dy))\n      : totalsFromUnion(totalsField, current!, previous)\n\n  return { delta, netM3: totals.fill - totals.cut, cutM3: totals.cut, fillM3: totals.fill }\n}\n\n/**\n * Integrate a signed per-cell delta grid (row-major) into cut/fill volumes via\n * the midpoint rule over grid quads: each interior quad contributes the mean of\n * its four corner deltas times the cell footprint.\n */\nfunction integrateDelta(\n  delta: Float32Array,\n  cols: number,\n  rows: number,\n  cellArea: number\n): { cut: number; fill: number } {\n  let cut = 0\n  let fill = 0\n  for (let r = 0; r < rows - 1; r++) {\n    for (let c = 0; c < cols - 1; c++) {\n      const d0 = delta[r * cols + c]\n      const d1 = delta[r * cols + c + 1]\n      const d2 = delta[(r + 1) * cols + c]\n      const d3 = delta[(r + 1) * cols + c + 1]\n      const mean = (d0 + d1 + d2 + d3) * 0.25\n      if (mean >= 0) fill += mean * cellArea\n      else cut += -mean * cellArea\n    }\n  }\n  return { cut, fill }\n}\n\n/**\n * Build an empty heightfield spanning the padded union of two surveys' bounding\n * boxes, at the resolution of `like`. Base datum is the lower of the two toes so\n * both surfaces are measured above common ground.\n */\nfunction unionField(\n  a: StockpileSurvey,\n  b: StockpileSurvey,\n  like: Heightfield\n): Heightfield {\n  const cols = like.cols\n  const rows = like.rows\n\n  let minX = Infinity\n  let maxX = -Infinity\n  let minY = Infinity\n  let maxY = -Infinity\n  let minZ = Infinity\n  for (const p of [...a.points, ...b.points]) {\n    if (p.x < minX) minX = p.x\n    if (p.x > maxX) maxX = p.x\n    if (p.y < minY) minY = p.y\n    if (p.y > maxY) maxY = p.y\n    if (p.z < minZ) minZ = p.z\n  }\n  if (!Number.isFinite(minX)) {\n    // No points on either side: degenerate to the current grid's own extent.\n    return like\n  }\n\n  const spanX = maxX - minX || 1\n  const spanY = maxY - minY || 1\n  const padX = spanX * EXTENT_PADDING\n  const padY = spanY * EXTENT_PADDING\n  minX -= padX\n  maxX += padX\n  minY -= padY\n  maxY += padY\n\n  const dx = (maxX - minX) / (cols - 1)\n  const dy = (maxY - minY) / (rows - 1)\n\n  return {\n    cols,\n    rows,\n    minX,\n    maxX,\n    minY,\n    maxY,\n    dx,\n    dy,\n    z: new Float32Array(cols * rows),\n    baseZ: minZ,\n    maxZ: 0,\n  }\n}\n\n/**\n * Resample both surveys onto the union grid, realign the previous surface to the\n * union base datum, and integrate the delta into cut/fill totals.\n */\nfunction totalsFromUnion(\n  union: Heightfield,\n  current: StockpileSurvey,\n  previous: StockpileSurvey\n): { cut: number; fill: number } {\n  const { cols, rows, dx, dy, baseZ } = union\n  const cur = resampleOnto(current, union)\n  const prev = resampleOnto(previous, union)\n  const prevBaseShift = prev.baseZ - baseZ\n  const curBaseShift = cur.baseZ - baseZ\n\n  const delta = new Float32Array(cols * rows)\n  for (let i = 0; i < delta.length; i++) {\n    delta[i] = cur.z[i] + curBaseShift - (prev.z[i] + prevBaseShift)\n  }\n  return integrateDelta(delta, cols, rows, Math.abs(dx * dy))\n}\n\n/** Resample a survey onto an existing grid's world extent (IDW, same options). */\nfunction resampleOnto(\n  survey: StockpileSurvey,\n  target: Heightfield\n): Heightfield {\n  const { cols, rows, minX, maxX, minY, maxY } = target\n  const points = survey.points\n  const dx = (maxX - minX) / (cols - 1)\n  const dy = (maxY - minY) / (rows - 1)\n\n  if (points.length === 0) {\n    return { ...target, z: new Float32Array(cols * rows), baseZ: target.baseZ, maxZ: 0 }\n  }\n\n  let minZ = Infinity\n  for (const p of points) if (p.z < minZ) minZ = p.z\n\n  const smooth = Math.max(dx, dy) * 0.9\n  const smooth2 = smooth * smooth\n  const z = new Float32Array(cols * rows)\n  let maxZ = 0\n\n  for (let r = 0; r < rows; r++) {\n    const wy = minY + r * dy\n    for (let c = 0; c < cols; c++) {\n      const wx = minX + c * dx\n      let wsum = 0\n      let vsum = 0\n      let exact = -1\n      for (let i = 0; i < points.length; i++) {\n        const p = points[i]\n        const ddx = wx - p.x\n        const ddy = wy - p.y\n        const d2 = ddx * ddx + ddy * ddy\n        if (d2 < 1e-9) {\n          exact = i\n          break\n        }\n        const w = 1 / (d2 + smooth2) ** 1.3\n        wsum += w\n        vsum += w * p.z\n      }\n      const h =\n        exact >= 0\n          ? points[exact].z - minZ\n          : wsum > 0\n            ? Math.max(0, vsum / wsum - minZ)\n            : 0\n      z[r * cols + c] = h\n      if (h > maxZ) maxZ = h\n    }\n  }\n\n  return { ...target, z, baseZ: minZ, maxZ }\n}\n\n/** Bilinear sample of the height grid at world coordinates (metres). */\nexport function sampleHeight(field: Heightfield, x: number, y: number): number {\n  const { cols, rows, minX, minY, dx, dy, z } = field\n  const fx = (x - minX) / dx\n  const fy = (y - minY) / dy\n  const c0 = Math.floor(fx)\n  const r0 = Math.floor(fy)\n  if (c0 < 0 || r0 < 0 || c0 >= cols - 1 || r0 >= rows - 1) {\n    // Clamp to nearest valid node outside the grid.\n    const cc = Math.min(cols - 1, Math.max(0, c0))\n    const rr = Math.min(rows - 1, Math.max(0, r0))\n    return z[rr * cols + cc]\n  }\n  const tx = fx - c0\n  const ty = fy - r0\n  const h00 = z[r0 * cols + c0]\n  const h10 = z[r0 * cols + c0 + 1]\n  const h01 = z[(r0 + 1) * cols + c0]\n  const h11 = z[(r0 + 1) * cols + c0 + 1]\n  const a = h00 + (h10 - h00) * tx\n  const b = h01 + (h11 - h01) * tx\n  return a + (b - a) * ty\n}\n\nexport interface SectionProfile {\n  /** Along-section distance samples, metres from the start of the line. */\n  distance: number[]\n  /** Surface elevation above base at each sample, metres. */\n  height: number[]\n  /** Total section length, metres. */\n  length: number\n  /** Peak height on the section, metres. */\n  peak: number\n  axis: SectionAxis\n}\n\n/**\n * Sample a cross-section profile through the grid. `ns` runs south→north at a\n * given east position; `ew` runs west→east at a given north position. `pos` is\n * 0–1 along the perpendicular axis (0.5 = centroid line).\n */\nexport function computeSection(\n  field: Heightfield,\n  axis: SectionAxis,\n  pos: number,\n  samples = 96\n): SectionProfile {\n  const { minX, maxX, minY, maxY } = field\n  const distance: number[] = []\n  const height: number[] = []\n  let peak = 0\n\n  if (axis === \"ns\") {\n    const x = minX + (maxX - minX) * pos\n    const length = maxY - minY\n    for (let i = 0; i < samples; i++) {\n      const t = i / (samples - 1)\n      const y = minY + length * t\n      const h = sampleHeight(field, x, y)\n      distance.push(length * t)\n      height.push(h)\n      if (h > peak) peak = h\n    }\n    return { distance, height, length, peak, axis }\n  }\n\n  const y = minY + (maxY - minY) * pos\n  const length = maxX - minX\n  for (let i = 0; i < samples; i++) {\n    const t = i / (samples - 1)\n    const x = minX + length * t\n    const h = sampleHeight(field, x, y)\n    distance.push(length * t)\n    height.push(h)\n    if (h > peak) peak = h\n  }\n  return { distance, height, length, peak, axis }\n}\n\n/** Locate the highest node in the grid → its world coordinates and height. */\nexport function findPeak(field: Heightfield): {\n  x: number\n  y: number\n  z: number\n} {\n  const { cols, minX, minY, dx, dy, z } = field\n  let best = -Infinity\n  let bi = 0\n  for (let i = 0; i < z.length; i++) {\n    if (z[i] > best) {\n      best = z[i]\n      bi = i\n    }\n  }\n  const r = Math.floor(bi / cols)\n  const c = bi - r * cols\n  return { x: minX + c * dx, y: minY + r * dy, z: best }\n}\n\n/**\n * Test-coverage analysis: the mean distance from every face node to its nearest\n * sample, and the \"one sample per N m³\" figure the survey report quotes.\n */\nexport function computeTestCoverage(\n  field: Heightfield,\n  samples: { x: number; y: number }[],\n  volumeM3: number\n): {\n  /** Row-major distance (m) to the nearest sample, for the coverage overlay. */\n  nearest: Float32Array\n  maxNearest: number\n  /** Volume served per sample (m³). Infinity when there are no samples. */\n  volumePerSample: number\n} {\n  const { cols, rows, minX, minY, dx, dy, z, maxZ } = field\n  const nearest = new Float32Array(cols * rows)\n  const faceFloor = Math.max(0.15, maxZ * 0.08)\n  let maxNearest = 0\n\n  for (let r = 0; r < rows; r++) {\n    const wy = minY + r * dy\n    for (let c = 0; c < cols; c++) {\n      const wx = minX + c * dx\n      let nd = Infinity\n      for (const s of samples) {\n        const ddx = wx - s.x\n        const ddy = wy - s.y\n        const d = Math.hypot(ddx, ddy)\n        if (d < nd) nd = d\n      }\n      const idx = r * cols + c\n      nearest[idx] = Number.isFinite(nd) ? nd : 0\n      if (z[idx] >= faceFloor && nearest[idx] > maxNearest) {\n        maxNearest = nearest[idx]\n      }\n    }\n  }\n\n  const volumePerSample =\n    samples.length > 0 ? volumeM3 / samples.length : Infinity\n\n  return { nearest, maxNearest, volumePerSample }\n}\n\n/** Bounding centroid of the survey points (site-local metres). */\nexport function surveyCentroid(survey: StockpileSurvey): { x: number; y: number } {\n  const pts = survey.points\n  if (pts.length === 0) return { x: 0, y: 0 }\n  let sx = 0\n  let sy = 0\n  for (const p of pts) {\n    sx += p.x\n    sy += p.y\n  }\n  return { x: sx / pts.length, y: sy / pts.length }\n}\n\n/** Convenience: does this survey carry any usable points? */\nexport function hasSurface(survey: StockpileSurvey | undefined): boolean {\n  return !!survey && survey.points.length > 0\n}\n\n/** Re-export the point type consumers of the geometry helpers commonly need. */\nexport type { SurveyPoint }\n",
      "type": "registry:component",
      "target": "components/blocks/domain/stockpile-visualiser/stockpile-geometry.ts"
    },
    {
      "path": "components/blocks/domain/stockpile-visualiser/stockpile-markers.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AlertTriangle,\n  Check,\n  Clock,\n  FlipBackward,\n  XClose,\n} from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Popover,\n  PopoverContent,\n  PopoverDescription,\n  PopoverHeader,\n  PopoverTitle,\n  PopoverTrigger,\n} from \"@/components/ui/popover\"\n\nimport type { TestLocation, TestStatus } from \"./types\"\n\n/** Per-status pin appearance and accessible wording. */\ninterface StatusMeta {\n  label: string\n  /** Tailwind classes for the filled pin body. */\n  pin: string\n  /** Icon inside the pin (null → hollow ring for scheduled). */\n  Icon: React.ComponentType<{ className?: string }> | null\n  /** Whether the pin pulses (at-lab, attention, fail). */\n  pulse: boolean\n}\n\nexport const TEST_STATUS_META: Record<TestStatus, StatusMeta> = {\n  scheduled: {\n    label: \"Scheduled\",\n    pin: \"bg-bg-primary text-fg-quaternary ring-2 ring-border-primary ring-dashed\",\n    Icon: null,\n    pulse: false,\n  },\n  sampled: {\n    label: \"Sampled\",\n    pin: \"bg-utility-blue-500 text-white ring-2 ring-white/70\",\n    Icon: FlipBackward,\n    pulse: false,\n  },\n  \"at-lab\": {\n    label: \"At lab\",\n    pin: \"bg-utility-purple-500 text-white ring-2 ring-white/70\",\n    Icon: Clock,\n    pulse: true,\n  },\n  pass: {\n    label: \"Pass\",\n    pin: \"bg-utility-green-600 text-white ring-2 ring-white/70\",\n    Icon: Check,\n    pulse: false,\n  },\n  fail: {\n    label: \"Fail\",\n    pin: \"bg-utility-red-600 text-white ring-2 ring-white/70\",\n    Icon: XClose,\n    pulse: true,\n  },\n  attention: {\n    label: \"Attention\",\n    pin: \"bg-utility-yellow-500 text-white ring-2 ring-white/70\",\n    Icon: AlertTriangle,\n    pulse: true,\n  },\n}\n\n/**\n * TestMarker — an absolutely-positioned, tabbable pin on the pile surface. It is\n * placed with the same projection the canvas uses, so it sits on the real\n * surface point; occluded markers dim and drop out of the tab order. Clicking or\n * focusing opens a Popover with the suite, dates, summary and a determinand\n * mini-table (values against limits, exceedances in red).\n */\nexport function TestMarker({\n  test,\n  x,\n  y,\n  depth,\n  occluded,\n  selected,\n  onSelect,\n}: {\n  test: TestLocation\n  x: number\n  y: number\n  depth: number\n  occluded: boolean\n  selected: boolean\n  onSelect?: (test: TestLocation) => void\n}) {\n  const meta = TEST_STATUS_META[test.status]\n\n  return (\n    <div\n      className=\"pointer-events-none absolute\"\n      style={{\n        left: x,\n        top: y,\n        // Nearer markers stack above farther ones, always above the canvas and\n        // below the breach flag (z-40). Clamped to a positive band so ordering\n        // stays purely by depth.\n        zIndex: Math.max(10, Math.min(38, 24 + Math.round(depth))),\n        opacity: occluded ? 0.32 : 1,\n        transition: \"opacity 160ms ease\",\n      }}\n    >\n      <Popover>\n        <PopoverTrigger\n          render={\n            <button\n              type=\"button\"\n              aria-label={`Test ${test.label}: ${meta.label}. ${test.suite}.`}\n              tabIndex={occluded ? -1 : 0}\n              onClick={() => onSelect?.(test)}\n              onFocus={() => onSelect?.(test)}\n              className={cn(\n                \"pointer-events-auto absolute -translate-x-1/2 -translate-y-full cursor-pointer rounded-lg outline-none focus-visible:ring-4 focus-visible:ring-ring/25\",\n                selected && \"z-10\"\n              )}\n            >\n              <MarkerPin meta={meta} label={test.label} selected={selected} />\n            </button>\n          }\n        />\n        <PopoverContent side=\"top\" className=\"w-72 gap-3\">\n          <PopoverHeader>\n            <div className=\"flex items-center justify-between gap-2\">\n              <PopoverTitle className=\"font-mono text-sm\">{test.label}</PopoverTitle>\n              <StatusChip status={test.status} />\n            </div>\n            <PopoverDescription>{test.suite}</PopoverDescription>\n          </PopoverHeader>\n\n          <dl className=\"grid grid-cols-2 gap-x-3 gap-y-1.5 text-xs\">\n            {test.sampledAt ? (\n              <MetaRow label=\"Sampled\" value={formatDate(test.sampledAt)} />\n            ) : null}\n            {typeof test.depthM === \"number\" ? (\n              <MetaRow label=\"Depth\" value={`${test.depthM.toFixed(1)} m`} />\n            ) : null}\n          </dl>\n\n          {test.summary ? (\n            <p className=\"text-xs text-text-tertiary\">{test.summary}</p>\n          ) : null}\n\n          {test.determinands?.length ? (\n            <DeterminandTable determinands={test.determinands} />\n          ) : null}\n        </PopoverContent>\n      </Popover>\n    </div>\n  )\n}\n\nfunction MarkerPin({\n  meta,\n  label,\n  selected,\n}: {\n  meta: StatusMeta\n  label: string\n  selected: boolean\n}) {\n  const { Icon } = meta\n  return (\n    <div className=\"flex flex-col items-center gap-0.5\">\n      <div className=\"relative\">\n        {meta.pulse ? (\n          <span\n            className={cn(\n              \"absolute inset-0 rounded-full motion-safe:animate-ping\",\n              meta.pin.includes(\"red\")\n                ? \"bg-utility-red-500/50\"\n                : meta.pin.includes(\"yellow\")\n                  ? \"bg-utility-yellow-500/50\"\n                  : \"bg-utility-purple-500/45\"\n            )}\n          />\n        ) : null}\n        <span\n          className={cn(\n            \"relative flex size-4 items-center justify-center rounded-full shadow-sm\",\n            meta.pin,\n            selected && \"scale-125 ring-offset-1 ring-offset-bg-secondary\"\n          )}\n        >\n          {Icon ? <Icon className=\"size-2.5\" aria-hidden=\"true\" /> : null}\n        </span>\n      </div>\n      <span\n        className={cn(\n          \"rounded bg-bg-primary/85 px-1 py-px text-[9px] leading-none font-semibold text-text-secondary shadow-sm ring-1 ring-border-secondary backdrop-blur-sm\",\n          selected && \"ring-border-brand text-text-brand-secondary\"\n        )}\n      >\n        {label}\n      </span>\n      {/* Stem down to the surface point. */}\n      <span className=\"h-2 w-px bg-fg-quaternary/50\" />\n    </div>\n  )\n}\n\nfunction StatusChip({ status }: { status: TestStatus }) {\n  const meta = TEST_STATUS_META[status]\n  const toneClass: Record<TestStatus, string> = {\n    scheduled: \"bg-bg-secondary text-text-tertiary\",\n    sampled: \"bg-utility-blue-50 text-utility-blue-700\",\n    \"at-lab\": \"bg-utility-purple-50 text-utility-purple-700\",\n    pass: \"bg-utility-green-50 text-utility-green-700\",\n    fail: \"bg-utility-red-50 text-utility-red-700\",\n    attention: \"bg-utility-yellow-50 text-utility-yellow-700\",\n  }\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium\",\n        toneClass[status]\n      )}\n    >\n      {meta.label}\n    </span>\n  )\n}\n\nfunction MetaRow({ label, value }: { label: string; value: string }) {\n  return (\n    <div className=\"flex flex-col\">\n      <dt className=\"text-[10px] font-medium tracking-wide text-fg-quaternary uppercase\">\n        {label}\n      </dt>\n      <dd className=\"text-text-secondary tabular-nums\">{value}</dd>\n    </div>\n  )\n}\n\nfunction DeterminandTable({\n  determinands,\n}: {\n  determinands: NonNullable<TestLocation[\"determinands\"]>\n}) {\n  return (\n    <div className=\"overflow-hidden rounded-lg border border-border-secondary\">\n      <table className=\"w-full text-xs\">\n        <thead>\n          <tr className=\"bg-bg-secondary text-fg-quaternary\">\n            <th className=\"px-2 py-1 text-left font-medium\">Determinand</th>\n            <th className=\"px-2 py-1 text-right font-medium\">Result</th>\n            <th className=\"px-2 py-1 text-right font-medium\">Limit</th>\n          </tr>\n        </thead>\n        <tbody>\n          {determinands.map((d) => (\n            <tr\n              key={d.name}\n              className=\"border-t border-border-secondary last:border-b-0\"\n            >\n              <td className=\"px-2 py-1 text-text-tertiary\">{d.name}</td>\n              <td\n                className={cn(\n                  \"px-2 py-1 text-right tabular-nums\",\n                  d.exceeds\n                    ? \"font-semibold text-utility-red-600\"\n                    : \"text-text-secondary\"\n                )}\n              >\n                {formatResult(d.value, d.unit)}\n              </td>\n              <td className=\"px-2 py-1 text-right text-fg-quaternary tabular-nums\">\n                {typeof d.limit === \"number\" ? formatResult(d.limit, d.unit) : \"—\"}\n              </td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  )\n}\n\n/** Render a determinand value; zero-with-a-limit reads as \"not detected\". */\nfunction formatResult(value: number, unit: string): string {\n  if (value === 0 && unit === \"\") return \"n/d\"\n  const num = Number.isInteger(value) ? value.toString() : value.toFixed(1)\n  return unit ? `${num} ${unit}` : num\n}\n\nfunction formatDate(iso: string): string {\n  const d = new Date(iso)\n  if (Number.isNaN(d.getTime())) return iso\n  return d.toLocaleDateString(\"en-GB\", {\n    day: \"numeric\",\n    month: \"short\",\n    year: \"numeric\",\n  })\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/stockpile-visualiser/stockpile-markers.tsx"
    },
    {
      "path": "components/blocks/domain/stockpile-visualiser/stockpile-panel.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  ArrowDown,\n  ArrowUp,\n  ClipboardCheck,\n  Droplets03,\n  FlipBackward,\n  Ruler,\n} from \"@untitledui/icons\"\n\nimport { cn } from \"@/lib/utils\"\n\nimport { TEST_STATUS_META } from \"./stockpile-markers\"\nimport type {\n  Stockpile,\n  StockpileEvent,\n  StockpileEventKind,\n  TestLocation,\n} from \"./types\"\n\n/**\n * StockpilePanel — the right-hand statistics rail. Identity, the moisture and\n * agronomy stats, the tabbable test list (selecting a row emphasises its marker\n * and opens its popover) and the events ledger tail. Purely presentational; the\n * gauges and compliance strip live in the main component so their count-ups can\n * animate alongside the surface morph.\n */\nexport function StockpilePanel({\n  stockpile,\n  selectedTestId,\n  onTestSelect,\n  className,\n}: {\n  stockpile: Stockpile\n  selectedTestId: string | null\n  onTestSelect: (test: TestLocation) => void\n  className?: string\n}) {\n  const { nutrients } = stockpile\n\n  return (\n    <div className={cn(\"flex flex-col gap-5\", className)}>\n      {/* Moisture + agronomy stats */}\n      {(typeof stockpile.moisturePct === \"number\" || nutrients) && (\n        <section className=\"flex flex-col gap-2\">\n          <SectionLabel icon={Droplets03}>Condition</SectionLabel>\n          <div className=\"grid grid-cols-3 gap-2\">\n            {typeof stockpile.moisturePct === \"number\" ? (\n              <Stat label=\"Moisture\" value={`${stockpile.moisturePct}%`} />\n            ) : null}\n            {typeof nutrients?.ph === \"number\" ? (\n              <Stat label=\"pH\" value={nutrients.ph.toFixed(1)} />\n            ) : null}\n            {typeof nutrients?.organicMatterPct === \"number\" ? (\n              <Stat label=\"Organic\" value={`${nutrients.organicMatterPct}%`} />\n            ) : null}\n            {typeof nutrients?.pMgPerL === \"number\" ? (\n              <Stat label=\"P\" value={`${nutrients.pMgPerL}`} unit=\"mg/L\" />\n            ) : null}\n            {typeof nutrients?.kMgPerL === \"number\" ? (\n              <Stat label=\"K\" value={`${nutrients.kMgPerL}`} unit=\"mg/L\" />\n            ) : null}\n            {typeof nutrients?.mgMgPerL === \"number\" ? (\n              <Stat label=\"Mg\" value={`${nutrients.mgMgPerL}`} unit=\"mg/L\" />\n            ) : null}\n          </div>\n        </section>\n      )}\n\n      {/* Test list */}\n      <section className=\"flex flex-col gap-2\">\n        <SectionLabel icon={ClipboardCheck}>\n          Sampling &amp; testing\n          <span className=\"ml-auto text-xs font-normal text-fg-quaternary tabular-nums\">\n            {stockpile.tests.length} points\n          </span>\n        </SectionLabel>\n        <ul className=\"flex flex-col gap-1\">\n          {stockpile.tests.map((test) => {\n            const meta = TEST_STATUS_META[test.status]\n            const active = selectedTestId === test.id\n            return (\n              <li key={test.id}>\n                <button\n                  type=\"button\"\n                  onClick={() => onTestSelect(test)}\n                  aria-pressed={active}\n                  className={cn(\n                    \"flex w-full items-center gap-2.5 rounded-lg border px-2.5 py-1.5 text-left transition\",\n                    active\n                      ? \"border-border-brand bg-utility-brand-50\"\n                      : \"border-transparent hover:border-border-secondary hover:bg-bg-secondary\"\n                  )}\n                >\n                  <span\n                    className={cn(\n                      \"flex size-4 shrink-0 items-center justify-center rounded-full\",\n                      meta.pin\n                    )}\n                  >\n                    {meta.Icon ? <meta.Icon className=\"size-2.5\" aria-hidden=\"true\" /> : null}\n                  </span>\n                  <span className=\"min-w-0 flex-1\">\n                    <span className=\"block truncate font-mono text-xs font-medium text-text-secondary\">\n                      {test.label}\n                    </span>\n                    <span className=\"block truncate text-[11px] text-text-tertiary\">\n                      {test.suite}\n                    </span>\n                  </span>\n                  <span className=\"shrink-0 text-[11px] font-medium text-fg-quaternary\">\n                    {meta.label}\n                  </span>\n                </button>\n              </li>\n            )\n          })}\n        </ul>\n      </section>\n\n      {/* Events ledger tail */}\n      {stockpile.events && stockpile.events.length > 0 ? (\n        <section className=\"flex flex-col gap-2\">\n          <SectionLabel icon={FlipBackward}>Recent activity</SectionLabel>\n          <ul className=\"flex flex-col\">\n            {stockpile.events.slice(0, 6).map((event) => (\n              <EventRow key={event.id} event={event} />\n            ))}\n          </ul>\n        </section>\n      ) : null}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n\nfunction SectionLabel({\n  icon: Icon,\n  children,\n}: {\n  icon: React.ComponentType<{ className?: string }>\n  children: React.ReactNode\n}) {\n  return (\n    <div className=\"flex items-center gap-1.5 text-xs font-semibold text-fg-tertiary\">\n      <Icon className=\"size-3.5 text-fg-quaternary\" aria-hidden=\"true\" />\n      {children}\n    </div>\n  )\n}\n\nfunction Stat({\n  label,\n  value,\n  unit,\n}: {\n  label: string\n  value: string\n  unit?: string\n}) {\n  return (\n    <div className=\"flex flex-col rounded-lg bg-bg-secondary px-2.5 py-1.5\">\n      <span className=\"text-[10px] font-medium tracking-wide text-fg-quaternary uppercase\">\n        {label}\n      </span>\n      <span className=\"text-sm font-semibold text-text-secondary tabular-nums\">\n        {value}\n        {unit ? (\n          <span className=\"ml-0.5 text-[10px] font-normal text-fg-quaternary\">\n            {unit}\n          </span>\n        ) : null}\n      </span>\n    </div>\n  )\n}\n\nconst EVENT_META: Record<\n  StockpileEventKind,\n  { Icon: React.ComponentType<{ className?: string }>; tint: string; verb: string }\n> = {\n  delivery: { Icon: ArrowDown, tint: \"text-utility-green-600\", verb: \"Delivery\" },\n  removal: { Icon: ArrowUp, tint: \"text-utility-orange-600\", verb: \"Removal\" },\n  survey: { Icon: Ruler, tint: \"text-utility-blue-600\", verb: \"Survey\" },\n  adjustment: { Icon: FlipBackward, tint: \"text-fg-tertiary\", verb: \"Adjustment\" },\n  test: { Icon: ClipboardCheck, tint: \"text-utility-purple-600\", verb: \"Test\" },\n}\n\nfunction EventRow({ event }: { event: StockpileEvent }) {\n  const meta = EVENT_META[event.kind]\n  return (\n    <li className=\"flex items-center gap-2.5 border-b border-border-secondary py-1.5 last:border-b-0\">\n      <meta.Icon className={cn(\"size-3.5 shrink-0\", meta.tint)} aria-hidden=\"true\" />\n      <div className=\"min-w-0 flex-1\">\n        <div className=\"flex items-baseline gap-1.5\">\n          <span className=\"text-xs font-medium text-text-secondary\">{meta.verb}</span>\n          {typeof event.volumeM3 === \"number\" ? (\n            <span className=\"text-xs text-text-tertiary tabular-nums\">\n              {event.volumeM3.toFixed(1)} m³\n            </span>\n          ) : null}\n          {event.vehicleReg ? (\n            <span className=\"font-mono text-[10px] text-fg-quaternary\">\n              {event.vehicleReg}\n            </span>\n          ) : null}\n        </div>\n        {event.note ? (\n          <p className=\"truncate text-[11px] text-text-tertiary\">{event.note}</p>\n        ) : null}\n      </div>\n      <time className=\"shrink-0 text-[10px] text-fg-quaternary tabular-nums\">\n        {formatTime(event.at)}\n      </time>\n    </li>\n  )\n}\n\nfunction formatTime(iso: string): string {\n  const d = new Date(iso)\n  if (Number.isNaN(d.getTime())) return \"\"\n  return d.toLocaleTimeString(\"en-GB\", { hour: \"2-digit\", minute: \"2-digit\" })\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/stockpile-visualiser/stockpile-panel.tsx"
    },
    {
      "path": "components/blocks/domain/stockpile-visualiser/stockpile-scene.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  Maximize01,\n  Minus,\n  Plus,\n  RefreshCcw01,\n} from \"@untitledui/icons\"\nimport { useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\n\nimport {\n  buildHeightfield,\n  computeCutFill,\n  computeSlopeDeg,\n  computeTestCoverage,\n  computeVolumeM3,\n  findPeak,\n  resolveMaxHeightM,\n  sampleHeight,\n} from \"./stockpile-geometry\"\nimport { TestMarker } from \"./stockpile-markers\"\nimport type {\n  Heightfield,\n  StockpileOverlay,\n  StockpileSceneProps,\n  TestLocation,\n} from \"./types\"\n\n// ---------------------------------------------------------------------------\n// Camera & projection — yaw/pitch/zoom orthographic with tilt.\n// ---------------------------------------------------------------------------\n\ninterface Camera {\n  /** Rotation about the vertical (z) axis, radians. */\n  yaw: number\n  /** Tilt from plan view, radians (clamped ~15°–80°). */\n  pitch: number\n  /** Zoom multiplier. */\n  zoom: number\n}\n\nconst PITCH_MIN = (15 * Math.PI) / 180\nconst PITCH_MAX = (80 * Math.PI) / 180\nconst ZOOM_MIN = 0.55\nconst ZOOM_MAX = 2.4\n\nconst DEFAULT_CAMERA: Camera = {\n  yaw: -0.62,\n  pitch: (52 * Math.PI) / 180,\n  zoom: 1,\n}\n\n/** Vertical exaggeration so gentle survey piles read as three-dimensional. */\nconst Z_EXAGGERATION = 1.65\n\ninterface Projector {\n  project: (x: number, y: number, z: number) => [number, number, number]\n  /** Light direction, already rotated into view for flat shading. */\n  light: [number, number, number]\n}\n\n/**\n * Build a projector for the given camera, world extent and pixel viewport. World\n * coordinates are centred on the pile footprint, scaled to fit the viewport,\n * rotated by yaw, tilted by pitch, then flattened orthographically. Returns\n * screen x/y plus a depth key for the painter's sort (larger = nearer camera).\n */\nfunction makeProjector(\n  camera: Camera,\n  field: Heightfield,\n  width: number,\n  height: number\n): Projector {\n  const cx = (field.minX + field.maxX) / 2\n  const cy = (field.minY + field.maxY) / 2\n  const spanX = field.maxX - field.minX\n  const spanY = field.maxY - field.minY\n  const span = Math.max(spanX, spanY) || 1\n\n  const cosY = Math.cos(camera.yaw)\n  const sinY = Math.sin(camera.yaw)\n  const cosP = Math.cos(camera.pitch)\n  const sinP = Math.sin(camera.pitch)\n\n  // Fit the footprint to ~78% of the smaller viewport dimension, then zoom.\n  const fit = (Math.min(width, height) * 0.78) / span\n  const scale = fit * camera.zoom\n  const originX = width / 2\n  // Nudge the horizon up a touch so the ground apron and legend have room.\n  const originY = height * 0.56\n\n  const light = normalize([-0.4, -0.55, 0.85])\n\n  const project = (x: number, y: number, z: number): [number, number, number] => {\n    const lx = x - cx\n    const ly = y - cy\n    const lz = z * Z_EXAGGERATION\n\n    // Yaw about z.\n    const rx = lx * cosY - ly * sinY\n    const ry = lx * sinY + ly * cosY\n\n    // Pitch: tilt the plane toward the viewer. Screen y combines the tilted\n    // ground depth and the raised elevation.\n    const screenX = originX + rx * scale\n    const screenY = originY - (ry * cosP) * scale - lz * sinP * scale\n\n    // Depth for painter's sort: nearer (more negative ry after pitch) draws\n    // last. Include a slight elevation term so tall near cells sit on top.\n    const depth = ry * cosP + lz * sinP * 0.15\n    return [screenX, screenY, depth]\n  }\n\n  return { project, light }\n}\n\nfunction normalize(v: [number, number, number]): [number, number, number] {\n  const l = Math.hypot(v[0], v[1], v[2]) || 1\n  return [v[0] / l, v[1] / l, v[2] / l]\n}\n\n// ---------------------------------------------------------------------------\n// Theme colour resolution — read CSS vars, re-resolve on theme/brand change.\n// ---------------------------------------------------------------------------\n\ninterface Rgb {\n  r: number\n  g: number\n  b: number\n}\n\n/** Tone → the UUI utility ramp it maps to (theme-aware, resolved from CSS). */\nconst TONE_RAMP: Record<string, string> = {\n  brown: \"orange\",\n  amber: \"amber\",\n  olive: \"green\",\n  grey: \"neutral\",\n  slate: \"slate\",\n}\n\n/** The CSS custom properties the renderer samples each time the theme changes. */\nfunction collectVarNames(tone: string): string[] {\n  const ramp = TONE_RAMP[tone] ?? \"orange\"\n  const rampSteps = [100, 200, 300, 400, 500, 600, 700]\n  const names: string[] = []\n  for (const s of rampSteps) names.push(`--color-utility-${ramp}-${s}`)\n  // Diverging + status ramps used by the analysis overlays.\n  for (const s of [200, 300, 400, 500, 600, 700]) {\n    names.push(`--color-utility-blue-${s}`)\n    names.push(`--color-utility-red-${s}`)\n    names.push(`--color-utility-green-${s}`)\n    names.push(`--color-utility-yellow-${s}`)\n    names.push(`--color-utility-orange-${s}`)\n  }\n  names.push(\n    \"--color-bg-primary\",\n    \"--color-bg-secondary\",\n    \"--color-border-secondary\",\n    \"--color-border-primary\",\n    \"--color-fg-quaternary\",\n    \"--color-fg-tertiary\",\n    \"--color-utility-brand-500\",\n    \"--color-utility-brand-600\"\n  )\n  return names\n}\n\ntype ColorMap = Record<string, Rgb>\n\n/**\n * Parse a CSS colour string that is already in hex or rgb()/rgba() form into\n * 0–255 components. Returns null for anything else (e.g. oklch(), colour names,\n * var() chains) so the caller can fall back to a browser probe that normalises\n * the value to rgb first.\n */\nfunction parseColor(input: string): Rgb | null {\n  const s = input.trim()\n  if (!s) return null\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1)\n    if (!/^[0-9a-fA-F]{3}$|^[0-9a-fA-F]{6}$/.test(hex)) return null\n    const full =\n      hex.length === 3\n        ? hex\n            .split(\"\")\n            .map((c) => c + c)\n            .join(\"\")\n        : hex\n    const n = parseInt(full, 16)\n    return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }\n  }\n  const m = s.match(/rgba?\\(([^)]+)\\)/)\n  if (m) {\n    const parts = m[1].split(/[,/\\s]+/).filter(Boolean)\n    return {\n      r: Math.round(parseFloat(parts[0])),\n      g: Math.round(parseFloat(parts[1])),\n      b: Math.round(parseFloat(parts[2])),\n    }\n  }\n  return null\n}\n\n/**\n * Resolve the renderer's palette from CSS custom properties, re-resolving when\n * the html `class` (light/dark) or `data-brand` changes so the canvas always\n * matches the active theme. Returns a version counter that bumps on change.\n */\nfunction useThemeColors(el: HTMLElement | null, tone: string) {\n  const [colors, setColors] = React.useState<ColorMap>({})\n  const [version, setVersion] = React.useState(0)\n  const toneRef = React.useRef(tone)\n  toneRef.current = tone\n\n  const resolve = React.useCallback(() => {\n    if (!el) return\n    const cs = getComputedStyle(el)\n    const map: ColorMap = {}\n\n    // Modern Tailwind palettes resolve to oklch() strings, which parseColor\n    // cannot read. Normalise those (and any other non-hex/rgb form) through a\n    // single detached probe span: the browser reports getComputedStyle().color\n    // as rgb()/rgba() regardless of the input colour space. The probe is\n    // attached to the component wrapper (falling back to <body>) and removed\n    // after the whole batch, so no DOM node leaks.\n    const host = el ?? (typeof document !== \"undefined\" ? document.body : null)\n    const probeHolder: { el: HTMLSpanElement | null } = { el: null }\n    const probeToRgb = (raw: string): Rgb | null => {\n      if (!host) return null\n      let probe = probeHolder.el\n      if (!probe) {\n        probe = document.createElement(\"span\")\n        probe.style.cssText =\n          \"position:absolute;width:0;height:0;visibility:hidden;pointer-events:none\"\n        host.appendChild(probe)\n        probeHolder.el = probe\n      }\n      probe.style.color = \"\"\n      probe.style.color = raw\n      // If the browser rejected the value, style.color stays empty.\n      if (!probe.style.color) return null\n      return parseColor(getComputedStyle(probe).color)\n    }\n\n    for (const name of collectVarNames(toneRef.current)) {\n      const raw = cs.getPropertyValue(name)\n      if (!raw) continue\n      const parsed = parseColor(raw) ?? probeToRgb(raw)\n      if (parsed) map[name] = parsed\n    }\n\n    probeHolder.el?.remove()\n\n    setColors(map)\n    setVersion((v) => v + 1)\n  }, [el])\n\n  React.useEffect(() => {\n    resolve()\n  }, [resolve, tone])\n\n  React.useEffect(() => {\n    if (typeof document === \"undefined\") return\n    const target = document.documentElement\n    const observer = new MutationObserver(() => resolve())\n    observer.observe(target, {\n      attributes: true,\n      attributeFilter: [\"class\", \"data-brand\", \"data-density\"],\n    })\n    return () => observer.disconnect()\n  }, [resolve])\n\n  return { colors, version }\n}\n\nfunction rgbStr(c: Rgb | undefined, fallback = \"rgb(160,120,80)\"): string {\n  if (!c) return fallback\n  return `rgb(${c.r},${c.g},${c.b})`\n}\n\nfunction mix(a: Rgb, b: Rgb, t: number): Rgb {\n  return {\n    r: Math.round(a.r + (b.r - a.r) * t),\n    g: Math.round(a.g + (b.g - a.g) * t),\n    b: Math.round(a.b + (b.b - a.b) * t),\n  }\n}\n\nfunction shade(c: Rgb, factor: number): Rgb {\n  // factor 1 = unchanged, <1 darker, >1 lighter (toward white).\n  if (factor <= 1) {\n    return { r: c.r * factor, g: c.g * factor, b: c.b * factor }\n  }\n  const t = Math.min(1, factor - 1)\n  return { r: c.r + (255 - c.r) * t, g: c.g + (255 - c.g) * t, b: c.b + (255 - c.b) * t }\n}\n\n// ---------------------------------------------------------------------------\n// Overlay cell colour — chooses a per-cell fill for the active overlay.\n// ---------------------------------------------------------------------------\n\ninterface OverlayField {\n  /** Row-major scalar 0–1 the overlay colours by, or null for material. */\n  scalar: Float32Array | null\n}\n\n// ---------------------------------------------------------------------------\n// The Scene component.\n// ---------------------------------------------------------------------------\n\n/**\n * StockpileScene — the bare hand-rolled 3D view. Renders the surveyed pile as a\n * flat-shaded, painter-sorted heightfield on Canvas2D with a soft ground plane\n * and north arrow, overlays the analysis colour ramps, and places tabbable test\n * markers with the same projection. Pointer drag rotates, wheel zooms,\n * double-click and toolbar reset the view; keyboard controls the focused canvas.\n */\nexport function StockpileScene({\n  stockpile,\n  overlay,\n  section = null,\n  sectionPos = 0.5,\n  onTestSelect,\n  selectedTestId = null,\n  className,\n}: StockpileSceneProps) {\n  const reduce = useReducedMotion()\n  const wrapperRef = React.useRef<HTMLDivElement>(null)\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n\n  const [camera, setCamera] = React.useState<Camera>(DEFAULT_CAMERA)\n  const [size, setSize] = React.useState({ w: 640, h: 420 })\n  const [themeEl, setThemeEl] = React.useState<HTMLElement | null>(null)\n\n  const tone = stockpile.material.tone ?? \"brown\"\n  const { colors, version } = useThemeColors(themeEl, tone)\n\n  React.useEffect(() => {\n    setThemeEl(wrapperRef.current)\n  }, [])\n\n  // --- Target heightfield (rebuilt when the survey changes) ----------------\n  const targetField = React.useMemo(\n    () => buildHeightfield(stockpile.survey),\n    [stockpile.survey]\n  )\n\n  // The live (animated) heights morph toward the target. Kept in a ref so the\n  // draw loop never re-renders React.\n  const liveZRef = React.useRef<Float32Array>(new Float32Array(targetField.z))\n  const morphRef = React.useRef<{\n    from: Float32Array\n    to: Float32Array\n    start: number\n    dur: number\n  } | null>(null)\n\n  // Kick a morph whenever the target grid changes (respecting reduced motion).\n  React.useEffect(() => {\n    const live = liveZRef.current\n    if (live.length !== targetField.z.length) {\n      liveZRef.current = new Float32Array(targetField.z)\n      morphRef.current = null\n      return\n    }\n    if (reduce) {\n      liveZRef.current = new Float32Array(targetField.z)\n      morphRef.current = null\n      return\n    }\n    morphRef.current = {\n      from: new Float32Array(live),\n      to: new Float32Array(targetField.z),\n      start: performance.now(),\n      dur: 700,\n    }\n  }, [targetField, reduce])\n\n  // --- Analysis (recomputed on the target grid) ----------------------------\n  const analysis = React.useMemo(() => {\n    const repose = stockpile.material.angleOfReposeDeg\n    const slope = computeSlopeDeg(targetField, repose)\n    const volume = computeVolumeM3(targetField, stockpile.material.densityTPerM3)\n    const coverage = computeTestCoverage(\n      targetField,\n      stockpile.tests.map((t) => ({ x: t.x, y: t.y })),\n      volume.volumeM3\n    )\n    const cutFill = stockpile.previousSurvey\n      ? computeCutFill(targetField, stockpile.previousSurvey, stockpile.survey)\n      : null\n    return { slope, volume, coverage, cutFill }\n  }, [targetField, stockpile.material, stockpile.tests, stockpile.previousSurvey])\n\n  // Overlay scalar field selection.\n  const overlayField = React.useMemo<OverlayField>(() => {\n    const n = targetField.z.length\n    if (overlay === \"elevation\") {\n      const scalar = new Float32Array(n)\n      const max = targetField.maxZ || 1\n      for (let i = 0; i < n; i++) scalar[i] = targetField.z[i] / max\n      return { scalar }\n    }\n    if (overlay === \"slope\") {\n      const scalar = new Float32Array(n)\n      const repose = stockpile.material.angleOfReposeDeg\n      for (let i = 0; i < n; i++) {\n        // Map slope to 0 (flat) → 1 (well over repose), centred at the angle.\n        scalar[i] = Math.min(1, analysis.slope.slopeDeg[i] / (repose * 1.4))\n      }\n      return { scalar }\n    }\n    if (overlay === \"test-coverage\") {\n      const scalar = new Float32Array(n)\n      const max = analysis.coverage.maxNearest || 1\n      for (let i = 0; i < n; i++) {\n        scalar[i] = Math.min(1, analysis.coverage.nearest[i] / max)\n      }\n      return { scalar }\n    }\n    if (overlay === \"cut-fill\" && analysis.cutFill) {\n      const scalar = new Float32Array(n)\n      // Normalise signed delta into 0–1 with 0.5 as no-change.\n      let maxAbs = 0\n      for (let i = 0; i < n; i++) {\n        const a = Math.abs(analysis.cutFill.delta[i])\n        if (a > maxAbs) maxAbs = a\n      }\n      maxAbs = maxAbs || 1\n      for (let i = 0; i < n; i++) {\n        scalar[i] = 0.5 + (analysis.cutFill.delta[i] / maxAbs) * 0.5\n      }\n      return { scalar }\n    }\n    return { scalar: null }\n  }, [overlay, targetField, analysis, stockpile.material.angleOfReposeDeg])\n\n  // --- DPR-aware sizing ----------------------------------------------------\n  React.useEffect(() => {\n    const wrapper = wrapperRef.current\n    if (!wrapper) return\n    const ro = new ResizeObserver((entries) => {\n      for (const entry of entries) {\n        const { width, height } = entry.contentRect\n        setSize({ w: Math.max(240, width), h: Math.max(200, height) })\n      }\n    })\n    ro.observe(wrapper)\n    return () => ro.disconnect()\n  }, [])\n\n  // --- Precomputed cell world coords (reused across frames) ----------------\n  const cells = React.useMemo(() => buildCellIndex(targetField), [targetField])\n\n  // --- The draw loop -------------------------------------------------------\n  const drawStateRef = React.useRef({ camera, size, overlay, version })\n  drawStateRef.current = { camera, size, overlay, version }\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current\n    if (!canvas) return\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx) return\n\n    let raf = 0\n    // Per-instance scratch arrays (zero allocation in the draw loop).\n    const order = new Int32Array(cells.count)\n    for (let i = 0; i < cells.count; i++) order[i] = i\n    const depths = new Float32Array(cells.count)\n    const nodeCount = cells.worldX.length\n    const screenX = new Float32Array(nodeCount)\n    const screenY = new Float32Array(nodeCount)\n\n    const render = () => {\n      const { camera: cam, size: sz } = drawStateRef.current\n      const dpr = Math.min(2.5, window.devicePixelRatio || 1)\n      const pxW = Math.round(sz.w * dpr)\n      const pxH = Math.round(sz.h * dpr)\n      if (canvas.width !== pxW || canvas.height !== pxH) {\n        canvas.width = pxW\n        canvas.height = pxH\n      }\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n      ctx.clearRect(0, 0, sz.w, sz.h)\n\n      // Advance the morph, if any.\n      const morph = morphRef.current\n      const live = liveZRef.current\n      if (morph) {\n        const t = Math.min(1, (performance.now() - morph.start) / morph.dur)\n        const e = easeOutCubic(t)\n        for (let i = 0; i < live.length; i++) {\n          live[i] = morph.from[i] + (morph.to[i] - morph.from[i]) * e\n        }\n        if (t >= 1) morphRef.current = null\n      }\n\n      drawScene(ctx, cam, sz, targetField, live, cells, colors, {\n        overlay: drawStateRef.current.overlay,\n        overlayField,\n        tone,\n        section,\n        sectionPos,\n        reposeDeg: stockpile.material.angleOfReposeDeg,\n        order,\n        depths,\n        screenX,\n        screenY,\n      })\n\n      // Keep animating while morphing; otherwise the effect re-runs on camera\n      // change already, so a single frame suffices when idle.\n      if (morphRef.current) {\n        raf = requestAnimationFrame(render)\n      }\n    }\n\n    raf = requestAnimationFrame(render)\n    return () => cancelAnimationFrame(raf)\n  }, [\n    camera,\n    size,\n    colors,\n    version,\n    overlayField,\n    cells,\n    targetField,\n    section,\n    sectionPos,\n    tone,\n    stockpile.material.angleOfReposeDeg,\n    overlay,\n  ])\n\n  // --- Pointer interaction -------------------------------------------------\n  const dragRef = React.useRef<{ x: number; y: number } | null>(null)\n\n  const onPointerDown = (e: React.PointerEvent) => {\n    if (e.button !== 0) return\n    ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)\n    dragRef.current = { x: e.clientX, y: e.clientY }\n  }\n  const onPointerMove = (e: React.PointerEvent) => {\n    const drag = dragRef.current\n    if (!drag) return\n    const dx = e.clientX - drag.x\n    const dy = e.clientY - drag.y\n    dragRef.current = { x: e.clientX, y: e.clientY }\n    setCamera((c) => ({\n      yaw: c.yaw - dx * 0.008,\n      pitch: clamp(c.pitch - dy * 0.006, PITCH_MIN, PITCH_MAX),\n      zoom: c.zoom,\n    }))\n  }\n  const onPointerUp = (e: React.PointerEvent) => {\n    ;(e.target as HTMLElement).releasePointerCapture?.(e.pointerId)\n    dragRef.current = null\n  }\n  const onWheel = (e: React.WheelEvent) => {\n    setCamera((c) => ({\n      ...c,\n      zoom: clamp(c.zoom * (e.deltaY < 0 ? 1.08 : 0.93), ZOOM_MIN, ZOOM_MAX),\n    }))\n  }\n  const onDoubleClick = () => setCamera(DEFAULT_CAMERA)\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const step = 0.12\n    switch (e.key) {\n      case \"ArrowLeft\":\n        e.preventDefault()\n        setCamera((c) => ({ ...c, yaw: c.yaw + step }))\n        break\n      case \"ArrowRight\":\n        e.preventDefault()\n        setCamera((c) => ({ ...c, yaw: c.yaw - step }))\n        break\n      case \"ArrowUp\":\n        e.preventDefault()\n        setCamera((c) => ({ ...c, pitch: clamp(c.pitch - step * 0.6, PITCH_MIN, PITCH_MAX) }))\n        break\n      case \"ArrowDown\":\n        e.preventDefault()\n        setCamera((c) => ({ ...c, pitch: clamp(c.pitch + step * 0.6, PITCH_MIN, PITCH_MAX) }))\n        break\n      case \"+\":\n      case \"=\":\n        e.preventDefault()\n        setCamera((c) => ({ ...c, zoom: clamp(c.zoom * 1.12, ZOOM_MIN, ZOOM_MAX) }))\n        break\n      case \"-\":\n      case \"_\":\n        e.preventDefault()\n        setCamera((c) => ({ ...c, zoom: clamp(c.zoom / 1.12, ZOOM_MIN, ZOOM_MAX) }))\n        break\n      case \"0\":\n        e.preventDefault()\n        setCamera(DEFAULT_CAMERA)\n        break\n    }\n  }\n\n  // --- Projected marker positions (same projection as the canvas) ----------\n  const projector = React.useMemo(\n    () => makeProjector(camera, targetField, size.w, size.h),\n    [camera, targetField, size]\n  )\n\n  const maxHeightM = resolveMaxHeightM(stockpile.maxHeightM, stockpile.shuClass)\n  const breached =\n    maxHeightM != null && analysis.volume.maxHeightM > maxHeightM + 0.01\n  const peak = React.useMemo(() => findPeak(targetField), [targetField])\n\n  // World-space direction from the pile toward the camera, in plan. The camera\n  // looks along +ry (after yaw); nearer = smaller ry, so the camera sits toward\n  // −y in rotated space. Un-rotate that to world coords for the occlusion march.\n  const camDir = React.useMemo(() => {\n    const cy = Math.cos(camera.yaw)\n    const sy = Math.sin(camera.yaw)\n    // Rotated −y direction (0, −1) back into world space (inverse yaw).\n    return { x: -(-sy), y: -cy }\n  }, [camera.yaw])\n\n  const markers = stockpile.tests.map((test) => {\n    const z = sampleHeight(targetField, test.x, test.y)\n    const [sx, sy, depth] = projector.project(test.x, test.y, z)\n    // Occlusion approximation: march the surface a short way toward the camera;\n    // if it rises above the marker's sight line, dim and untab the marker.\n    const occluded = isOccluded(targetField, test.x, test.y, z, camDir)\n    return { test, sx, sy, depth, occluded }\n  })\n\n  // Peak flag marker screen position.\n  const [peakSx, peakSy] = projector.project(peak.x, peak.y, peak.z)\n\n  return (\n    <TooltipProvider delay={200}>\n      <div\n        ref={wrapperRef}\n        className={cn(\n          \"group/scene relative isolate size-full overflow-hidden rounded-xl bg-bg-secondary select-none\",\n          className\n        )}\n      >\n        {/* Focusable canvas wrapper: keyboard controls + visible focus ring. */}\n        <div\n          role=\"img\"\n          tabIndex={0}\n          aria-label={`Interactive 3D survey of stockpile ${stockpile.name}. Drag to rotate, scroll to zoom, arrow keys and plus or minus to control, zero to reset.`}\n          onKeyDown={onKeyDown}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={onPointerUp}\n          onPointerLeave={onPointerUp}\n          onWheel={onWheel}\n          onDoubleClick={onDoubleClick}\n          className=\"absolute inset-0 cursor-grab touch-none rounded-xl outline-none focus-visible:ring-4 focus-visible:ring-ring/25 active:cursor-grabbing\"\n        >\n          <canvas\n            ref={canvasRef}\n            className=\"size-full\"\n            style={{ width: size.w, height: size.h }}\n          />\n        </div>\n\n        {/* Overlay DOM layer: markers + peak flag, positioned by projection. */}\n        <div className=\"pointer-events-none absolute inset-0\">\n          {markers.map(({ test, sx, sy, depth, occluded }) => (\n            <TestMarker\n              key={test.id}\n              test={test}\n              x={sx}\n              y={sy}\n              depth={depth}\n              occluded={occluded}\n              selected={selectedTestId === test.id}\n              onSelect={onTestSelect}\n            />\n          ))}\n\n          {breached ? (\n            <div\n              className=\"pointer-events-none absolute z-40 -translate-x-1/2 -translate-y-full\"\n              style={{ left: peakSx, top: peakSy }}\n            >\n              <BreachFlag heightM={analysis.volume.maxHeightM} limitM={maxHeightM} reduce={reduce} />\n            </div>\n          ) : null}\n        </div>\n\n        {/* Legend — adapts to the overlay mode. */}\n        <SceneLegend\n          overlay={overlay}\n          colors={colors}\n          tone={tone}\n          maxZ={targetField.maxZ}\n          maxNearest={analysis.coverage.maxNearest}\n          reposeDeg={stockpile.material.angleOfReposeDeg}\n          cutFill={analysis.cutFill}\n        />\n\n        {/* Camera toolbar. */}\n        <div className=\"absolute top-3 right-3 flex flex-col gap-1.5\">\n          <SceneButton\n            label=\"Zoom in\"\n            onClick={() =>\n              setCamera((c) => ({ ...c, zoom: clamp(c.zoom * 1.12, ZOOM_MIN, ZOOM_MAX) }))\n            }\n          >\n            <Plus className=\"size-4\" />\n          </SceneButton>\n          <SceneButton\n            label=\"Zoom out\"\n            onClick={() =>\n              setCamera((c) => ({ ...c, zoom: clamp(c.zoom / 1.12, ZOOM_MIN, ZOOM_MAX) }))\n            }\n          >\n            <Minus className=\"size-4\" />\n          </SceneButton>\n          <SceneButton\n            label=\"Rotate view\"\n            onClick={() => setCamera((c) => ({ ...c, yaw: c.yaw - 0.35 }))}\n          >\n            <RefreshCcw01 className=\"size-4\" />\n          </SceneButton>\n          <SceneButton label=\"Reset view\" onClick={() => setCamera(DEFAULT_CAMERA)}>\n            <Maximize01 className=\"size-4\" />\n          </SceneButton>\n        </div>\n      </div>\n    </TooltipProvider>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Toolbar button (dark tooltip, ghost surface over the canvas).\n// ---------------------------------------------------------------------------\n\nfunction SceneButton({\n  label,\n  onClick,\n  children,\n}: {\n  label: string\n  onClick: () => void\n  children: React.ReactNode\n}) {\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={\n          <Button\n            variant=\"secondary\"\n            size=\"icon-sm\"\n            aria-label={label}\n            onClick={onClick}\n            className=\"size-8 bg-bg-primary/85 shadow-sm ring-1 ring-border-secondary backdrop-blur-sm\"\n          >\n            {children}\n          </Button>\n        }\n      />\n      <TooltipContent side=\"left\">{label}</TooltipContent>\n    </Tooltip>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Height-breach flag — a pulsing pin at the peak.\n// ---------------------------------------------------------------------------\n\nfunction BreachFlag({\n  heightM,\n  limitM,\n  reduce,\n}: {\n  heightM: number\n  limitM: number\n  reduce: boolean | null\n}) {\n  return (\n    <div className=\"flex flex-col items-center\">\n      <div className=\"rounded-md bg-utility-red-600 px-2 py-0.5 text-[10px] font-semibold whitespace-nowrap text-white shadow-lg\">\n        {heightM.toFixed(1)} m · limit {limitM} m\n      </div>\n      <div className=\"relative mt-0.5 size-3\">\n        <span\n          className={cn(\n            \"absolute inset-0 rounded-full bg-utility-red-500/60\",\n            !reduce && \"animate-ping\"\n          )}\n        />\n        <span className=\"absolute inset-[3px] rounded-full bg-utility-red-600 ring-2 ring-white/70\" />\n      </div>\n      <div className=\"h-3 w-px bg-utility-red-600/70\" />\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Legend — adapts per overlay.\n// ---------------------------------------------------------------------------\n\nfunction SceneLegend({\n  overlay,\n  colors,\n  tone,\n  maxZ,\n  maxNearest,\n  reposeDeg,\n  cutFill,\n}: {\n  overlay: StockpileOverlay\n  colors: ColorMap\n  tone: string\n  maxZ: number\n  maxNearest: number\n  reposeDeg: number\n  cutFill: { netM3: number } | null\n}) {\n  const ramp = TONE_RAMP[tone] ?? \"orange\"\n\n  let content: React.ReactNode\n  if (overlay === \"material\") {\n    content = (\n      <LegendGradient\n        stops={[\n          rgbStr(colors[`--color-utility-${ramp}-300`]),\n          rgbStr(colors[`--color-utility-${ramp}-500`]),\n          rgbStr(colors[`--color-utility-${ramp}-700`]),\n        ]}\n        left=\"Toe\"\n        right=\"Crest\"\n      />\n    )\n  } else if (overlay === \"elevation\") {\n    content = (\n      <LegendGradient\n        stops={[\n          rgbStr(colors[\"--color-utility-blue-300\"]),\n          rgbStr(colors[\"--color-utility-green-400\"]),\n          rgbStr(colors[\"--color-utility-yellow-400\"]),\n          rgbStr(colors[\"--color-utility-red-500\"]),\n        ]}\n        left=\"0 m\"\n        right={`${maxZ.toFixed(1)} m`}\n      />\n    )\n  } else if (overlay === \"slope\") {\n    content = (\n      <LegendGradient\n        stops={[\n          rgbStr(colors[\"--color-utility-green-400\"]),\n          rgbStr(colors[\"--color-utility-yellow-400\"]),\n          rgbStr(colors[\"--color-utility-red-600\"]),\n        ]}\n        left=\"0°\"\n        right={`${reposeDeg}°+`}\n      />\n    )\n  } else if (overlay === \"test-coverage\") {\n    content = (\n      <LegendGradient\n        stops={[\n          rgbStr(colors[\"--color-utility-green-400\"]),\n          rgbStr(colors[\"--color-utility-yellow-400\"]),\n          rgbStr(colors[\"--color-utility-red-500\"]),\n        ]}\n        left=\"At sample\"\n        right={`${maxNearest.toFixed(0)} m away`}\n      />\n    )\n  } else {\n    content = (\n      <LegendGradient\n        stops={[\n          rgbStr(colors[\"--color-utility-red-500\"]),\n          rgbStr(colors[\"--color-bg-secondary\"]),\n          rgbStr(colors[\"--color-utility-blue-500\"]),\n        ]}\n        left=\"Removed\"\n        right=\"Added\"\n        caption={cutFill ? `net ${cutFill.netM3 >= 0 ? \"+\" : \"−\"}${Math.abs(Math.round(cutFill.netM3))} m³` : undefined}\n      />\n    )\n  }\n\n  return (\n    <div className=\"pointer-events-none absolute bottom-3 left-3 rounded-lg bg-bg-primary/85 px-3 py-2 shadow-sm ring-1 ring-border-secondary backdrop-blur-sm\">\n      {content}\n    </div>\n  )\n}\n\nfunction LegendGradient({\n  stops,\n  left,\n  right,\n  caption,\n}: {\n  stops: string[]\n  left: string\n  right: string\n  caption?: string\n}) {\n  return (\n    <div className=\"flex flex-col gap-1\">\n      <div\n        className=\"h-2 w-32 rounded-full\"\n        style={{ background: `linear-gradient(90deg, ${stops.join(\",\")})` }}\n      />\n      <div className=\"flex items-center justify-between text-[10px] font-medium text-fg-tertiary tabular-nums\">\n        <span>{left}</span>\n        {caption ? <span className=\"text-fg-quaternary\">{caption}</span> : null}\n        <span>{right}</span>\n      </div>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Cell index — precomputed grid-quad corner coordinates (reused every frame).\n// ---------------------------------------------------------------------------\n\ninterface CellIndex {\n  count: number\n  /** For each cell: the four corner grid indices (row-major node indices). */\n  corners: Int32Array\n  /** World x/y of every node (row-major), so only z varies per frame. */\n  worldX: Float32Array\n  worldY: Float32Array\n  cols: number\n  rows: number\n}\n\nfunction buildCellIndex(field: Heightfield): CellIndex {\n  const { cols, rows, minX, minY, dx, dy } = field\n  const worldX = new Float32Array(cols * rows)\n  const worldY = new Float32Array(cols * rows)\n  for (let r = 0; r < rows; r++) {\n    for (let c = 0; c < cols; c++) {\n      const i = r * cols + c\n      worldX[i] = minX + c * dx\n      worldY[i] = minY + r * dy\n    }\n  }\n  const count = (cols - 1) * (rows - 1)\n  const corners = new Int32Array(count * 4)\n  let k = 0\n  for (let r = 0; r < rows - 1; r++) {\n    for (let c = 0; c < cols - 1; c++) {\n      corners[k * 4] = r * cols + c\n      corners[k * 4 + 1] = r * cols + c + 1\n      corners[k * 4 + 2] = (r + 1) * cols + c + 1\n      corners[k * 4 + 3] = (r + 1) * cols + c\n      k++\n    }\n  }\n  return { count, corners, worldX, worldY, cols, rows }\n}\n\n// ---------------------------------------------------------------------------\n// drawScene — the painter's-algorithm render (called each frame).\n// ---------------------------------------------------------------------------\n\ninterface DrawOptions {\n  overlay: StockpileOverlay\n  overlayField: OverlayField\n  tone: string\n  section: import(\"./types\").SectionAxis | null\n  sectionPos: number\n  reposeDeg: number\n  order: Int32Array\n  depths: Float32Array\n  screenX: Float32Array\n  screenY: Float32Array\n}\n\nfunction drawScene(\n  ctx: CanvasRenderingContext2D,\n  camera: Camera,\n  size: { w: number; h: number },\n  field: Heightfield,\n  z: Float32Array,\n  cells: CellIndex,\n  colors: ColorMap,\n  opts: DrawOptions\n) {\n  const projector = makeProjector(camera, field, size.w, size.h)\n  const { project, light } = projector\n\n  drawGround(ctx, projector, field, colors)\n\n  const { corners, worldX, worldY, count } = cells\n  const { order, depths } = opts\n\n  // Project every node once per frame into the caller's scratch buffers.\n  const nodeCount = worldX.length\n  const sx = opts.screenX\n  const sy = opts.screenY\n  for (let i = 0; i < nodeCount; i++) {\n    const [px, py] = project(worldX[i], worldY[i], z[i])\n    sx[i] = px\n    sy[i] = py\n  }\n\n  // Cell depth = mean of its corners' depth key.\n  for (let i = 0; i < count; i++) {\n    const a = corners[i * 4]\n    const b = corners[i * 4 + 1]\n    const c = corners[i * 4 + 2]\n    const d = corners[i * 4 + 3]\n    // Depth from the projector (recompute z-depth cheaply via world midpoint).\n    const mx = (worldX[a] + worldX[c]) * 0.5\n    const my = (worldY[a] + worldY[c]) * 0.5\n    const mz = (z[a] + z[b] + z[c] + z[d]) * 0.25\n    depths[i] = project(mx, my, mz)[2]\n    order[i] = i\n  }\n\n  // Sort back-to-front (ascending depth: far first, near last).\n  sortByDepth(order, depths, count)\n\n  const baseCol = materialColors(colors, opts.tone)\n  const coarse = Math.max(1, Math.round(field.cols / 14))\n\n  for (let oi = 0; oi < count; oi++) {\n    const i = order[oi]\n    const a = corners[i * 4]\n    const b = corners[i * 4 + 1]\n    const c = corners[i * 4 + 2]\n    const d = corners[i * 4 + 3]\n\n    // Face normal from two edge vectors (world space, with z exaggeration).\n    const nx = worldX[a]\n    const ny = worldY[a]\n    const e1x = worldX[b] - nx\n    const e1y = worldY[b] - ny\n    const e1z = (z[b] - z[a]) * Z_EXAGGERATION\n    const e2x = worldX[d] - nx\n    const e2y = worldY[d] - ny\n    const e2z = (z[d] - z[a]) * Z_EXAGGERATION\n    let normX = e1y * e2z - e1z * e2y\n    let normY = e1z * e2x - e1x * e2z\n    let normZ = e1x * e2y - e1y * e2x\n    const nl = Math.hypot(normX, normY, normZ) || 1\n    normX /= nl\n    normY /= nl\n    normZ /= nl\n    if (normZ < 0) {\n      normX = -normX\n      normY = -normY\n      normZ = -normZ\n    }\n    const lambert = Math.max(\n      0.35,\n      normX * light[0] + normY * light[1] + normZ * light[2]\n    )\n\n    // Base fill for this cell (material or overlay ramp).\n    const meanZ = (z[a] + z[b] + z[c] + z[d]) * 0.25\n    let fill: Rgb\n    if (opts.overlayField.scalar) {\n      const s =\n        (opts.overlayField.scalar[a] +\n          opts.overlayField.scalar[b] +\n          opts.overlayField.scalar[c] +\n          opts.overlayField.scalar[d]) *\n        0.25\n      fill = overlayColor(s, opts, colors)\n    } else {\n      // Material: shade the ramp by relative height for a layered look.\n      const t = field.maxZ > 0 ? Math.min(1, meanZ / field.maxZ) : 0\n      fill = mix(baseCol.low, baseCol.high, t)\n    }\n\n    const lit = shade(fill, lambert)\n    ctx.fillStyle = `rgb(${lit.r | 0},${lit.g | 0},${lit.b | 0})`\n    ctx.beginPath()\n    ctx.moveTo(sx[a], sy[a])\n    ctx.lineTo(sx[b], sy[b])\n    ctx.lineTo(sx[c], sy[c])\n    ctx.lineTo(sx[d], sy[d])\n    ctx.closePath()\n    ctx.fill()\n\n    // Coarse survey-mesh stroke: only on a sub-grid, subtle darker edge.\n    const cr = Math.floor(i / (field.cols - 1))\n    const cc = i % (field.cols - 1)\n    if (cr % coarse === 0 || cc % coarse === 0) {\n      const edge = shade(fill, lambert * 0.72)\n      ctx.strokeStyle = `rgba(${edge.r | 0},${edge.g | 0},${edge.b | 0},0.55)`\n      ctx.lineWidth = 0.6\n      ctx.stroke()\n    }\n  }\n\n  if (opts.section) drawSection(ctx, projector, field, z, opts.section, opts.sectionPos, colors)\n  drawNorthArrow(ctx, camera, colors)\n}\n\n/** Overlay colour ramp for a normalised scalar 0–1. */\nfunction overlayColor(s: number, opts: DrawOptions, colors: ColorMap): Rgb {\n  const clampS = Math.min(1, Math.max(0, s))\n  if (opts.overlay === \"elevation\") {\n    return rampSample(\n      [\n        colors[\"--color-utility-blue-300\"],\n        colors[\"--color-utility-green-400\"],\n        colors[\"--color-utility-yellow-400\"],\n        colors[\"--color-utility-red-500\"],\n      ],\n      clampS\n    )\n  }\n  if (opts.overlay === \"slope\") {\n    return rampSample(\n      [\n        colors[\"--color-utility-green-400\"],\n        colors[\"--color-utility-yellow-400\"],\n        colors[\"--color-utility-red-600\"],\n      ],\n      clampS\n    )\n  }\n  if (opts.overlay === \"test-coverage\") {\n    return rampSample(\n      [\n        colors[\"--color-utility-green-400\"],\n        colors[\"--color-utility-yellow-400\"],\n        colors[\"--color-utility-red-500\"],\n      ],\n      clampS\n    )\n  }\n  // cut-fill diverging: red (removed) ↔ neutral ↔ blue (added).\n  return rampSample(\n    [\n      colors[\"--color-utility-red-500\"],\n      colors[\"--color-bg-secondary\"],\n      colors[\"--color-utility-blue-500\"],\n    ],\n    clampS\n  )\n}\n\nfunction rampSample(stops: (Rgb | undefined)[], t: number): Rgb {\n  const clean = stops.map((s) => s ?? { r: 150, g: 150, b: 150 })\n  if (clean.length === 1) return clean[0]\n  const seg = t * (clean.length - 1)\n  const i = Math.min(clean.length - 2, Math.floor(seg))\n  return mix(clean[i], clean[i + 1], seg - i)\n}\n\ninterface MaterialColors {\n  low: Rgb\n  high: Rgb\n}\nfunction materialColors(colors: ColorMap, tone: string): MaterialColors {\n  const ramp = TONE_RAMP[tone] ?? \"orange\"\n  return {\n    low: colors[`--color-utility-${ramp}-300`] ?? { r: 190, g: 150, b: 110 },\n    high: colors[`--color-utility-${ramp}-700`] ?? { r: 120, g: 80, b: 40 },\n  }\n}\n\n// --- Ground plane: soft radial-faded disc + faint grid ---------------------\n\nfunction drawGround(\n  ctx: CanvasRenderingContext2D,\n  projector: Projector,\n  field: Heightfield,\n  colors: ColorMap\n) {\n  const { project } = projector\n  const pad = (field.maxX - field.minX) * 0.18\n  const x0 = field.minX - pad\n  const x1 = field.maxX + pad\n  const y0 = field.minY - pad\n  const y1 = field.maxY + pad\n\n  // Faded ground disc via a radial-ish polygon at z = 0.\n  const steps = 48\n  const cx = (x0 + x1) / 2\n  const cy = (y0 + y1) / 2\n  const rx = (x1 - x0) / 2\n  const ry = (y1 - y0) / 2\n  ctx.beginPath()\n  for (let i = 0; i <= steps; i++) {\n    const a = (i / steps) * Math.PI * 2\n    const wx = cx + Math.cos(a) * rx\n    const wy = cy + Math.sin(a) * ry\n    const [px, py] = project(wx, wy, 0)\n    if (i === 0) ctx.moveTo(px, py)\n    else ctx.lineTo(px, py)\n  }\n  ctx.closePath()\n  const [ccx, ccy] = project(cx, cy, 0)\n  const grd = ctx.createRadialGradient(ccx, ccy, 4, ccx, ccy, Math.max(rx, ry) * 22)\n  const surf = colors[\"--color-bg-primary\"] ?? { r: 245, g: 245, b: 248 }\n  grd.addColorStop(0, `rgba(${surf.r},${surf.g},${surf.b},0.55)`)\n  grd.addColorStop(1, `rgba(${surf.r},${surf.g},${surf.b},0)`)\n  ctx.fillStyle = grd\n  ctx.fill()\n\n  // Faint concentric grid rings for a survey-plane feel.\n  const border = colors[\"--color-border-secondary\"] ?? { r: 200, g: 200, b: 206 }\n  ctx.strokeStyle = `rgba(${border.r},${border.g},${border.b},0.4)`\n  ctx.lineWidth = 0.5\n  for (let ring = 1; ring <= 3; ring++) {\n    const f = ring / 3\n    ctx.beginPath()\n    for (let i = 0; i <= steps; i++) {\n      const a = (i / steps) * Math.PI * 2\n      const wx = cx + Math.cos(a) * rx * f\n      const wy = cy + Math.sin(a) * ry * f\n      const [px, py] = project(wx, wy, 0)\n      if (i === 0) ctx.moveTo(px, py)\n      else ctx.lineTo(px, py)\n    }\n    ctx.closePath()\n    ctx.stroke()\n  }\n}\n\n// --- North arrow -----------------------------------------------------------\n\nfunction drawNorthArrow(\n  ctx: CanvasRenderingContext2D,\n  camera: Camera,\n  colors: ColorMap\n) {\n  // Position the compass in the top-left; rotate the needle by yaw so it tracks\n  // where north points on the projected ground.\n  const ox = 34\n  const oy = 34\n  const r = 16\n  const fg = colors[\"--color-fg-tertiary\"] ?? { r: 100, g: 100, b: 110 }\n  const brand = colors[\"--color-utility-brand-500\"] ?? { r: 42, g: 45, b: 120 }\n\n  ctx.save()\n  ctx.translate(ox, oy)\n  ctx.strokeStyle = `rgba(${fg.r},${fg.g},${fg.b},0.5)`\n  ctx.lineWidth = 1\n  ctx.beginPath()\n  ctx.arc(0, 0, r, 0, Math.PI * 2)\n  ctx.stroke()\n\n  // North in world = +y; project its screen direction under the current yaw.\n  const angle = -camera.yaw\n  const nx = Math.sin(angle)\n  const ny = -Math.cos(angle) * Math.cos(camera.pitch)\n  const nl = Math.hypot(nx, ny) || 1\n  const ux = nx / nl\n  const uy = ny / nl\n\n  ctx.fillStyle = `rgb(${brand.r},${brand.g},${brand.b})`\n  ctx.beginPath()\n  ctx.moveTo(ux * r, uy * r)\n  ctx.lineTo(-uy * 4 - ux * 4, ux * 4 - uy * 4)\n  ctx.lineTo(uy * 4 - ux * 4, -ux * 4 - uy * 4)\n  ctx.closePath()\n  ctx.fill()\n\n  ctx.fillStyle = `rgba(${fg.r},${fg.g},${fg.b},0.9)`\n  ctx.font = \"600 9px system-ui, sans-serif\"\n  ctx.textAlign = \"center\"\n  ctx.textBaseline = \"middle\"\n  ctx.fillText(\"N\", ux * (r + 7), uy * (r + 7))\n  ctx.restore()\n}\n\n// --- Section line on the surface ------------------------------------------\n\nfunction drawSection(\n  ctx: CanvasRenderingContext2D,\n  projector: Projector,\n  field: Heightfield,\n  z: Float32Array,\n  axis: import(\"./types\").SectionAxis,\n  pos: number,\n  colors: ColorMap\n) {\n  const { project } = projector\n  const brand = colors[\"--color-utility-brand-600\"] ?? { r: 18, g: 21, b: 65 }\n  ctx.strokeStyle = `rgb(${brand.r},${brand.g},${brand.b})`\n  ctx.lineWidth = 1.75\n  ctx.setLineDash([5, 3])\n  ctx.beginPath()\n  const samples = 80\n  for (let i = 0; i <= samples; i++) {\n    const t = i / samples\n    let wx: number\n    let wy: number\n    if (axis === \"ns\") {\n      wx = field.minX + (field.maxX - field.minX) * pos\n      wy = field.minY + (field.maxY - field.minY) * t\n    } else {\n      wx = field.minX + (field.maxX - field.minX) * t\n      wy = field.minY + (field.maxY - field.minY) * pos\n    }\n    const h = sampleHeight({ ...field, z }, wx, wy)\n    const [px, py] = project(wx, wy, h)\n    if (i === 0) ctx.moveTo(px, py)\n    else ctx.lineTo(px, py)\n  }\n  ctx.stroke()\n  ctx.setLineDash([])\n}\n\n// --- Occlusion approximation ----------------------------------------------\n\nfunction isOccluded(\n  field: Heightfield,\n  x: number,\n  y: number,\n  z: number,\n  camDir: { x: number; y: number }\n): boolean {\n  // March a few steps from the point toward the camera. If the intervening\n  // surface rises above the (upward-sloping) sight line to the raised camera,\n  // the marker is behind the crest — dim it.\n  const steps = 6\n  const reach = (field.maxY - field.minY) * 0.16\n  const len = Math.hypot(camDir.x, camDir.y) || 1\n  const dx = (camDir.x / len) * reach\n  const dy = (camDir.y / len) * reach\n  for (let i = 1; i <= steps; i++) {\n    const f = i / steps\n    const sh = sampleHeight(field, x + dx * f, y + dy * f)\n    const sight = z + field.maxZ * 0.18 * f\n    if (sh > sight + 0.15) return true\n  }\n  return false\n}\n\n// ---------------------------------------------------------------------------\n// Depth sort — insertion sort is fine for the near-sorted frame-to-frame case,\n// but a simple in-place quicksort on the index array keeps the worst case fast.\n// ---------------------------------------------------------------------------\n\nfunction sortByDepth(order: Int32Array, depths: Float32Array, count: number) {\n  quicksort(order, depths, 0, count - 1)\n}\n\nfunction quicksort(order: Int32Array, depths: Float32Array, lo: number, hi: number) {\n  while (lo < hi) {\n    if (hi - lo < 24) {\n      // Insertion sort for small ranges (and near-sorted frames).\n      for (let i = lo + 1; i <= hi; i++) {\n        const v = order[i]\n        const dv = depths[v]\n        let j = i - 1\n        while (j >= lo && depths[order[j]] > dv) {\n          order[j + 1] = order[j]\n          j--\n        }\n        order[j + 1] = v\n      }\n      return\n    }\n    const mid = (lo + hi) >> 1\n    const pivot = depths[order[mid]]\n    let i = lo\n    let j = hi\n    while (i <= j) {\n      while (depths[order[i]] < pivot) i++\n      while (depths[order[j]] > pivot) j--\n      if (i <= j) {\n        const tmp = order[i]\n        order[i] = order[j]\n        order[j] = tmp\n        i++\n        j--\n      }\n    }\n    // Recurse into the smaller side, loop on the larger (bounded stack).\n    if (j - lo < hi - i) {\n      quicksort(order, depths, lo, j)\n      lo = i\n    } else {\n      quicksort(order, depths, i, hi)\n      hi = j\n    }\n  }\n}\n\nfunction easeOutCubic(t: number): number {\n  const p = 1 - t\n  return 1 - p * p * p\n}\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return Math.min(hi, Math.max(lo, v))\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/stockpile-visualiser/stockpile-scene.tsx"
    },
    {
      "path": "components/blocks/domain/stockpile-visualiser/stockpile-visualiser.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport {\n  AlertTriangle,\n  Cube01,\n  PauseCircle,\n  PlayCircle,\n  Ruler,\n  Scales01,\n  ScissorsCut01,\n  Triangle,\n} from \"@untitledui/icons\"\nimport { useReducedMotion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipProvider,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\"\nimport { ToggleGroup, ToggleGroupItem } from \"@/components/ui/toggle-group\"\nimport { Button } from \"@/components/ui/button\"\n\nimport {\n  buildHeightfield,\n  computeCutFill,\n  computeSection,\n  computeSlopeDeg,\n  computeTestCoverage,\n  computeVolumeM3,\n  hasSurface,\n  resolveMaxHeightM,\n} from \"./stockpile-geometry\"\nimport { StockpilePanel } from \"./stockpile-panel\"\nimport { StockpileScene } from \"./stockpile-scene\"\nimport type {\n  SectionAxis,\n  SoilHandlingClass,\n  Stockpile,\n  StockpileClassification,\n  StockpileOverlay,\n  StockpileVisualiserProps,\n  TestLocation,\n  TestStatus,\n} from \"./types\"\n\n// ---------------------------------------------------------------------------\n// Meta tables keyed by union types (chain-of-custody idiom).\n// ---------------------------------------------------------------------------\n\nconst OVERLAY_META: Record<\n  StockpileOverlay,\n  { label: string; short: string }\n> = {\n  material: { label: \"Material\", short: \"Material\" },\n  elevation: { label: \"Elevation\", short: \"Elev.\" },\n  slope: { label: \"Slope / repose\", short: \"Slope\" },\n  \"test-coverage\": { label: \"Test coverage\", short: \"Coverage\" },\n  \"cut-fill\": { label: \"Cut / fill\", short: \"Cut/fill\" },\n}\n\nconst OVERLAY_ORDER: StockpileOverlay[] = [\n  \"material\",\n  \"elevation\",\n  \"slope\",\n  \"test-coverage\",\n  \"cut-fill\",\n]\n\nconst CLASSIFICATION_META: Record<\n  StockpileClassification,\n  { label: string; chip: string }\n> = {\n  unclassified: {\n    label: \"Unclassified\",\n    chip: \"bg-bg-secondary text-text-tertiary ring-border-secondary\",\n  },\n  inert: {\n    label: \"Inert\",\n    chip: \"bg-utility-green-50 text-utility-green-700 ring-utility-green-200\",\n  },\n  \"non-hazardous\": {\n    label: \"Non-hazardous\",\n    chip: \"bg-utility-blue-50 text-utility-blue-700 ring-utility-blue-200\",\n  },\n  hazardous: {\n    label: \"Hazardous\",\n    chip: \"bg-utility-red-50 text-utility-red-700 ring-utility-red-200\",\n  },\n}\n\nconst SHU_META: Record<\n  SoilHandlingClass,\n  { maxHeightM: number; note: string }\n> = {\n  A: { maxHeightM: 5, note: \"High resilience — stockpile up to 5 m.\" },\n  B: { maxHeightM: 4, note: \"Moderate resilience — stockpile up to 4 m.\" },\n  C: { maxHeightM: 3, note: \"Low resilience — stockpile up to 3 m, minimise storage time.\" },\n}\n\nconst STATUS_LABEL: Record<NonNullable<Stockpile[\"status\"]>, string> = {\n  active: \"Active\",\n  reserved: \"Reserved\",\n  depleted: \"Depleted\",\n  inactive: \"Inactive\",\n}\n\n// ---------------------------------------------------------------------------\n// Count-up hook — animates a number toward its target, instant on reduced motion.\n// ---------------------------------------------------------------------------\n\nfunction useCountUp(target: number, reduce: boolean | null): number {\n  const [value, setValue] = React.useState(target)\n  const fromRef = React.useRef(target)\n  const rafRef = React.useRef(0)\n\n  React.useEffect(() => {\n    if (reduce) {\n      fromRef.current = target\n      setValue(target)\n      return\n    }\n    const from = fromRef.current\n    if (from === target) return\n    const start = performance.now()\n    const dur = 700\n    const tick = (now: number) => {\n      const t = Math.min(1, (now - start) / dur)\n      const e = 1 - Math.pow(1 - t, 3)\n      const next = from + (target - from) * e\n      setValue(next)\n      if (t < 1) {\n        rafRef.current = requestAnimationFrame(tick)\n      } else {\n        fromRef.current = target\n      }\n    }\n    rafRef.current = requestAnimationFrame(tick)\n    return () => cancelAnimationFrame(rafRef.current)\n  }, [target, reduce])\n\n  return value\n}\n\n// ---------------------------------------------------------------------------\n// The main component.\n// ---------------------------------------------------------------------------\n\n/**\n * StockpileVisualiser — the flagship soil-stockpile survey tool. A rotatable,\n * zoomable 3D heightfield of the surveyed pile with pinned test locations, live\n * volume/tonnage, height and angle-of-repose compliance, and analysis overlays\n * (elevation, slope, test coverage, cut/fill vs a previous survey). Everything\n * animates smoothly on data change — the showcase drives it with a live\n * simulation. Props-driven; no backend coupling.\n */\nexport function StockpileVisualiser({\n  stockpile,\n  overlay: overlayProp,\n  defaultOverlay = \"material\",\n  onOverlayChange,\n  onTestSelect,\n  showPanel = true,\n  compact = false,\n  playing,\n  onPlayingChange,\n  className,\n}: StockpileVisualiserProps) {\n  const reduce = useReducedMotion()\n\n  // Controlled / uncontrolled overlay.\n  const [overlayState, setOverlayState] =\n    React.useState<StockpileOverlay>(defaultOverlay)\n  const overlay = overlayProp ?? overlayState\n  const setOverlay = (next: StockpileOverlay) => {\n    if (overlayProp === undefined) setOverlayState(next)\n    onOverlayChange?.(next)\n  }\n\n  const [selectedTestId, setSelectedTestId] = React.useState<string | null>(null)\n  const handleTestSelect = React.useCallback(\n    (test: TestLocation) => {\n      setSelectedTestId(test.id)\n      onTestSelect?.(test)\n    },\n    [onTestSelect]\n  )\n\n  // Cross-section tool.\n  const [section, setSection] = React.useState<SectionAxis | null>(null)\n  const [sectionPos, setSectionPos] = React.useState(0.5)\n\n  // --- Analysis (memoised on the current survey) ---------------------------\n  const surfaced = hasSurface(stockpile.survey)\n  const analysis = React.useMemo(() => {\n    const field = buildHeightfield(stockpile.survey)\n    const volume = computeVolumeM3(field, stockpile.material.densityTPerM3)\n    const slope = computeSlopeDeg(field, stockpile.material.angleOfReposeDeg)\n    const coverage = computeTestCoverage(\n      field,\n      stockpile.tests.map((t) => ({ x: t.x, y: t.y })),\n      volume.volumeM3\n    )\n    const cutFill = stockpile.previousSurvey\n      ? computeCutFill(field, stockpile.previousSurvey, stockpile.survey)\n      : null\n    return { field, volume, slope, coverage, cutFill }\n  }, [\n    stockpile.survey,\n    stockpile.material.densityTPerM3,\n    stockpile.material.angleOfReposeDeg,\n    stockpile.tests,\n    stockpile.previousSurvey,\n  ])\n\n  const maxHeightM = resolveMaxHeightM(stockpile.maxHeightM, stockpile.shuClass)\n  const breached =\n    maxHeightM != null && analysis.volume.maxHeightM > maxHeightM + 0.01\n  const reposeExceeded = analysis.slope.exceedFraction > 0.03\n\n  // Animated readouts.\n  const volume = useCountUp(analysis.volume.volumeM3, reduce)\n  const tonnes = useCountUp(analysis.volume.tonnes, reduce)\n  const maxH = useCountUp(analysis.volume.maxHeightM, reduce)\n\n  const capacityPct =\n    stockpile.capacityM3 && stockpile.capacityM3 > 0\n      ? Math.min(100, (analysis.volume.volumeM3 / stockpile.capacityM3) * 100)\n      : null\n\n  // Test tally.\n  const tally = React.useMemo(() => countTests(stockpile.tests), [stockpile.tests])\n\n  const sectionProfile = React.useMemo(\n    () => (section ? computeSection(analysis.field, section, sectionPos) : null),\n    [section, analysis.field, sectionPos]\n  )\n\n  const controlledPlaying = playing !== undefined\n  const isPlaying = controlledPlaying ? playing : false\n\n  if (!surfaced) {\n    return (\n      <EmptyState className={className} name={stockpile.name} />\n    )\n  }\n\n  const scene = (\n    <StockpileScene\n      stockpile={stockpile}\n      overlay={overlay}\n      section={section}\n      sectionPos={sectionPos}\n      onTestSelect={handleTestSelect}\n      selectedTestId={selectedTestId}\n    />\n  )\n\n  // Compact embed: header-less, panel-less, just the scene + a thin footer.\n  if (compact) {\n    return (\n      <div\n        className={cn(\n          \"flex flex-col gap-2 rounded-xl border border-border bg-card p-3 shadow-xs\",\n          className\n        )}\n      >\n        <div className=\"flex items-center justify-between gap-2\">\n          <span className=\"truncate font-mono text-xs font-medium text-text-secondary\">\n            {stockpile.name}\n          </span>\n          <ClassificationChip classification={stockpile.classification} />\n        </div>\n        <div className=\"relative aspect-[4/3] w-full overflow-hidden rounded-lg\">\n          {scene}\n        </div>\n        <div className=\"flex items-center justify-between text-xs\">\n          <span className=\"font-semibold text-foreground tabular-nums\">\n            {formatVolume(volume)} m³\n          </span>\n          <span className=\"text-text-tertiary tabular-nums\">\n            {formatTonnes(tonnes)} t · {maxH.toFixed(1)} m\n          </span>\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <TooltipProvider delay={250}>\n      <div\n        className={cn(\n          \"flex flex-col gap-4 rounded-xl border border-border bg-card p-4 shadow-xs sm:p-5\",\n          className\n        )}\n      >\n        {/* Header: identity + compliance strip */}\n        <header className=\"flex flex-col gap-3\">\n          <div className=\"flex flex-wrap items-start justify-between gap-3\">\n            <div className=\"min-w-0\">\n              <div className=\"flex items-center gap-2\">\n                <h3 className=\"truncate font-mono text-sm font-semibold text-foreground\">\n                  {stockpile.name}\n                </h3>\n                {stockpile.status ? (\n                  <span className=\"rounded-full bg-bg-secondary px-2 py-0.5 text-[11px] font-medium text-text-tertiary\">\n                    {STATUS_LABEL[stockpile.status]}\n                  </span>\n                ) : null}\n              </div>\n              <p className=\"mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs text-text-tertiary\">\n                <span>{stockpile.material.label}</span>\n                {stockpile.gridRef ? (\n                  <>\n                    <span aria-hidden=\"true\">·</span>\n                    <span className=\"font-mono\">{stockpile.gridRef}</span>\n                  </>\n                ) : null}\n              </p>\n            </div>\n\n            <ComplianceStrip\n              stockpile={stockpile}\n              tally={tally}\n              reduce={reduce}\n            />\n          </div>\n\n          {/* Banners */}\n          {breached && maxHeightM != null ? (\n            <Banner tone=\"error\" icon={AlertTriangle}>\n              Peak of {analysis.volume.maxHeightM.toFixed(1)} m breaches the{\" \"}\n              {maxHeightM} m maximum height\n              {stockpile.shuClass ? ` (SHU class ${stockpile.shuClass})` : \"\"}.\n            </Banner>\n          ) : null}\n          {reposeExceeded && analysis.slope.worstOctant ? (\n            <Banner tone=\"warning\" icon={Triangle}>\n              Faces exceed the {stockpile.material.angleOfReposeDeg}° angle of\n              repose on the {octantName(analysis.slope.worstOctant)} flank (\n              {Math.round(analysis.slope.exceedFraction * 100)}% of the face).\n            </Banner>\n          ) : null}\n        </header>\n\n        {/* Toolbar */}\n        <div className=\"flex flex-wrap items-center gap-2\">\n          <ToggleGroup\n            value={[overlay]}\n            onValueChange={(value: string[]) => {\n              const next = value[0] as StockpileOverlay | undefined\n              if (next) setOverlay(next)\n            }}\n            variant=\"outline\"\n            spacing={0}\n            aria-label=\"Surface overlay\"\n          >\n            {OVERLAY_ORDER.map((key) => {\n              // Hide cut/fill when there is no previous survey to compare against.\n              if (key === \"cut-fill\" && !stockpile.previousSurvey) return null\n              return (\n                <ToggleGroupItem\n                  key={key}\n                  value={key}\n                  aria-label={OVERLAY_META[key].label}\n                  className=\"px-2.5 text-xs\"\n                >\n                  {OVERLAY_META[key].short}\n                </ToggleGroupItem>\n              )\n            })}\n          </ToggleGroup>\n\n          <div className=\"flex items-center gap-1\">\n            <SectionToggle axis=\"ns\" active={section === \"ns\"} onToggle={setSection} />\n            <SectionToggle axis=\"ew\" active={section === \"ew\"} onToggle={setSection} />\n          </div>\n\n          {controlledPlaying ? (\n            <Tooltip>\n              <TooltipTrigger\n                render={\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    onClick={() => onPlayingChange?.(!isPlaying)}\n                    aria-label={isPlaying ? \"Pause live updates\" : \"Resume live updates\"}\n                    aria-pressed={isPlaying}\n                    className=\"ml-auto gap-1.5\"\n                  >\n                    {isPlaying ? (\n                      <PauseCircle className=\"size-3.5\" />\n                    ) : (\n                      <PlayCircle className=\"size-3.5\" />\n                    )}\n                    {isPlaying ? \"Live\" : \"Paused\"}\n                  </Button>\n                }\n              />\n              <TooltipContent>\n                {isPlaying ? \"Pause the survey feed\" : \"Resume the survey feed\"}\n              </TooltipContent>\n            </Tooltip>\n          ) : null}\n        </div>\n\n        {/* Scene + panel */}\n        <div\n          className={cn(\n            \"grid gap-4\",\n            showPanel ? \"lg:grid-cols-[1fr_18rem]\" : \"grid-cols-1\"\n          )}\n        >\n          <div className=\"flex flex-col gap-3\">\n            <div className=\"relative aspect-[16/10] w-full overflow-hidden rounded-xl sm:aspect-[16/9]\">\n              {scene}\n            </div>\n\n            {/* Gauges row */}\n            <div className=\"grid grid-cols-1 gap-3 sm:grid-cols-3\">\n              <Gauge\n                icon={Cube01}\n                label=\"Volume\"\n                value={`${formatVolume(volume)}`}\n                unit=\"m³\"\n                sub={\n                  capacityPct != null && stockpile.capacityM3\n                    ? `${formatVolume(analysis.volume.volumeM3)} of ${formatVolume(stockpile.capacityM3)} m³`\n                    : undefined\n                }\n                pct={capacityPct}\n                tooltip={`Integrated from the ${methodLabel(stockpile)} survey of ${formatDate(stockpile.survey.takenAt)}.`}\n              />\n              <Gauge\n                icon={Scales01}\n                label=\"Tonnage\"\n                value={formatTonnes(tonnes)}\n                unit=\"t\"\n                sub={`at ${stockpile.material.densityTPerM3} t/m³ (loose)`}\n                tooltip={`Tonnage = volume × bulk density (${stockpile.material.densityTPerM3} t/m³). Density is a loose assumption — confirm against a compaction test for haulage.`}\n              />\n              <Gauge\n                icon={Ruler}\n                label=\"Max height\"\n                value={maxH.toFixed(1)}\n                unit=\"m\"\n                sub={\n                  maxHeightM != null\n                    ? `limit ${maxHeightM} m${breached ? \" · breached\" : \"\"}`\n                    : undefined\n                }\n                pct={maxHeightM != null ? Math.min(100, (analysis.volume.maxHeightM / maxHeightM) * 100) : null}\n                danger={breached}\n                tooltip={\n                  maxHeightM != null\n                    ? `Permitted maximum ${maxHeightM} m${stockpile.shuClass ? ` from SHU class ${stockpile.shuClass}` : \"\"}.`\n                    : \"No permitted height set.\"\n                }\n              />\n            </div>\n\n            {/* Overlay-specific readout */}\n            <OverlayReadout\n              overlay={overlay}\n              stockpile={stockpile}\n              analysis={analysis}\n            />\n\n            {/* Cross-section profile */}\n            {sectionProfile ? (\n              <SectionProfileStrip\n                profile={sectionProfile}\n                maxHeightM={maxHeightM}\n                reposeDeg={stockpile.material.angleOfReposeDeg}\n                pos={sectionPos}\n                onPos={setSectionPos}\n              />\n            ) : null}\n          </div>\n\n          {showPanel ? (\n            <StockpilePanel\n              stockpile={stockpile}\n              selectedTestId={selectedTestId}\n              onTestSelect={handleTestSelect}\n              className=\"lg:border-l lg:border-border-secondary lg:pl-4\"\n            />\n          ) : null}\n        </div>\n      </div>\n    </TooltipProvider>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Compliance strip — classification / EWC / SHU / test tally chips.\n// ---------------------------------------------------------------------------\n\nfunction ComplianceStrip({\n  stockpile,\n  tally,\n  reduce,\n}: {\n  stockpile: Stockpile\n  tally: TestTally\n  reduce: boolean | null\n}) {\n  return (\n    <div className=\"flex flex-wrap items-center gap-1.5\">\n      <ClassificationChip classification={stockpile.classification} />\n      {stockpile.ewcCode ? (\n        <Chip className=\"bg-bg-secondary text-text-tertiary ring-border-secondary font-mono\">\n          EWC {stockpile.ewcCode}\n        </Chip>\n      ) : null}\n      {stockpile.shuClass ? (\n        <Tooltip>\n          <TooltipTrigger\n            render={\n              <button\n                type=\"button\"\n                className=\"inline-flex items-center gap-1 rounded-full bg-utility-brand-50 px-2 py-0.5 text-[11px] font-medium text-utility-brand-700 ring-1 ring-utility-brand-200 ring-inset\"\n              >\n                SHU {stockpile.shuClass}\n              </button>\n            }\n          />\n          <TooltipContent className=\"max-w-56 text-left font-normal\">\n            {SHU_META[stockpile.shuClass].note} Defra Code of Practice / BBV §5.3.\n          </TooltipContent>\n        </Tooltip>\n      ) : null}\n      <TestTallyChip tally={tally} reduce={reduce} />\n    </div>\n  )\n}\n\nfunction ClassificationChip({\n  classification,\n}: {\n  classification: StockpileClassification\n}) {\n  const meta = CLASSIFICATION_META[classification]\n  return <Chip className={meta.chip}>{meta.label}</Chip>\n}\n\nfunction Chip({\n  children,\n  className,\n}: {\n  children: React.ReactNode\n  className?: string\n}) {\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset\",\n        className\n      )}\n    >\n      {children}\n    </span>\n  )\n}\n\nfunction TestTallyChip({\n  tally,\n  reduce,\n}: {\n  tally: TestTally\n  reduce: boolean | null\n}) {\n  const parts: string[] = []\n  if (tally.pass) parts.push(`${tally.pass} pass`)\n  if (tally.fail) parts.push(`${tally.fail} fail`)\n  if (tally.atLab) parts.push(`${tally.atLab} at lab`)\n  if (tally.attention) parts.push(`${tally.attention} attention`)\n  const label = parts.length > 0 ? parts.join(\" · \") : \"No results yet\"\n  return (\n    <Chip\n      className={cn(\n        \"bg-bg-secondary text-text-secondary ring-border-secondary\",\n        tally.fail > 0 && \"text-utility-red-700 ring-utility-red-200 bg-utility-red-50\",\n        tally.fail > 0 && !reduce && \"motion-safe:animate-pulse\"\n      )}\n    >\n      {label}\n    </Chip>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Banner\n// ---------------------------------------------------------------------------\n\nfunction Banner({\n  tone,\n  icon: Icon,\n  children,\n}: {\n  tone: \"error\" | \"warning\"\n  icon: React.ComponentType<{ className?: string }>\n  children: React.ReactNode\n}) {\n  return (\n    <div\n      role=\"status\"\n      aria-live=\"polite\"\n      className={cn(\n        \"flex items-start gap-2 rounded-lg px-3 py-2 text-xs font-medium ring-1 ring-inset\",\n        tone === \"error\"\n          ? \"bg-utility-red-50 text-utility-red-700 ring-utility-red-200\"\n          : \"bg-utility-yellow-50 text-utility-yellow-700 ring-utility-yellow-200\"\n      )}\n    >\n      <Icon className=\"mt-px size-4 shrink-0\" aria-hidden=\"true\" />\n      <span>{children}</span>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Gauge\n// ---------------------------------------------------------------------------\n\nfunction Gauge({\n  icon: Icon,\n  label,\n  value,\n  unit,\n  sub,\n  pct,\n  danger,\n  tooltip,\n}: {\n  icon: React.ComponentType<{ className?: string }>\n  label: string\n  value: string\n  unit: string\n  sub?: string\n  pct?: number | null\n  danger?: boolean\n  tooltip?: string\n}) {\n  const body = (\n    <div className=\"flex flex-col gap-1.5 rounded-xl bg-bg-secondary px-3 py-2.5\">\n      <div className=\"flex items-center gap-1.5 text-[11px] font-medium tracking-wide text-fg-quaternary uppercase\">\n        <Icon className=\"size-3.5\" aria-hidden=\"true\" />\n        {label}\n      </div>\n      <div className=\"flex items-baseline gap-1\">\n        <span\n          className={cn(\n            \"text-xl font-semibold tabular-nums\",\n            danger ? \"text-utility-red-600\" : \"text-foreground\"\n          )}\n        >\n          {value}\n        </span>\n        <span className=\"text-xs font-medium text-fg-quaternary\">{unit}</span>\n      </div>\n      {typeof pct === \"number\" ? (\n        <div className=\"h-1.5 w-full overflow-hidden rounded-full bg-bg-quaternary\">\n          <div\n            className={cn(\n              \"h-full rounded-full transition-[width] duration-500\",\n              danger ? \"bg-utility-red-500\" : \"bg-primary\"\n            )}\n            // Fixed precision: the browser's CSSOM re-serialises long floats,\n            // which reads as a server/client hydration mismatch.\n            style={{ width: `${Math.min(100, Math.max(2, pct)).toFixed(2)}%` }}\n          />\n        </div>\n      ) : null}\n      {sub ? (\n        <p className=\"text-[11px] text-text-tertiary tabular-nums\">{sub}</p>\n      ) : null}\n    </div>\n  )\n\n  if (!tooltip) return body\n  return (\n    <Tooltip>\n      <TooltipTrigger render={<div tabIndex={0} aria-label={`${label}: ${value} ${unit}. ${tooltip}`}>{body}</div>} />\n      <TooltipContent className=\"max-w-60 text-left font-normal\">\n        {tooltip}\n      </TooltipContent>\n    </Tooltip>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Section toggle button\n// ---------------------------------------------------------------------------\n\nfunction SectionToggle({\n  axis,\n  active,\n  onToggle,\n}: {\n  axis: SectionAxis\n  active: boolean\n  onToggle: (axis: SectionAxis | null) => void\n}) {\n  const label = axis === \"ns\" ? \"North–south section\" : \"East–west section\"\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        render={\n          <Button\n            variant={active ? \"default\" : \"outline\"}\n            size=\"sm\"\n            aria-pressed={active}\n            aria-label={label}\n            onClick={() => onToggle(active ? null : axis)}\n            className=\"gap-1 px-2.5 text-xs\"\n          >\n            <ScissorsCut01 className=\"size-3.5\" />\n            {axis === \"ns\" ? \"N–S\" : \"E–W\"}\n          </Button>\n        }\n      />\n      <TooltipContent>{label}</TooltipContent>\n    </Tooltip>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Overlay-specific readout line\n// ---------------------------------------------------------------------------\n\ninterface AnalysisBundle {\n  field: import(\"./types\").Heightfield\n  volume: import(\"./types\").VolumeResult\n  slope: import(\"./types\").SlopeResult\n  coverage: {\n    maxNearest: number\n    volumePerSample: number\n    nearest: Float32Array\n  }\n  cutFill: import(\"./types\").CutFillResult | null\n}\n\nfunction OverlayReadout({\n  overlay,\n  stockpile,\n  analysis,\n}: {\n  overlay: StockpileOverlay\n  stockpile: Stockpile\n  analysis: AnalysisBundle\n}) {\n  if (overlay === \"slope\") {\n    const pct = Math.round(analysis.slope.exceedFraction * 100)\n    return (\n      <ReadoutLine\n        label=\"Face stability\"\n        value={`Peak slope ${analysis.slope.maxSlopeDeg.toFixed(0)}° vs ${stockpile.material.angleOfReposeDeg}° repose`}\n        status={pct > 3 ? \"warn\" : \"ok\"}\n        detail={pct > 0 ? `${pct}% of the face over repose` : \"Within the design angle\"}\n      />\n    )\n  }\n  if (overlay === \"test-coverage\") {\n    const target = 500\n    const per = analysis.coverage.volumePerSample\n    const short = per > target\n    return (\n      <ReadoutLine\n        label=\"Test coverage\"\n        value={\n          Number.isFinite(per)\n            ? `1 sample per ${formatVolume(per)} m³`\n            : \"No samples\"\n        }\n        status={short ? \"warn\" : \"ok\"}\n        detail={`target 1 per ${target} m³`}\n      />\n    )\n  }\n  if (overlay === \"cut-fill\" && analysis.cutFill) {\n    const net = analysis.cutFill.netM3\n    return (\n      <ReadoutLine\n        label=\"Movement\"\n        value={`${net >= 0 ? \"+\" : \"−\"}${formatVolume(Math.abs(net))} m³ net`}\n        status=\"info\"\n        detail={\n          stockpile.previousSurvey\n            ? `since ${formatDate(stockpile.previousSurvey.takenAt)} survey`\n            : undefined\n        }\n      />\n    )\n  }\n  if (overlay === \"elevation\") {\n    return (\n      <ReadoutLine\n        label=\"Elevation\"\n        value={`0 – ${analysis.field.maxZ.toFixed(1)} m above toe`}\n        status=\"info\"\n        detail={`base datum ${analysis.field.baseZ.toFixed(1)} m AOD`}\n      />\n    )\n  }\n  return null\n}\n\nfunction ReadoutLine({\n  label,\n  value,\n  status,\n  detail,\n}: {\n  label: string\n  value: string\n  status: \"ok\" | \"warn\" | \"info\"\n  detail?: string\n}) {\n  return (\n    <div className=\"flex flex-wrap items-center gap-x-2 gap-y-0.5 rounded-lg bg-bg-secondary px-3 py-2 text-xs\">\n      <span className=\"font-medium text-fg-tertiary\">{label}</span>\n      <span\n        className={cn(\n          \"font-semibold tabular-nums\",\n          status === \"warn\" && \"text-utility-yellow-700\",\n          status === \"ok\" && \"text-utility-green-700\",\n          status === \"info\" && \"text-text-secondary\"\n        )}\n      >\n        {value}\n      </span>\n      {detail ? (\n        <span className=\"text-fg-quaternary\">· {detail}</span>\n      ) : null}\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Cross-section profile strip (inline SVG)\n// ---------------------------------------------------------------------------\n\nfunction SectionProfileStrip({\n  profile,\n  maxHeightM,\n  reposeDeg,\n  pos,\n  onPos,\n}: {\n  profile: import(\"./stockpile-geometry\").SectionProfile\n  maxHeightM: number | null\n  reposeDeg: number\n  pos: number\n  onPos: (pos: number) => void\n}) {\n  const W = 640\n  const H = 128\n  const padX = 12\n  const padY = 12\n  const innerW = W - padX * 2\n  const innerH = H - padY * 2\n\n  const peak = Math.max(profile.peak, maxHeightM ?? 0, 1)\n  const yScale = innerH / (peak * 1.15)\n  const xScale = innerW / (profile.length || 1)\n\n  const toX = (d: number) => padX + d * xScale\n  const toY = (h: number) => H - padY - h * yScale\n\n  // Surface polyline + filled area.\n  let surfacePath = \"\"\n  let areaPath = `M ${toX(0)} ${H - padY} `\n  profile.distance.forEach((d, i) => {\n    const cmd = i === 0 ? \"M\" : \"L\"\n    surfacePath += `${cmd} ${toX(d).toFixed(1)} ${toY(profile.height[i]).toFixed(1)} `\n    areaPath += `L ${toX(d).toFixed(1)} ${toY(profile.height[i]).toFixed(1)} `\n  })\n  areaPath += `L ${toX(profile.length).toFixed(1)} ${H - padY} Z`\n\n  // Repose-angle reference wedge from the left toe.\n  const reposeRise = Math.tan((reposeDeg * Math.PI) / 180)\n  const wedgeRunM = Math.min(profile.length * 0.42, peak / reposeRise)\n  const wedgeTopH = wedgeRunM * reposeRise\n  const wedgePath = `M ${toX(0)} ${H - padY} L ${toX(wedgeRunM)} ${toY(wedgeTopH)} L ${toX(wedgeRunM)} ${H - padY} Z`\n\n  const axisLabel = profile.axis === \"ns\" ? \"S → N\" : \"W → E\"\n\n  return (\n    <div className=\"flex flex-col gap-2 rounded-xl border border-border-secondary bg-bg-primary p-3\">\n      <div className=\"flex items-center justify-between text-xs\">\n        <span className=\"font-medium text-fg-tertiary\">\n          Cross-section ({axisLabel})\n        </span>\n        <span className=\"text-fg-quaternary tabular-nums\">\n          {profile.length.toFixed(0)} m · peak {profile.peak.toFixed(1)} m\n        </span>\n      </div>\n      <svg\n        viewBox={`0 0 ${W} ${H}`}\n        className=\"h-32 w-full\"\n        role=\"img\"\n        aria-label={`Cross-section profile, ${axisLabel}, peak ${profile.peak.toFixed(1)} metres.`}\n        preserveAspectRatio=\"none\"\n      >\n        {/* Repose reference wedge */}\n        <path d={wedgePath} className=\"fill-utility-yellow-400/15\" />\n        <path\n          d={`M ${toX(0)} ${H - padY} L ${toX(wedgeRunM)} ${toY(wedgeTopH)}`}\n          className=\"stroke-utility-yellow-500/60\"\n          strokeWidth={1}\n          strokeDasharray=\"4 3\"\n          fill=\"none\"\n        />\n        {/* Max height line */}\n        {maxHeightM != null ? (\n          <line\n            x1={padX}\n            x2={W - padX}\n            y1={toY(maxHeightM)}\n            y2={toY(maxHeightM)}\n            className=\"stroke-utility-red-500/70\"\n            strokeWidth={1}\n            strokeDasharray=\"5 3\"\n          />\n        ) : null}\n        {/* Filled surface */}\n        <path d={areaPath} className=\"fill-utility-brand-500/12\" />\n        <path\n          d={surfacePath}\n          className=\"stroke-utility-brand-600\"\n          strokeWidth={1.75}\n          fill=\"none\"\n          strokeLinejoin=\"round\"\n        />\n        {/* Ground line */}\n        <line\n          x1={padX}\n          x2={W - padX}\n          y1={H - padY}\n          y2={H - padY}\n          className=\"stroke-border-primary\"\n          strokeWidth={1}\n        />\n      </svg>\n      <label className=\"flex items-center gap-2 text-[11px] text-fg-quaternary\">\n        <span className=\"shrink-0\">Position</span>\n        <input\n          type=\"range\"\n          min={0}\n          max={100}\n          value={Math.round(pos * 100)}\n          onChange={(e) => onPos(Number(e.target.value) / 100)}\n          aria-label=\"Cross-section position\"\n          className=\"h-1.5 flex-1 cursor-pointer appearance-none rounded-full bg-bg-quaternary accent-[var(--color-primary)]\"\n        />\n      </label>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Empty state\n// ---------------------------------------------------------------------------\n\nfunction EmptyState({ name, className }: { name: string; className?: string }) {\n  return (\n    <div\n      className={cn(\n        \"flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-border bg-card p-10 text-center shadow-xs\",\n        className\n      )}\n    >\n      <Cube01 className=\"size-8 text-fg-quaternary\" aria-hidden=\"true\" />\n      <p className=\"font-mono text-sm font-medium text-text-secondary\">{name}</p>\n      <p className=\"max-w-xs text-xs text-text-tertiary\">\n        No survey points yet. Upload a drone or GPS survey to visualise the\n        stockpile surface and compute its volume.\n      </p>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\ninterface TestTally {\n  pass: number\n  fail: number\n  atLab: number\n  attention: number\n  scheduled: number\n  sampled: number\n}\n\nfunction countTests(tests: TestLocation[]): TestTally {\n  const t: TestTally = {\n    pass: 0,\n    fail: 0,\n    atLab: 0,\n    attention: 0,\n    scheduled: 0,\n    sampled: 0,\n  }\n  const bump: Record<TestStatus, keyof TestTally> = {\n    pass: \"pass\",\n    fail: \"fail\",\n    \"at-lab\": \"atLab\",\n    attention: \"attention\",\n    scheduled: \"scheduled\",\n    sampled: \"sampled\",\n  }\n  for (const test of tests) t[bump[test.status]]++\n  return t\n}\n\nfunction octantName(octant: NonNullable<AnalysisBundle[\"slope\"][\"worstOctant\"]>): string {\n  const names: Record<string, string> = {\n    N: \"north\",\n    NE: \"north-east\",\n    E: \"east\",\n    SE: \"south-east\",\n    S: \"south\",\n    SW: \"south-west\",\n    W: \"west\",\n    NW: \"north-west\",\n  }\n  return names[octant] ?? octant\n}\n\nfunction methodLabel(stockpile: Stockpile): string {\n  const map: Record<NonNullable<Stockpile[\"survey\"][\"method\"]>, string> = {\n    \"drone-photogrammetry\": \"drone photogrammetry\",\n    \"gps-rover\": \"GPS rover\",\n    \"laser-scan\": \"laser scan\",\n    manual: \"manual\",\n  }\n  const m = stockpile.survey.method\n  return m ? map[m] : \"survey\"\n}\n\nfunction formatVolume(v: number): string {\n  return Math.round(v).toLocaleString(\"en-GB\")\n}\n\nfunction formatTonnes(t: number): string {\n  return Math.round(t).toLocaleString(\"en-GB\")\n}\n\nfunction formatDate(iso: string): string {\n  const d = new Date(iso)\n  if (Number.isNaN(d.getTime())) return iso\n  return d.toLocaleDateString(\"en-GB\", { day: \"numeric\", month: \"short\" })\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/stockpile-visualiser/stockpile-visualiser.tsx"
    },
    {
      "path": "components/blocks/domain/stockpile-visualiser/types.ts",
      "content": "/** Shared types for the StockpileVisualiser flagship block. */\n\n/** A surface tint keyed to a theme-aware colour ramp (resolved from CSS vars). */\nexport type StockpileTone = \"brown\" | \"amber\" | \"olive\" | \"grey\" | \"slate\"\n\nexport interface StockpileMaterial {\n  /** Free-string key, e.g. \"topsoil\" | \"subsoil-upper\" | \"6F2\". */\n  key: string\n  /** Human label, e.g. \"Topsoil (BS 3882)\". */\n  label: string\n  /** Short code shown on chips, e.g. \"TS\", \"USS\", \"6F2\". */\n  shortCode?: string\n  /** Bulk density in tonnes per cubic metre (loose), e.g. 1.7. */\n  densityTPerM3: number\n  /** Design angle of repose in degrees, e.g. 35 for topsoil, 30 for wet clay. */\n  angleOfReposeDeg: number\n  /** Surface tint, mapped to a theme-aware colour ramp. */\n  tone?: StockpileTone\n}\n\n/** A surveyed point in site-local metres (x east, y north, z elevation). */\nexport interface SurveyPoint {\n  x: number\n  y: number\n  z: number\n}\n\nexport type SurveyMethod =\n  | \"drone-photogrammetry\"\n  | \"gps-rover\"\n  | \"laser-scan\"\n  | \"manual\"\n\nexport interface StockpileSurvey {\n  id: string\n  /** ISO date the survey was taken. */\n  takenAt: string\n  method?: SurveyMethod\n  points: SurveyPoint[]\n}\n\nexport type TestStatus =\n  | \"scheduled\"\n  | \"sampled\"\n  | \"at-lab\"\n  | \"pass\"\n  | \"fail\"\n  | \"attention\"\n\n/** A determinand result row shown in the test popover mini-table. */\nexport interface Determinand {\n  name: string\n  value: number\n  unit: string\n  /** Regulatory / acceptance limit for this determinand, if any. */\n  limit?: number\n  /** True when the value breaches the limit. */\n  exceeds?: boolean\n}\n\nexport interface TestLocation {\n  id: string\n  /** Field label, e.g. \"TP-03\", \"WS-01\". */\n  label: string\n  /** Site-local metres; z is derived from the surface. */\n  x: number\n  y: number\n  /** Sample depth below the surface, in metres. */\n  depthM?: number\n  status: TestStatus\n  /** Test suite, e.g. \"WAC leachate\", \"MCERTS chemical\", \"Agronomy (pH/OM/PKMg)\". */\n  suite: string\n  /** ISO date the sample was taken. */\n  sampledAt?: string\n  /** One-liner shown in the popover. */\n  summary?: string\n  determinands?: Determinand[]\n}\n\nexport type StockpileClassification =\n  | \"unclassified\"\n  | \"inert\"\n  | \"non-hazardous\"\n  | \"hazardous\"\n\n/** BBV / Defra Soil Handling Unit resilience class. */\nexport type SoilHandlingClass = \"A\" | \"B\" | \"C\"\n\nexport type StockpileEventKind =\n  | \"delivery\"\n  | \"removal\"\n  | \"survey\"\n  | \"adjustment\"\n  | \"test\"\n\nexport interface StockpileEvent {\n  id: string\n  /** ISO datetime. */\n  at: string\n  kind: StockpileEventKind\n  /** Magnitude in cubic metres; the kind carries direction. */\n  volumeM3?: number\n  vehicleReg?: string\n  note?: string\n  /** Running stock balance after this event, in cubic metres. */\n  balanceM3?: number\n}\n\nexport type StockpileStatus = \"active\" | \"reserved\" | \"depleted\" | \"inactive\"\n\nexport interface StockpileNutrients {\n  ph?: number\n  organicMatterPct?: number\n  pMgPerL?: number\n  kMgPerL?: number\n  mgMgPerL?: number\n}\n\nexport interface Stockpile {\n  id: string\n  /** BBV-style identifier, e.g. \"SL2A_TS_CLA_GWC_0011\". */\n  name: string\n  material: StockpileMaterial\n  classification: StockpileClassification\n  /** Drives the default max-height guidance: A 5 m, B 4 m, C 3 m. */\n  shuClass?: SoilHandlingClass\n  survey: StockpileSurvey\n  /** A prior survey, used by the cut/fill overlay. */\n  previousSurvey?: StockpileSurvey\n  capacityM3?: number\n  /** Design / permit max height in metres (defaults from shuClass when absent). */\n  maxHeightM?: number\n  tests: TestLocation[]\n  events?: StockpileEvent[]\n  /** European Waste Catalogue code, e.g. \"17 05 04\". */\n  ewcCode?: string\n  /** OS grid reference, e.g. \"SP 18035 76423\". */\n  gridRef?: string\n  moisturePct?: number\n  nutrients?: StockpileNutrients\n  status?: StockpileStatus\n}\n\nexport type StockpileOverlay =\n  | \"material\"\n  | \"elevation\"\n  | \"slope\"\n  | \"test-coverage\"\n  | \"cut-fill\"\n\n/** Preset cross-section orientation. */\nexport type SectionAxis = \"ns\" | \"ew\"\n\n/**\n * A regular height grid interpolated from a survey point cloud. World extent is\n * the padded bounding box of the points; `z` is a row-major Float32Array of\n * length `cols * rows` (index = row * cols + col).\n */\nexport interface Heightfield {\n  cols: number\n  rows: number\n  /** World-space extent (site-local metres). */\n  minX: number\n  maxX: number\n  minY: number\n  maxY: number\n  /** World spacing between grid nodes, in metres. */\n  dx: number\n  dy: number\n  /** Row-major elevations, metres. */\n  z: Float32Array\n  /** Elevation of the flat ground the pile sits on (the toe datum), metres. */\n  baseZ: number\n  /** Peak elevation across the grid, metres. */\n  maxZ: number\n}\n\n/** The output of the volume integration. */\nexport interface VolumeResult {\n  /** Fill volume above the base datum, cubic metres. */\n  volumeM3: number\n  tonnes: number\n  maxHeightM: number\n}\n\n/** Per-cell cut/fill against a previous heightfield. */\nexport interface CutFillResult {\n  /** Row-major signed elevation delta (current − previous), metres. */\n  delta: Float32Array\n  /** Net volume change, cubic metres (positive = material added). */\n  netM3: number\n  cutM3: number\n  fillM3: number\n}\n\n/** Slope analysis of the surface faces. */\nexport interface SlopeResult {\n  /** Row-major per-cell maximum face slope, degrees. */\n  slopeDeg: Float32Array\n  maxSlopeDeg: number\n  /** Fraction (0–1) of surface cells exceeding the repose angle. */\n  exceedFraction: number\n  /** Compass octant of the worst exceedance region, e.g. \"NW\"; null when none. */\n  worstOctant: CompassOctant | null\n}\n\nexport type CompassOctant = \"N\" | \"NE\" | \"E\" | \"SE\" | \"S\" | \"SW\" | \"W\" | \"NW\"\n\nexport interface StockpileVisualiserProps {\n  stockpile: Stockpile\n  /** Controlled overlay mode. */\n  overlay?: StockpileOverlay\n  /** Uncontrolled initial overlay mode (default \"material\"). */\n  defaultOverlay?: StockpileOverlay\n  onOverlayChange?: (overlay: StockpileOverlay) => void\n  onTestSelect?: (test: TestLocation) => void\n  /** Show the right-hand stats panel (default true). */\n  showPanel?: boolean\n  /** Header-less, panel-less embed for dashboards. */\n  compact?: boolean\n  /** Optional play/pause control state for a running simulation. */\n  playing?: boolean\n  onPlayingChange?: (playing: boolean) => void\n  className?: string\n}\n\nexport interface StockpileSceneProps {\n  stockpile: Stockpile\n  overlay: StockpileOverlay\n  /** Cross-section line to draw on the surface, if the tool is active. */\n  section?: SectionAxis | null\n  /** Section position along its perpendicular axis, 0–1 (default 0.5, centroid). */\n  sectionPos?: number\n  onTestSelect?: (test: TestLocation) => void\n  /** The currently selected test id (for marker emphasis). */\n  selectedTestId?: string | null\n  className?: string\n}\n",
      "type": "registry:component",
      "target": "components/blocks/domain/stockpile-visualiser/types.ts"
    }
  ],
  "type": "registry:block"
}