/* ============================================================
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 (
);
}
/* ---------------- 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,
});