← back to Charge And Explore
public/charge-viz.js
96 lines
// 3D charge visualization — an animated battery filling to the target SOC.
// Loaded as an ES module (three.js from CDN via import map). Exposes
// window.ChargeViz.{mount,update,unmount}. Mobile-first: responsive, light.
import * as THREE from "three";
let renderer, scene, camera, fillMesh, raf, container, labelEl;
let targetFill = 0.8, curFill = 0.1, targetSoc = 80, minutes = "";
function webglOK() {
try { const c = document.createElement("canvas"); return !!(window.WebGLRenderingContext && (c.getContext("webgl") || c.getContext("experimental-webgl"))); }
catch { return false; }
}
function mount(el, data) {
container = el;
if (!webglOK()) { el.innerHTML = '<div style="padding:16px;color:#5b6b7a;font-size:13px">3D view needs WebGL.</div>'; return; }
const w = el.clientWidth || 320, h = el.clientHeight || 240;
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(38, w / h, 0.1, 100);
camera.position.set(2.4, 1.6, 3.4);
camera.lookAt(0, 0.2, 0);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
renderer.setSize(w, h);
el.appendChild(renderer.domElement);
scene.add(new THREE.AmbientLight(0xffffff, 0.75));
const key = new THREE.DirectionalLight(0xffffff, 1.1); key.position.set(3, 5, 4); scene.add(key);
const rim = new THREE.DirectionalLight(0x8fe8c8, 0.5); rim.position.set(-3, 2, -2); scene.add(rim);
// battery shell (glass)
const shell = new THREE.Mesh(
new THREE.BoxGeometry(1.5, 2.4, 1.5),
new THREE.MeshPhysicalMaterial({ color: 0xdfe7ec, transparent: true, opacity: 0.18, roughness: 0.15, metalness: 0.1, transmission: 0.6 }),
);
scene.add(shell);
// battery cap
const cap = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.18, 0.6), new THREE.MeshStandardMaterial({ color: 0x9fb0bd }));
cap.position.y = 1.29; scene.add(cap);
// green charge fill (scales up from the bottom)
fillMesh = new THREE.Mesh(
new THREE.BoxGeometry(1.4, 2.3, 1.4),
new THREE.MeshStandardMaterial({ color: 0x0bb37f, emissive: 0x0a7d5a, emissiveIntensity: 0.35, roughness: 0.4 }),
);
fillMesh.geometry.translate(0, 1.15, 0); // pivot at bottom so scale.y fills upward
fillMesh.position.y = -1.15;
scene.add(fillMesh);
labelEl = document.createElement("div");
labelEl.style.cssText = "position:absolute;left:0;right:0;bottom:8px;text-align:center;font:600 13px system-ui;color:#0f1720;pointer-events:none";
el.style.position = "relative";
el.appendChild(labelEl);
if (data) applyData(data);
window.addEventListener("resize", onResize);
animate();
}
function applyData(d) {
targetSoc = Math.max(0, Math.min(100, d.targetSocPercent ?? 80));
targetFill = targetSoc / 100;
curFill = Math.max(0.03, (d.arrivalSocPercent ?? 20) / 100);
minutes = d.minutes || "";
if (labelEl) labelEl.textContent = `charging to ${targetSoc}%${minutes ? " · " + minutes + " min" : ""}`;
}
function update(d) { if (!renderer) return; applyData(d); }
function onResize() {
if (!renderer || !container) return;
const w = container.clientWidth || 320, h = container.clientHeight || 240;
camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h);
}
function animate() {
raf = requestAnimationFrame(animate);
// ease the fill toward target; loop it subtly so it feels alive
curFill += (targetFill - curFill) * 0.03;
if (Math.abs(curFill - targetFill) < 0.005) curFill = Math.max(0.03, (targetFill * 0.35)); // reset to re-fill (loop)
const f = Math.max(0.03, Math.min(1, curFill));
fillMesh.scale.y = f;
scene.rotation.y += 0.005;
renderer.render(scene, camera);
}
function unmount() {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
if (renderer) { renderer.dispose(); renderer.domElement.remove(); }
renderer = scene = camera = fillMesh = null;
}
window.ChargeViz = { mount, update, unmount };
// If the app already staged charge data before this module loaded, honor it.
if (window.__chargeData) { const el = document.getElementById("chargeviz"); if (el) mount(el, window.__chargeData); }