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%
28.2 KB · 467 lines tsx
Raw Blame History
1import { ArrowRight, Search } from 'lucide-react';2import type { Metadata } from 'next';3import Link from 'next/link';4import { ChangeRow } from '@/components/changes/change-row';5import { Sparkline, TimelineLanes } from '@/components/charts';6import { DataStrip, type StripItem, Ticker, type TickerItem } from '@/components/layout/terminal';7import { EntityBadge, OpennessBadge } from '@/components/ui/badges';8import { EntityLink } from '@/components/ui/entity';9import { LiveAgo } from '@/components/ui/live';10import { Container, Section } from '@/components/ui/section';11import { Unavailable } from '@/components/ui/unavailable';12import { api, safe } from '@/lib/api';13import { fmtDate, fmtDeltaPct, fmtInt, fmtParams, fmtScore, fmtTokens, fmtUsdPerM, hostOf, num } from '@/lib/format';14import { DESCRIPTION, EXAMPLE_QUERIES, eventTone, routes, signatureProducts, SITE_NAME, TAGLINE } from '@/lib/site';15import type { ChangeEvent, EntitySummary } from '@/lib/types';1617export const metadata: Metadata = {18  title: { absolute: `${SITE_NAME} — ${TAGLINE}` },19  description: DESCRIPTION,20  alternates: { canonical: '/' },21};22export const revalidate = 60;2324/** Fallback "How counted" texts — replaced by `stats.definitions` when the API provides them. */25const DEFINITIONS: Record<string, string> = {26  model: 'Entities typed “model” in the graph. After canonicalisation: canonical model releases only — artifacts (quantizations, conversions) and folded evaluation variants are excluded.',27  organization: 'Companies, labs, universities and other organizations (entity types company · organization · lab · university).',28  paper: 'Research papers ingested from arXiv and publisher feeds (one entity per paper, deduplicated by identifier).',29  dataset: 'Datasets known to the graph (Hugging Face and publisher pages).',30  benchmark_results: 'Live benchmark result rows (one per model × benchmark × metric × configuration). Superseded runs are closed, never deleted.',31  provider: 'API providers that serve models (each with its own model listing and prices).',32  prices_current: 'Price offers currently valid (USD per 1M tokens) across all providers — one live row per model × provider × provider model id.',33  documents: 'Source documents crawled by first-party connectors; every fact links back to one of them (snapshots are archived forever).',34};3536const OPEN_EVENT_TYPES = new Set(['NEW_MODEL', 'RELEASE', 'VERSION_RELEASED']);3738function occurredAt(e: ChangeEvent): string {39  return e.effective_at ?? e.observed_at;40}4142function priceMove(e: ChangeEvent): { input: [number | null, number | null]; output: [number | null, number | null] } {43  const o = (e.old_value && typeof e.old_value === 'object' ? e.old_value : {}) as Record<string, unknown>;44  const n = (e.new_value && typeof e.new_value === 'object' ? e.new_value : {}) as Record<string, unknown>;45  return { input: [num(o.input_per_mtok), num(n.input_per_mtok)], output: [num(o.output_per_mtok), num(n.output_per_mtok)] };46}4748/** Deterministic pseudo-random constellation for the graph teaser (decorative; the numbers beside it are real). */49function constellation(seed: number, n: number): { x: number; y: number; t: number; r: number }[] {50  let s = seed;51  const rnd = () => {52    s = (s * 1103515245 + 12345) & 0x7fffffff;53    return s / 0x7fffffff;54  };55  return Array.from({ length: n }, () => ({ x: 8 + rnd() * 304, y: 8 + rnd() * 144, t: Math.floor(rnd() * 6), r: 1.2 + rnd() * 2.2 }));56}57const NODE_TYPES = ['model', 'company', 'paper', 'provider', 'benchmark', 'hardware'];5859export default async function HomePage() {60  const [stats, daily, frontier, majors, priceIndex, open, openFallback, benchmarks, papers, timeline, recent] = await Promise.all([61    safe(api.stats()),62    safe(api.changesDaily()),63    safe(api.frontier()),64    safe(api.changes({ importance_min: 3, limit: 8 })),65    safe(api.priceIndex(30)),66    safe(api.open(30, 8)),67    safe(api.models({ openness: 'open-weights', sort: 'release', limit: 8 })),68    safe(api.benchmarks()),69    safe(api.papers({ sort: 'published', limit: 6 })),70    safe(api.timeline({ category: 'model', limit: 400 })),71    safe(api.changes({ importance_min: 2, limit: 30 })),72  ]);7374  const ent = stats?.entities ?? {};75  const orgs = stats ? (num(ent.company) ?? 0) + (num(ent.organization) ?? 0) + (num(ent.lab) ?? 0) + (num(ent.university) ?? 0) : null;76  const defs = { ...DEFINITIONS, ...(stats?.definitions ?? {}) };77  const live24 = num(stats?.change_events_live_24h);78  const strip: StripItem[] | null = stats79    ? [80        { label: 'Models', value: fmtInt(ent.model), href: routes.models(), definition: defs.model },81        { label: 'Organizations', value: fmtInt(orgs), href: routes.companies(), definition: defs.organization },82        { label: 'Papers', value: fmtInt(ent.paper), href: routes.papers(), definition: defs.paper },83        { label: 'Datasets', value: fmtInt(ent.dataset), href: routes.datasets(), definition: defs.dataset },84        { label: 'Benchmark results', value: fmtInt(stats.benchmark_results), href: routes.benchmarks(), definition: defs.benchmark_results },85        { label: 'Providers', value: fmtInt(ent.provider), href: routes.providers(), definition: defs.provider },86        { label: 'Current prices', value: fmtInt(stats.prices_current), href: routes.prices(), definition: defs.prices_current },87        { label: 'Source documents', value: fmtInt(stats.documents), href: routes.sources(), definition: defs.documents, hint: `${fmtInt(stats.sources)} sources` },88      ]89    : null;9091  // ---- today92  const today = daily?.date ?? new Date().toISOString().slice(0, 10);93  const sections = (daily?.sections ?? []).filter((s) => s.items.length > 0).slice(0, 6);94  const todayTotal = num(daily?.total) ?? Object.values(daily?.counts ?? {}).reduce<number>((n, v) => n + (num(v) ?? 0), 0);9596  // ---- frontier moves97  const frontierMoves: ChangeEvent[] = frontier?.recent_frontier_movements?.length ? frontier.recent_frontier_movements.slice(0, 6) : (majors?.items ?? []).slice(0, 6);98  const frontierLive = !!frontier?.recent_frontier_movements?.length;99100  // ---- prices101  const movers = (priceIndex?.movers ?? []).filter((e) => e.event_type === 'PRICE_CHANGED').slice(0, 7);102  const medianSeries = (priceIndex?.series ?? []).map((p) => num(p.median_input)).filter((v): v is number => v !== null);103  const medianLast = medianSeries.length ? (medianSeries[medianSeries.length - 1] as number) : null;104105  // ---- open models106  const openItems: EntitySummary[] = open?.items?.length ? open.items.slice(0, 8) : (openFallback?.items ?? []).slice(0, 8);107  const openLive = !!open?.items?.length;108109  // ---- benchmarks110  const leaders = (benchmarks?.items ?? [])111    .filter((b) => b.top && (num(b.result_count) ?? 0) > 0)112    .sort((a, b) => (num(b.model_count) ?? 0) - (num(a.model_count) ?? 0))113    .slice(0, 6);114115  // ---- release timeline (last 12 months, NEW_MODEL / RELEASE, importance ≥ 2), lanes = top organizations116  const since = Date.now() - 365 * 86400000;117  const releaseEvents = (timeline?.items ?? [])118    .flatMap((m) => m.events)119    .filter((e) => OPEN_EVENT_TYPES.has(e.event_type) && e.importance >= 2 && e.entity && new Date(occurredAt(e)).getTime() >= since);120  const orgCount = new Map<string, number>();121  for (const e of releaseEvents) orgCount.set(e.entity?.organization?.name ?? 'Other', (orgCount.get(e.entity?.organization?.name ?? 'Other') ?? 0) + 1);122  const topOrgs = [...orgCount.entries()]123    .filter(([k]) => k !== 'Other')124    .sort((a, b) => b[1] - a[1])125    .slice(0, 7)126    .map(([k]) => k);127  const lanes = [...topOrgs.map((k, i) => ({ key: k, label: k, color: `var(--series-${(i % 8) + 1})` })), ...(orgCount.size > topOrgs.length ? [{ key: 'Other', label: 'Other', color: 'var(--ink-3)' }] : [])];128  const laneEvents = releaseEvents.map((e) => {129    const org = e.entity?.organization?.name ?? 'Other';130    return { id: e.id, lane: topOrgs.includes(org) ? org : 'Other', at: occurredAt(e), importance: e.importance, label: e.entity?.name ?? e.summary, sub: e.summary, href: e.entity ? routes.entity(e.entity) : undefined };131  });132133  // ---- ticker134  const ticker: TickerItem[] = (recent?.items ?? []).slice(0, 24).map((e) => ({ id: e.id, tone: eventTone(e.event_type), href: e.entity ? routes.entity(e.entity) : routes.changes(), label: e.summary.length > 90 ? `${e.summary.slice(0, 89)}…` : e.summary, meta: fmtDate(occurredAt(e)) }));135136  const nodes = constellation(20260912, 42);137138  return (139    <>140      {/* ------------------------------------------------------------------------------------------------ hero (compact) */}141      <div className="relative overflow-hidden border-b border-rule">142        <div className="grid-bg pointer-events-none absolute inset-0" aria-hidden />143        <Container className="relative py-8 md:py-12">144          <p className="eyebrow">The temporal knowledge graph of the AI ecosystem</p>145          <div className="mt-2 grid gap-6 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:items-end">146            <div>147              <h1 className="display text-[34px] md:text-[52px]">Explore the AI ecosystem.</h1>148              <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-ink-2 md:text-base">149                Not a directory — a continuously reconstructed, source-attributed, historical map of models, organizations, research, providers and prices, benchmarks, hardware and datasets.150              </p>151            </div>152            <p className="max-w-md text-sm leading-relaxed text-ink-2 lg:justify-self-end lg:text-right">153              Every number here answers: <span className="text-ink">who said it, when, where, and how it was extracted.</span>154            </p>155          </div>156          <form action="/search" method="get" role="search" className="mt-5 flex max-w-2xl items-stretch border border-rule-strong bg-surface focus-within:border-accent">157            <label htmlFor="home-q" className="sr-only">Search</label>158            <span className="flex items-center pl-3 text-ink-3"><Search className="size-5" aria-hidden /></span>159            <input id="home-q" name="q" type="search" placeholder="Ask anything: “open models over 100B released in 2026”, “cheapest 1M context”…" className="h-12 min-w-0 flex-1 bg-transparent px-3 text-[16px] text-ink placeholder:text-ink-3 focus:outline-none" autoComplete="off" />160            <button type="submit" className="flex items-center gap-1.5 bg-ink px-4 text-sm font-medium text-canvas hover:opacity-90 md:px-5">161              Search <ArrowRight className="hidden size-4 md:block" aria-hidden />162            </button>163          </form>164          <ul className="mt-2.5 flex flex-wrap gap-2">165            {EXAMPLE_QUERIES.map((q) => (166              <li key={q}>167                <Link href={routes.search(q)} className="inline-block border border-rule px-2.5 py-1 text-xs text-ink-2 hover:border-rule-strong hover:text-ink">168                  {q}169                </Link>170              </li>171            ))}172            <li className="text-xs text-ink-3 self-center">173              or press <kbd className="mono border border-rule px-1">⌘K</kbd>174            </li>175          </ul>176        </Container>177      </div>178179      <Container>180        {/* ------------------------------------------------------------------------------------------------ live strip */}181        <div className="pt-5">182          <div className="mb-2 flex flex-wrap items-center justify-between gap-2">183            <p className="eyebrow flex items-center gap-2">184              Live ecosystem185              {stats && (186                <span className="flex items-center gap-1.5 text-[11px] font-normal normal-case tracking-normal text-ink-3">187                  <span className="dot pulse" aria-hidden /> computed <LiveAgo at={stats.computed_at} />188                  {live24 !== null && <span>· {fmtInt(live24)} live events / 24 h</span>}189                </span>190              )}191            </p>192            <Link href={routes.methodology()} className="text-xs text-ink-3 hover:text-ink">193              How counts are computed →194            </Link>195          </div>196          {strip ? <DataStrip items={strip} /> : <Unavailable what="Live counters" reason="The API did not answer. Counters are never cached or hardcoded." />}197        </div>198        {ticker.length > 0 && <Ticker items={ticker} className="mt-3" label="Recent" />}199200        {/* ------------------------------------------------------------------------------------------------ editorial grid */}201        <div className="grid gap-x-12 lg:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)]">202          <div className="min-w-0">203            {/* today */}204            <Section eyebrow={`Today in AI · ${fmtDate(today)}`} title="What changed" action={{ href: routes.changesDay(today), label: 'Daily digest' }} lede={daily ? `${fmtInt(todayTotal)} events recorded today across ${sections.length} ${sections.length === 1 ? 'section' : 'sections'}.` : undefined}>205              {sections.length ? (206                <div className="space-y-6">207                  {sections.map((s) => (208                    <div key={s.category}>209                      <p className="eyebrow mb-1 flex items-baseline justify-between">210                        <span>{s.label}</span>211                        <span className="tnum font-normal">{fmtInt(num(s.total) ?? num(daily?.counts?.[s.category]) ?? s.items.length)}</span>212                      </p>213                      <ul className="border-t border-rule">214                        {s.items.slice(0, 5).map((e) => (215                          <ChangeRow key={e.id} e={e} dense />216                        ))}217                      </ul>218                    </div>219                  ))}220                </div>221              ) : daily ? (222                <p className="border-y border-rule py-8 text-center text-sm text-ink-3">No material events recorded today yet. The change engine only emits events when a source states a material change.</p>223              ) : (224                <Unavailable what="Daily digest" />225              )}226              <p className="mt-4 text-xs text-ink-3">227                {daily?.backfill_excluded !== undefined ? (228                  <>229                    {fmtInt(daily.backfill_excluded)} historical backfill events are excluded — see the <Link href={routes.timeline()} className="link">Timeline</Link>.230                  </>231                ) : (232                  <>233                    Digest keyed on observation time; historical backfill is shown on the <Link href={routes.timeline()} className="link">Timeline</Link> by effective date.234                  </>235                )}236              </p>237            </Section>238239            {/* frontier moves */}240            <Section eyebrow="Frontier moves" title={frontierLive ? 'Leadership changes' : 'Major events'} lede={frontierLive ? 'A model took or lost the lead on a benchmark family.' : 'Latest importance-3 events — the frontier feed appears once leaderboard trust levels are computed.'} action={{ href: routes.frontier(), label: 'Frontier' }}>241              {frontierMoves.length ? (242                <ul className="border-t border-rule">243                  {frontierMoves.map((e) => (244                    <ChangeRow key={e.id} e={e} dense />245                  ))}246                </ul>247              ) : majors ? (248                <p className="border-y border-rule py-6 text-center text-sm text-ink-3">No major events recorded yet.</p>249              ) : (250                <Unavailable what="Frontier moves" compact />251              )}252            </Section>253254            {/* benchmark leaders */}255            <Section eyebrow="Benchmark leaders" title="Who leads, on what" action={{ href: routes.benchmarks(), label: 'All benchmarks' }}>256              {leaders.length ? (257                <ul className="grid border-t border-rule sm:grid-cols-2">258                  {leaders.map((b) => (259                    <li key={b.id} className="row-y grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 border-b border-rule sm:odd:pr-6 sm:even:pl-6">260                      <div className="min-w-0">261                        <p className="truncate text-sm">262                          <EntityLink e={b} className="font-medium" />263                          {typeof b.attributes?.metric === 'string' && <span className="ml-1.5 text-xs text-ink-3">{b.attributes.metric}</span>}264                        </p>265                        <p className="mt-0.5 truncate text-xs text-ink-2">266                          leader <EntityLink e={b.top!.model} className="text-ink" />267                          {b.top!.model.organization && <span className="text-ink-3"> · {b.top!.model.organization.name}</span>}268                        </p>269                        <p className="tnum mt-0.5 text-[11px] text-ink-3">270                          {fmtInt(b.result_count)} results · {fmtInt(b.model_count)} models271                          {typeof b.attributes?.trust_level === 'string' ? <> · trust {b.attributes.trust_level}</> : <> · trust —</>}272                        </p>273                      </div>274                      <p className="tnum self-center text-lg font-semibold text-type-benchmark">{fmtScore(b.top!.score)}</p>275                    </li>276                  ))}277                </ul>278              ) : benchmarks ? (279                <p className="border-y border-rule py-6 text-center text-sm text-ink-3">No benchmark results ingested yet.</p>280              ) : (281                <Unavailable what="Benchmarks" compact />282              )}283            </Section>284          </div>285286          <div className="min-w-0">287            {/* price moves */}288            <Section eyebrow="Price moves" title="USD per 1M tokens" action={{ href: routes.prices(), label: 'Price index' }}>289              <div className="flex items-end justify-between gap-4 border-b border-rule pb-3">290                <div>291                  <p className="eyebrow">Median input · 30 d</p>292                  {medianSeries.length >= 2 ? (293                    <Sparkline values={medianSeries} variant="trend" width={140} height={32} stroke="var(--accent-2)" invert format={(v) => fmtUsdPerM(v)} title="Median input price, last 30 days" />294                  ) : (295                    <p className="mt-1 text-xs text-ink-3">{medianLast !== null ? `${fmtUsdPerM(medianLast)} today · ` : ''}needs ≥ 2 daily snapshots to draw</p>296                  )}297                </div>298                <p className="tnum text-right text-xs text-ink-3">299                  {stats ? <>{fmtInt(stats.prices_current)} live offers</> : null}300                  <br />301                  {priceIndex?.series?.length ? <>{fmtInt(priceIndex.series[priceIndex.series.length - 1]?.models)} models priced</> : null}302                </p>303              </div>304              {movers.length ? (305                <ul>306                  {movers.map((e) => {307                    const m = priceMove(e);308                    const dIn = fmtDeltaPct(m.input[0], m.input[1]);309                    const dOut = fmtDeltaPct(m.output[0], m.output[1]);310                    const provider = hostOf(e.source_url);311                    return (312                      <li key={e.id} className="row-y grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 border-b border-rule text-sm">313                        <div className="min-w-0">314                          {e.entity ? <EntityLink e={e.entity} className="truncate font-medium" /> : <span>{e.summary}</span>}315                          <p className="truncate text-xs text-ink-3">316                            {provider ?? e.connector_name} · {fmtDate(occurredAt(e))}317                          </p>318                        </div>319                        <div className="tnum text-right text-xs">320                          <p>321                            <span className="text-ink-3">in </span>322                            <span className="font-medium text-accent-2">{fmtUsdPerM(m.input[1])}</span>323                            {dIn && <span className={m.input[1]! < m.input[0]! ? 'ml-1 text-positive' : 'ml-1 text-danger'}>{dIn}</span>}324                          </p>325                          <p>326                            <span className="text-ink-3">out </span>327                            <span className="font-medium text-accent-2">{fmtUsdPerM(m.output[1])}</span>328                            {dOut && <span className={m.output[1]! < m.output[0]! ? 'ml-1 text-positive' : 'ml-1 text-danger'}>{dOut}</span>}329                          </p>330                        </div>331                      </li>332                    );333                  })}334                </ul>335              ) : priceIndex ? (336                <p className="py-6 text-center text-sm text-ink-3">No price changes recorded in the window.</p>337              ) : (338                <Unavailable what="Price moves" compact className="mt-3" />339              )}340            </Section>341342            {/* open models */}343            <Section eyebrow={openLive ? 'New open models · 30 d' : 'Open models · latest releases'} title="Open weights" action={{ href: routes.open(), label: 'Open model frontier' }}>344              {openItems.length ? (345                <ul className="border-t border-rule">346                  {openItems.map((m) => {347                    const a = m.attributes ?? {};348                    return (349                      <li key={m.id} className="row-y grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 border-b border-rule text-sm">350                        <div className="min-w-0">351                          <p className="truncate">352                            <EntityLink e={m} className="font-medium" />353                            {m.organization && <span className="text-xs text-ink-3"> · {m.organization.name}</span>}354                          </p>355                          <p className="tnum truncate text-xs text-ink-3">356                            {num(a.parameter_count) !== null && <>{fmtParams(a.parameter_count)} params · </>}357                            {num(a.context_length) !== null && <>{fmtTokens(a.context_length)} ctx · </>}358                            {typeof a.license === 'string' ? a.license : 'license —'}359                          </p>360                        </div>361                        <div className="text-right">362                          <p className="tnum text-xs text-ink-2">{typeof a.release_date === 'string' ? fmtDate(a.release_date) : '—'}</p>363                          <OpennessBadge openness={typeof a.openness === 'string' ? a.openness : 'open-weights'} className="mt-0.5" />364                        </div>365                      </li>366                    );367                  })}368                </ul>369              ) : openFallback || open ? (370                <p className="border-y border-rule py-6 text-center text-sm text-ink-3">No open-weight releases recorded in the window.</p>371              ) : (372                <Unavailable what="Open models" compact />373              )}374            </Section>375376            {/* research */}377            <Section eyebrow="Research" title="Latest papers" action={{ href: routes.papers(), label: 'Research' }}>378              {papers?.items?.length ? (379                <ul className="border-t border-rule">380                  {papers.items.slice(0, 6).map((p) => {381                    const a = p.attributes ?? {};382                    const authors = Array.isArray(a.authors) ? (a.authors as string[]) : [];383                    return (384                      <li key={p.id} className="row-y border-b border-rule text-sm">385                        <EntityLink e={p} className="line-clamp-2 font-medium leading-snug" />386                        <p className="tnum mt-0.5 truncate text-xs text-ink-3">387                          {typeof a.published_at === 'string' && <>{fmtDate(a.published_at)} · </>}388                          {authors.length > 0 && <>{authors.slice(0, 2).join(', ')}{authors.length > 2 ? ` +${authors.length - 2}` : ''} · </>}389                          {typeof a.arxiv_id === 'string' && <span className="mono">arXiv {a.arxiv_id}</span>}390                          {typeof a.primary_category === 'string' && <> · {a.primary_category}</>}391                        </p>392                      </li>393                    );394                  })}395                </ul>396              ) : papers ? (397                <p className="border-y border-rule py-6 text-center text-sm text-ink-3">No papers ingested yet.</p>398              ) : (399                <Unavailable what="Research" compact />400              )}401            </Section>402          </div>403        </div>404405        {/* ------------------------------------------------------------------------------------------------ release timeline */}406        <Section eyebrow="Model release timeline · 12 months" title="Releases by organization" lede="New models and releases of importance ≥ 2, by effective date when the source states one. Hover a dot; drag to read a range." action={{ href: routes.timeline({ category: 'model' }), label: 'Full timeline' }}>407          {laneEvents.length >= 2 && lanes.length ? (408            <TimelineLanes lanes={lanes} events={laneEvents} from={new Date(since)} to={new Date()} brushable title="Model releases by organization, last 12 months" />409          ) : timeline ? (410            <p className="border-y border-rule py-6 text-center text-sm text-ink-3">Not enough dated release events in the last 12 months to draw a timeline.</p>411          ) : (412            <Unavailable what="Timeline" compact />413          )}414        </Section>415416        {/* ------------------------------------------------------------------------------------------------ graph teaser */}417        <Section eyebrow="Ecosystem graph" title="Every entity, every relation" action={{ href: routes.graph(), label: 'Explore the graph' }}>418          <div className="grid items-center gap-6 md:grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">419            <div className="text-sm text-ink-2">420              <p className="tnum text-2xl font-semibold tracking-tight text-ink">421                {stats ? fmtInt(stats.entities_total) : '—'} <span className="text-base font-normal text-ink-3">entities</span> · {stats ? fmtInt(stats.relations) : '—'} <span className="text-base font-normal text-ink-3">relations</span>422              </p>423              <p className="mt-2 leading-relaxed">Organizations develop models, providers serve them, papers describe them, benchmarks evaluate them, hardware runs them. Start from any entity and follow the edges — lineage, ownership, availability — with the source of every relation.</p>424              <p className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs">425                <Link href={routes.graph()} className="link">Knowledge graph →</Link>426                <Link href={routes.families()} className="link">Model families →</Link>427                <Link href={routes.compare()} className="link">Compare →</Link>428              </p>429            </div>430            <Link href={routes.graph()} className="block border border-rule bg-surface/40 hover:border-rule-strong" aria-label="Open the knowledge graph">431              <svg viewBox="0 0 320 160" className="block h-auto w-full" aria-hidden>432                {nodes.map((n, i) =>433                  nodes.slice(i + 1, i + 3).map((m, j) => (434                    <line key={`${i}-${j}`} x1={n.x} y1={n.y} x2={m.x} y2={m.y} stroke="var(--rule-strong)" strokeWidth={0.6} opacity={0.7} />435                  )),436                )}437                {nodes.map((n, i) => (438                  <circle key={i} cx={n.x} cy={n.y} r={n.r} fill={`var(--type-${NODE_TYPES[n.t]})`} opacity={0.9} />439                ))}440              </svg>441              <p className="flex flex-wrap gap-x-3 gap-y-1 border-t border-rule px-3 py-1.5">442                {NODE_TYPES.map((t) => (443                  <EntityBadge key={t} type={t} small />444                ))}445              </p>446            </Link>447          </div>448        </Section>449450        {/* ------------------------------------------------------------------------------------------------ signature products */}451        <Section eyebrow="Signature products" title="Twelve ways to read the atlas" hairline>452          <ul className="grid grid-cols-2 border-l border-t border-rule sm:grid-cols-3 lg:grid-cols-6">453            {signatureProducts.map((p) => (454              <li key={p.label} className="border-b border-r border-rule">455                <Link href={p.href} className="block h-full px-3 py-3 hover:bg-surface-2">456                  <p className="text-sm font-medium text-ink">{p.label}</p>457                  {p.hint && <p className="mt-0.5 text-xs leading-snug text-ink-3">{p.hint}</p>}458                </Link>459              </li>460            ))}461          </ul>462        </Section>463      </Container>464    </>465  );466}467