← 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); }