'use client'; import Link from 'next/link'; import { useEffect, useRef, useState } from 'react'; import { compactNumber, divergingColor } from './scale'; export interface TreemapNode { id: string; label: string; size: number; change: number | null; href?: string; sublabel?: string; /** whether the size is a real measurement (volume) or a fallback (asset count) */ sizeLabel: string; } interface Rect { x: number; y: number; w: number; h: number; } /** Squarified treemap layout. */ function squarify(items: TreemapNode[], rect: Rect): Array<{ node: TreemapNode; rect: Rect }> { const total = items.reduce((a, n) => a + n.size, 0); if (!total || !items.length) return []; const scaled = items.map((n) => ({ node: n, area: (n.size / total) * rect.w * rect.h })); const out: Array<{ node: TreemapNode; rect: Rect }> = []; let x = rect.x; let y = rect.y; let w = rect.w; let h = rect.h; let row: typeof scaled = []; const worst = (r: typeof scaled, side: number) => { const s = r.reduce((a, b) => a + b.area, 0); if (!s) return Infinity; const min = Math.min(...r.map((z) => z.area)); const max = Math.max(...r.map((z) => z.area)); return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min)); }; const layoutRow = (r: typeof scaled) => { const s = r.reduce((a, b) => a + b.area, 0); if (w >= h) { const rw = s / h; let cy = y; for (const it of r) { const rh = it.area / rw; out.push({ node: it.node, rect: { x, y: cy, w: rw, h: rh } }); cy += rh; } x += rw; w -= rw; } else { const rh = s / w; let cx = x; for (const it of r) { const rw = it.area / rh; out.push({ node: it.node, rect: { x: cx, y, w: rw, h: rh } }); cx += rw; } y += rh; h -= rh; } }; for (const it of scaled) { const side = Math.min(w, h); if (!row.length || worst([...row, it], side) <= worst(row, side)) row.push(it); else { layoutRow(row); row = [it]; } } if (row.length) layoutRow(row); return out; } /** Market heat map (§151): area = size measure, colour = diverging change around 0. */ export function Treemap({ nodes, height = 420, ariaLabel, limit = 0.15 }: { nodes: TreemapNode[]; height?: number; ariaLabel: string; limit?: number }) { const ref = useRef(null); const [width, setWidth] = useState(960); const [hover, setHover] = useState(null); useEffect(() => { const el = ref.current; if (!el) return; const ro = new ResizeObserver((es) => { for (const e of es) setWidth(Math.max(280, Math.floor(e.contentRect.width))); }); ro.observe(el); return () => ro.disconnect(); }, []); const items = nodes.filter((n) => n.size > 0).sort((a, b) => b.size - a.size); const cells = squarify(items, { x: 0, y: 0, w: width, h: height }); return (
{cells.map(({ node, rect }) => { const big = rect.w > 90 && rect.h > 44; const body = (
setHover(node)} onMouseLeave={() => setHover(null)} > {rect.w > 48 && rect.h > 24 ? (
{node.label} {big ? {node.change === null ? 'n/a' : `${node.change > 0 ? '+' : ''}${(node.change * 100).toFixed(1)}%`} : null}
) : null}
); return node.href ? ( {body} ) : (
{body}
); })}
−{Math.round(limit * 100)}% · 0 · +{Math.round(limit * 100)}% (period change) · area = {items[0]?.sizeLabel ?? 'size'} {hover ? ( {hover.label}: {hover.change === null ? 'change unavailable' : `${hover.change > 0 ? '+' : ''}${(hover.change * 100).toFixed(2)}%`} · {compactNumber(hover.size, { currency: hover.sizeLabel.includes('$') })} {hover.sublabel ? `· ${hover.sublabel}` : ''} ) : null}
); }