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%
7.0 KB · 135 lines tsx
Raw Blame History
1'use client';2import { useMemo, useRef } from 'react';3import { Legend, LineChart, lineLayout, type Series, stepPoints } from '@/components/charts/charts';4import { useEvidence } from '@/components/evidence';5import { Note } from '@/components/ui/section';6import { cn } from '@/lib/cn';7import { fmtDate, fmtUsdPerM, hostOf, num } from '@/lib/format';8import type { Price } from '@/lib/types';910/*11  Price history 3.0 (client): one step line per provider (output price by default, input on toggle by the parent), a legend, and a12  transitions list. Clicking the chart or a transition opens the evidence drawer for that price row (source, tier, observed time).13*/1415type Transition = { id: string; provider: string; providerSlug: string; at: string; from: number | null; to: number | null; row: Price };1617export function buildTransitions(history: Price[], field: 'input_per_mtok' | 'output_per_mtok'): Transition[] {18  const byProv = new Map<string, Price[]>();19  for (const p of history) {20    const arr = byProv.get(p.provider.slug) ?? [];21    arr.push(p);22    byProv.set(p.provider.slug, arr);23  }24  const out: Transition[] = [];25  for (const rows of byProv.values()) {26    const sorted = [...rows].sort((a, b) => a.valid_from.localeCompare(b.valid_from));27    let prev: number | null = null;28    for (const r of sorted) {29      const v = num(r[field]);30      if (v === null) continue;31      if (prev === null || prev !== v) out.push({ id: r.id, provider: r.provider.name, providerSlug: r.provider.slug, at: r.valid_from, from: prev, to: v, row: r });32      prev = v;33    }34  }35  return out.sort((a, b) => a.at.localeCompare(b.at));36}3738export function PriceHistoryChart({ history, field, modelSlug, modelName, className }: { history: Price[]; field: 'input_per_mtok' | 'output_per_mtok'; modelSlug: string; modelName: string; className?: string }) {39  const { open } = useEvidence();40  const wrap = useRef<HTMLDivElement>(null);41  const byProv = useMemo(() => {42    const m = new Map<string, Price[]>();43    for (const p of history) {44      const arr = m.get(p.provider.name) ?? [];45      arr.push(p);46      m.set(p.provider.name, arr);47    }48    return m;49  }, [history]);50  // "Now" anchor for open-ended steps = the latest observation in the data (deterministic → identical server and client SVG).51  const nowAnchor = useMemo(() => history.reduce((m, p) => (p.observed_at > m ? p.observed_at : m), history[0]?.observed_at ?? ''), [history]);52  const series: Series[] = useMemo(53    () =>54      [...byProv.entries()].slice(0, 8).map(([name, rows]) => {55        const sorted = [...rows].sort((a, b) => a.valid_from.localeCompare(b.valid_from));56        const last = sorted[sorted.length - 1];57        const pts = stepPoints(sorted.map((r) => ({ at: r.valid_from, value: num(r[field]) })));58        if (last && !last.valid_to && num(last[field]) !== null && nowAnchor && nowAnchor > last.valid_from) pts.push({ x: new Date(nowAnchor), y: num(last[field]) as number });59        return { name, points: pts };60      }).filter((s) => s.points.length > 0),61    [byProv, field, nowAnchor],62  );63  const transitions = useMemo(() => buildTransitions(history, field), [history, field]);64  const L = useMemo(() => lineLayout({ series, height: 220, xTime: true, yDomain: [0, Math.max(...series.flatMap((s) => s.points.map((p) => p.y)), 0) * 1.15 || 1] }), [series]);65  const label = field === 'input_per_mtok' ? 'Input price' : 'Output price';6667  const openFor = (t: Transition) => {68    open({69      slug: modelSlug,70      property: `price.${field === 'input_per_mtok' ? 'input' : 'output'}.${t.providerSlug}`,71      value: t.to,72      display: `${fmtUsdPerM(t.to)} / 1M tokens`,73      label: `${label} · ${t.provider}`,74      fallback: { source_id: null, source_name: hostOf(t.row.source_url) ?? t.provider, url: t.row.source_url, observed_at: t.row.observed_at, tier: t.row.tier, confidence: 'high', extractor: 'deterministic', unit: 'USD / 1M tokens' },75      entity: { name: modelName, entity_type: 'model' },76    });77  };78  const onClick = (clientX: number) => {79    if (!L || !wrap.current || !transitions.length) return;80    const rect = wrap.current.getBoundingClientRect();81    const vx = ((clientX - rect.left) / rect.width) * L.w;82    const t = L.xInvert(vx);83    let best = transitions[0] as Transition;84    for (const tr of transitions) if (Math.abs(new Date(tr.at).getTime() - t) < Math.abs(new Date(best.at).getTime() - t)) best = tr;85    openFor(best);86  };8788  const points = series.reduce((n, s) => n + s.points.length, 0);89  if (!history.length) return null;90  return (91    <div className={cn('space-y-3', className)} data-price-history={field}>92      <p className="eyebrow">93        {label} · USD / 1M tokens <span className="tnum text-ink-3">{byProv.size} provider{byProv.size === 1 ? '' : 's'}</span>94      </p>95      {points < 2 || !L ? (96        <Note>Price history starts with the first observation — no change recorded yet for this side ({history.length} row{history.length === 1 ? '' : 's'}).</Note>97      ) : (98        <>99          <div ref={wrap} className="cursor-pointer" onClick={(e) => onClick(e.clientX)} title="Click to open the evidence of the nearest price change" data-price-chart>100            <LineChart series={series} height={220} step yFormat={(v) => fmtUsdPerM(v)} yDomain={[0, Math.max(...series.flatMap((s) => s.points.map((p) => p.y))) * 1.15 || 1]} yLabel={`${label} history of ${modelName}`}>101              {transitions.map((t) => (102                <circle key={t.id} cx={L.x(new Date(t.at).getTime())} cy={L.y(t.to ?? 0)} r={3.5} fill="var(--accent-2)" stroke="var(--canvas)" strokeWidth={1.2}>103                  <title>{`${t.provider}: ${t.from === null ? 'first observed' : fmtUsdPerM(t.from)} → ${fmtUsdPerM(t.to)} · ${fmtDate(t.at)}`}</title>104                </circle>105              ))}106            </LineChart>107          </div>108          <Legend series={series} />109        </>110      )}111      <ul className="divide-y divide-rule border-y border-rule text-sm">112        {transitions113          .slice()114          .reverse()115          .slice(0, 12)116          .map((t) => (117            <li key={t.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-3 py-1.5 sm:grid-cols-[10rem_minmax(0,1fr)_auto]">118              <span className="truncate text-ink-2">{t.provider}</span>119              <span className="tnum col-span-2 sm:col-span-1">120                {t.from === null ? <span className="text-ink-3">first observed </span> : <span className="text-ink-3">{fmtUsdPerM(t.from)} → </span>}121                <span className={cn('font-medium', t.from !== null && t.to !== null && t.to < t.from ? 'text-positive' : t.from !== null && t.to !== null && t.to > t.from ? 'text-danger' : 'text-accent-2')}>{fmtUsdPerM(t.to)}</span>122              </span>123              <span className="tnum flex items-center gap-2 text-xs text-ink-3">124                {fmtDate(t.at)}125                <button type="button" className="evidence text-accent" onClick={() => openFor(t)} data-evidence={`${modelSlug}:price.${t.providerSlug}`}>126                  Evidence127                </button>128              </span>129            </li>130          ))}131      </ul>132    </div>133  );134}135