{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"treemap","type":"registry:ui","title":"Treemap","description":"Parts of a whole as rectangles sized by value, with groups you can zoom into.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-count-to.json","https://dashral.com/r/lib-locale.json"],"files":[{"path":"registry/dashral/treemap.tsx","type":"registry:file","target":"components/dashral/treemap.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRightIcon } from \"lucide-react\"\n\nimport { CountTo } from \"./lib/count-to\"\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\n\nconst percentOptions: Intl.NumberFormatOptions = { style: \"percent\", maximumFractionDigits: 1 }\n\nexport type TreemapDatum = {\n  label: string\n  /** Ignored when there are children: a group's value is theirs added up. */\n  value?: number\n  /** Any CSS color; by default the theme's chart colors in order (--chart-1 … --chart-5). Children take their group's. */\n  color?: string\n  /** Makes it a group: click it to zoom in. */\n  children?: TreemapDatum[]\n}\n\ntype TreemapProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  data: TreemapDatum[]\n  /** Height in pixels; the width fills the container. */\n  height?: number\n  /** BCP 47 locale for values and percentages. */\n  locale?: string\n  /** How values are written, e.g. `{ style: \"currency\", currency: \"USD\" }`. */\n  formatOptions?: Intl.NumberFormatOptions\n  /** The name of the top level in the trail, e.g. \"All products\". */\n  rootLabel?: string\n  /** The trail (and the hovered tile's details) above the tiles. */\n  showHeader?: boolean\n  /** What the chart shows, e.g. \"Storage by folder\". */\n  \"aria-label\"?: string\n  classNames?: { header?: string; tile?: string; label?: string; value?: string }\n}\n\ntype Rect = { x: number; y: number; w: number; h: number }\ntype Tile = { datum: TreemapDatum; key: string; index: number; value: number; color: string; rect: Rect }\n\nconst colorAt = (i: number) => `var(--chart-${(i % 5) + 1})`\nconst valueOf = (d: TreemapDatum): number =>\n  d.children?.length ? d.children.reduce((sum, child) => sum + valueOf(child), 0) : Math.max(0, d.value ?? 0)\n\n/**\n * Squarified treemap (Bruls, Huizing and van Wijk): lays values out as rectangles inside a w × h box, each area in\n * proportion, as close to square as it can. Returns one rect per value, in the values' order.\n */\nfunction squarify(values: number[], width: number, height: number): Rect[] {\n  const out: Rect[] = values.map(() => ({ x: 0, y: 0, w: 0, h: 0 }))\n  const total = values.reduce((sum, v) => sum + v, 0)\n  if (!total) return out\n  const scale = (width * height) / total\n  const order = values\n    .map((v, i) => ({ area: v * scale, i }))\n    .filter((d) => d.area > 0)\n    .sort((a, b) => b.area - a.area)\n  let box = { x: 0, y: 0, w: width, h: height }\n  let row: typeof order = []\n  // How far from square the worst rect in a row is, laid along a side this long.\n  const worst = (items: typeof order, side: number) => {\n    const sum = items.reduce((s, d) => s + d.area, 0)\n    const max = Math.max(...items.map((d) => d.area))\n    const min = Math.min(...items.map((d) => d.area))\n    return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min))\n  }\n  const place = () => {\n    const sum = row.reduce((s, d) => s + d.area, 0)\n    if (box.w >= box.h) {\n      // A column down the left of what's left.\n      const w = Math.min(box.w, sum / box.h)\n      let y = box.y\n      for (const d of row) {\n        out[d.i] = { x: box.x, y, w, h: d.area / w }\n        y += d.area / w\n      }\n      box = { x: box.x + w, y: box.y, w: box.w - w, h: box.h }\n    } else {\n      // A row along the top.\n      const h = Math.min(box.h, sum / box.w)\n      let x = box.x\n      for (const d of row) {\n        out[d.i] = { x, y: box.y, w: d.area / h, h }\n        x += d.area / h\n      }\n      box = { x: box.x, y: box.y + h, w: box.w, h: box.h - h }\n    }\n    row = []\n  }\n  for (const d of order) {\n    const side = Math.min(box.w, box.h)\n    if (row.length && worst([...row, d], side) > worst(row, side)) place()\n    row.push(d)\n  }\n  place()\n  return out\n}\n\n/** Rects in 0–100 units (percent of the box), laid out for a box with this width-to-height ratio. */\nfunction layout(values: number[], ratio: number): Rect[] {\n  const aspect = Math.min(Math.max(ratio || 1, 0.01), 100)\n  return squarify(values, aspect * 100, 100).map((r) => ({ x: r.x / aspect, y: r.y, w: r.w / aspect, h: r.h }))\n}\n\nconst place = (r: Rect): React.CSSProperties => ({ left: `${r.x}%`, top: `${r.y}%`, width: `${r.w}%`, height: `${r.h}%` })\n\n/**\n * Parts of a whole as rectangles, sized by value — for many parts, or parts within parts (storage by folder, revenue\n * by product). A group shows its items faintly inside; click it and they zoom out to fill the chart. Plain HTML, no\n * chart library: labels are real text, and every value is listed for screen readers.\n */\nfunction Treemap({\n  data,\n  height = 280,\n  locale: localeProp,\n  formatOptions,\n  rootLabel: rootLabelProp,\n  showHeader = true,\n  className,\n  classNames,\n  \"aria-label\": ariaLabelProp,\n  onKeyDown,\n  ...props\n}: TreemapProps) {\n  const { t, locale: contextLocale } = useLocale()\n  const locale = localeProp ?? contextLocale\n  const rootLabel = rootLabelProp ?? t(\"all\")\n  const ariaLabel = ariaLabelProp ?? t(\"treemap\")\n  const box = React.useRef<HTMLDivElement>(null)\n  const [width, setWidth] = React.useState(height * 2)\n  React.useLayoutEffect(() => {\n    const node = box.current\n    if (!node) return\n    const observer = new ResizeObserver(([entry]) => entry && setWidth(entry.contentRect.width))\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [])\n\n  // The groups zoomed into, as indexes from the top.\n  const [path, setPath] = React.useState<number[]>([])\n  const [active, setActive] = React.useState<string | null>(null)\n  const trail = path.reduce<TreemapDatum[]>((list, i) => [...list, (list.at(-1)?.children ?? data)[i]!], [])\n  // A path that no longer fits the data (it changed) falls back to the top.\n  const valid = trail.every((d) => d?.children?.length)\n  const level = valid ? (trail.at(-1)?.children ?? data) : data\n  const levelPath = valid ? path : []\n  const groupColor = valid ? trail.reduce<string | undefined>((color, d, n) => d.color ?? color ?? colorAt(path[n]!), undefined) : undefined\n\n  const aspect = width / height\n  const values = level.map(valueOf)\n  const total = values.reduce((sum, v) => sum + v, 0)\n  const tiles: Tile[] = layout(values, aspect).map((rect, index) => ({\n    datum: level[index]!,\n    key: [...levelPath, index].join(\"/\"),\n    index,\n    value: values[index]!,\n    color: level[index]!.color ?? groupColor ?? colorAt(index),\n    rect,\n  }))\n  // A group's label sits in a strip along its top (when there's room for it), and its items are laid out below.\n  const STRIP = 28\n  const size = (tile: Tile) => ({ w: (tile.rect.w / 100) * width, h: (tile.rect.h / 100) * height })\n  const stripOf = (tile: Tile) => (tile.datum.children?.length && size(tile).w >= 56 && size(tile).h >= STRIP * 2 ? STRIP : 0)\n  // Each group's items, in percent of the area under the strip.\n  const inner = (tile: Tile) =>\n    tile.datum.children?.length\n      ? layout(tile.datum.children.map(valueOf), size(tile).w / Math.max(1e-6, size(tile).h - stripOf(tile)))\n      : []\n\n  // Where each tile starts before sliding to its place: a zoom in grows the group's items from where they sat in it;\n  // a zoom out shrinks the group back from the whole chart; the first time, every tile grows from its middle.\n  const [from, setFrom] = React.useState<Map<string, Rect> | null>(() => new Map())\n  React.useEffect(() => {\n    if (!from) return\n    let frame = requestAnimationFrame(() => (frame = requestAnimationFrame(() => setFrom(null))))\n    return () => cancelAnimationFrame(frame)\n  }, [from])\n  const start = (tile: Tile): { rect: Rect; hidden?: boolean } => {\n    if (!from) return { rect: tile.rect }\n    if (from.size === 0) return { rect: { x: tile.rect.x + tile.rect.w / 2, y: tile.rect.y + tile.rect.h / 2, w: 0, h: 0 }, hidden: true }\n    const rect = from.get(tile.key)\n    return rect ? { rect } : { rect: tile.rect, hidden: true }\n  }\n\n  const tileRefs = React.useRef(new Map<string, HTMLButtonElement>())\n  const trailRef = React.useRef<HTMLButtonElement>(null)\n  const [focusAfter, setFocusAfter] = React.useState<string | \"trail\" | null>(null)\n  React.useEffect(() => {\n    if (!focusAfter) return\n    ;(focusAfter === \"trail\" ? trailRef.current : tileRefs.current.get(focusAfter))?.focus()\n    setFocusAfter(null)\n  }, [focusAfter])\n\n  const zoomIn = (tile: Tile) => {\n    // The area under the strip, in percent of the chart.\n    const strip = (stripOf(tile) / height) * 100\n    const r = { ...tile.rect, y: tile.rect.y + strip, h: tile.rect.h - strip }\n    setFrom(new Map(inner(tile).map((c, i) => [[...levelPath, tile.index, i].join(\"/\"), { x: r.x + (c.x * r.w) / 100, y: r.y + (c.y * r.h) / 100, w: (c.w * r.w) / 100, h: (c.h * r.h) / 100 }])))\n    setPath([...levelPath, tile.index])\n    setActive(null)\n    setFocusAfter(\"trail\")\n  }\n  const zoomTo = (depth: number) => {\n    if (depth >= levelPath.length) return\n    const key = levelPath.slice(0, depth + 1).join(\"/\")\n    setFrom(new Map([[key, { x: 0, y: 0, w: 100, h: 100 }]]))\n    setPath(levelPath.slice(0, depth))\n    setActive(null)\n    setFocusAfter(key)\n  }\n\n  const format = new Intl.NumberFormat(locale, formatOptions)\n  const percent = new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 1 })\n  const describe = (tile: Tile) => `${tile.datum.label}: ${format.format(tile.value)} (${percent.format(total ? tile.value / total : 0)})`\n  const hovered = tiles.find((tile) => tile.key === active)\n  const crumbs = [rootLabel, ...trail.map((d) => d.label)]\n\n  return (\n    <div\n      data-slot=\"treemap\"\n      role=\"group\"\n      aria-label={ariaLabel}\n      className={cn(\"grid gap-2\", className)}\n      onKeyDown={(event) => {\n        onKeyDown?.(event)\n        if (event.key === \"Escape\" && levelPath.length && !event.defaultPrevented) {\n          event.preventDefault()\n          zoomTo(levelPath.length - 1)\n        }\n      }}\n      {...props}\n    >\n      {showHeader && (\n        <div className={cn(\"flex min-h-6 flex-wrap items-center justify-between gap-x-4 gap-y-1 text-sm\", classNames?.header)}>\n          <nav aria-label={t(\"treemapLevels\")} className=\"flex min-w-0 items-center gap-1\">\n            {crumbs.map((label, depth) => {\n              const current = depth === levelPath.length\n              return (\n                <React.Fragment key={depth}>\n                  {depth > 0 && <ChevronRightIcon aria-hidden className=\"size-3.5 shrink-0 text-muted-foreground rtl:-scale-x-100\" />}\n                  {current ? (\n                    <span aria-current=\"page\" className=\"truncate font-medium\">\n                      {label}\n                      <span className=\"ms-2 font-normal text-muted-foreground tabular-nums\">\n                        <CountTo value={total} locale={locale} formatOptions={formatOptions} />\n                      </span>\n                    </span>\n                  ) : (\n                    <button\n                      type=\"button\"\n                      ref={depth === levelPath.length - 1 ? trailRef : undefined}\n                      onClick={() => zoomTo(depth)}\n                      className=\"truncate rounded-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50\"\n                    >\n                      {label}\n                    </button>\n                  )}\n                </React.Fragment>\n              )\n            })}\n          </nav>\n          <p aria-live=\"polite\" className=\"truncate text-muted-foreground tabular-nums\">\n            {hovered && (\n              <>\n                <span className=\"text-foreground\">{hovered.datum.label}</span>{\" \"}\n                {/* Moving from tile to tile counts the numbers to the new one's; read out once, in full. */}\n                <span aria-hidden>\n                  <CountTo value={hovered.value} locale={locale} formatOptions={formatOptions} /> ·{\" \"}\n                  <CountTo value={total ? hovered.value / total : 0} locale={locale} formatOptions={percentOptions} />\n                </span>\n                <span className=\"sr-only\">\n                  {format.format(hovered.value)} · {percent.format(total ? hovered.value / total : 0)}\n                </span>\n              </>\n            )}\n          </p>\n        </div>\n      )}\n      <div ref={box} className=\"relative w-full\" style={{ height }}>\n        <ul className=\"absolute inset-0\">\n          {tiles.map((tile) => {\n            const group = !!tile.datum.children?.length\n            const begin = start(tile)\n            const px = size(tile)\n            const strip = stripOf(tile)\n            const showLabel = px.w >= 56 && px.h >= 30\n            const showValue = px.w >= 56 && px.h >= 50\n            const body = group ? (\n              <>\n                {strip > 0 && (\n                  <span className=\"flex h-7 min-w-0 items-center gap-1.5 px-2 text-xs\">\n                    <span className={cn(\"truncate font-medium text-foreground\", classNames?.label)}>{tile.datum.label}</span>\n                    {px.w >= 120 && (\n                      <span className={cn(\"shrink-0 text-muted-foreground tabular-nums\", classNames?.value)}>\n                        <CountTo value={tile.value} locale={locale} formatOptions={formatOptions} />\n                      </span>\n                    )}\n                  </span>\n                )}\n                <span aria-hidden className=\"absolute inset-x-0 bottom-0\" style={{ top: strip }}>\n                  {inner(tile).map((c, i) => (\n                    <span\n                      key={i}\n                      className=\"absolute rounded-[3px] border border-(--tile)/25\"\n                      style={{ left: `calc(${c.x}% + 2px)`, top: `calc(${c.y}% + 2px)`, width: `calc(${c.w}% - 4px)`, height: `calc(${c.h}% - 4px)` }}\n                    />\n                  ))}\n                </span>\n              </>\n            ) : (\n              <span className=\"grid min-w-0 gap-0.5 p-2\">\n                {showLabel && <span className={cn(\"truncate text-xs font-medium text-foreground\", classNames?.label)}>{tile.datum.label}</span>}\n                {showValue && (\n                  <span className={cn(\"truncate text-xs text-muted-foreground tabular-nums\", classNames?.value)}>\n                    <CountTo value={tile.value} locale={locale} formatOptions={formatOptions} />\n                  </span>\n                )}\n              </span>\n            )\n            const tileClass = cn(\n              \"relative flex size-full flex-col items-stretch justify-start overflow-hidden rounded-md text-start bg-[color-mix(in_oklab,var(--tile)_20%,var(--card))] shadow-[inset_0_2px_0_var(--tile),inset_0_0_0_1px_color-mix(in_oklab,var(--tile)_35%,transparent)] transition-[background-color,opacity] outline-none\",\n              \"focus-visible:ring-2 focus-visible:ring-ring/50\",\n              group && \"cursor-zoom-in hover:bg-[color-mix(in_oklab,var(--tile)_30%,var(--card))]\",\n              classNames?.tile\n            )\n            return (\n              <li\n                key={tile.key}\n                className={cn(\n                  \"absolute p-px transition-[left,top,width,height,opacity] duration-(--duration-considered) motion-reduce:transition-none\",\n                  from?.size === 0 ? \"ease-enter\" : \"ease-standard\",\n                  tile.value === 0 && \"hidden\"\n                )}\n                style={{\n                  ...place(begin.rect),\n                  \"--tile\": tile.color,\n                  opacity: begin.hidden ? 0 : active && active !== tile.key ? 0.5 : 1,\n                } as React.CSSProperties}\n                onPointerEnter={() => setActive(tile.key)}\n                onPointerLeave={() => setActive(null)}\n                title={describe(tile)}\n              >\n                {group ? (\n                  <button\n                    type=\"button\"\n                    ref={(node) => {\n                      if (node) tileRefs.current.set(tile.key, node)\n                      else tileRefs.current.delete(tile.key)\n                    }}\n                    aria-label={t(\"zoomIn\", { name: describe(tile) })}\n                    onClick={() => zoomIn(tile)}\n                    onFocus={() => setActive(tile.key)}\n                    onBlur={() => setActive(null)}\n                    className={tileClass}\n                  >\n                    {body}\n                  </button>\n                ) : (\n                  <div className={tileClass}>\n                    <span className=\"sr-only\">{describe(tile)}</span>\n                    <span aria-hidden>{body}</span>\n                  </div>\n                )}\n              </li>\n            )\n          })}\n        </ul>\n      </div>\n    </div>\n  )\n}\n\nexport { Treemap, type TreemapProps }\n"}],"cssVars":{"theme":{"ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{}}}