/* ============================================================ METABOLE — UI primitives, hooks, charts ============================================================ */ const { useState, useRef, useEffect, useMemo, useCallback } = React; /* ---------------- Hooks ---------------- */ function useInView(options = {}) { const ref = useRef(null); const [inView, setInView] = useState(false); useEffect(() => { const el = ref.current; if (!el) return; if (window.__forceInView) { setInView(true); return; } const once = options.once !== false; const margin = options.vh ?? 0.9; const check = () => { const r = el.getBoundingClientRect(); const vh = window.innerHeight || document.documentElement.clientHeight; return r.top < vh * margin && r.bottom > 0; }; if (check()) { setInView(true); if (once) return; } let io; if (typeof IntersectionObserver !== "undefined") { io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setInView(true); if (once) io.disconnect(); } else if (!once) setInView(false); }, { threshold: options.threshold ?? 0.12, rootMargin: options.rootMargin ?? "0px 0px -6% 0px" }); io.observe(el); } const onScroll = () => { const v = check(); if (v) { setInView(true); if (once) cleanup(); } else if (!once) setInView(false); }; const cleanup = () => { window.removeEventListener("scroll", onScroll); window.removeEventListener("resize", onScroll); }; window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll); return () => { io && io.disconnect(); cleanup(); }; }, []); return [ref, inView]; } function Reveal({ children, delay = 0, className = "", style = {}, as = "div" }) { const [ref, inView] = useInView(); const Tag = as; return ( {children} ); } function useCountUp(target, active, { dur = 1200, decimals = 0 } = {}) { const [val, setVal] = useState(0); useEffect(() => { if (!active) return; let raf, start; const from = 0, to = Number(target); const tick = (t) => { if (start == null) start = t; const p = Math.min(1, (t - start) / dur); const e = 1 - Math.pow(1 - p, 3); setVal(from + (to - from) * e); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [active, target]); return decimals ? val.toFixed(decimals) : Math.round(val); } function AnimatedNumber({ value, decimals = 0, active = true, suffix = "", prefix = "" }) { const v = useCountUp(value, active, { decimals }); return {prefix}{v}{suffix}; } /* ---------------- Icons (simple geometric line glyphs) ---------------- */ function Icon({ name, size = 24, stroke = 1.6, color = "currentColor", style }) { const p = { fill: "none", stroke: color, strokeWidth: stroke, strokeLinecap: "round", strokeLinejoin: "round", }; const paths = { camera: , trend: , grid: , chat: , book: , wave: , moon: , spark: , swatch: , medal: , doc: , shield: , lock: , apple: , check: , arrow: , plus: , droplet: , bolt: , bell: , syringe: , pin: , clock: , star: , minus: , flask: , }; return ( ); } /* ---------------- Logo ---------------- */ function Logo({ size = 22, color = "var(--green)", text = true }) { return ( {text && Metabole} ); } /* ---------------- WeightTrend chart ---------------- */ function buildPath(pts) { return pts.map((pt, i) => (i === 0 ? "M" : "L") + pt[0].toFixed(1) + " " + pt[1].toFixed(1)).join(" "); } function WeightTrend({ active, w = 360, h = 170, compact = false }) { const data = [214, 212.4, 210.1, 209.6, 207.2, 205.8, 204.9, 202.3, 201.1, 199.4, 197.8, 196.2, 195.1, 193.6]; const pad = { l: 8, r: 8, t: 16, b: 18 }; const min = 190, max = 216; const innerW = w - pad.l - pad.r, innerH = h - pad.t - pad.b; const pts = data.map((d, i) => [pad.l + (i / (data.length - 1)) * innerW, pad.t + (1 - (d - min) / (max - min)) * innerH]); const line = buildPath(pts); const area = line + ` L${pts[pts.length - 1][0]} ${pad.t + innerH} L${pts[0][0]} ${pad.t + innerH} Z`; const goalY = pad.t + (1 - (188 - min) / (max - min)) * innerH; const ref = useRef(null); const [len, setLen] = useState(600); useEffect(() => { if (ref.current) setLen(ref.current.getTotalLength()); }, []); const last = pts[pts.length - 1]; return ( {[0, 1, 2, 3].map(i => ( ))} {!compact && GOAL 188} ); } /* ---------------- Sparkline ---------------- */ function Sparkline({ data, color = "var(--green)", w = 90, h = 30, active = true }) { const min = Math.min(...data), max = Math.max(...data); const pts = data.map((d, i) => [(i / (data.length - 1)) * w, h - 2 - ((d - min) / (max - min || 1)) * (h - 4)]); return ( ); } /* ---------------- Sleep weekly bars ---------------- */ function WeeklyBars({ active, data = [7.2, 6.4, 8.1, 5.9, 7.8, 6.7, 8.4], goal = 8, labels = ["M", "T", "W", "T", "F", "S", "S"] }) { const max = 9; return (
{data.map((d, i) => (
= goal ? "var(--green)" : "var(--mint-2)", height: active ? (d / max * 100) + "%" : "0%", transition: `height .9s cubic-bezier(.2,.7,.3,1) ${i * 70}ms`, }} />
{labels[i]}
))}
); } /* ---------------- Sleep heatmap ---------------- */ function SleepHeatmap({ active, weeks = 16 }) { const cells = useMemo(() => Array.from({ length: weeks * 7 }, (_, i) => { const base = Math.sin(i * 0.5) * 0.3 + 0.55 + (Math.random() * 0.35); return Math.max(0.08, Math.min(1, base)); }), [weeks]); return (
{Array.from({ length: 7 }).map((_, row) => ( Array.from({ length: weeks }).map((_, col) => { const v = cells[col * 7 + row]; return
; }) ))}
); } /* ---------------- Progress ring ---------------- */ function Ring({ pct = 72, size = 64, stroke = 7, color = "var(--green)", track = "var(--mint)", active = true, label, value }) { const r = (size - stroke) / 2; const c = 2 * Math.PI * r; return (
{value ?? pct + "%"}
{label &&
{label}
}
); } Object.assign(window, { useInView, Reveal, useCountUp, AnimatedNumber, Icon, Logo, WeightTrend, Sparkline, WeeklyBars, SleepHeatmap, Ring, buildPath, });