"use client"; import { useEffect, useId, useRef, useState } from "react"; import { motion, useAnimationFrame, useInView, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; type BatteryGaugeProps = { /** Charge, 0 to 100 (controlled). Leave out and it runs by itself. */ value?: number; /** Charge at start (uncontrolled). */ defaultValue?: number; /** Plugged in (controlled). */ charging?: boolean; /** Plugged in at start (uncontrolled). */ defaultCharging?: boolean; /** Percent per second while plugged in. It drains at a third of this. */ rate?: number; /** At or below this it reads Low. */ low?: number; /** Called when the plug is pulled or pushed in. */ onPlug?: (charging: boolean) => void; /** Set the width here. Everything scales with it. */ className?: string; }; const JAR = { left: 78, right: 282, floor: 214, top: 62 }; const PLATES = [108, 136, 164, 192, 220, 248]; const BUBBLES = Array.from({ length: 16 }, (_, i) => ({ x: PLATES[i % 6] + 22 - (i % 2) * 8 + (i % 3) * 2, speed: 26 + ((i * 13) % 22), r: 1.3 + (i % 3) * 0.6, start: (i * 41) % 100 })); /* BatteryGauge: the battery is a real wet cell on a bench. A glass jar of acid with lead plates hanging in it, brass posts and a cloth wire to a wall socket. The acid rises and goes from red to green with the charge, gas bubbles off the plates while it charges, and the acid sloshes when you move the pointer or pull the plug. */ export function BatteryGauge({ value, defaultValue = 34, charging, defaultCharging = true, rate = 3, low = 15, onPlug, className = "w-full max-w-lg" }: BatteryGaugeProps) { const reduce = useReducedMotion(); const id = useId().replace(/:/g, ""); const box = useRef(null); const inView = useInView(box, { amount: 0.1 }); const [innerOn, setInnerOn] = useState(defaultCharging); const on = charging ?? innerOn; const [shown, setShown] = useState(value ?? defaultValue); const [sparks, setSparks] = useState(0); const level = value ?? shown; const charge = useRef(value ?? defaultValue); const slosh = useRef({ tilt: 0, speed: 0, x: 0 }); const liquid = useRef(null); const bubbleEls = useRef<(SVGCircleElement | null)[]>([]); const fill = useMotionValue(level); const smooth = useSpring(fill, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 60, damping: 16 }); useEffect(() => fill.set(level), [fill, level]); const acid = useTransform(smooth, [0, 15, 40, 75, 100], ["#c8452f", "#c8452f", "#d99a2b", "#7fb446", "#4fbf78"]); const plug = useSpring(on ? 1 : 0, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 260, damping: 18 }); useEffect(() => plug.set(on ? 1 : 0), [plug, on]); const jackX = useTransform(plug, [0, 1], [-34, 0]); const wire = useTransform(jackX, (x) => `M115 26 C 115 -20, 330 -6, 336 70 S ${346 + x} 158, ${360 + x} 158`); useAnimationFrame((time, delta) => { if (!inView) return; const dt = Math.min(delta, 50) / 1000; const t = time / 1000; if (value === undefined) { const next = Math.min(100, Math.max(0, charge.current + (on ? rate : -rate / 3) * dt)); charge.current = next; if (Math.round(next) !== shown) setShown(Math.round(next)); } // Spring for the tilt of the acid. const s = slosh.current; s.speed += (-30 * s.tilt - 2.4 * s.speed) * dt; s.tilt += s.speed * dt; const top = JAR.floor - (smooth.get() / 100) * (JAR.floor - JAR.top); let d = ""; for (let x = JAR.left; x <= JAR.right; x += 6) { const u = (x - 180) / 102; const wave = reduce ? 0 : Math.sin(x / 22 + t * 2.2) * 1.3 + Math.sin(x / 9 - t * 3.1) * Math.min(2, Math.abs(s.speed) * 1.2); d += `${d ? "L" : "M"}${x} ${(top + (reduce ? 0 : s.tilt * u * 15) + wave).toFixed(1)}`; } liquid.current?.setAttribute("d", `${d}L${JAR.right} ${JAR.floor + 4}L${JAR.left} ${JAR.floor + 4}Z`); BUBBLES.forEach((b, i) => { const el = bubbleEls.current[i]; if (!el) return; const span = Math.max(1, JAR.floor - top); const y = JAR.floor - 6 - ((b.start + t * b.speed) % span); el.setAttribute("cy", y.toFixed(1)); el.setAttribute("cx", (b.x + Math.sin(t * 3 + i) * 1.6).toFixed(1)); el.setAttribute("opacity", on && !reduce ? String(Math.min(1, (y - top) / 8) * 0.8) : "0"); }); }); const toggle = () => { const next = !on; setInnerOn(next); setSparks((n) => n + 1); slosh.current.speed += next ? 1.3 : -1.3; onPlug?.(next); }; const status = level >= 100 ? "Full" : level <= low ? "Low" : on ? "Charging" : "Draining"; const mark = status === "Full" ? "●" : status === "Low" ? "!" : on ? "▲" : "▼"; return (
{ const s = slosh.current; s.speed = Math.max(-2.2, Math.min(2.2, s.speed + (e.clientX - s.x) * 0.012)); s.x = e.clientX; }} > {/* Socket on the wall. */} {on ? "ON" : "OFF"} {/* Bench. */} {/* Lead plates. */} {PLATES.map((x) => ( ))} {/* Acid. */} {BUBBLES.map((b, i) => ( { bubbleEls.current[i] = el; }} r={b.r} cx={b.x} cy={JAR.floor} fill="#fff" opacity="0" /> ))} {/* Jar. */} {/* Lid and posts. */} + - 6 CELL {/* Wire and jack. */} {sparks > 0 && !reduce && ( {[-70, -30, 10, 50, 100, 150].map((a) => ( ))} )} {/* Brass plate with the reading. */} {level}% {mark} {status}
); }