// ── Morse code engine ────────────────────────────────────────────────────── // SOS: · · · — — — · · · const MORSE_SEQ = [ [1,true],[1,false],[1,true],[1,false],[1,true], // S: ... [3,false], [3,true],[1,false],[3,true],[1,false],[3,true], // O: --- [3,false], [1,true],[1,false],[1,true],[1,false],[1,true], // S: ... [8,false], // word gap ]; const UNIT = 180; // ms per unit function useMorse(active) { const [lit, setLit] = React.useState(false); const timer = React.useRef(null); const idx = React.useRef(0); React.useEffect(() => { if (!active) { setLit(false); return; } function tick() { const [dur, on] = MORSE_SEQ[idx.current % MORSE_SEQ.length]; setLit(on); idx.current++; timer.current = setTimeout(tick, dur * UNIT); } tick(); return () => clearTimeout(timer.current); }, [active]); return lit; } // ── Meter ────────────────────────────────────────────────────────────────── const MAX_ALT = 2000; const TICK_ALTS = [0, 400, 800, 1200, 1600, 2000]; const METER_H = 500; const AXIS_X = 48; function AltitudeMeter({ coffee, visible, animSpeed }) { const [progress, setProgress] = React.useState(0); const [displayAlt, setDisplayAlt] = React.useState(0); const raf = React.useRef(null); const duration = animSpeed === 'slow' ? 3200 : animSpeed === 'fast' ? 900 : 2000; const targetPct = coffee.altitude / MAX_ALT; const fillPx = progress * targetPct * METER_H; // current height in px from bottom const dotY = METER_H - fillPx; // SVG y-coordinate of the dot const morseActive = visible && progress > 0.97; const dotLit = useMorse(morseActive); React.useEffect(() => { setProgress(0); setDisplayAlt(0); if (raf.current) cancelAnimationFrame(raf.current); if (!visible) return; const t = setTimeout(() => { let start = null; function tick(ts) { if (!start) start = ts; const p = Math.min((ts - start) / duration, 1); const eased = 1 - Math.pow(1 - p, 4); setProgress(eased); setDisplayAlt(Math.round(eased * coffee.altitude)); if (p < 1) raf.current = requestAnimationFrame(tick); } raf.current = requestAnimationFrame(tick); }, 280); return () => { clearTimeout(t); if (raf.current) cancelAnimationFrame(raf.current); }; }, [coffee.id, visible, animSpeed]); // ── Generate contour lines ────────────────────────────────────────────── // ~36 horizontal lines, each with a seed-based length variation const LINE_SPACING = 14; const lines = []; for (let i = 1; i * LINE_SPACING <= METER_H + LINE_SPACING; i++) { const bottomPx = i * LINE_SPACING; const y = METER_H - bottomPx; if (y < -10) break; // Pseudo-random length using index as seed const seed = ((i * 97 + 31) % 100) / 100; const len = 70 + seed * 55; // 70–125 px const isLit = bottomPx <= fillPx + 1; lines.push({ y, len, isLit, seed, idx: i }); } // Is the topmost lit line (the "surface") const topLitLine = lines.filter(l => l.isLit).slice(-1)[0]; return (
{/* Ghost altitude numeral */} {/* SVG canvas */}
{/* ── Vertical axis ── */} {/* ── Tick marks + altitude labels ── */} {TICK_ALTS.map(alt => { const ty = METER_H - (alt / MAX_ALT) * METER_H; const isLit = (alt / MAX_ALT) * METER_H <= fillPx + 2; return ( {/* Extended tick across the panel */} {alt === 0 ? 'S.L.' : alt} ); })} {/* ── Contour lines ── */} {lines.map(({ y, len, isLit, seed, idx: li }) => { const opacity = isLit ? 0.5 + seed * 0.45 // lit: 0.5–0.95 : 0.04 + seed * 0.04; // dim: 0.04–0.08 const sw = isLit ? (seed > 0.7 ? 1.2 : 0.7) : 0.5; return ( ); })} {/* ── Surface line + dot ── */} {fillPx > 8 && ( {/* Horizontal dashed reach line to dot */} {/* Outer halo (when lit) */} {dotLit && ( )} {/* Mid ring */} {/* Core dot */} )} {/* Altitude counter — tracks dot Y position */}
8 ? 'rgba(255,255,255,0.2)' : 'transparent'}`, minWidth: 90, }}>
8 ? 1 : 0, }}> {displayAlt.toLocaleString()}
8 ? 1 : 0, }}> M.A.S.L.
{/* Morse signal indicator */} {morseActive && (
{[0,1,2].map(i => (
))}
)}
{/* Label */}
GROWING ALTITUDE
); } Object.assign(window, { AltitudeMeter });