a11oy / static /3d /surfaces /energy.js
betterwithage's picture
chore(sync): mirror static/3d kit + Dockerfile-COPY'd web/*.html to Space (hf-sync)
b4e5d7a verified
Raw
History Blame
35.7 kB
// 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 <span style='color:#46586a;font-weight:400'>· flagship · live joules funnel</span>";
_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 };