HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
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