SPB Git

spb/findata

Public
13.3 KB · 275 lines tsx
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
Files · a5f98ef8a13b9f73ec8d98afb905553dc1fd27ed
Menu

Theme