SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
5.3 KB · 140 lines tsx
Raw Blame History
1'use client';23import Link from 'next/link';4import { useEffect, useRef, useState } from 'react';5import { compactNumber, divergingColor } from './scale';67export interface TreemapNode {8  id: string;9  label: string;10  size: number;11  change: number | null;12  href?: string;13  sublabel?: string;14  /** whether the size is a real measurement (volume) or a fallback (asset count) */15  sizeLabel: string;16}1718interface Rect {19  x: number;20  y: number;21  w: number;22  h: number;23}2425/** Squarified treemap layout. */26function squarify(items: TreemapNode[], rect: Rect): Array<{ node: TreemapNode; rect: Rect }> {27  const total = items.reduce((a, n) => a + n.size, 0);28  if (!total || !items.length) return [];29  const scaled = items.map((n) => ({ node: n, area: (n.size / total) * rect.w * rect.h }));30  const out: Array<{ node: TreemapNode; rect: Rect }> = [];31  let x = rect.x;32  let y = rect.y;33  let w = rect.w;34  let h = rect.h;35  let row: typeof scaled = [];36  const worst = (r: typeof scaled, side: number) => {37    const s = r.reduce((a, b) => a + b.area, 0);38    if (!s) return Infinity;39    const min = Math.min(...r.map((z) => z.area));40    const max = Math.max(...r.map((z) => z.area));41    return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min));42  };43  const layoutRow = (r: typeof scaled) => {44    const s = r.reduce((a, b) => a + b.area, 0);45    if (w >= h) {46      const rw = s / h;47      let cy = y;48      for (const it of r) {49        const rh = it.area / rw;50        out.push({ node: it.node, rect: { x, y: cy, w: rw, h: rh } });51        cy += rh;52      }53      x += rw;54      w -= rw;55    } else {56      const rh = s / w;57      let cx = x;58      for (const it of r) {59        const rw = it.area / rh;60        out.push({ node: it.node, rect: { x: cx, y, w: rw, h: rh } });61        cx += rw;62      }63      y += rh;64      h -= rh;65    }66  };67  for (const it of scaled) {68    const side = Math.min(w, h);69    if (!row.length || worst([...row, it], side) <= worst(row, side)) row.push(it);70    else {71      layoutRow(row);72      row = [it];73    }74  }75  if (row.length) layoutRow(row);76  return out;77}7879/** Market heat map (§151): area = size measure, colour = diverging change around 0. */80export function Treemap({ nodes, height = 420, ariaLabel, limit = 0.15 }: { nodes: TreemapNode[]; height?: number; ariaLabel: string; limit?: number }) {81  const ref = useRef<HTMLDivElement>(null);82  const [width, setWidth] = useState(960);83  const [hover, setHover] = useState<TreemapNode | null>(null);84  useEffect(() => {85    const el = ref.current;86    if (!el) return;87    const ro = new ResizeObserver((es) => {88      for (const e of es) setWidth(Math.max(280, Math.floor(e.contentRect.width)));89    });90    ro.observe(el);91    return () => ro.disconnect();92  }, []);93  const items = nodes.filter((n) => n.size > 0).sort((a, b) => b.size - a.size);94  const cells = squarify(items, { x: 0, y: 0, w: width, h: height });95  return (96    <div ref={ref} className="relative" role="img" aria-label={ariaLabel}>97      <div className="relative overflow-hidden rounded-md border border-border bg-elevated" style={{ height }}>98        {cells.map(({ node, rect }) => {99          const big = rect.w > 90 && rect.h > 44;100          const body = (101            <div102              className="absolute overflow-hidden p-1.5 transition-opacity"103              style={{ left: rect.x, top: rect.y, width: rect.w, height: rect.h, background: divergingColor(node.change, limit), boxShadow: 'inset 0 0 0 1px var(--ri-bg-elevated)', opacity: hover && hover.id !== node.id ? 0.7 : 1 }}104              onMouseEnter={() => setHover(node)}105              onMouseLeave={() => setHover(null)}106            >107              {rect.w > 48 && rect.h > 24 ? (108                <div className="flex h-full flex-col justify-between">109                  <span className={`truncate font-medium text-fg ${big ? 'text-[12px]' : 'text-[10px]'}`}>{node.label}</span>110                  {big ? <span className="num text-[11px] text-muted">{node.change === null ? 'n/a' : `${node.change > 0 ? '+' : ''}${(node.change * 100).toFixed(1)}%`}</span> : null}111                </div>112              ) : null}113            </div>114          );115          return node.href ? (116            <Link key={node.id} href={node.href} className="contents">117              {body}118            </Link>119          ) : (120            <div key={node.id} className="contents">121              {body}122            </div>123          );124        })}125      </div>126      <div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted">127        <span className="flex items-center gap-1.5">128          <span className="inline-block h-2.5 w-10 rounded-sm" style={{ background: `linear-gradient(90deg, ${divergingColor(-limit)}, var(--ri-bg-inset), ${divergingColor(limit)})` }} />129          −{Math.round(limit * 100)}% · 0 · +{Math.round(limit * 100)}% (period change) · area = {items[0]?.sizeLabel ?? 'size'}130        </span>131        {hover ? (132          <span className="num text-fg">133            {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}` : ''}134          </span>135        ) : null}136      </div>137    </div>138  );139}140