SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
17.5 KB · 428 lines tsx
Raw Blame History
1import { extent, max, min } from 'd3-array';2import { scaleLinear, scaleLog, scaleTime } from 'd3-scale';3import { area, curveMonotoneX, curveStepAfter, line } from 'd3-shape';4import type { ReactNode } from 'react';5import { cn } from '@/lib/cn';6import { fmtCompact, fmtDeltaPct } from '@/lib/format';78/*9  Pure-SVG, server-safe charts. Colours come from CSS variables (--series-1..8, --accent, --ink-3, --rule) so they10  follow the theme. Every chart accepts `className` and sizes to its container via viewBox + width 100%.11  Interactive variants (hover crosshair, brushing) live in client files next to this one and reuse `lineLayout()`.12*/1314export type Point = { x: number | Date; y: number };1516/**17 * Sparkline. `variant="trend"` adds a delta label (last vs first, %) coloured positive/negative — pass `invert` when18 * lower is better (prices) so a drop reads as positive.19 */20export function Sparkline({21  values,22  width = 120,23  height = 28,24  className,25  stroke = 'var(--accent)',26  fill = true,27  strokeWidth = 1.5,28  variant = 'line',29  invert = false,30  format,31  title,32}: {33  values: number[];34  width?: number;35  height?: number;36  className?: string;37  stroke?: string;38  fill?: boolean;39  strokeWidth?: number;40  variant?: 'line' | 'trend';41  invert?: boolean;42  format?: (v: number) => string;43  title?: string;44}) {45  const clean = values.filter((v) => Number.isFinite(v));46  if (clean.length < 2) return <span className={cn('inline-block text-xs text-ink-3', className)}>—</span>;47  const x = scaleLinear().domain([0, clean.length - 1]).range([1, width - 1]);48  const [lo, hi] = extent(clean) as [number, number];49  const y = scaleLinear().domain([lo === hi ? lo - 1 : lo, lo === hi ? hi + 1 : hi]).range([height - 2, 2]);50  const l = line<number>().x((_, i) => x(i)).y((d) => y(d)).curve(curveMonotoneX);51  const a = area<number>().x((_, i) => x(i)).y0(height).y1((d) => y(d)).curve(curveMonotoneX);52  const first = clean[0] as number;53  const last = clean[clean.length - 1] as number;54  const delta = fmtDeltaPct(first, last);55  const up = last > first;56  const good = invert ? !up : up;57  const svg = (58    <svg viewBox={`0 0 ${width} ${height}`} width={width} height={height} className={cn('overflow-visible', variant === 'line' && className)} role={title ? 'img' : undefined} aria-label={title} aria-hidden={title ? undefined : true}>59      {fill && <path d={a(clean) ?? ''} fill={stroke} opacity={0.1} />}60      <path d={l(clean) ?? ''} fill="none" stroke={stroke} strokeWidth={strokeWidth} />61      <circle cx={x(clean.length - 1)} cy={y(last)} r={2} fill={stroke} />62    </svg>63  );64  if (variant === 'line') return svg;65  return (66    <span className={cn('inline-flex items-center gap-2', className)}>67      {svg}68      <span className="tnum flex flex-col leading-tight">69        {format && <span className="text-sm font-medium text-ink">{format(last)}</span>}70        {delta && <span className={cn('text-[11px] font-medium', last === first ? 'text-ink-3' : good ? 'text-positive' : 'text-danger')}>{delta}</span>}71      </span>72    </span>73  );74}7576/** Vertical bars (counts per bucket). Labels shown for ≤ 16 bars or every nth. */77export function Bars({ data, height = 120, className, color = 'var(--series-1)', showLabels = true, format = fmtCompact }: { data: { label: string; value: number }[]; height?: number; className?: string; color?: string; showLabels?: boolean; format?: (v: number) => string }) {78  if (!data.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>;79  const w = 600;80  const pad = { l: 0, r: 0, t: 14, b: showLabels ? 18 : 2 };81  const m = max(data, (d) => d.value) ?? 0;82  const y = scaleLinear().domain([0, m || 1]).range([height - pad.b, pad.t]);83  const bw = (w - pad.l - pad.r) / data.length;84  const every = Math.ceil(data.length / 12);85  return (86    <svg viewBox={`0 0 ${w} ${height}`} className={cn('block w-full', className)} role="img" aria-label="Bar chart">87      <line x1={0} x2={w} y1={height - pad.b} y2={height - pad.b} stroke="var(--rule-strong)" />88      {data.map((d, i) => {89        const h = height - pad.b - y(d.value);90        return (91          <g key={d.label + i}>92            <rect x={pad.l + i * bw + bw * 0.15} y={y(d.value)} width={bw * 0.7} height={Math.max(0, h)} fill={color} opacity={0.9} />93            {d.value > 0 && data.length <= 24 && (94              <text x={pad.l + i * bw + bw / 2} y={y(d.value) - 3} textAnchor="middle" fontSize={9} fill="var(--ink-3)" className="tnum">95                {format(d.value)}96              </text>97            )}98            {showLabels && i % every === 0 && (99              <text x={pad.l + i * bw + bw / 2} y={height - 5} textAnchor="middle" fontSize={9} fill="var(--ink-3)">100                {d.label}101              </text>102            )}103          </g>104        );105      })}106    </svg>107  );108}109110/** Horizontal bars with labels — for rankings (top orgs, categories). Uses HTML for crisp text. */111export function HBars({ data, className, color = 'var(--series-1)', format = fmtCompact, max: maxOverride, href }: { data: { label: string; value: number; sub?: string; href?: string }[]; className?: string; color?: string; format?: (v: number) => string; max?: number; href?: (d: { label: string }) => string | undefined }) {112  if (!data.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>;113  const m = (maxOverride ?? max(data, (d) => d.value) ?? 0) || 1;114  return (115    <ol className={cn('space-y-1.5', className)}>116      {data.map((d) => {117        const link = d.href ?? href?.(d);118        const label = link ? (119          <a href={link} className="truncate text-ink hover:text-accent hover:underline">120            {d.label}121          </a>122        ) : (123          <span className="truncate text-ink">{d.label}</span>124        );125        return (126          <li key={d.label} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 text-sm">127            <div className="flex min-w-0 items-baseline gap-2">128              {label}129              {d.sub && <span className="truncate text-xs text-ink-3">{d.sub}</span>}130            </div>131            <span className="tnum text-xs text-ink-2">{format(d.value)}</span>132            <div className="col-span-2 h-1 rounded-sm bg-surface-2">133              <div className="h-full rounded-sm" style={{ width: `${(100 * d.value) / m}%`, background: color }} />134            </div>135          </li>136        );137      })}138    </ol>139  );140}141142export type Series = { name: string; color?: string; points: Point[] };143144export interface LineChartProps {145  series: Series[];146  height?: number;147  className?: string;148  yFormat?: (v: number) => string;149  xTime?: boolean;150  yLabel?: string;151  showDots?: boolean;152  yDomain?: [number, number];153  yScale?: 'linear' | 'log';154  /** Step interpolation (prices, statuses): the value holds until the next point. */155  step?: boolean;156  /** Extra SVG layers (crosshair, tooltips) rendered inside the chart — used by `InteractiveLineChart`. */157  children?: ReactNode;158  /** Layout width in viewBox units (default 720). */159  width?: number;160}161162export const LINE_W = 720;163export const LINE_PAD = { l: 44, r: 16, t: 12, b: 24 };164165export type LineLayout = {166  w: number;167  height: number;168  pad: typeof LINE_PAD;169  x: (v: number) => number;170  y: (v: number) => number;171  /** Inverse of x for hover: viewBox x → time/number. */172  xInvert: (px: number) => number;173  series: { name: string; color: string; pts: { x: number; y: number }[] }[];174  ticksY: number[];175  ticksX: number[];176  fmtX: (v: number) => string;177  log: boolean;178  xTime: boolean;179};180181const toNum = (v: number | Date) => (v instanceof Date ? v.getTime() : Number(v));182183/** Shared scale/layout computation for LineChart and its interactive wrapper. Returns null with < 2 usable points. */184export function lineLayout({ series, height = 220, xTime = true, yDomain, yScale = 'linear', width = LINE_W }: Pick<LineChartProps, 'series' | 'height' | 'xTime' | 'yDomain' | 'yScale' | 'width'>): LineLayout | null {185  const log = yScale === 'log';186  const keep = (p: Point) => Number.isFinite(p.y) && (!log || p.y > 0);187  const all = series.flatMap((s) => s.points).filter(keep);188  if (all.length < 2) return null;189  const w = width;190  const pad = LINE_PAD;191  const xs = all.map((p) => toNum(p.x));192  const [x0, x1] = extent(xs) as [number, number];193  const xScale = xTime ? scaleTime().domain([new Date(x0), new Date(x1 === x0 ? x0 + 86400000 : x1)]).range([pad.l, w - pad.r]) : scaleLinear().domain([x0, x1 === x0 ? x0 + 1 : x1]).range([pad.l, w - pad.r]);194  const ys = all.map((p) => p.y);195  let [y0, y1] = yDomain ?? (extent(ys) as [number, number]);196  if (y0 === y1) {197    y0 = y0 * 0.9;198    y1 = y1 * 1.1 || 1;199  }200  if (!yDomain && !log) y0 = Math.min(0, y0);201  if (log) {202    y0 = Math.max(Number.EPSILON, y0 <= 0 ? (min(ys.filter((v) => v > 0)) as number) : y0) / 1.25;203    y1 = y1 * 1.25;204  }205  const yScaleFn = log ? scaleLog().domain([y0, y1]).range([height - pad.b, pad.t]) : scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]);206  const x = (v: number) => (xTime ? (xScale as ReturnType<typeof scaleTime>)(new Date(v)) : (xScale as ReturnType<typeof scaleLinear>)(v)) as number;207  const xInvert = (px: number) => {208    const inv = (xScale as { invert: (p: number) => Date | number }).invert(px);209    return inv instanceof Date ? inv.getTime() : Number(inv);210  };211  const ticksY = log ? logTicks(y0, y1) : (yScaleFn as ReturnType<typeof scaleLinear>).ticks(4);212  const ticksX = (xScale as { ticks: (n: number) => (Date | number)[] }).ticks(5).map(toNum);213  const fmtX = (v: number) => (xTime ? new Date(v).toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v));214  return {215    w,216    height,217    pad,218    x,219    y: (v: number) => yScaleFn(v) as number,220    xInvert,221    series: series.map((s, i) => ({222      name: s.name,223      color: s.color ?? `var(--series-${(i % 8) + 1})`,224      pts: s.points225        .filter(keep)226        .map((p) => ({ x: toNum(p.x), y: p.y }))227        .sort((a, b) => a.x - b.x),228    })),229    ticksY,230    ticksX,231    fmtX,232    log,233    xTime,234  };235}236237/**238 * Multi-series line chart with time or linear x axis, light grid, end labels.239 * `yScale="log"` uses a log₁₀ y axis (points ≤ 0 are dropped; domain padded around the positive extent) — for prices240 * spanning several orders of magnitude. Default `linear` keeps the historical behaviour (domain anchored at 0).241 * `step` switches to step-after interpolation (see `StepChart`). Pass `children` to draw extra layers.242 */243export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain, yScale = 'linear', step = false, children, width }: LineChartProps) {244  const L = lineLayout({ series, height, xTime, yDomain, yScale, width });245  if (!L) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>;246  const l = line<{ x: number; y: number }>()247    .x((p) => L.x(p.x))248    .y((p) => L.y(p.y))249    .curve(step ? curveStepAfter : curveMonotoneX);250  return (251    <svg viewBox={`0 0 ${L.w} ${L.height}`} className={cn('block w-full', className)} role="img" aria-label={yLabel ?? 'Line chart'}>252      {yLabel && <title>{yLabel}</title>}253      {L.ticksY.map((t) => (254        <g key={t}>255          <line x1={L.pad.l} x2={L.w - L.pad.r} y1={L.y(t)} y2={L.y(t)} stroke="var(--rule)" />256          <text x={L.pad.l - 6} y={L.y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum">257            {yFormat(t)}258          </text>259        </g>260      ))}261      {L.ticksX.map((t, i) => (262        <text key={i} x={L.x(t)} y={L.height - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)">263          {L.fmtX(t)}264        </text>265      ))}266      {L.series.map((s) => {267        if (s.pts.length === 0) return null;268        const last = s.pts[s.pts.length - 1] as { x: number; y: number };269        return (270          <g key={s.name}>271            <path d={l(s.pts) ?? ''} fill="none" stroke={s.color} strokeWidth={1.75} />272            {(showDots || s.pts.length < 12) && s.pts.map((p, j) => <circle key={j} cx={L.x(p.x)} cy={L.y(p.y)} r={2.2} fill={s.color} />)}273            <circle cx={L.x(last.x)} cy={L.y(last.y)} r={3} fill={s.color} />274          </g>275        );276      })}277      {children}278    </svg>279  );280}281282/** Step chart = LineChart in step mode (prices, statuses). */283export function StepChart(props: Omit<LineChartProps, 'step'>) {284  return <LineChart {...props} step />;285}286287/** Powers of ten inside [lo, hi] (at least the two bounds when the range spans < 1 decade). */288export function logTicks(lo: number, hi: number): number[] {289  const out: number[] = [];290  for (let e = Math.ceil(Math.log10(lo)); e <= Math.floor(Math.log10(hi)); e++) out.push(10 ** e);291  if (out.length < 2) return [lo, hi];292  return out;293}294295export function Legend({ series, className }: { series: { name: string; color?: string }[]; className?: string }) {296  return (297    <ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2', className)}>298      {series.map((s, i) => (299        <li key={s.name} className="flex items-center gap-1.5">300          <span className="inline-block h-[3px] w-4 rounded-sm" style={{ background: s.color ?? `var(--series-${(i % 8) + 1})` }} />301          {s.name}302        </li>303      ))}304    </ul>305  );306}307308/** Step chart (price history): keeps a value until the next change. */309export function stepPoints(rows: { at: string; value: number | null }[]): Point[] {310  const out: Point[] = [];311  for (const r of rows) {312    if (r.value === null || !Number.isFinite(r.value)) continue;313    const t = new Date(r.at);314    if (Number.isNaN(t.getTime())) continue;315    const prev = out[out.length - 1];316    if (prev) out.push({ x: new Date(t.getTime() - 1), y: prev.y });317    out.push({ x: t, y: r.value });318  }319  return out;320}321322/* ------------------------------------------------------------------------------------------------------------ Heatmap */323export type HeatCell = { value: number | null; label?: string; href?: string; title?: string };324325/**326 * Matrix heatmap (server-safe HTML table): row/column labels, sticky headers inside `.table-scroll`, colour scale from tokens327 * (`color-mix` of `--accent` — or a per-column `colors`) between `min` and `max` (defaults: matrix extent). Empty cells read "—".328 * `direction="lower"` inverts the scale for lower-is-better metrics.329 */330export function Heatmap({331  rows,332  cols,333  cell,334  format = (v) => (Number.isInteger(v) ? String(v) : v.toFixed(1)),335  min: minOverride,336  max: maxOverride,337  color = 'var(--accent)',338  direction = 'higher',339  className,340  caption,341  rowHeader = '',342}: {343  rows: { key: string; label: string; href?: string; sub?: string }[];344  cols: { key: string; label: string; href?: string; sub?: string }[];345  cell: (rowKey: string, colKey: string) => HeatCell | null | undefined;346  format?: (v: number) => string;347  min?: number;348  max?: number;349  color?: string;350  direction?: 'higher' | 'lower';351  className?: string;352  caption?: string;353  rowHeader?: string;354}) {355  const values: number[] = [];356  for (const r of rows) for (const c of cols) {357    const v = cell(r.key, c.key)?.value;358    if (v !== null && v !== undefined && Number.isFinite(v)) values.push(v);359  }360  if (!rows.length || !cols.length || !values.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>;361  const lo = minOverride ?? (min(values) as number);362  const hi = maxOverride ?? (max(values) as number);363  const t = (v: number) => {364    const r = hi === lo ? 1 : (v - lo) / (hi - lo);365    const k = direction === 'lower' ? 1 - r : r;366    return Math.max(0, Math.min(1, k));367  };368  return (369    <div className={cn('table-scroll', className)}>370      <table className="heatmap">371        {caption && <caption className="sr-only">{caption}</caption>}372        <thead>373          <tr>374            <th scope="col" className="text-left">{rowHeader}</th>375            {cols.map((c) => (376              <th key={c.key} scope="col">377                {c.href ? (378                  <a href={c.href} className="hover:text-accent">379                    {c.label}380                  </a>381                ) : (382                  c.label383                )}384                {c.sub && <span className="block text-[10px] font-normal text-ink-3">{c.sub}</span>}385              </th>386            ))}387          </tr>388        </thead>389        <tbody>390          {rows.map((r) => (391            <tr key={r.key}>392              <th scope="row">393                {r.href ? (394                  <a href={r.href} className="hover:text-accent">395                    {r.label}396                  </a>397                ) : (398                  r.label399                )}400                {r.sub && <span className="block text-[10px] font-normal text-ink-3">{r.sub}</span>}401              </th>402              {cols.map((c) => {403                const h = cell(r.key, c.key);404                const v = h?.value;405                if (v === null || v === undefined || !Number.isFinite(v))406                  return (407                    <td key={c.key} className="empty" title={h?.title}>408                      —409                    </td>410                  );411                const k = t(v);412                const pct = Math.round(8 + k * 72);413                const style = { background: `color-mix(in srgb, ${color} ${pct}%, var(--surface))`, color: k > 0.62 ? 'var(--accent-ink)' : 'var(--ink)' };414                const text = h?.label ?? format(v);415                return (416                  <td key={c.key} style={style} title={h?.title ?? `${r.label} · ${c.label}: ${text}`}>417                    {h?.href ? <a href={h.href}>{text}</a> : text}418                  </td>419                );420              })}421            </tr>422          ))}423        </tbody>424      </table>425    </div>426  );427}428