TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { Metadata } from 'next';2import { PageHeader } from '@/components/ui/page-header';3import { Segmented } from '@/components/ui/tabs';4import { Card, EmptyState } from '@/components/ui/primitives';5import { Treemap } from '@/components/charts/treemap';6import { getMarketRows } from '@/lib/queries/markets';7import { spEnum, sp1, type SP } from '@/lib/search-params';8import { getCategory } from '@rareindex/taxonomy';910export const metadata: Metadata = { title: 'Market map', description: 'Heatmap of collectible category performance: area by 30-day volume or tracked assets, colour by period change.' };11export const revalidate = 300;1213const PERIODS = ['1d', '7d', '30d', '1y'] as const;14const SIZES = ['volume', 'assets', 'sales'] as const;1516export default async function MarketMapPage({ searchParams }: { searchParams: Promise<SP> }) {17 const sp = await searchParams;18 const period = spEnum(sp, 'p', PERIODS, '30d');19 const size = spEnum(sp, 'size', SIZES, 'volume');20 const parent = sp1(sp, 'family') ?? null;21 const rows = await getMarketRows(parent);22 const key = period === '1d' ? 'change1d' : period === '7d' ? 'change7d' : period === '1y' ? 'change1y' : 'change30d';23 const nodes = rows24 .filter((r) => r.counts.assets > 0)25 .map((r) => {26 const vol = r.counts.volume30dUsd ?? 0;27 const s = size === 'volume' ? vol : size === 'sales' ? r.counts.sales : r.counts.assets;28 return { id: r.node.slug, label: r.node.name, size: s, change: r.snapshot?.[key] ?? null, href: r.node.children.length && !parent ? `/market-map?p=${period}&size=${size}&family=${r.node.slug}` : `/markets/${r.node.slug}`, sublabel: `${r.counts.assets.toLocaleString('en-US')} assets · ${r.counts.sales.toLocaleString('en-US')} sales`, sizeLabel: size === 'volume' ? '30D volume ($)' : size === 'sales' ? 'sales' : 'tracked assets' };29 })30 .filter((n) => n.size > 0);31 const withChange = nodes.filter((n) => n.change !== null).length;32 const q = (p: string, s: string) => `/market-map?p=${p}&size=${s}${parent ? `&family=${parent}` : ''}`;33 return (34 <div>35 <PageHeader kicker="Heatmap"36 crumbs={parent ? [{ label: 'Market map', href: '/market-map' }, { label: getCategory(parent)?.name ?? parent }] : undefined}37 title={parent ? `${getCategory(parent)?.name ?? parent} map` : 'Market map'}38 description="Area is a real activity measure (volume, sales or tracked assets); colour is the period change of the category index — grey means no change is computable yet. Click a family to drill into its subcategories."39 compact40 actions={41 <>42 <Segmented active={period} options={PERIODS.map((p) => ({ id: p, label: p.toUpperCase(), href: q(p, size) }))} />43 <Segmented active={size} options={SIZES.map((s) => ({ id: s, label: s === 'volume' ? 'Volume' : s === 'sales' ? 'Sales' : 'Assets', href: q(period, s) }))} />44 </>45 }46 />47 {nodes.length ? (48 <>49 <Treemap ariaLabel="Market heatmap" nodes={nodes} height={520} limit={period === '1d' ? 0.05 : period === '7d' ? 0.1 : period === '30d' ? 0.15 : 0.4} />50 <p className="mt-2 text-[11px] text-subtle">51 {nodes.length} categories with data · {withChange} with a computed {period.toUpperCase()} change{size === 'volume' && nodes.every((n) => n.size === 0) ? ' · no volume yet, showing assets' : ''}.52 </p>53 </>54 ) : (55 <Card>56 <EmptyState title="No market data yet" description={size === 'volume' ? 'No 30-day volume recorded yet — try sizing by tracked assets.' : 'The map fills as assets, sales and category snapshots are ingested.'} />57 </Card>58 )}59 </div>60 );61}62