"use client"; import { useEffect, useId, useRef } from "react"; import { animate, motion, useAnimationFrame, useAnimationControls, useInView, useMotionValue, useReducedMotion, useSpring } from "motion/react"; type NeedleGaugeProps = { /** The reading. */ value?: number; /** Bottom of the scale. */ min?: number; /** Top of the scale. */ max?: number; /** Big numbers on the scale, including both ends. */ ticks?: number; /** From this value up the scale is red and the lamp lights. */ redline?: number; /** Printed on the dial. */ label?: string; /** Printed after the reading. */ unit?: string; /** Needle shiver, in degrees. 0 holds it dead still. */ shiver?: number; /** Set the width here. Everything scales with it. */ className?: string; }; const PIVOT = { x: 210, y: 232 }; const SWEEP = 50; const R = 158; const STEPS = 5; // small marks between two numbers const polar = (deg: number, r: number) => { const a = ((deg - 90) * Math.PI) / 180; return [PIVOT.x + Math.cos(a) * r, PIVOT.y + Math.sin(a) * r]; }; const arc = (from: number, to: number, r: number) => { const [x1, y1] = polar(from, r); const [x2, y2] = polar(to, r); return `M${x1.toFixed(1)} ${y1.toFixed(1)}A${r} ${r} 0 0 1 ${x2.toFixed(1)} ${y2.toFixed(1)}`; }; /* NeedleGauge: a panel meter from a test bench. Bakelite case, cream enamel dial, and a mirror band under the scale that throws a faint second needle. The needle overshoots and settles on its spring, shivers a little, and jumps if you tap the glass. */ export function NeedleGauge({ value = 64, min = 0, max = 100, ticks = 6, redline = 80, label = "Server load", unit = "%", shiver = 0.22, className = "w-full max-w-md" }: NeedleGaugeProps) { const reduce = useReducedMotion(); const id = useId().replace(/:/g, ""); const box = useRef(null); const inView = useInView(box, { amount: 0.1 }); const needle = useRef(null); const ghost = useRef(null); const shell = useAnimationControls(); const place = (v: number) => (Math.min(max, Math.max(min, v)) - min) / (max - min); const angle = (v: number) => -SWEEP + place(v) * SWEEP * 2; const raw = useMotionValue(angle(value)); const spring = useSpring(raw, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 70, damping: 6, mass: 1 }); const tap = useMotionValue(0); useEffect(() => raw.set(angle(value))); useAnimationFrame((time) => { if (!inView) return; const t = time / 1000; const noise = reduce ? 0 : (Math.sin(t * 13) + Math.sin(t * 29.3) * 0.6 + Math.sin(t * 4.7)) * shiver * 0.5; const a = Math.max(-SWEEP - 3, Math.min(SWEEP + 3, spring.get() + tap.get() + noise)); needle.current?.setAttribute("transform", `rotate(${a.toFixed(2)} ${PIVOT.x} ${PIVOT.y})`); ghost.current?.setAttribute("transform", `translate(1.6 0.8) rotate(${a.toFixed(2)} ${PIVOT.x} ${PIVOT.y})`); }); const knock = () => { if (reduce) return; animate(tap, [0, 5, -3.4, 1.8, -0.8, 0], { duration: 0.7, ease: "easeOut" }); shell.start({ y: [0, 2, 0], transition: { duration: 0.14 } }); }; const big = Array.from({ length: ticks }, (_, i) => i / (ticks - 1)); const small = Array.from({ length: (ticks - 1) * STEPS + 1 }, (_, i) => i / ((ticks - 1) * STEPS)); const redFrom = -SWEEP + place(redline) * SWEEP * 2; const over = value >= redline; const mono = "ui-monospace, Consolas, monospace"; return ( {[[14, 14, 20], [406, 14, 110], [14, 286, 65], [406, 286, 150]].map(([x, y, r]) => ( ))} {/* Mirror band. */} {/* Scale. */} {small.map((f) => { const a = -SWEEP + f * SWEEP * 2; const major = big.some((b) => Math.abs(b - f) < 1e-6); const [x1, y1] = polar(a, R); const [x2, y2] = polar(a, R + (major ? 13 : 7)); return ; })} {big.map((f) => { const a = -SWEEP + f * SWEEP * 2; const [x, y] = polar(a, R + 26); return ( {Math.round(min + f * (max - min))} ); })} {label} {/* Needle. */} {/* Bakelite cover over the pivot. */} {value}{unit} {/* Lamp. */} {over && !reduce && ( )} OVER ); }