"use client"; import { useEffect, useId, useRef, useState } from "react"; import { AnimatePresence, motion, useAnimationFrame, useInView, useReducedMotion, useSpring } from "motion/react"; type StreakFlameProps = { /** Days in a row (controlled). */ streak?: number; /** Days in a row at start (uncontrolled). */ defaultStreak?: number; /** Today is done (controlled). */ lit?: boolean; /** Today is done at start (uncontrolled). */ defaultLit?: boolean; /** Word after the count. */ unit?: string; /** Called with the new streak when today is lit. */ onLight?: (streak: number) => void; /** Set the width here. Everything scales with it. */ className?: string; }; const WICK = { x: 319, y: 170 }; const LAYERS = [ { scale: 1, fill: "o", seed: 0 }, { scale: 0.7, fill: "y", seed: 1.7 }, { scale: 0.4, fill: "w", seed: 3.1 }, ]; const CHIPS = [0, 1, 2, 3, 4, 5, 6]; // One tongue of flame, drawn fresh each frame from a few waves. const tongue = (h: number, w: number, t: number, lean: number, seed: number) => { const left: string[] = []; const right: string[] = []; const stretch = h * (1 + Math.sin(t * 8 + seed) * 0.05 + Math.sin(t * 13.7 + seed) * 0.03); for (let i = 0; i <= 12; i++) { const s = i / 12; const half = w * Math.pow(Math.sin(Math.PI * Math.pow(s, 0.62)), 0.85) * (1 - s * 0.25); const sway = lean * s * s * h + Math.sin(t * 6.3 + s * 4.6 + seed) * s * w * 0.3 + Math.sin(t * 10 + s * 8 + seed) * s * s * w * 0.14; const y = WICK.y + 4 - s * stretch; left.push(`${(WICK.x - half + sway).toFixed(1)} ${y.toFixed(1)}`); right.unshift(`${(WICK.x + half + sway).toFixed(1)} ${y.toFixed(1)}`); } return `M${left.join("L")}L${right.join("L")}Z`; }; /* StreakFlame: the streak is a clay lamp in a stone niche. The flame burns higher the longer the streak, sinks low on a day not yet done, and takes the wind from your pointer. Light today and it leaps; lose the streak and it dies to a thread of smoke. */ export function StreakFlame({ streak, defaultStreak = 12, lit, defaultLit = false, unit = "day streak", onLight, className = "w-full max-w-lg" }: StreakFlameProps) { const reduce = useReducedMotion(); const id = useId().replace(/:/g, ""); const box = useRef(null); const inView = useInView(box, { amount: 0.1 }); const [innerStreak, setInnerStreak] = useState(defaultStreak); const [innerLit, setInnerLit] = useState(defaultLit); const [startLit] = useState(lit ?? defaultLit); const count = streak ?? innerStreak; const done = lit ?? innerLit; const dead = count === 0 && !done; const paths = useRef<(SVGPathElement | null)[]>([]); const glow = useRef(null); const wind = useRef({ now: 0, goal: 0 }); const full = 44 + 72 * (1 - Math.exp(-count / 16)); const goal = dead ? 0 : done ? full : full * 0.42; const size = useSpring(goal, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 120, damping: 7 }); useEffect(() => size.set(goal), [size, goal]); useAnimationFrame((time, delta) => { if (!inView) return; const t = reduce ? 1 : time / 1000; const h = Math.max(0, size.get()); const w = wind.current; w.now += (w.goal - w.now) * Math.min(1, delta / 160); paths.current.forEach((el, i) => { if (!el) return; const l = LAYERS[i]; el.setAttribute("d", h < 1 ? "" : tongue(h * l.scale, Math.max(3, h * 0.2) * l.scale, t, w.now, l.seed)); }); const c = glow.current; if (c) { c.setAttribute("r", (70 + h * 1.5 + Math.sin(t * 9) * 3).toFixed(1)); c.setAttribute("opacity", Math.min(1, h / 60).toFixed(2)); } }); const light = () => { if (done) return; setInnerLit(true); setInnerStreak(count + 1); onLight?.(count + 1); }; const past = Math.min(6, Math.max(0, count - (done ? 1 : 0))); const mono = "ui-monospace, Consolas, monospace"; return (
{ const b = box.current?.getBoundingClientRect(); if (b) wind.current.goal = Math.max(-0.5, Math.min(0.5, -((e.clientX - b.left) / b.width - 0.69) * 0.9)); }} onPointerLeave={() => { wind.current.goal = 0; }} > {/* Stone wall, cut into blocks. */} {[0, 1, 2, 3, 4].map((r) => ( {[0, 1, 2, 3].map((c) => ( ))} ))} {/* Niche. */} {/* Smoke when it dies. */} {dead && !reduce && ( )} {LAYERS.map((l, i) => ( { paths.current[i] = el; }} fill={`url(#${id}${l.fill})`} style={{ mixBlendMode: i ? "normal" : "screen" }} /> ))} {/* Sparks when today is lit. */} {done && !startLit && !reduce && ( {[-60, -35, -15, 10, 30, 55].map((a, i) => ( ))} )} {/* The lamp, on the sill. */} {/* The count, cut in the wall. */} {count} {unit} {/* This week: small lamps, the last is today. */} {CHIPS.map((i) => { const today = i === 6; const on = today ? done : i >= 6 - past; const x = 44 + i * 23; return ( {on && } {today && } ); })}
); }