"use client"; import { useEffect, useId, useState } from "react"; import { useReducedMotion } from "motion/react"; type HourSandProps = { /** Colour of the sand. */ sand?: string; /** Colour of the wooden frame. */ wood?: string; /** Seconds for one full turn, before speed. */ seconds?: number; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; /** Read out by screen readers. */ label?: string; className?: string; }; const clamp = (v: number) => Math.min(1, Math.max(0, v)); const span = (p: number, a: number, b: number) => clamp((p - a) / (b - a)); const ease = (t: number) => t * t * (3 - 2 * t); const lerp = (a: number, b: number, t: number) => a + (b - a) * t; // Overshoots a little past the end, the weight of a turned glass. const swing = (t: number) => 1 + 2.2 * (t - 1) ** 3 + 1.2 * (t - 1) ** 2; const DRAIN = 0.82; // The clock runs out here, then the frame turns over. const FLIP = 0.86; const REST = 0.38; const GLASS = "M44 24 C44 70 74 84 76.5 100 C74 116 44 130 44 176 L116 176 C116 130 86 116 83.5 100 C86 84 116 70 116 24 Z"; const GRAINS = [0, 1, 2, 3, 4, 5]; // A turned spindle, the same from either end so the flip lands clean. const POST = (x: number) => `M${x - 4} 20 q3 13 0 26 q-7 27 0 54 q-7 27 0 54 q3 13 0 26 h8 q-3 -13 0 -26 q7 -27 0 -54 q7 -27 0 -54 q-3 -13 0 -26 Z`; /* HourSand: a turned-wood sand glass. A dimple sinks in the top bulb, a thin stream with loose grains falls, a cone piles up; when it runs out the whole frame flips over and starts again. */ export function HourSand({ sand = "#e0a955", wood = "#7a4a26", seconds = 6, speed = 1, label = "Loading", className = "" }: HourSandProps) { const reduce = useReducedMotion(); const id = useId().replace(/:/g, ""); const [clock, setClock] = useState(REST); useEffect(() => { if (reduce) return; const length = seconds / speed; const start = performance.now() - REST * length * 1000; let raf = 0; const tick = (now: number) => { setClock(((now - start) / (length * 1000)) % 1); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [reduce, seconds, speed]); const p = reduce ? REST : clock; const left = 1 - span(p, 0, DRAIN); // Share of the sand still in the top bulb. const fell = 1 - left; const turn = span(p, FLIP, 1); const running = p < DRAIN; // After the flip the pile falls to the neck side, which is the shape the top bulb starts from. const settle = ease(span(turn, 0.45, 1)); const topY = 98 - 52 * Math.sqrt(left); const dip = left > 0 ? Math.min(10, left * 34) * Math.min(1, fell * 8) : 0; const peak = lerp(178 - 66 * Math.sqrt(fell), 154, settle); const edge = lerp(178 - 46 * fell ** 1.4, 154, settle); const floor = lerp(178, 98, settle); const fall = peak - 102; return (