// Icons + formatters + chart primitives shared across screens. // Heroicons-style 1.6 stroke; sized via currentColor. const Icon = ({ d, size = 18, fill, stroke = 1.6, ...p }) => ( {typeof d === 'string' ? : d} ); const IconDash = (p) => } />; const IconBot = (p) => } />; const IconWallet = (p) => } />; const IconClock = (p) => } />; const IconUser = (p) => } />; const IconShield = (p) => } />; const IconBolt = (p) => ; const IconArrowUp = (p) => ; const IconArrowDn = (p) => ; const IconArrowRt = (p) => ; const IconPlus = (p) => ; const IconMinus = (p) => ; const IconCheck = (p) => ; const IconX = (p) => ; const IconSearch = (p) => } />; const IconChart = (p) => } />; const IconSettings= (p) => } />; const IconUpload = (p) => } />; const IconID = (p) => } />; const IconQueue = (p) => } />; const IconPower = (p) => } />; const IconKey = (p) => } />; const IconExchange= (p) => } />; const IconRadar = (p) => } />; const IconGoogle = ({ size = 18 }) => ( ); /* ---------- Formatting helpers (USD) ---------- */ const USD = (n, opts = {}) => { const { sign = false, decimals = 2 } = opts; const num = Number(n || 0); const s = (sign && num > 0 ? '+' : '') + '$' + Math.abs(num).toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }); return num < 0 ? '-' + s.replace('-', '') : s; }; const fmtNum = (n, d = 4) => Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }); const fmtPct = (n, d = 2) => (n > 0 ? '+' : '') + Number(n || 0).toFixed(d) + '%'; const fmtDate = (ts) => new Date(ts).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); const fmtDateTime = (ts) => new Date(ts).toLocaleString('en-GB', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short' }); /* ---------- useSpring: critically-damped-ish spring ---------- */ function useSpring(initial, { stiffness = 220, damping = 26, mass = 1 } = {}) { const [val, setVal] = React.useState(initial); const ref = React.useRef({ v: initial, vel: 0, target: initial, raf: 0 }); const set = React.useCallback((target) => { ref.current.target = target; if (ref.current.raf) return; const step = () => { const s = ref.current; const force = -stiffness * (s.v - s.target); const damp = -damping * s.vel; const accel = (force + damp) / mass; s.vel += accel * (1 / 60); s.v += s.vel * (1 / 60); if (Math.abs(s.vel) < 0.001 && Math.abs(s.v - s.target) < 0.001) { s.v = s.target; s.vel = 0; s.raf = 0; setVal(s.v); return; } setVal(s.v); s.raf = requestAnimationFrame(step); }; ref.current.raf = requestAnimationFrame(step); }, [stiffness, damping, mass]); React.useEffect(() => () => cancelAnimationFrame(ref.current.raf), []); return [val, set]; } /* ---------- MorphNumber: digit-by-digit spring animation ---------- Each digit is a vertical strip 0-9; the target digit is revealed by translating the strip. New digits added on the left (e.g. 999 -> 1000) fade in from a tiny scale. Requires a numeric `size` so the strip geometry can be computed. */ function MorphNumber({ value, decimals = 2, prefix = '', suffix = '', size = 18, weight = 'inherit', color = 'inherit' }) { if (value == null || !Number.isFinite(value)) value = 0; const negative = value < 0; const abs = Math.abs(value); const str = abs.toFixed(decimals); const [intPart, decPart] = str.split('.'); const intWithSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); const chars = (negative ? '-' : '') + (decPart ? intWithSep + '.' + decPart : intWithSep); return ( {prefix && {prefix}} {chars.split('').map((c, i) => { if (/[0-9]/.test(c)) return ; const w = c === '.' || c === ',' ? size * 0.28 : size * 0.5; return {c}; })} {suffix && {suffix}} ); } function MorphDigit({ d, size }) { const [y, setY] = useSpring(-d * size); React.useEffect(() => { setY(-d * size); }, [d, size]); return ( {[0,1,2,3,4,5,6,7,8,9].map(n => ( {n} ))} ); } /* ---------- Sparkline (with springy stroke-dasharray reveal) ---------- */ function Sparkline({ data, color = 'currentColor', fill = true, height = 64 }) { const w = 200, h = height; const pathRef = React.useRef(null); if (!data || !data.length) return null; const min = Math.min(...data), max = Math.max(...data), rng = max - min || 1; const pts = data.map((v, i) => [(i / Math.max(1, data.length - 1)) * w, h - ((v - min) / rng) * (h - 8) - 4]); const path = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(' '); const area = path + ` L${w},${h} L0,${h} Z`; const gradId = React.useMemo(() => 'g' + Math.random().toString(36).slice(2, 7), []); React.useEffect(() => { const p = pathRef.current; if (!p) return; const len = p.getTotalLength(); p.style.strokeDasharray = len; p.style.strokeDashoffset = len; // Force layout so the transition applies cleanly when path changes. p.getBoundingClientRect(); p.style.transition = 'stroke-dashoffset 700ms cubic-bezier(.2,.7,.3,1)'; p.style.strokeDashoffset = 0; }, [path]); return ( {fill && } ); } /* ---------- LineChart: edge-to-edge, fill + line + hover crosshair ---------- Minimal by design — no grid, no axis labels, no baseline. The prototype relies on the change pill above and the live value to communicate scale; chart's job is to show the SHAPE of the move. Hover crosshair surfaces the exact value on demand without cluttering the resting state. */ function LineChart({ data, color = 'var(--accent)', height = 280, baseline = null, labels = null }) { const w = 800, h = height; const pathRef = React.useRef(null); const svgRef = React.useRef(null); const [hover, setHover] = React.useState(null); const gradId = React.useMemo(() => 'lc' + Math.random().toString(36).slice(2, 7), []); // Edge-to-edge: no horizontal padding, minimal vertical breathing room // for the stroke + endpoint dot. baseline + labels are kept in the API // for backward compat but no longer rendered. const padTop = 8, padBot = 8, padL = 0, padR = 0; const innerW = w - padL - padR; const innerH = h - padTop - padBot; const hasData = data && data.length > 0; const minV = hasData ? Math.min(...data) : 0; const maxV = hasData ? Math.max(...data) : 1; const rng = (maxV - minV) || 1; const xAt = i => padL + (i / Math.max(1, (data || [0]).length - 1)) * innerW; const yAt = v => padTop + innerH - ((v - minV) / rng) * innerH; const pts = hasData ? data.map((v, i) => [xAt(i), yAt(v)]) : []; const path = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(' '); const area = hasData ? path + ` L${pts[pts.length-1][0]},${h - padBot} L${pts[0][0]},${h - padBot} Z` : ''; // Springy reveal — re-runs whenever the path string changes (e.g. on // bucket switch), so the new line draws itself in instead of cutting. React.useEffect(() => { const p = pathRef.current; if (!p || !path) return; const len = p.getTotalLength(); p.style.strokeDasharray = len; p.style.strokeDashoffset = len; p.getBoundingClientRect(); p.style.transition = 'stroke-dashoffset 900ms cubic-bezier(.2,.7,.3,1)'; p.style.strokeDashoffset = 0; }, [path]); if (!hasData) return null; const onMove = (e) => { const r = svgRef.current.getBoundingClientRect(); const x = ((e.clientX - r.left) / r.width) * w; const t = Math.max(0, Math.min(1, (x - padL) / innerW)); setHover(Math.round(t * (data.length - 1))); }; return ( setHover(null)}> {hover != null && pts[hover] && ( {(() => { const tipW = 88, tipH = 26; const tipX = Math.min(w - tipW - 2, Math.max(2, pts[hover][0] - tipW / 2)); const tipY = Math.max(2, pts[hover][1] - tipH - 12); // Hard-coded near-black tooltip so the text reads on both light // and dark themes — using var(--ink) flips with the theme and // breaks contrast. return ( {Number(data[hover]).toFixed(4)} ); })()} )} ); } /* ---------- KPI card ---------- */ function KPI({ label, value, delta, sub, pos, neg }) { return (
{label}
{value}
{delta &&
{pos && }{neg && }{delta}
} {sub &&
{sub}
}
); } /* ---------- FeeRow utility ---------- */ function FeeRow({ label, v, hint }) { return (
{label}
{hint &&
{hint}
}
{v}
); } /* ---------- ToggleSwitch ---------- */ function ToggleSwitch({ on, onChange, disabled }) { return ( ); } /* ---------- useApi hook: load on mount + refetch helper ---------- */ function useApi(path, deps = []) { const [data, setData] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [reloadKey, setReloadKey] = React.useState(0); React.useEffect(() => { let alive = true; setLoading(true); setError(null); if (!path) { setLoading(false); return; } api.get(path) .then(d => { if (alive) { setData(d); setLoading(false); } }) .catch(e => { if (alive) { setError(e); setLoading(false); } }); return () => { alive = false; }; }, [path, reloadKey, ...deps]); return [data, { loading, error, reload: () => setReloadKey(k => k + 1) }]; } Object.assign(window, { Icon, IconDash, IconBot, IconWallet, IconClock, IconUser, IconShield, IconBolt, IconArrowUp, IconArrowDn, IconArrowRt, IconPlus, IconMinus, IconCheck, IconX, IconSearch, IconChart, IconSettings, IconUpload, IconID, IconQueue, IconPower, IconKey, IconExchange, IconRadar, IconGoogle, USD, fmtNum, fmtPct, fmtDate, fmtDateTime, Sparkline, LineChart, KPI, FeeRow, ToggleSwitch, useApi, useSpring, MorphNumber, });