"use client"; import { useMemo, useState } from "react"; import clsx from "clsx"; import { Area, Bar, BarChart, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import { fmtCompact, fmtInt, fmtNum, fmtPct } from "@/lib/format"; /* Tooltip box styled with tokens (values lead, labels follow). */ function TipBox({ title, rows }: { title: string; rows: { label: string; value: string; color?: string }[] }) { return (
{title}
{rows.map((r) => (
{r.color && } {r.label} {r.value}
))}
); } type TipPayload = { payload?: Record; dataKey?: string | number; value?: unknown }[]; // ---------------------------------------------------------------- price (close) line export function CloseLineChart({ data, height = 240 }: { data: { t: string; close: number; label: string }[]; height?: number }) { if (!data.length) return null; return (
fmtNum(Number(v), 2)} /> { if (!active || !payload?.length) return null; const p = payload[0].payload as { t: string; close: number; open?: number; high?: number; low?: number; volume?: number }; return ( ); }} />
); } // ---------------------------------------------------------------- volume bars export function VolumeBarChart({ data, height = 110, color = "var(--series-1)", valueKey = "volume", title = "volume" }: { data: { label: string; [k: string]: unknown }[]; height?: number; color?: string; valueKey?: string; title?: string }) { if (!data.length) return null; return (
fmtCompact(Number(v))} /> { if (!active || !payload?.length) return null; const p = payload[0].payload as Record; return ; }} />
); } // ---------------------------------------------------------------- generic horizontal bars (counts by category) export function CategoryBars({ data, height, color = "var(--series-1)", format = fmtInt }: { data: { name: string; value: number }[]; height?: number; color?: string; format?: (n: number) => string }) { if (!data.length) return null; const h = height ?? Math.max(80, data.length * 28 + 16); return (
fmtCompact(Number(v))} /> { if (!active || !payload?.length) return null; const p = payload[0].payload as { name: string; value: number }; return ; }} /> format(Number(v)) }} />
); } // ---------------------------------------------------------------- completeness heatmap (calendar cells) const SEQ = ["--seq-100", "--seq-200", "--seq-300", "--seq-400", "--seq-500", "--seq-600", "--seq-700"]; export function seqColor(x: number | null | undefined): string { if (x === null || x === undefined || Number.isNaN(x)) return "var(--surface-3)"; const i = Math.min(SEQ.length - 1, Math.max(0, Math.floor(Math.max(0, Math.min(1, x)) * SEQ.length))); return `var(${SEQ[i]})`; } export interface HeatCell { session_date: string; completeness: number | null; symbols: number; bars: number; } export function CompletenessHeatmap({ cells, start, end }: { cells: HeatCell[]; start: string; end: string }) { const [hover, setHover] = useState(null); // Build week columns Mon..Fri (sessions are weekdays). Missing day = no data. const { weeks, byDate } = useMemo(() => { const map = new Map(cells.map((c) => [c.session_date, c])); const s = new Date(start + "T00:00:00Z"); const e = new Date(end + "T00:00:00Z"); // align start to Monday const dow = (s.getUTCDay() + 6) % 7; // Mon=0 s.setUTCDate(s.getUTCDate() - dow); const weeks: string[][] = []; for (let d = new Date(s); d <= e; ) { const w: string[] = []; for (let i = 0; i < 7; i++) { w.push(d.toISOString().slice(0, 10)); d.setUTCDate(d.getUTCDate() + 1); } weeks.push(w.slice(0, 5)); // Mon–Fri } return { weeks, byDate: map }; }, [cells, start, end]); const cell = 14; const gap = 2; const w = weeks.length * (cell + gap) + 28; const h = 5 * (cell + gap) + 18; const days = ["Mon", "Tue", "Wed", "Thu", "Fri"]; return (
{days.map((d, i) => ( {d} ))} {weeks.map((wk, wi) => { const first = wk[0]; const showMonth = first.slice(8, 10) <= "07"; return ( {showMonth && ( {first.slice(0, 7)} )} {wk.map((date, di) => { const c = byDate.get(date); const inRange = date >= start && date <= end; if (!inRange) return null; return ( 0 ? 1 : 0)) : "var(--surface-2)"} stroke={hover?.session_date === date ? "var(--ink-1)" : "none"} strokeWidth={1.5} tabIndex={c ? 0 : -1} onMouseEnter={() => c && setHover(c)} onFocus={() => c && setHover(c)} onMouseLeave={() => setHover(null)} onBlur={() => setHover(null)} > {c ? `${date}: ${fmtPct(c.completeness)} · ${fmtInt(c.symbols)} symbols · ${fmtInt(c.bars)} bars` : `${date}: no data`} ); })} ); })}
0 % {SEQ.map((s) => ( ))} 100 % no data
{hover ? ( <> {hover.session_date} · {fmtPct(hover.completeness)} · {fmtInt(hover.symbols)} symbols · {fmtCompact(hover.bars)} bars ) : ( "hover a cell" )}
); } // ---------------------------------------------------------------- meter (storage gauge) export function Meter({ value, max, marks = [], tone = "accent", label, className }: { value: number; max: number; marks?: { at: number; label: string }[]; tone?: "accent" | "warn" | "crit" | "ok"; label?: string; className?: string }) { const pct = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0; const fill = { accent: "var(--accent)", warn: "var(--warn)", crit: "var(--crit)", ok: "var(--ok)" }[tone]; const track = { accent: "var(--accent-soft)", warn: "var(--warn-soft)", crit: "var(--crit-soft)", ok: "var(--ok-soft)" }[tone]; return (
{marks.map((m) => { const x = max > 0 ? Math.max(0, Math.min(1, m.at / max)) : 0; return (
{m.label}
); })}
); } // ---------------------------------------------------------------- segmented bar (one whole split in parts) export function SegmentedBar({ parts, total, height = 8 }: { parts: { label: string; value: number; color: string }[]; total?: number; height?: number }) { const sum = total ?? parts.reduce((a, p) => a + p.value, 0); if (sum <= 0) return
; return (
{parts.filter((p) => p.value > 0).map((p) => (
))}
    {parts.map((p) => (
  • {p.label}
  • ))}
); }