SPB Git

spb/ai-atlas

Public
10.1 KB · 198 lines tsx
1import { extent, max } from 'd3-array';2import { scaleLinear, scaleLog, scaleTime } from 'd3-scale';3import { area, curveMonotoneX, line } from 'd3-shape';4import { cn } from '@/lib/cn';5import { fmtCompact } from '@/lib/format';67/*8  Pure-SVG, server-safe charts. Colours come from CSS variables (--series-1..8, --accent, --ink-3, --rule) so they9  follow the theme. Every chart accepts `className` and sizes to its container via viewBox + width 100%.10*/1112export type Point = { x: number | Date; y: number };1314export function Sparkline({ values, width = 120, height = 28, className, stroke = 'var(--accent)', fill = true, strokeWidth = 1.5 }: { values: number[]; width?: number; height?: number; className?: string; stroke?: string; fill?: boolean; strokeWidth?: number }) {15  const clean = values.filter((v) => Number.isFinite(v));16  if (clean.length < 2) return <span className={cn('inline-block text-xs text-ink-3', className)}>—</span>;17  const x = scaleLinear().domain([0, clean.length - 1]).range([1, width - 1]);18  const [lo, hi] = extent(clean) as [number, number];19  const y = scaleLinear().domain([lo === hi ? lo - 1 : lo, lo === hi ? hi + 1 : hi]).range([height - 2, 2]);20  const l = line<number>().x((_, i) => x(i)).y((d) => y(d)).curve(curveMonotoneX);21  const a = area<number>().x((_, i) => x(i)).y0(height).y1((d) => y(d)).curve(curveMonotoneX);22  const last = clean[clean.length - 1] as number;23  return (24    <svg viewBox={`0 0 ${width} ${height}`} width={width} height={height} className={cn('overflow-visible', className)} aria-hidden>25      {fill && <path d={a(clean) ?? ''} fill={stroke} opacity={0.1} />}26      <path d={l(clean) ?? ''} fill="none" stroke={stroke} strokeWidth={strokeWidth} />27      <circle cx={x(clean.length - 1)} cy={y(last)} r={2} fill={stroke} />28    </svg>29  );30}3132/** Vertical bars (counts per bucket). Labels shown for ≤ 16 bars or every nth. */33export 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 }) {34  if (!data.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>;35  const w = 600;36  const pad = { l: 0, r: 0, t: 14, b: showLabels ? 18 : 2 };37  const m = max(data, (d) => d.value) ?? 0;38  const y = scaleLinear().domain([0, m || 1]).range([height - pad.b, pad.t]);39  const bw = (w - pad.l - pad.r) / data.length;40  const every = Math.ceil(data.length / 12);41  return (42    <svg viewBox={`0 0 ${w} ${height}`} className={cn('block w-full', className)} role="img" aria-label="Bar chart">43      <line x1={0} x2={w} y1={height - pad.b} y2={height - pad.b} stroke="var(--rule-strong)" />44      {data.map((d, i) => {45        const h = height - pad.b - y(d.value);46        return (47          <g key={d.label + i}>48            <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} />49            {d.value > 0 && data.length <= 24 && (50              <text x={pad.l + i * bw + bw / 2} y={y(d.value) - 3} textAnchor="middle" fontSize={9} fill="var(--ink-3)" className="tnum">51                {format(d.value)}52              </text>53            )}54            {showLabels && i % every === 0 && (55              <text x={pad.l + i * bw + bw / 2} y={height - 5} textAnchor="middle" fontSize={9} fill="var(--ink-3)">56                {d.label}57              </text>58            )}59          </g>60        );61      })}62    </svg>63  );64}6566/** Horizontal bars with labels — for rankings (top orgs, categories). Uses HTML for crisp text. */67export 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 }) {68  if (!data.length) return <p className={cn('text-xs text-ink-3', className)}>No data</p>;69  const m = (maxOverride ?? max(data, (d) => d.value) ?? 0) || 1;70  return (71    <ol className={cn('space-y-1.5', className)}>72      {data.map((d) => {73        const link = d.href ?? href?.(d);74        const label = link ? (75          <a href={link} className="truncate text-ink hover:text-accent hover:underline">76            {d.label}77          </a>78        ) : (79          <span className="truncate text-ink">{d.label}</span>80        );81        return (82          <li key={d.label} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 text-sm">83            <div className="flex min-w-0 items-baseline gap-2">84              {label}85              {d.sub && <span className="truncate text-xs text-ink-3">{d.sub}</span>}86            </div>87            <span className="tnum text-xs text-ink-2">{format(d.value)}</span>88            <div className="col-span-2 h-1 rounded-sm bg-surface-2">89              <div className="h-full rounded-sm" style={{ width: `${(100 * d.value) / m}%`, background: color }} />90            </div>91          </li>92        );93      })}94    </ol>95  );96}9798export type Series = { name: string; color?: string; points: Point[] };99100/**101 * Multi-series line chart with time or linear x axis, light grid, end labels.102 * `yScale="log"` uses a log₁₀ y axis (points ≤ 0 are dropped; domain padded around the positive extent) — for prices103 * spanning several orders of magnitude. Default `linear` keeps the historical behaviour (domain anchored at 0).104 */105export function LineChart({ series, height = 220, className, yFormat = fmtCompact, xTime = true, yLabel, showDots = false, yDomain, yScale = 'linear' }: { series: Series[]; height?: number; className?: string; yFormat?: (v: number) => string; xTime?: boolean; yLabel?: string; showDots?: boolean; yDomain?: [number, number]; yScale?: 'linear' | 'log' }) {106  const log = yScale === 'log';107  const keep = (p: Point) => Number.isFinite(p.y) && (!log || p.y > 0);108  const all = series.flatMap((s) => s.points).filter(keep);109  if (all.length < 2) return <p className={cn('text-xs text-ink-3', className)}>Not enough history</p>;110  const w = 720;111  const pad = { l: 44, r: 16, t: 12, b: 24 };112  const xs = all.map((p) => (p.x instanceof Date ? p.x.getTime() : Number(p.x)));113  const [x0, x1] = extent(xs) as [number, number];114  const x = 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]);115  const ys = all.map((p) => p.y);116  let [y0, y1] = yDomain ?? (extent(ys) as [number, number]);117  if (y0 === y1) {118    y0 = y0 * 0.9;119    y1 = y1 * 1.1 || 1;120  }121  if (!yDomain && !log) y0 = Math.min(0, y0);122  if (log) {123    y0 = Math.max(Number.EPSILON, y0 <= 0 ? Math.min(...ys.filter((v) => v > 0)) : y0) / 1.25;124    y1 = y1 * 1.25;125  }126  const y = log ? scaleLog().domain([y0, y1]).range([height - pad.b, pad.t]) : scaleLinear().domain([y0, y1]).nice(4).range([height - pad.b, pad.t]);127  const toX = (p: Point) => x(p.x instanceof Date ? p.x : xTime ? new Date(Number(p.x)) : Number(p.x)) as number;128  const l = line<Point>().x(toX).y((p) => y(p.y)).curve(curveMonotoneX);129  const ticks = log ? logTicks(y0, y1) : y.ticks(4);130  const xt = (x as { ticks: (n: number) => (Date | number)[] }).ticks(5);131  const fmtX = (v: Date | number) => (v instanceof Date ? v.toLocaleDateString('en-GB', { month: 'short', year: '2-digit', timeZone: 'UTC' }) : String(v));132  return (133    <svg viewBox={`0 0 ${w} ${height}`} className={cn('block w-full', className)} role="img" aria-label={yLabel ?? 'Line chart'}>134      {ticks.map((t) => (135        <g key={t}>136          <line x1={pad.l} x2={w - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />137          <text x={pad.l - 6} y={y(t) + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)" className="tnum">138            {yFormat(t)}139          </text>140        </g>141      ))}142      {xt.map((t, i) => (143        <text key={i} x={x(t as never) as number} y={height - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)">144          {fmtX(t)}145        </text>146      ))}147      {series.map((s, i) => {148        const pts = s.points.filter(keep).sort((a, b) => (a.x instanceof Date ? a.x.getTime() : Number(a.x)) - (b.x instanceof Date ? b.x.getTime() : Number(b.x)));149        if (pts.length === 0) return null;150        const color = s.color ?? `var(--series-${(i % 8) + 1})`;151        const last = pts[pts.length - 1] as Point;152        return (153          <g key={s.name}>154            <path d={l(pts) ?? ''} fill="none" stroke={color} strokeWidth={1.75} />155            {(showDots || pts.length < 12) && pts.map((p, j) => <circle key={j} cx={toX(p)} cy={y(p.y)} r={2.2} fill={color} />)}156            <circle cx={toX(last)} cy={y(last.y)} r={3} fill={color} />157          </g>158        );159      })}160    </svg>161  );162}163164/** Powers of ten inside [lo, hi] (at least the two bounds when the range spans < 1 decade). */165function logTicks(lo: number, hi: number): number[] {166  const out: number[] = [];167  for (let e = Math.ceil(Math.log10(lo)); e <= Math.floor(Math.log10(hi)); e++) out.push(10 ** e);168  if (out.length < 2) return [lo, hi];169  return out;170}171172export function Legend({ series, className }: { series: { name: string; color?: string }[]; className?: string }) {173  return (174    <ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2', className)}>175      {series.map((s, i) => (176        <li key={s.name} className="flex items-center gap-1.5">177          <span className="inline-block h-[3px] w-4 rounded-sm" style={{ background: s.color ?? `var(--series-${(i % 8) + 1})` }} />178          {s.name}179        </li>180      ))}181    </ul>182  );183}184185/** Step chart (price history): keeps a value until the next change. */186export function stepPoints(rows: { at: string; value: number | null }[]): Point[] {187  const out: Point[] = [];188  for (const r of rows) {189    if (r.value === null || !Number.isFinite(r.value)) continue;190    const t = new Date(r.at);191    if (Number.isNaN(t.getTime())) continue;192    const prev = out[out.length - 1];193    if (prev) out.push({ x: new Date(t.getTime() - 1), y: prev.y });194    out.push({ x: t, y: r.value });195  }196  return out;197}198
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme