// SPDX-License-Identifier: Apache-2.0 // © 2026 Lutar, Stephen P. Jr. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11 // // surfaces/energy.js — ENERGY · Harvest surface (Dev1, the FLAGSHIP). // // Leader / technique modeled: Electricity Maps (live carbon/price choropleth, 15-min // resolution, flow arcs) + deck.gl GPUGridLayer / ColumnLayer / ArcLayer (GPU hexbin // column grid + animated flow arcs + extruded negative-price columns). // // RENDERING CHOICE (documented in VENDOR_MANIFEST.md): we render the deck.gl *technique* // in pure three.js r170 inside the shell-owned ctx.stage.scene. deck.gl needs its own GL // context + canvas and its column/arc/grid layers are WebGL-only in v9 (no WebGPU), so a // second deck.gl canvas would fight the toolkit's OrbitControls / bloom / WebGPU path / // clearScene() lifecycle. three.js keeps the surface on the shared WebGPU-or-WebGL2 path // and inside the one scene graph the shell owns. deck.gl is still vendored 0-CDN per the // Dev0 contract for Dev9's estate map. // // DOCTRINE v11 (binding): WIRE TO LIVE DATA — every value on screen traces to a real // a11oy endpoint via ctx.live.poll and carries its honesty label read STRAIGHT from the // JSON (MEASURED/MODELED/SAMPLE/STRUCTURAL-ONLY). We NEVER fabricate or hardcode telemetry. // If a value is not live we render it grayed with NO-LIVE-DATA. If the endpoint 404s or // returns degraded we render the honest degraded state, not a crash. // // THE FUNNEL (founder's explicit ask — centerpiece): visualize OUR real harvested joules // as a live-filling reservoir, fed from the MEASURED exporter (joules_evidence. // joules_measured_total / power_w_sample), with negative-price windows soaking in real // time. HONEST NOTE: off-box this API emits joules_label="sample" and joules_evidence={} // (MEASURED requires on-box NVML). So off-box the reservoir shows its structure + the // honest SAMPLE/NO-LIVE-DATA posture and does NOT invent a fill height; on-box, when a // real fresh exporter sample arrives, the same geometry fills from the MEASURED joules. // // LIVE ENDPOINTS: // /api/a11oy/v1/harvest/posture — posture, rank, wasted_energy_available, soak_hard, // measured_any, drivers, readings[] (per-feed reachable/measured + value), // joules_label, joules_evidence{joules_measured_total, exporter_node, power_w_sample}, // and (on-box) flat price_now_eur_mwh / next_min_eur_mwh / next_negative_windows / // renewable_share_pct / uk_gco2_per_kwh. // /api/a11oy/v1/anatomy/loop — reservoir.work_credits (the loop's stored credits). // // CONTRACT: default-export { id, title, endpoints[], mount(ctx), unmount() }. const ID = "energy"; const TITLE = "Energy · Harvest"; const POSTURE_EP = "/api/a11oy/v1/harvest/posture"; const LOOP_EP = "/api/a11oy/v1/anatomy/loop"; // palette const C = { teal: 0x39d3c4, gold: 0xe8c074, green: 0x2fd07a, red: 0xff6b6b, blue: 0x6fb1ff, slate: 0x8a97a3, cream: 0xeef3f6, dim: 0x46586a, }; let _stage = null, _THREE = null, _ctx = null; let _hPosture = null, _hLoop = null; let _root = null; // THREE.Group holding everything we add (one remove on unmount) let _overlay = null; // DOM HUD panel let _frameFn = null; // our per-frame callback (guards on _root null after unmount) let _hud = {}; // references to live HUD chip/value elements let _scene = null; // built scene-object refs the frame loop + pollers animate const _anim = {}; // animated-state targets the frame loop eases toward // ---------------------------------------------------------------------------- // honest field extraction. The real /harvest/posture nests live readings in // readings[] (per-feed reachable/measured/value) + drivers, and ALSO may carry // flat fields on-box. We read BOTH shapes, never invent. Each getter returns // { value:Number|null, live:Bool, label:String|null, src:String } so the viz can // render NO-LIVE-DATA honestly when a feed is down or the value is absent. // ---------------------------------------------------------------------------- function _num(x) { if (x === null || x === undefined) return null; const n = typeof x === "number" ? x : parseFloat(x); return Number.isFinite(n) ? n : null; } // find a reading whose feed/name matches any of the given substrings (case-insensitive) function _findReading(json, needles) { const rs = (json && Array.isArray(json.readings)) ? json.readings : []; for (const r of rs) { const key = String((r && (r.feed || r.name || r.id)) || "").toLowerCase(); if (needles.some((n) => key.indexOf(n) >= 0)) return r; } return null; } // pull a numeric value out of a FeedReading dict, honestly tracking reachable/measured function _readingValue(r, fields) { if (!r) return { value: null, live: false, label: null, src: "absent" }; const reachable = r.reachable !== false; // default true unless explicitly false let v = null; for (const f of fields) { v = _num(r[f]); if (v !== null) break; } const measured = r.measured === true; const label = measured ? "MEASURED" : (reachable && v !== null ? "SAMPLE" : null); return { value: v, live: reachable && v !== null, label, src: String((r.feed || r.name) || "feed") }; } // price (EUR/MWh): prefer flat on-box field, else aWATTar DE/AT reading function _getPrice(json) { const flat = _num(json && json.price_now_eur_mwh); if (flat !== null) return { value: flat, live: true, label: "SAMPLE", src: "price_now_eur_mwh" }; const r = _findReading(json, ["awattar", "price", "lmp", "marketprice", "caiso"]); return _readingValue(r, ["price_eur_mwh", "marketprice", "value", "price", "lmp"]); } function _getNextMin(json) { const flat = _num(json && json.next_min_eur_mwh); if (flat !== null) return { value: flat, live: true, label: "SAMPLE", src: "next_min_eur_mwh" }; return { value: null, live: false, label: null, src: "absent" }; } function _getRenewable(json) { const flat = _num(json && json.renewable_share_pct); if (flat !== null) return { value: flat, live: true, label: "SAMPLE", src: "renewable_share_pct" }; const r = _findReading(json, ["ren_share", "renewable", "energy_charts_ren"]); return _readingValue(r, ["renewable_share_pct", "share", "value", "ren_share"]); } function _getCarbon(json) { const flat = _num(json && json.uk_gco2_per_kwh); if (flat !== null) return { value: flat, live: true, label: "SAMPLE", src: "uk_gco2_per_kwh" }; const r = _findReading(json, ["carbon", "gco2", "intensity"]); return _readingValue(r, ["uk_gco2_per_kwh", "gco2_per_kwh", "intensity", "value", "gco2"]); } function _getFrequency(json) { const r = _findReading(json, ["frequency", "freq"]); return _readingValue(r, ["hz", "frequency", "value", "freq"]); } function _getForecast(json) { const r = _findReading(json, ["forecast", "open_meteo", "meteo"]); return _readingValue(r, ["score", "value", "surplus_score"]); } // negative-price windows: array of {start,end,...} or a count function _getNegWindows(json) { const w = json && json.next_negative_windows; if (Array.isArray(w)) return { list: w, count: w.length, live: true }; const n = _num(w); if (n !== null) return { list: null, count: n, live: true }; return { list: null, count: null, live: false }; } // joules evidence — MEASURED only on-box; off-box {} (we render the honest empty posture) function _getJoules(json) { const ev = (json && json.joules_evidence) || {}; const total = _num(ev.joules_measured_total); const powerW = _num(ev.power_w_sample); const node = ev.exporter_node || null; // joules_label is the doctrine truth token straight off the JSON ("measured"/"sample") const label = (json && (json.joules_label || (json.joules_evidence && json.joules_evidence.label))) || null; return { total, powerW, node, label: label ? String(label).toUpperCase() : null, measured: total !== null && String(label || "").toLowerCase().indexOf("measured") >= 0, }; } // ---------------------------------------------------------------------------- // scene builders. Each returns objects added to _root; the frame loop animates // them from the live values written into _anim by the pollers. // ---------------------------------------------------------------------------- function _makeText(text, color, scale) { // tiny canvas-sprite label (system font), used for axis ticks / value callouts const cnv = document.createElement("canvas"); const ctx = cnv.getContext("2d"); const fs = 44; ctx.font = "600 " + fs + "px ui-monospace,Menlo,monospace"; const w = Math.ceil(ctx.measureText(text).width) + 12, h = fs + 12; cnv.width = w; cnv.height = h; ctx.font = "600 " + fs + "px ui-monospace,Menlo,monospace"; ctx.fillStyle = "#" + color.toString(16).padStart(6, "0"); ctx.textBaseline = "middle"; ctx.fillText(text, 6, h / 2); const tex = new _THREE.CanvasTexture(cnv); if ("colorSpace" in tex && _THREE.SRGBColorSpace) tex.colorSpace = _THREE.SRGBColorSpace; const spr = new _THREE.Sprite(new _THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false })); const s = scale || 0.5; spr.scale.set(s * (w / h), s, 1); spr.userData.szlText = true; spr.userData.tex = tex; return spr; } function build() { const THREE = _THREE; _root = new THREE.Group(); _stage.scene.add(_root); // ---- DEMO 1: GPU-style hexbin/column grid floor (deck.gl GPUGridLayer technique) ---- // A hex-packed field of instanced columns; height/color animate to renewable share + // carbon tint when live. This is the "grid surface" the rest of the scene sits on. const GRID = { cols: 0, mesh: null, dummy: new THREE.Object3D(), base: [], rings: 5 }; const cells = []; const hexR = 0.62, gap = 0.06; for (let q = -GRID.rings; q <= GRID.rings; q++) { for (let r = -GRID.rings; r <= GRID.rings; r++) { const x = (hexR * 1.5) * q; const z = (hexR * Math.sqrt(3)) * (r + q / 2); if (Math.hypot(x, z) > (GRID.rings + 0.4) * hexR * 1.5) continue; cells.push([x, z]); } } GRID.cols = cells.length; const colGeo = new THREE.CylinderGeometry(hexR - gap, hexR - gap, 1, 6); colGeo.translate(0, 0.5, 0); // grow upward from y=0 const colMat = new THREE.MeshStandardMaterial({ color: C.teal, emissive: C.teal, emissiveIntensity: 0.25, metalness: 0.3, roughness: 0.55, }); GRID.mesh = new THREE.InstancedMesh(colGeo, colMat, GRID.cols); GRID.mesh.instanceColor = new THREE.InstancedBufferAttribute(new Float32Array(GRID.cols * 3), 3); for (let i = 0; i < GRID.cols; i++) { GRID.base.push({ x: cells[i][0], z: cells[i][1], h: 0.05, hT: 0.05, phase: Math.random() * Math.PI * 2 }); } GRID.mesh.position.y = -2.2; _root.add(GRID.mesh); // ---- DEMO 2: negative-price extruded column field (deck.gl ColumnLayer technique) ---- // Up to 12 columns around a ring; when negative-price windows are live, columns extrude // (height = abs(price)) and turn red (sign(price) < 0). Otherwise they sit flat/dim. const NEG = { group: new THREE.Group(), cols: [], n: 12 }; for (let i = 0; i < NEG.n; i++) { const a = (i / NEG.n) * Math.PI * 2; const g = new THREE.BoxGeometry(0.34, 1, 0.34); g.translate(0, 0.5, 0); const m = new THREE.MeshStandardMaterial({ color: C.dim, emissive: C.dim, emissiveIntensity: 0.2, transparent: true, opacity: 0.85 }); const mesh = new THREE.Mesh(g, m); mesh.position.set(Math.cos(a) * 6.4, -2.2, Math.sin(a) * 6.4); mesh.scale.y = 0.05; NEG.cols.push({ mesh, a, soak: 0, soakT: 0 }); NEG.group.add(mesh); } _root.add(NEG.group); // ---- DEMO 3: THE FUNNEL — live-filling joules reservoir (founder centerpiece) ---- // A transparent glass funnel/reservoir. Fill height animates to OUR harvested joules // (joules_evidence.joules_measured_total) ONLY when label==MEASURED on-box. Off-box // (label=sample, evidence={}) the fluid stays at 0 with the honest SAMPLE/NO-LIVE-DATA // chip floating on it — we never fabricate a fill. A MEASURED honesty billboard rides // on the reservoir per the founder's explicit ask. const RES = { group: new THREE.Group() }; const shellGeo = new THREE.CylinderGeometry(1.7, 0.9, 4.0, 40, 1, true); const shellMat = new THREE.MeshPhysicalMaterial({ color: C.cream, metalness: 0, roughness: 0.08, transmission: 0.92, transparent: true, opacity: 0.22, thickness: 0.4, side: THREE.DoubleSide, emissive: C.teal, emissiveIntensity: 0.04, }); RES.shell = new THREE.Mesh(shellGeo, shellMat); RES.group.add(RES.shell); // rim + base rings const rim = new THREE.Mesh(new THREE.TorusGeometry(1.7, 0.04, 12, 40), new THREE.MeshStandardMaterial({ color: C.teal, emissive: C.teal, emissiveIntensity: 0.6 })); rim.rotation.x = Math.PI / 2; rim.position.y = 2.0; RES.group.add(rim); // the FLUID — its scale.y is the live fill fraction; starts EMPTY (honest) const fluidGeo = new THREE.CylinderGeometry(1.55, 0.78, 3.8, 40); fluidGeo.translate(0, 1.9, 0); // bottom at reservoir base, grows up RES.fluidMat = new THREE.MeshStandardMaterial({ color: C.green, emissive: C.green, emissiveIntensity: 0.5, transparent: true, opacity: 0.78, metalness: 0.2, roughness: 0.3 }); RES.fluid = new THREE.Mesh(fluidGeo, RES.fluidMat); RES.fluid.position.y = -2.0; RES.fluid.scale.y = 0.0001; RES.group.add(RES.fluid); RES.group.position.set(0, 0.2, 0); _root.add(RES.group); _anim.fill = 0; _anim.fillT = 0; // reservoir fill fraction (0..1), eased _anim.fluidPulse = 0; // MEASURED honesty billboard riding on the reservoir (founder's explicit ask) try { RES.chip = _ctx.label.billboard(THREE, "SAMPLE", { text: "joules", scale: 0.55, position: [0, 2.55, 0], depthTest: false }); RES.group.add(RES.chip); } catch (_) {} // ---- DEMO 4: animated flow arcs (deck.gl ArcLayer technique) ---- // Arcs from the grid floor up into the reservoir, representing energy flowing INTO the // funnel. Dash offset animates; brightness rises when wasted_energy_available is live. const ARC = { group: new THREE.Group(), arcs: [], n: 7 }; for (let i = 0; i < ARC.n; i++) { const a = (i / ARC.n) * Math.PI * 2; const src = new THREE.Vector3(Math.cos(a) * 5.5, -2.0, Math.sin(a) * 5.5); const mid = new THREE.Vector3(Math.cos(a) * 2.6, 3.2, Math.sin(a) * 2.6); const dst = new THREE.Vector3(0, 1.0, 0); const curve = new THREE.QuadraticBezierCurve3(src, mid, dst); const geo = new THREE.TubeGeometry(curve, 40, 0.035, 6, false); const mat = new THREE.MeshStandardMaterial({ color: C.teal, emissive: C.teal, emissiveIntensity: 0.5, transparent: true, opacity: 0.0 }); const mesh = new THREE.Mesh(geo, mat); ARC.arcs.push({ mesh, phase: i / ARC.n }); ARC.group.add(mesh); } _root.add(ARC.group); _anim.flow = 0; _anim.flowT = 0; // 0..1 flow intensity // ---- DEMO 5: renewable >100% halo ring ---- // A glowing ring whose radius/opacity grows with renewable share; if share crosses // 100% (genuine oversupply) it flares gold — the "free wind" signal. const HALO = {}; HALO.ring = new THREE.Mesh(new THREE.TorusGeometry(3.0, 0.06, 16, 80), new THREE.MeshBasicMaterial({ color: C.green, transparent: true, opacity: 0.0 })); HALO.ring.rotation.x = Math.PI / 2; HALO.ring.position.y = 2.6; _root.add(HALO.ring); _anim.renew = 0; _anim.renewT = 0; // renewable share fraction (0..1.2) // ---- DEMO 6: power_w live gauge (radial arc) ---- const GAUGE = {}; const gaugeBg = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.05, 48, 1, 0, Math.PI), new THREE.MeshBasicMaterial({ color: C.dim, transparent: true, opacity: 0.5, side: THREE.DoubleSide })); GAUGE.fill = new THREE.Mesh(new THREE.RingGeometry(0.9, 1.05, 48, 1, 0, 0.001), new THREE.MeshBasicMaterial({ color: C.gold, side: THREE.DoubleSide })); GAUGE.group = new THREE.Group(); GAUGE.group.add(gaugeBg, GAUGE.fill); GAUGE.group.position.set(-6.5, 1.6, 0); GAUGE.group.rotation.y = Math.PI / 2; _root.add(GAUGE.group); _anim.powerW = 0; _anim.powerWT = 0; // 0..1 fraction of an assumed 1kW scale // ---- DEMO 7: carbon-intensity tint dome (Electricity Maps choropleth tint) ---- const DOME = {}; DOME.mesh = new THREE.Mesh(new THREE.SphereGeometry(9.5, 32, 24, 0, Math.PI * 2, 0, Math.PI / 2), new THREE.MeshBasicMaterial({ color: C.green, transparent: true, opacity: 0.04, side: THREE.BackSide })); _root.add(DOME.mesh); _anim.carbon = 0; _anim.carbonT = 0; // 0(clean)..1(dirty) // ---- DEMO 8: next_negative_windows countdown ribbon ---- const RIBBON = { group: new THREE.Group(), ticks: [] }; for (let i = 0; i < 16; i++) { const m = new THREE.Mesh(new THREE.BoxGeometry(0.18, 0.18, 0.04), new THREE.MeshStandardMaterial({ color: C.dim, emissive: C.dim, emissiveIntensity: 0.3 })); m.position.set(-2.4 + i * 0.32, 4.6, 0); RIBBON.ticks.push(m); RIBBON.group.add(m); } _root.add(RIBBON.group); _anim.negCount = 0; _anim.negCountT = 0; // ---- DEMO 9: grid-feed health constellation (3/3 ... n/n) ---- // One small node per live feed reading; green=reachable+measured, blue=reachable, // red=unreachable. Honest 3/3 health badge in the HUD. const HEALTH = { group: new THREE.Group(), nodes: [] }; for (let i = 0; i < 8; i++) { const a = (i / 8) * Math.PI * 2; const m = new THREE.Mesh(new THREE.SphereGeometry(0.16, 12, 12), new THREE.MeshStandardMaterial({ color: C.dim, emissive: C.dim, emissiveIntensity: 0.4 })); m.position.set(6.6 + Math.cos(a) * 0.9, 1.6 + Math.sin(a) * 0.9, 0); HEALTH.nodes.push(m); HEALTH.group.add(m); } _root.add(HEALTH.group); // ---- DEMO 10: soak-decision beacon ---- // A pillar in the reservoir core that turns green and pulses when soak_hard / wasted // energy is live (the loop SHOULD soak now), amber when marginal, gray when idle. const SOAK = {}; SOAK.mesh = new THREE.Mesh(new THREE.OctahedronGeometry(0.45, 0), new THREE.MeshStandardMaterial({ color: C.slate, emissive: C.slate, emissiveIntensity: 0.5 })); SOAK.mesh.position.set(0, 4.0, 0); _root.add(SOAK.mesh); _anim.soak = 0; _anim.soakT = 0; // 0 idle .. 1 soak-now // ---- DEMO 11: work_credits reservoir-credits ring (from /anatomy/loop) ---- const CREDITS = {}; CREDITS.ring = new THREE.Mesh(new THREE.TorusGeometry(2.2, 0.05, 12, 64, 0.001), new THREE.MeshStandardMaterial({ color: C.blue, emissive: C.blue, emissiveIntensity: 0.6 })); CREDITS.ring.rotation.x = Math.PI / 2; CREDITS.ring.position.y = -1.9; _root.add(CREDITS.ring); _anim.credits = 0; _anim.creditsT = 0; // ---- DEMO 12: ambient particle field (wasted-energy motes drifting to the funnel) ---- const MOTES = {}; const mN = 280; const mGeo = new THREE.BufferGeometry(); const mPos = new Float32Array(mN * 3); MOTES.seed = []; for (let i = 0; i < mN; i++) { const a = Math.random() * Math.PI * 2, rad = 3 + Math.random() * 6, y = -2 + Math.random() * 6; mPos[i * 3] = Math.cos(a) * rad; mPos[i * 3 + 1] = y; mPos[i * 3 + 2] = Math.sin(a) * rad; MOTES.seed.push({ a, rad, y, spd: 0.2 + Math.random() * 0.6 }); } mGeo.setAttribute("position", new THREE.BufferAttribute(mPos, 3)); MOTES.points = new THREE.Points(mGeo, new THREE.PointsMaterial({ color: C.teal, size: 0.06, transparent: true, opacity: 0.5, depthWrite: false })); MOTES.pos = mPos; MOTES.geo = mGeo; _root.add(MOTES.points); // stash builders for the frame loop + pollers _scene = { GRID, NEG, RES, ARC, HALO, GAUGE, DOME, RIBBON, HEALTH, SOAK, CREDITS, MOTES }; // enable bloom for the holographic glow (safe no-op on WebGPU per toolkit) try { _stage.setBloom(true); } catch (_) {} // ---- per-frame animation (eases _anim.* toward live targets) ---- _frameFn = () => { if (!_root) return; // unmounted — guard (shell keeps frame cbs registered) const t = (typeof performance !== "undefined" ? performance.now() : Date.now()) / 1000; const ease = (cur, target, k) => cur + (target - cur) * k; // grid columns: idle wave + renewable-driven height + carbon tint _anim.renew = ease(_anim.renew, _anim.renewT, 0.06); _anim.carbon = ease(_anim.carbon, _anim.carbonT, 0.06); const gridColor = new THREE.Color().lerpColors(new THREE.Color(C.green), new THREE.Color(C.red), Math.min(1, _anim.carbon)); for (let i = 0; i < GRID.cols; i++) { const b = GRID.base[i]; const wave = 0.18 + 0.12 * Math.sin(t * 1.2 + b.phase); b.hT = 0.08 + wave + _anim.renew * 0.9 * (0.6 + 0.4 * Math.sin(t * 0.7 + b.phase)); b.h = ease(b.h, b.hT, 0.08); GRID.dummy.position.set(b.x, 0, b.z); GRID.dummy.scale.set(1, b.h, 1); GRID.dummy.updateMatrix(); GRID.mesh.setMatrixAt(i, GRID.dummy.matrix); GRID.mesh.setColorAt(i, gridColor); } GRID.mesh.instanceMatrix.needsUpdate = true; if (GRID.mesh.instanceColor) GRID.mesh.instanceColor.needsUpdate = true; // negative-price columns soak animation for (let i = 0; i < NEG.cols.length; i++) { const c = NEG.cols[i]; c.soak = ease(c.soak, c.soakT, 0.07); c.mesh.scale.y = Math.max(0.05, c.soak * 5.0); const lit = c.soakT > 0.05; c.mesh.material.color.setHex(lit ? C.red : C.dim); c.mesh.material.emissive.setHex(lit ? C.red : C.dim); c.mesh.material.emissiveIntensity = lit ? 0.5 + 0.3 * Math.sin(t * 3 + i) : 0.2; } // THE FUNNEL fill _anim.fill = ease(_anim.fill, _anim.fillT, 0.05); RES.fluid.scale.y = Math.max(0.0001, _anim.fill); _anim.fluidPulse = 0.5 + 0.5 * Math.sin(t * 2); RES.fluidMat.emissiveIntensity = 0.35 + 0.25 * _anim.fluidPulse; // flow arcs _anim.flow = ease(_anim.flow, _anim.flowT, 0.06); for (let i = 0; i < ARC.arcs.length; i++) { const arc = ARC.arcs[i]; const pulse = (Math.sin(t * 1.5 + arc.phase * Math.PI * 2) + 1) / 2; arc.mesh.material.opacity = _anim.flow * (0.2 + 0.6 * pulse); arc.mesh.material.emissiveIntensity = 0.3 + 0.7 * pulse * _anim.flow; } // renewable halo HALO.ring.material.opacity = Math.min(0.9, _anim.renew * 0.7); HALO.ring.scale.setScalar(0.8 + _anim.renew * 0.5); const over = _anim.renew > 1.0; HALO.ring.material.color.setHex(over ? C.gold : C.green); // power gauge _anim.powerW = ease(_anim.powerW, _anim.powerWT, 0.07); const ang = Math.max(0.001, Math.min(Math.PI, _anim.powerW * Math.PI)); GAUGE.fill.geometry.dispose(); GAUGE.fill.geometry = new THREE.RingGeometry(0.9, 1.05, 48, 1, 0, ang); // carbon dome tint DOME.mesh.material.color.copy(gridColor); DOME.mesh.material.opacity = 0.03 + _anim.carbon * 0.06; // countdown ribbon _anim.negCount = ease(_anim.negCount, _anim.negCountT, 0.1); for (let i = 0; i < RIBBON.ticks.length; i++) { const lit = i < Math.round(_anim.negCount * RIBBON.ticks.length / Math.max(1, _scene._negMax || 4)); const m = RIBBON.ticks[i]; const flash = lit ? 0.5 + 0.4 * Math.sin(t * 4 + i) : 0.25; m.material.color.setHex(lit ? C.gold : C.dim); m.material.emissive.setHex(lit ? C.gold : C.dim); m.material.emissiveIntensity = flash; } // soak beacon _anim.soak = ease(_anim.soak, _anim.soakT, 0.08); const sCol = _anim.soak > 0.6 ? C.green : (_anim.soak > 0.2 ? C.gold : C.slate); SOAK.mesh.material.color.setHex(sCol); SOAK.mesh.material.emissive.setHex(sCol); SOAK.mesh.material.emissiveIntensity = 0.4 + 0.5 * _anim.soak * (0.5 + 0.5 * Math.sin(t * 4)); SOAK.mesh.rotation.y += 0.01 + 0.04 * _anim.soak; SOAK.mesh.scale.setScalar(0.9 + 0.2 * _anim.soak * Math.sin(t * 5)); // work_credits ring sweep (from /anatomy/loop) _anim.credits = ease(_anim.credits, _anim.creditsT, 0.06); CREDITS.ring.geometry.dispose(); CREDITS.ring.geometry = new THREE.TorusGeometry(2.2, 0.05, 12, 64, Math.max(0.001, _anim.credits * Math.PI * 2)); // motes drift inward + up (energy flowing to the funnel) for (let i = 0; i < MOTES.seed.length; i++) { const s = MOTES.seed[i]; s.a += 0.003 * s.spd; const pull = 0.2 + _anim.flow * 0.8; s.rad -= 0.01 * pull * s.spd; s.y += 0.012 * pull; if (s.rad < 1.2 || s.y > 4.5) { s.rad = 3 + Math.random() * 6; s.y = -2 + Math.random() * 1.5; } MOTES.pos[i * 3] = Math.cos(s.a) * s.rad; MOTES.pos[i * 3 + 1] = s.y; MOTES.pos[i * 3 + 2] = Math.sin(s.a) * s.rad; } MOTES.geo.attributes.position.needsUpdate = true; MOTES.points.material.opacity = 0.25 + _anim.flow * 0.4; _root.rotation.y += 0.0012; // gentle estate drift }; _stage.onFrame(_frameFn); } // ---------------------------------------------------------------------------- // HUD — a DOM panel of live values, EACH carrying its honesty chip. Reads live; // never fabricates. Built once; updated in place by the pollers. // ---------------------------------------------------------------------------- function buildHUD() { const lab = _ctx.label; _overlay = document.createElement("div"); _overlay.className = "szl3d-energy-hud"; Object.assign(_overlay.style, { position: "absolute", left: "14px", top: "14px", zIndex: "6", display: "flex", flexDirection: "column", gap: "7px", maxWidth: "min(94%,430px)", font: "12px ui-monospace,SFMono-Regular,Menlo,monospace", color: "#cfe0ea", background: "rgba(6,11,16,.72)", border: "1px solid #15212c", borderRadius: "10px", padding: "12px 14px", backdropFilter: "blur(3px)", }); const title = document.createElement("div"); title.style.cssText = "font:600 13px ui-sans-serif,system-ui;color:#eef3f6;letter-spacing:.4px;display:flex;gap:8px;align-items:center"; title.innerHTML = "⚡ Energy · Harvest · flagship · live joules funnel"; _overlay.appendChild(title); const badge = _ctx.live.createBadge(); _overlay.appendChild(badge.el); _hud.badge = badge; // a small row factory: label text + value + honesty chip function row(key, name) { const wrap = document.createElement("div"); wrap.style.cssText = "display:flex;align-items:center;gap:8px;justify-content:space-between"; const left = document.createElement("span"); left.textContent = name; left.style.color = "#9fb1bf"; const right = document.createElement("span"); right.style.cssText = "display:flex;align-items:center;gap:6px"; const val = document.createElement("span"); val.textContent = "—"; val.style.color = "#eef3f6"; const chip = lab.chip("STRUCTURAL-ONLY"); chip.style.transform = "scale(.92)"; right.appendChild(val); right.appendChild(chip); wrap.appendChild(left); wrap.appendChild(right); _overlay.appendChild(wrap); _hud[key] = { val, chip }; } row("posture", "posture"); row("price", "price EUR/MWh"); row("renew", "renewable %"); row("carbon", "carbon gCO₂/kWh"); row("neg", "neg-price windows"); row("joules", "harvested joules"); row("power", "power_w sample"); row("credits", "loop work_credits"); row("health", "grid feeds"); // doctrine note (the off-box joules honesty reality) const note = document.createElement("div"); note.style.cssText = "color:#6d7d8a;font-size:10.5px;line-height:1.45;margin-top:4px;border-top:1px solid #15212c;padding-top:6px"; note.textContent = "joules MEASURED only on-box (NVML exporter). Off-box label=sample, evidence empty — the funnel shows its structure, never a fabricated fill. Modeled on Electricity Maps + deck.gl; rendered in three.js (see manifest)."; _overlay.appendChild(note); const legend = _ctx.label.legend(); legend.style.opacity = "0.85"; legend.style.marginTop = "2px"; _overlay.appendChild(legend); (_ctx.container || document.body).appendChild(_overlay); } function _setRow(key, text, label) { const r = _hud[key]; if (!r) return; r.val.textContent = text; _ctx.label.updateChip(r.chip, label || "STRUCTURAL-ONLY"); } // ---------------------------------------------------------------------------- // pollers — wire EVERY value to the real endpoints; render honest degraded/missing. // ---------------------------------------------------------------------------- function onPosture(json, meta) { if (!_scene) return; // degraded / missing posture → honest HUD, don't fabricate if (meta.state === "missing" || meta.state === "error") { ["posture", "price", "renew", "carbon", "neg", "joules", "power", "health"].forEach((k) => _setRow(k, "NO-LIVE-DATA", "STRUCTURAL-ONLY")); _anim.flowT = 0; _anim.soakT = 0; _anim.fillT = 0; return; } const degraded = meta.state === "degraded" || json.ok === false; // posture / soak decision const posture = (json && json.posture) || "unknown"; const wasted = json && (json.wasted_energy_available === true || _num(json.wasted_energy_available) > 0); const soakHard = json && json.soak_hard === true; _setRow("posture", String(posture) + (degraded ? " (degraded)" : ""), degraded ? "STRUCTURAL-ONLY" : "SAMPLE"); _anim.soakT = soakHard ? 1.0 : (wasted ? 0.55 : 0.1); _anim.flowT = wasted ? 1.0 : (posture && String(posture).toLowerCase().indexOf("soak") >= 0 ? 0.7 : 0.15); // price → negative-price columns const price = _getPrice(json); if (price.live) { _setRow("price", price.value.toFixed(1), price.label); const neg = price.value < 0; const mag = Math.min(1, Math.abs(price.value) / 80); _scene.NEG.cols.forEach((c, i) => { c.soakT = neg ? (0.2 + mag) : 0.0; }); } else { _setRow("price", "NO-LIVE-DATA", "STRUCTURAL-ONLY"); _scene.NEG.cols.forEach((c) => { c.soakT = 0; }); } // renewable share → halo + grid height const renew = _getRenewable(json); if (renew.live) { _setRow("renew", renew.value.toFixed(1) + "%", renew.label); _anim.renewT = renew.value / 100; } else { _setRow("renew", "NO-LIVE-DATA", "STRUCTURAL-ONLY"); _anim.renewT = 0; } // carbon → dome + grid tint const carb = _getCarbon(json); if (carb.live) { _setRow("carbon", Math.round(carb.value) + "", carb.label); _anim.carbonT = Math.min(1, carb.value / 500); // ~500 gCO2/kWh = "dirty" } else { _setRow("carbon", "NO-LIVE-DATA", "STRUCTURAL-ONLY"); _anim.carbonT = 0; } // next negative windows → countdown ribbon const nw = _getNegWindows(json); if (nw.live && nw.count !== null) { _setRow("neg", nw.count + (nw.count === 1 ? " window" : " windows"), "SAMPLE"); _scene._negMax = Math.max(_scene._negMax || 4, nw.count || 0, 4); _anim.negCountT = nw.count; } else { _setRow("neg", "NO-LIVE-DATA", "STRUCTURAL-ONLY"); _anim.negCountT = 0; } // THE FUNNEL — joules. MEASURED only on-box; off-box honest sample posture, no fill. const j = _getJoules(json); const jlabel = j.label || (json.joules_label ? String(json.joules_label).toUpperCase() : "STRUCTURAL-ONLY"); if (_scene.RES.chip) { try { _scene.RES.group.remove(_scene.RES.chip); _scene.RES.chip = _ctx.label.billboard(_THREE, jlabel, { text: j.measured && j.total !== null ? (j.total.toFixed(0) + " J") : "joules", scale: 0.55, position: [0, 2.55, 0], depthTest: false, }); _scene.RES.group.add(_scene.RES.chip); } catch (_) {} } if (j.measured && j.total !== null) { // on-box: fill the reservoir from REAL measured joules (log-scaled to a sane visual range) const frac = Math.max(0.02, Math.min(1, Math.log10(1 + j.total) / 6)); // 1e6 J ~= full _anim.fillT = frac; _setRow("joules", j.total.toFixed(0) + " J (" + (j.node || "exporter") + ")", "MEASURED"); _scene.RES.fluidMat.color.setHex(C.green); _scene.RES.fluidMat.emissive.setHex(C.green); } else { // off-box / no fresh sample: honest — reservoir stays empty, label sample/structural _anim.fillT = 0; _setRow("joules", jlabel === "SAMPLE" ? "sample (no on-box NVML)" : "NO-LIVE-DATA", jlabel === "SAMPLE" ? "SAMPLE" : "STRUCTURAL-ONLY"); _scene.RES.fluidMat.color.setHex(C.blue); _scene.RES.fluidMat.emissive.setHex(C.blue); } // power_w gauge if (j.powerW !== null) { _setRow("power", j.powerW.toFixed(1) + " W", j.measured ? "MEASURED" : "SAMPLE"); _anim.powerWT = Math.min(1, j.powerW / 1000); } else { _setRow("power", "NO-LIVE-DATA", "STRUCTURAL-ONLY"); _anim.powerWT = 0; } // grid-feed health constellation + badge (n reachable / total) const rs = Array.isArray(json.readings) ? json.readings : []; let reachable = 0, measured = 0; for (let i = 0; i < _scene.HEALTH.nodes.length; i++) { const node = _scene.HEALTH.nodes[i]; const r = rs[i]; if (!r) { node.material.color.setHex(C.dim); node.material.emissive.setHex(C.dim); node.material.emissiveIntensity = 0.15; node.visible = i < Math.max(3, rs.length); continue; } node.visible = true; const ok = r.reachable !== false; const meas = r.measured === true; if (ok) reachable++; if (meas) measured++; const col = meas ? C.green : (ok ? C.blue : C.red); node.material.color.setHex(col); node.material.emissive.setHex(col); node.material.emissiveIntensity = 0.6; } if (rs.length) _setRow("health", reachable + "/" + rs.length + " reachable" + (measured ? " · " + measured + " measured" : ""), measured ? "MEASURED" : (reachable ? "SAMPLE" : "STRUCTURAL-ONLY")); else _setRow("health", "NO-LIVE-DATA", "STRUCTURAL-ONLY"); } function onLoop(json, meta) { if (!_scene) return; if (meta.state === "missing" || meta.state === "error") { _setRow("credits", "NO-LIVE-DATA", "STRUCTURAL-ONLY"); _anim.creditsT = 0; return; } const res = (json && json.reservoir) || {}; const credits = _num(res.work_credits); if (credits !== null) { const lbl = (json && json.intake && json.intake.joules_label) ? String(json.intake.joules_label).toUpperCase() : "STRUCTURAL-ONLY"; _setRow("credits", credits.toFixed(2), lbl === "MEASURED" ? "MEASURED" : (lbl === "SAMPLE" ? "SAMPLE" : "STRUCTURAL-ONLY")); _anim.creditsT = Math.max(0.02, Math.min(1, credits / 100)); } else { _setRow("credits", meta.state === "degraded" ? "degraded" : "NO-LIVE-DATA", "STRUCTURAL-ONLY"); _anim.creditsT = 0; } } // ---------------------------------------------------------------------------- // contract: mount / unmount // ---------------------------------------------------------------------------- function mount(ctx) { _ctx = ctx; _stage = ctx.stage; _THREE = ctx.THREE; build(); buildHUD(); // wire BOTH live endpoints. The HUD badge tracks the primary (posture) endpoint. _hPosture = ctx.live.poll(POSTURE_EP, 5000, onPosture, { badge: _hud.badge }); _hLoop = ctx.live.poll(LOOP_EP, 7000, onLoop); return { id: ID, started: true }; } function unmount() { try { if (_hPosture) _hPosture.stop(); } catch (_) {} try { if (_hLoop) _hLoop.stop(); } catch (_) {} try { if (_overlay && _overlay.parentNode) _overlay.parentNode.removeChild(_overlay); } catch (_) {} try { if (_root && _stage) _stage.scene.remove(_root); } catch (_) {} // dispose geometries/materials we created under _root try { if (_root) _root.traverse((o) => { if (o.geometry && o.geometry.dispose) o.geometry.dispose(); if (o.material) { const m = o.material; (Array.isArray(m) ? m : [m]).forEach((x) => { if (x.map && x.map.dispose) x.map.dispose(); if (x.dispose) x.dispose(); }); } }); } catch (_) {} try { if (_stage) _stage.setBloom(false); } catch (_) {} _hPosture = null; _hLoop = null; _overlay = null; _root = null; _scene = null; _frameFn = null; _hud = {}; _ctx = null; _stage = null; _THREE = null; } export default { id: ID, title: TITLE, endpoints: [POSTURE_EP, LOOP_EP], mount, unmount };