"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 (
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 (
);
})}
);
}
// ---------------------------------------------------------------- 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}
))}
);
}