1"use client";2import { useMemo, useState } from "react";3import clsx from "clsx";4import { Area, Bar, BarChart, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";5import { fmtCompact, fmtInt, fmtNum, fmtPct } from "@/lib/format";67/* Tooltip box styled with tokens (values lead, labels follow). */8function TipBox({ title, rows }: { title: string; rows: { label: string; value: string; color?: string }[] }) {9 return (10 <div className="rounded-md border border-border bg-surface-1 px-2.5 py-2 text-xs shadow-md">11 <div className="mb-1 text-ink-3">{title}</div>12 {rows.map((r) => (13 <div key={r.label} className="flex items-center justify-between gap-4">14 <span className="flex items-center gap-1.5 text-ink-2">15 {r.color && <span className="inline-block h-0.5 w-3 rounded" style={{ background: r.color }} aria-hidden />}16 {r.label}17 </span>18 <span className="font-semibold tnum text-ink-1">{r.value}</span>19 </div>20 ))}21 </div>22 );23}2425type TipPayload = ReadonlyArray<{ payload?: unknown; value?: unknown }>;2627// ---------------------------------------------------------------- price (close) line28export function CloseLineChart({ data, height = 240 }: { data: { t: string; close: number; label: string }[]; height?: number }) {29 if (!data.length) return null;30 return (31 <div className="viz" style={{ height }}>32 <ResponsiveContainer width="100%" height="100%">33 <ComposedChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 0 }}>34 <CartesianGrid vertical={false} />35 <XAxis dataKey="label" tickLine={false} minTickGap={32} />36 <YAxis domain={["auto", "auto"]} tickLine={false} axisLine={false} width={56} tickFormatter={(v) => fmtNum(Number(v), 2)} />37 <Tooltip38 cursor={{ stroke: "var(--axis)" }}39 content={({ active, payload }: { active?: boolean; payload?: TipPayload }) => {40 if (!active || !payload?.length) return null;41 const p = payload[0].payload as { t: string; close: number; open?: number; high?: number; low?: number; volume?: number };42 return (43 <TipBox44 title={p.t}45 rows={[46 { label: "close", value: fmtNum(p.close, 4), color: "var(--series-1)" },47 ...(p.open !== undefined ? [{ label: "open", value: fmtNum(p.open, 4) }] : []),48 ...(p.high !== undefined ? [{ label: "high", value: fmtNum(p.high, 4) }] : []),49 ...(p.low !== undefined ? [{ label: "low", value: fmtNum(p.low, 4) }] : []),50 ...(p.volume !== undefined ? [{ label: "volume", value: fmtInt(p.volume) }] : []),51 ]}52 />53 );54 }}55 />56 <Area type="monotone" dataKey="close" stroke="none" fill="var(--series-1)" fillOpacity={0.1} isAnimationActive={false} />57 <Line type="monotone" dataKey="close" stroke="var(--series-1)" strokeWidth={2} dot={false} activeDot={{ r: 4, stroke: "var(--surface-1)", strokeWidth: 2 }} isAnimationActive={false} />58 </ComposedChart>59 </ResponsiveContainer>60 </div>61 );62}6364// ---------------------------------------------------------------- volume bars65export 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 }) {66 if (!data.length) return null;67 return (68 <div style={{ height }}>69 <ResponsiveContainer width="100%" height="100%">70 <BarChart data={data} margin={{ top: 4, right: 12, bottom: 4, left: 0 }} barCategoryGap={2}>71 <CartesianGrid vertical={false} />72 <XAxis dataKey="label" tickLine={false} minTickGap={32} hide />73 <YAxis tickLine={false} axisLine={false} width={56} tickFormatter={(v) => fmtCompact(Number(v))} />74 <Tooltip75 cursor={{ fill: "var(--surface-3)" }}76 content={({ active, payload }: { active?: boolean; payload?: TipPayload }) => {77 if (!active || !payload?.length) return null;78 const p = payload[0].payload as Record<string, unknown>;79 return <TipBox title={String(p.t ?? p.label)} rows={[{ label: title, value: fmtInt(Number(p[valueKey])), color }]} />;80 }}81 />82 <Bar dataKey={valueKey} fill={color} maxBarSize={24} radius={[3, 3, 0, 0]} isAnimationActive={false} />83 </BarChart>84 </ResponsiveContainer>85 </div>86 );87}8889// ---------------------------------------------------------------- generic horizontal bars (counts by category)90export function CategoryBars({ data, height, color = "var(--series-1)", format = fmtInt }: { data: { name: string; value: number }[]; height?: number; color?: string; format?: (n: number) => string }) {91 if (!data.length) return null;92 const h = height ?? Math.max(80, data.length * 28 + 16);93 return (94 <div style={{ height: h }}>95 <ResponsiveContainer width="100%" height="100%">96 <BarChart data={data} layout="vertical" margin={{ top: 4, right: 48, bottom: 4, left: 8 }} barCategoryGap={4}>97 <CartesianGrid horizontal={false} />98 <XAxis type="number" tickLine={false} axisLine={false} tickFormatter={(v) => fmtCompact(Number(v))} />99 <YAxis type="category" dataKey="name" width={130} tickLine={false} axisLine={false} tick={{ fontSize: 11 }} />100 <Tooltip101 cursor={{ fill: "var(--surface-3)" }}102 content={({ active, payload }: { active?: boolean; payload?: TipPayload }) => {103 if (!active || !payload?.length) return null;104 const p = payload[0].payload as { name: string; value: number };105 return <TipBox title={p.name} rows={[{ label: "count", value: format(p.value), color }]} />;106 }}107 />108 <Bar dataKey="value" fill={color} maxBarSize={20} radius={[0, 3, 3, 0]} isAnimationActive={false} label={{ position: "right", fill: "var(--ink-2)", fontSize: 11, formatter: (v: unknown) => format(Number(v)) }} />109 </BarChart>110 </ResponsiveContainer>111 </div>112 );113}114115// ---------------------------------------------------------------- completeness heatmap (calendar cells)116const SEQ = ["--seq-100", "--seq-200", "--seq-300", "--seq-400", "--seq-500", "--seq-600", "--seq-700"];117export function seqColor(x: number | null | undefined): string {118 if (x === null || x === undefined || Number.isNaN(x)) return "var(--surface-3)";119 const i = Math.min(SEQ.length - 1, Math.max(0, Math.floor(Math.max(0, Math.min(1, x)) * SEQ.length)));120 return `var(${SEQ[i]})`;121}122123export interface HeatCell {124 session_date: string;125 completeness: number | null;126 symbols: number;127 bars: number;128}129130export function CompletenessHeatmap({ cells, start, end }: { cells: HeatCell[]; start: string; end: string }) {131 const [hover, setHover] = useState<HeatCell | null>(null);132 // Build week columns Mon..Fri (sessions are weekdays). Missing day = no data.133 const { weeks, byDate } = useMemo(() => {134 const map = new Map(cells.map((c) => [c.session_date, c]));135 const s = new Date(start + "T00:00:00Z");136 const e = new Date(end + "T00:00:00Z");137 // align start to Monday138 const dow = (s.getUTCDay() + 6) % 7; // Mon=0139 s.setUTCDate(s.getUTCDate() - dow);140 const weeks: string[][] = [];141 for (let d = new Date(s); d <= e; ) {142 const w: string[] = [];143 for (let i = 0; i < 7; i++) {144 w.push(d.toISOString().slice(0, 10));145 d.setUTCDate(d.getUTCDate() + 1);146 }147 weeks.push(w.slice(0, 5)); // Mon–Fri148 }149 return { weeks, byDate: map };150 }, [cells, start, end]);151152 const cell = 14;153 const gap = 2;154 const w = weeks.length * (cell + gap) + 28;155 const h = 5 * (cell + gap) + 18;156 const days = ["Mon", "Tue", "Wed", "Thu", "Fri"];157158 return (159 <div>160 <div className="overflow-x-auto">161 <svg width={w} height={h} role="img" aria-label="Completeness per session" className="block">162 {days.map((d, i) => (163 <text key={d} x={0} y={18 + i * (cell + gap) + cell - 3} fontSize={9} fill="var(--ink-3)">164 {d}165 </text>166 ))}167 {weeks.map((wk, wi) => {168 const first = wk[0];169 const showMonth = first.slice(8, 10) <= "07";170 return (171 <g key={wi} transform={`translate(${28 + wi * (cell + gap)}, 0)`}>172 {showMonth && (173 <text x={0} y={9} fontSize={9} fill="var(--ink-3)">174 {first.slice(0, 7)}175 </text>176 )}177 {wk.map((date, di) => {178 const c = byDate.get(date);179 const inRange = date >= start && date <= end;180 if (!inRange) return null;181 return (182 <rect183 key={date}184 x={0}185 y={18 + di * (cell + gap)}186 width={cell}187 height={cell}188 rx={2}189 fill={c ? seqColor(c.completeness ?? (c.bars > 0 ? 1 : 0)) : "var(--surface-2)"}190 stroke={hover?.session_date === date ? "var(--ink-1)" : "none"}191 strokeWidth={1.5}192 tabIndex={c ? 0 : -1}193 onMouseEnter={() => c && setHover(c)}194 onFocus={() => c && setHover(c)}195 onMouseLeave={() => setHover(null)}196 onBlur={() => setHover(null)}197 >198 <title>{c ? `${date}: ${fmtPct(c.completeness)} · ${fmtInt(c.symbols)} symbols · ${fmtInt(c.bars)} bars` : `${date}: no data`}</title>199 </rect>200 );201 })}202 </g>203 );204 })}205 </svg>206 </div>207 <div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-ink-3">208 <div className="flex items-center gap-1">209 <span>0 %</span>210 {SEQ.map((s) => (211 <span key={s} className="inline-block h-3 w-3 rounded-sm" style={{ background: `var(${s})` }} aria-hidden />212 ))}213 <span>100 %</span>214 <span className="ml-2 inline-block h-3 w-3 rounded-sm bg-surface-2 border border-hairline" aria-hidden /> <span>no data</span>215 </div>216 <div className="tnum min-h-[1.2em]">217 {hover ? (218 <>219 <span className="font-medium text-ink-1">{hover.session_date}</span> · {fmtPct(hover.completeness)} · {fmtInt(hover.symbols)} symbols · {fmtCompact(hover.bars)} bars220 </>221 ) : (222 "hover a cell"223 )}224 </div>225 </div>226 </div>227 );228}229230// ---------------------------------------------------------------- meter (storage gauge)231export 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 }) {232 const pct = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;233 const fill = { accent: "var(--accent)", warn: "var(--warn)", crit: "var(--crit)", ok: "var(--ok)" }[tone];234 const track = { accent: "var(--accent-soft)", warn: "var(--warn-soft)", crit: "var(--crit-soft)", ok: "var(--ok-soft)" }[tone];235 return (236 <div className={clsx("relative", className)} role="meter" aria-valuenow={value} aria-valuemin={0} aria-valuemax={max} aria-label={label}>237 <div className="h-3 w-full overflow-hidden rounded-full" style={{ background: track }}>238 <div className="h-full rounded-full" style={{ width: `${pct * 100}%`, background: fill }} />239 </div>240 {marks.map((m) => {241 const x = max > 0 ? Math.max(0, Math.min(1, m.at / max)) : 0;242 return (243 <div key={m.label} className="absolute top-[-4px] flex flex-col" style={{ left: `${x * 100}%`, transform: x > 0.8 ? "translateX(-100%)" : x < 0.2 ? "none" : "translateX(-50%)", alignItems: x > 0.8 ? "flex-end" : x < 0.2 ? "flex-start" : "center" }}>244 <div className="h-5 w-px bg-ink-2" />245 <div className="mt-0.5 whitespace-nowrap text-[10px] text-ink-3">{m.label}</div>246 </div>247 );248 })}249 </div>250 );251}252253// ---------------------------------------------------------------- segmented bar (one whole split in parts)254export function SegmentedBar({ parts, total, height = 8 }: { parts: { label: string; value: number; color: string }[]; total?: number; height?: number }) {255 const sum = total ?? parts.reduce((a, p) => a + p.value, 0);256 if (sum <= 0) return <div className="h-2 w-full rounded-full bg-surface-3" />;257 return (258 <div>259 <div className="flex w-full gap-0.5 overflow-hidden rounded-full" style={{ height }}>260 {parts.filter((p) => p.value > 0).map((p) => (261 <div key={p.label} style={{ width: `${(p.value / sum) * 100}%`, background: p.color }} title={`${p.label}: ${fmtPct(p.value / sum)}`} />262 ))}263 </div>264 <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2">265 {parts.map((p) => (266 <li key={p.label} className="flex items-center gap-1.5">267 <span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: p.color }} aria-hidden />268 {p.label}269 </li>270 ))}271 </ul>272 </div>273 );274}275