{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "treemap",
  "type": "registry:ui",
  "title": "Treemap",
  "description": "A squarified treemap: click to drill and the tiles grow to fill the view, with a breadcrumb back and metrics that morph every tile.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/treemap/treemap.tsx",
      "content": "\"use client\";\n\nimport {\n  useEffect,\n  useImperativeHandle,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  useSyncExternalStore,\n  type CSSProperties,\n  type KeyboardEvent,\n  type PointerEvent,\n  type Ref,\n} from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface TreemapNode {\n  /** Stable identity, unique in the tree. A tile that survives a data change morphs to its new size. */\n  id: string;\n  label: string;\n  /** Leaf size. A branch's size is the sum of its children. */\n  value?: number;\n  /** Optional second measure for shading, such as growth. A branch uses the size weighted mean of its children. */\n  color?: number;\n  children?: TreemapNode[];\n}\n\n/** Use a treemap for a hierarchy of parts, such as revenue by region, country, and plan, when sizes matter more than exact comparisons. */\nexport interface TreemapProps {\n  /** The root. Its label names the whole in the first breadcrumb. */\n  data: TreemapNode;\n  /** What the whole is, such as \"Revenue\". Names the chart for assistive technology. */\n  label: string;\n  formatValue?: (value: number) => string;\n  /** Names the shading measure in the tooltip and scale, such as \"Growth\". Tiles take the hue of their top level branch and grow deeper with the measure. Leave it out to draw every tile at one depth. */\n  colorLabel?: string;\n  formatColor?: (value: number) => string;\n  /** Range of the color measure. Defaults to the range of the leaves. */\n  colorDomain?: [number, number];\n  /** Controlled id of the node that fills the view. */\n  focus?: string;\n  defaultFocus?: string;\n  onFocusChange?: (id: string) => void;\n  /** Height of the tiles in pixels. */\n  height?: number;\n  emptyLabel?: string;\n  ref?: Ref<HTMLElement>;\n  className?: string;\n}\n\ntype Rect = { x: number; y: number; w: number; h: number };\ntype Flat = {\n  id: string;\n  label: string;\n  value: number;\n  color: number | null;\n  depth: number;\n  parent: string | null;\n  children: string[];\n  path: string[];\n};\ntype Drawn = Rect & { o: number };\n\nconst { spring, ease, blur, duration } = motionTokens;\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = 0.0005) => {\n  const root = (2 * Math.PI) / (visualDuration * 1.2);\n  return {\n    type: \"spring\" as const,\n    stiffness: root * root,\n    damping: 2 * (1 - bounce) * root,\n    restDelta,\n    restSpeed: restDelta * 2,\n  };\n};\nconst zoom = physical({ visualDuration: 0.62, bounce: 0.06 });\nconst reveal = physical({ visualDuration: 0.9, bounce: 0 });\nconst glide = physical(spring.snappy, 0.01);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: 0.01 };\n/** Top level branches take the four series hues in order; a fifth and beyond stay neutral rather than reuse a hue. */\nconst TILE =\n  \"group/tile invisible absolute top-0 left-0 flex cursor-pointer flex-col gap-px overflow-hidden rounded-[6px] bg-(--fill) px-2 py-[6px] text-ink will-change-[transform,width,height] [transition:background-color_var(--duration-considered)_var(--ease-standard),color_var(--duration-considered)_var(--ease-standard),filter_var(--duration-fast)_var(--ease-standard),box-shadow_var(--duration-fast)_var(--ease-standard)] data-[level=2]:px-[7px] data-[level=2]:py-[5px] motion-reduce:transition-none\";\nconst BRANCH =\n  \"data-[level=1]:cursor-zoom-in data-[level=1]:flex-row data-[level=1]:items-baseline data-[level=1]:justify-between data-[level=1]:gap-2 data-[level=1]:rounded-[8px] data-[level=1]:bg-[color-mix(in_oklab,var(--hue)_9%,var(--surface))] data-[level=1]:px-2 data-[level=1]:py-[3px]\";\nconst hueOf = (index: number) =>\n  index < 0 ? \"var(--series-1)\" : index < 4 ? `var(--series-${index + 1})` : \"var(--ink)\";\nconst GAP = 2,\n  PAD = 3,\n  HEADER = 22,\n  TIP = 14,\n  STEPS = 5;\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\nconst percent = new Intl.NumberFormat(\"en-US\", { style: \"percent\", maximumFractionDigits: 1 });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst mix = (a: number, b: number, t: number) => a + (b - a) * t;\nconst inset = (r: Rect, top: number, side: number): Rect => ({\n  x: r.x + side,\n  y: r.y + top,\n  w: Math.max(0, r.w - side * 2),\n  h: Math.max(0, r.h - top - side),\n});\n\nfunction flatten(root: TreemapNode) {\n  const flat = new Map<string, Flat>();\n  const visit = (\n    node: TreemapNode,\n    depth: number,\n    parent: string | null,\n    path: string[],\n  ): { value: number; weighted: number; colored: number } => {\n    const kids = node.children ?? [];\n    let value = 0,\n      weighted = 0,\n      colored = 0;\n    if (kids.length)\n      for (const kid of kids) {\n        const r = visit(kid, depth + 1, node.id, depth === 0 ? [] : [...path, node.label]);\n        value += r.value;\n        weighted += r.weighted;\n        colored += r.colored;\n      }\n    else {\n      value = Math.max(0, node.value ?? 0);\n      if (node.color !== undefined && Number.isFinite(node.color)) {\n        weighted = node.color * value;\n        colored = value;\n      }\n    }\n    flat.set(node.id, {\n      id: node.id,\n      label: node.label,\n      value,\n      color: colored ? weighted / colored : null,\n      depth,\n      parent,\n      children: kids.map((kid) => kid.id),\n      path: depth === 0 ? [node.label] : [...path, node.label],\n    });\n    return { value, weighted, colored };\n  };\n  visit(root, 0, null, []);\n  return flat;\n}\n\n/** Squarified layout: rows of tiles along the short side, each row closed when adding a tile would make the worst aspect ratio worse. */\nfunction squarify(items: { id: string; value: number }[], rect: Rect, out: Map<string, Rect>) {\n  const list = items.filter((item) => item.value > 0).sort((a, b) => b.value - a.value);\n  const total = list.reduce((sum, item) => sum + item.value, 0);\n  if (!total || rect.w <= 0 || rect.h <= 0) {\n    for (const item of items) out.set(item.id, { x: rect.x, y: rect.y, w: 0, h: 0 });\n    return;\n  }\n  const scale = (rect.w * rect.h) / total;\n  let { x, y, w, h } = rect,\n    i = 0;\n  const worst = (row: number[], side: number) => {\n    const sum = row.reduce((a, b) => a + b, 0),\n      max = Math.max(...row),\n      min = Math.min(...row);\n    return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min));\n  };\n  while (i < list.length) {\n    const side = Math.min(w, h),\n      row: number[] = [list[i]!.value * scale];\n    let j = i + 1;\n    while (j < list.length) {\n      const next = [...row, list[j]!.value * scale];\n      if (worst(next, side) > worst(row, side)) break;\n      row.push(list[j]!.value * scale);\n      j++;\n    }\n    const sum = row.reduce((a, b) => a + b, 0);\n    if (w >= h) {\n      const cw = sum / h;\n      let cy = y;\n      for (let k = i; k < j; k++) {\n        const th = (list[k]!.value * scale) / cw;\n        out.set(list[k]!.id, { x, y: cy, w: cw, h: th });\n        cy += th;\n      }\n      x += cw;\n      w -= cw;\n    } else {\n      const rh = sum / w;\n      let cx = x;\n      for (let k = i; k < j; k++) {\n        const tw = (list[k]!.value * scale) / rh;\n        out.set(list[k]!.id, { x: cx, y, w: tw, h: rh });\n        cx += tw;\n      }\n      y += rh;\n      h -= rh;\n    }\n    i = j;\n  }\n  for (const item of items) if (!out.has(item.id)) out.set(item.id, { x: rect.x, y: rect.y, w: 0, h: 0 });\n}\n\n/** Rects of every descendant when `focus` fills the view: its children fill the view, and each branch holds its own children under a header. */\nfunction layoutFrom(flat: Map<string, Flat>, focus: string, view: Rect) {\n  const out = new Map<string, Rect>();\n  const nest = (id: string, rect: Rect, top: boolean) => {\n    const node = flat.get(id)!;\n    if (!node.children.length) return;\n    const room = top ? rect : inset(rect, rect.h > 48 && rect.w > 64 ? HEADER : PAD, PAD);\n    const placed = new Map<string, Rect>();\n    squarify(\n      node.children.map((child) => ({ id: child, value: flat.get(child)!.value })),\n      room,\n      placed,\n    );\n    for (const [child, r] of placed) {\n      const gapped = { x: r.x + GAP / 2, y: r.y + GAP / 2, w: Math.max(0, r.w - GAP), h: Math.max(0, r.h - GAP) };\n      out.set(child, gapped);\n      nest(child, gapped, false);\n    }\n  };\n  nest(focus, view, true);\n  return out;\n}\n/** The affine map that carries rect `from` onto rect `to`, applied to `r`. */\nconst carry = (r: Rect, from: Rect, to: Rect): Rect => {\n  const sx = to.w / (from.w || 1),\n    sy = to.h / (from.h || 1);\n  return { x: to.x + (r.x - from.x) * sx, y: to.y + (r.y - from.y) * sy, w: r.w * sx, h: r.h * sy };\n};\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nexport function Treemap({\n  data,\n  label,\n  formatValue = (value) => grouped.format(value),\n  colorLabel,\n  formatColor = (value) => grouped.format(value),\n  colorDomain,\n  focus,\n  defaultFocus,\n  onFocusChange,\n  height = 420,\n  emptyLabel = \"No data yet\",\n  ref,\n  className,\n}: TreemapProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  const stage = useRef<HTMLDivElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  const tiles = useRef(new Map<string, HTMLDivElement>());\n  useImperativeHandle(ref, () => figure.current as HTMLElement);\n  const inView = useInView(figure, { once: true, amount: 0.25 });\n\n  const signature = JSON.stringify(data);\n  const flat = useMemo(() => flatten(data), [signature]); // eslint-disable-line react-hooks/exhaustive-deps\n  const root = flat.get(data.id)!;\n  const nodes = useMemo(() => [...flat.values()].filter((node) => node.depth > 0), [flat]);\n  const [ownFocus, setOwnFocus] = useState(defaultFocus ?? data.id);\n  const wanted = flat.get(focus ?? ownFocus);\n  const center = wanted && wanted.children.length ? wanted : root;\n  const zoomTo = (id: string) => {\n    if (focus === undefined) setOwnFocus(id);\n    onFocusChange?.(id);\n  };\n  const [active, setActive] = useState<string | null>(null);\n  const [pointer, setPointer] = useState<{ x: number; y: number } | null>(null);\n\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const node = stage.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => setWidth(node.clientWidth));\n    observer.observe(node);\n    setWidth(node.clientWidth);\n    return () => observer.disconnect();\n  }, []);\n  const view = useMemo<Rect>(() => ({ x: 0, y: 0, w: width, h: height }), [width, height]);\n  const target = useMemo(() => layoutFrom(flat, center.id, view), [flat, center.id, view]);\n  const levelOf = (node: Flat) => node.depth - center.depth;\n\n  // Color scale: a few neutral steps from light to dark, so shade reads as more without adding a hue.\n  const leaves = nodes.filter((node) => !node.children.length && node.color !== null);\n  const [lo, hi] =\n    colorDomain ??\n    (leaves.length\n      ? [Math.min(...leaves.map((node) => node.color!)), Math.max(...leaves.map((node) => node.color!))]\n      : [0, 1]);\n  const colored = colorLabel !== undefined && leaves.length > 0;\n  const shade = (value: number | null) => {\n    if (!colored || value === null) return 34;\n    const t = clamp((value - lo) / (hi - lo || 1), 0, 1);\n    return Math.round(14 + (Math.round(t * (STEPS - 1)) / (STEPS - 1)) * 44);\n  };\n  const branchOf = useMemo(() => {\n    const map = new Map<string, number>();\n    root.children.forEach((id, index) => {\n      const walk = (at: string) => {\n        map.set(at, index);\n        flat.get(at)?.children.forEach(walk);\n      };\n      walk(id);\n    });\n    return map;\n  }, [flat, root]);\n\n  // Every tile keeps its on screen rect; a spring carries it to the new layout. Tiles entering or leaving ride the same zoom as the tile they belong to.\n  const drawn = useRef(new Map<string, Drawn>());\n  const levels = useRef(new Map<string, number>());\n  const lastFocus = useRef<{ id: string; layout: Map<string, Rect> } | null>(null);\n  const paint = () => {\n    for (const node of nodes) {\n      const el = tiles.current.get(node.id),\n        d = drawn.current.get(node.id);\n      if (!el) continue;\n      if (!d || d.o < 0.01 || d.w < 0.5 || d.h < 0.5) {\n        el.style.visibility = \"hidden\";\n        el.style.pointerEvents = \"none\";\n        continue;\n      }\n      el.style.visibility = \"visible\";\n      el.style.transform = `translate3d(${d.x.toFixed(2)}px, ${d.y.toFixed(2)}px, 0)`;\n      el.style.width = `${d.w.toFixed(2)}px`;\n      el.style.height = `${d.h.toFixed(2)}px`;\n      el.style.opacity = d.o.toFixed(3);\n      el.style.pointerEvents = d.o > 0.6 ? \"auto\" : \"none\";\n      const level = levels.current.get(node.id) ?? 0;\n      el.dataset.level = String(level);\n      el.dataset.room = d.w > 64 && d.h > 38 ? (d.h > 54 ? \"full\" : \"name\") : \"none\";\n    }\n  };\n  const paintRef = useRef(paint);\n  useLayoutEffect(() => {\n    paintRef.current = paint;\n  });\n\n  const shown = (inView || reduced) && width > 0;\n  useEffect(() => {\n    if (!shown) return;\n    const prev = lastFocus.current;\n    const isUnder = (id: string, ancestor: string) => {\n      for (let at: Flat | undefined = flat.get(id); at; at = at.parent ? flat.get(at.parent) : undefined)\n        if (at.id === ancestor) return true;\n      return false;\n    };\n    const visible = (node: Flat) => {\n      const l = node.depth - center.depth;\n      return l >= 1 && l <= 2 && isUnder(node.id, center.id);\n    };\n    const nextVisible = new Set(nodes.filter(visible).map((node) => node.id));\n    const before = new Map(drawn.current);\n    const wasVisible = new Set([...before].filter(([, d]) => d.o > 0.5).map(([id]) => id));\n    // The pivot: the tile that grows to fill the view, or the view that shrinks back into its tile.\n    let toOld: ((r: Rect) => Rect) | null = null,\n      toNew: ((r: Rect) => Rect) | null = null;\n    if (prev && prev.id !== center.id && before.size) {\n      if (isUnder(center.id, prev.id)) {\n        const inOld = prev.layout.get(center.id);\n        if (inOld) {\n          toOld = (r) => carry(r, view, inOld);\n          toNew = (r) => carry(r, inOld, view);\n        }\n      } else if (isUnder(prev.id, center.id)) {\n        const inNew = target.get(prev.id);\n        if (inNew) {\n          toOld = (r) => carry(r, inNew, view);\n          toNew = (r) => carry(r, view, inNew);\n        }\n      }\n    }\n    const from = new Map<string, Drawn>(),\n      to = new Map<string, Drawn>();\n    const first = !before.size;\n    for (const node of nodes) {\n      const goal = target.get(node.id),\n        now = before.get(node.id);\n      const inNext = nextVisible.has(node.id),\n        inPrev = wasVisible.has(node.id);\n      if (inNext && goal) {\n        to.set(node.id, { ...goal, o: 1 });\n        if (inPrev && now) from.set(node.id, now);\n        else if (first) from.set(node.id, { x: goal.x + goal.w / 2, y: goal.y + goal.h / 2, w: 0, h: 0, o: 0 });\n        else from.set(node.id, { ...(toOld ? toOld(goal) : goal), o: 0 });\n        levels.current.set(node.id, levelOf(node));\n      } else if (inPrev && now) {\n        from.set(node.id, now);\n        to.set(node.id, { ...(toNew ? toNew(now) : now), o: 0 });\n      }\n    }\n    for (const node of nodes) if (!from.has(node.id)) drawn.current.set(node.id, { x: 0, y: 0, w: 0, h: 0, o: 0 });\n    lastFocus.current = { id: center.id, layout: target };\n    const finish = () => {\n      for (const [id, d] of to) drawn.current.set(id, d);\n      paintRef.current();\n    };\n    if (reduced) {\n      finish();\n      return;\n    }\n    const controls = animate(0, 1, {\n      ...(first ? reveal : zoom),\n      onUpdate: (t) => {\n        for (const [id, a] of from) {\n          const b = to.get(id)!;\n          // On the first reveal, tiles grow in a quick wave from the largest.\n          const local = first\n            ? clamp((t - Math.min(0.3, (1 - (b.w * b.h) / (view.w * view.h || 1)) * 0.3)) / 0.7, 0, 1)\n            : t;\n          drawn.current.set(id, {\n            x: mix(a.x, b.x, local),\n            y: mix(a.y, b.y, local),\n            w: mix(a.w, b.w, local),\n            h: mix(a.h, b.h, local),\n            o: mix(a.o, b.o, local),\n          });\n        }\n        paintRef.current();\n      },\n      onComplete: finish,\n    });\n    return () => controls.stop();\n  }, [shown, target, reduced]); // eslint-disable-line react-hooks/exhaustive-deps\n  useLayoutEffect(() => {\n    paintRef.current();\n  });\n\n  const activeNode = active ? (flat.get(active) ?? null) : null;\n  const parentOf = (node: Flat) => (node.parent ? flat.get(node.parent)! : node);\n  const share = (node: Flat, of: Flat) => (of.value ? node.value / of.value : 0);\n\n  // Tooltip: trails the pointer, or sits by the tile in focus, and stays inside the chart.\n  const tipX = useMotionValue(0),\n    tipY = useMotionValue(0);\n  const tipSpringX = useSpring(tipX, follow),\n    tipSpringY = useSpring(tipY, follow);\n  const wasOn = useRef(false);\n  useLayoutEffect(() => {\n    const bubble = tip.current;\n    if (!bubble || !activeNode || !width) {\n      wasOn.current = false;\n      return;\n    }\n    const r = target.get(activeNode.id);\n    const ax = pointer?.x ?? (r ? r.x + r.w / 2 : 0),\n      ay = pointer?.y ?? (r ? r.y + Math.min(r.h / 2, 28) : 0);\n    const tw = bubble.offsetWidth,\n      th = bubble.offsetHeight;\n    let left = ax + TIP;\n    if (left + tw > width) left = ax - TIP - tw;\n    let top = ay + TIP;\n    if (top + th > height) top = ay - TIP - th;\n    tipX.set(clamp(left, 0, Math.max(0, width - tw)));\n    tipY.set(clamp(top, 0, Math.max(0, height - th)));\n    if (!wasOn.current || reduced) {\n      tipSpringX.jump(tipX.get());\n      tipSpringY.jump(tipY.get());\n    }\n    wasOn.current = true;\n  });\n\n  // Keyboard: arrows hop to the nearest tile in that direction on the top level, Enter zooms in, Escape zooms out.\n  const topTiles = center.children.map((id) => flat.get(id)!).filter((node) => node.value > 0);\n  const topOf = (node: Flat | null) => {\n    let at = node;\n    while (at && at.parent && at.parent !== center.id) at = flat.get(at.parent) ?? null;\n    return at && at.parent === center.id ? at : null;\n  };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    const current = topOf(activeNode);\n    const dirs: Record<string, [number, number]> = {\n      ArrowRight: [1, 0],\n      ArrowLeft: [-1, 0],\n      ArrowDown: [0, 1],\n      ArrowUp: [0, -1],\n    };\n    if (event.key in dirs) {\n      event.preventDefault();\n      setPointer(null);\n      if (!current) {\n        setActive(topTiles[0]?.id ?? null);\n        return;\n      }\n      const [dx, dy] = dirs[event.key]!,\n        o = target.get(current.id)!,\n        ox = o.x + o.w / 2,\n        oy = o.y + o.h / 2;\n      let best: Flat | null = null,\n        score = Infinity;\n      for (const node of topTiles) {\n        if (node.id === current.id) continue;\n        const r = target.get(node.id);\n        if (!r) continue;\n        const vx = r.x + r.w / 2 - ox,\n          vy = r.y + r.h / 2 - oy,\n          along = vx * dx + vy * dy,\n          across = Math.abs(vx * dy - vy * dx);\n        if (along <= 1) continue;\n        const s = along + across * 1.5;\n        if (s < score) {\n          score = s;\n          best = node;\n        }\n      }\n      if (best) setActive(best.id);\n    } else if (event.key === \"Home\" || event.key === \"End\") {\n      event.preventDefault();\n      setPointer(null);\n      setActive((event.key === \"Home\" ? topTiles[0] : topTiles[topTiles.length - 1])?.id ?? null);\n    } else if (event.key === \"Enter\" || event.key === \" \") {\n      if (current?.children.length) {\n        event.preventDefault();\n        zoomTo(current.id);\n        setActive(current.children[0]!);\n      }\n    } else if (event.key === \"Escape\" || event.key === \"Backspace\") {\n      if (center.parent) {\n        event.preventDefault();\n        zoomTo(center.parent);\n        setActive(center.id);\n      } else if (active) {\n        event.preventDefault();\n        setActive(null);\n      }\n    }\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>, id: string) => {\n    if (event.pointerType !== \"mouse\") return;\n    const rect = stage.current?.getBoundingClientRect();\n    if (rect) setPointer({ x: event.clientX - rect.left, y: event.clientY - rect.top });\n    setActive(id);\n  };\n  const onTileClick = (node: Flat) => {\n    const top = topOf(node);\n    if (top?.children.length) zoomTo(top.id);\n    else if (!activeNode || activeNode.id !== node.id) setActive(node.id);\n  };\n\n  const trail: Flat[] = [];\n  for (let at: Flat | undefined = center; at; at = at.parent ? flat.get(at.parent) : undefined) trail.unshift(at);\n  const empty = !root.value;\n  const describe = (node: Flat) =>\n    `${node.path.join(\", \")}, ${formatValue(node.value)}, ${percent.format(share(node, parentOf(node)))} of ${parentOf(node).label}${colored && node.color !== null ? `, ${colorLabel} ${formatColor(node.color)}` : \"\"}`;\n  const focusTop = topOf(activeNode);\n\n  return (\n    <figure\n      ref={figure}\n      className={cn(\"m-0 grid min-w-0 gap-3 font-body tracking-body text-ink\", className)}\n      aria-label={label}\n    >\n      {/* Breadcrumb on the left, the total of what fills the view on the right. The trail is pulled back by the crumb padding, so the first label lines up with the tiles. */}\n      <div className=\"flex min-w-0 items-center justify-between gap-3\">\n        <nav className=\"-ml-1 min-w-0\" aria-label={`${label} path`}>\n          <ol className=\"relative m-0 flex min-h-8 min-w-0 list-none flex-wrap items-center p-0\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {trail.map((node, index) => (\n                <motion.li\n                  key={node.id}\n                  className=\"inline-flex min-w-0 items-center\"\n                  layout={reduced ? false : \"position\"}\n                  initial={{ opacity: 0, x: reduced ? 0 : -6, filter: reduced ? \"none\" : `blur(${blur.subtle}px)` }}\n                  animate={{ opacity: 1, x: 0, filter: \"blur(0px)\" }}\n                  exit={{\n                    opacity: 0,\n                    x: reduced ? 0 : -6,\n                    transition: { duration: duration.instant, ease: [...ease.exit] },\n                  }}\n                  transition={{ ...spring.smooth }}\n                >\n                  {index > 0 && (\n                    <span className=\"px-1 text-(length:--text-sm) text-line-strong\" aria-hidden=\"true\">\n                      /\n                    </span>\n                  )}\n                  {index < trail.length - 1 ? (\n                    <button\n                      type=\"button\"\n                      className=\"-mx-1 my-0 min-h-8 cursor-pointer rounded-button border-0 bg-transparent px-2 py-0 text-sm leading-body whitespace-nowrap text-ink-2 [-webkit-tap-highlight-color:transparent] [transition:color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-fast)_var(--ease-standard),transform_var(--duration-instant)_var(--ease-standard)] hover:bg-[color-mix(in_oklab,var(--ink)_5%,transparent)] hover:text-ink active:[transform:scale(.97)] motion-reduce:transition-none\"\n                      onClick={() => {\n                        zoomTo(node.id);\n                        setActive(null);\n                      }}\n                    >\n                      {node.label}\n                    </button>\n                  ) : (\n                    <span\n                      className=\"px-1 text-sm leading-body font-medium whitespace-nowrap text-ink\"\n                      aria-current=\"location\"\n                    >\n                      {node.label}\n                    </span>\n                  )}\n                </motion.li>\n              ))}\n            </AnimatePresence>\n          </ol>\n        </nav>\n        <p className=\"relative m-0 inline-grid flex-none justify-items-end overflow-hidden text-sm leading-body font-medium text-ink tabular-nums\">\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={formatValue(center.value)}\n              className=\"block\"\n              initial={{ opacity: 0, y: reduced ? 0 : 6 }}\n              animate={{ opacity: 1, y: 0 }}\n              exit={{ opacity: 0, y: reduced ? 0 : -6 }}\n              transition={{ duration: duration.standard, ease: [...ease.enter] }}\n            >\n              {formatValue(center.value)}\n            </motion.span>\n          </AnimatePresence>\n        </p>\n      </div>\n      {/* Tiles are absolutely placed and sized each frame, so text never scales while a tile grows or shrinks. */}\n      {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- the plot is a focusable chart widget explored by pointer and arrow keys; <fieldset> would add form semantics */}\n      <div\n        ref={stage}\n        className=\"relative min-w-0 overflow-hidden rounded-[12px] outline-none select-none [-webkit-tap-highlight-color:transparent]\"\n        style={{ height }}\n        data-active={activeNode ? true : undefined}\n        role=\"group\"\n        tabIndex={empty ? -1 : 0}\n        aria-roledescription=\"treemap\"\n        aria-label={`${label}, ${center.label}. Arrow keys move between tiles, Enter zooms in, Escape zooms out.`}\n        onKeyDown={onKeyDown}\n        onBlur={() => setActive(null)}\n        onPointerLeave={() => {\n          setActive(null);\n          setPointer(null);\n        }}\n      >\n        {/* The frame loop writes data-level and data-room, so the tile rules that follow them stay attribute variants. A branch on the top level prints its name and total on one header line. */}\n        {nodes.map((node) => {\n          const top = topOf(node),\n            isBranch = node.children.length > 0,\n            lineage = focusTop?.id === node.id && active !== node.id;\n          return (\n            // oxlint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- a pointer shortcut; the stage handles arrow keys, Enter and Escape for the tiles\n            <div\n              key={node.id}\n              ref={(element) => {\n                if (element) tiles.current.set(node.id, element);\n                else tiles.current.delete(node.id);\n              }}\n              className={cn(\n                TILE,\n                isBranch && BRANCH,\n                active === node.id && \"shadow-[inset_0_0_0_2px_var(--ink)]\",\n                lineage && \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--hue)_70%,transparent)]\",\n                activeNode && focusTop !== null && top !== null && focusTop.id !== top.id && \"[filter:opacity(.5)]\",\n              )}\n              data-branch={isBranch || undefined}\n              data-active={active === node.id || undefined}\n              data-lineage={lineage || undefined}\n              data-dim={(focusTop !== null && top !== null && focusTop.id !== top.id) || undefined}\n              style={\n                {\n                  \"--hue\": hueOf(branchOf.get(node.id) ?? -1),\n                  \"--fill\": `color-mix(in oklab, ${hueOf(branchOf.get(node.id) ?? -1)} ${shade(node.color)}%, var(--surface))`,\n                  zIndex: node.depth,\n                } as CSSProperties\n              }\n              onPointerMove={(event) => {\n                event.stopPropagation();\n                onPointerMove(event, node.id);\n              }}\n              onClick={(event) => {\n                event.stopPropagation();\n                onTileClick(node);\n              }}\n            >\n              <span className=\"truncate text-xs leading-4 font-medium opacity-0 transition-opacity duration-fast ease-standard group-data-[room=full]/tile:opacity-100 group-data-[room=name]/tile:opacity-100 motion-reduce:transition-none\">\n                {node.label}\n              </span>\n              <span\n                className={cn(\n                  \"truncate text-xs leading-4 tabular-nums opacity-0 transition-opacity duration-fast ease-standard motion-reduce:transition-none\",\n                  isBranch\n                    ? \"not-group-data-[level=1]/tile:group-data-[room=full]/tile:opacity-72 group-data-[level=1]/tile:flex-none group-data-[level=1]/tile:not-group-data-[room=none]/tile:opacity-60\"\n                    : \"group-data-[room=full]/tile:opacity-72\",\n                )}\n              >\n                {formatValue(node.value)}\n              </span>\n            </div>\n          );\n        })}\n        {empty && (\n          <p className=\"pointer-events-none absolute inset-0 m-0 grid place-items-center text-(length:--text-sm) text-ink-3\">\n            {emptyLabel}\n          </p>\n        )}\n        <motion.div\n          ref={tip}\n          className={cn(\n            \"pointer-events-none absolute top-0 left-0 z-50 grid max-w-[min(240px,100%)] min-w-[160px] grid-cols-[minmax(0,1fr)] gap-0.5 rounded-[14px] border border-line bg-surface px-3 py-[10px] shadow-floating [transition:opacity_var(--duration-fast)_var(--ease-standard),scale_var(--duration-fast)_var(--ease-standard)] motion-reduce:transition-none\",\n            activeNode ? \"opacity-100 [scale:1]\" : \"opacity-0 [scale:.96]\",\n          )}\n          style={{ x: reduced ? tipX : tipSpringX, y: reduced ? tipY : tipSpringY }}\n          aria-hidden=\"true\"\n        >\n          {activeNode && (\n            <>\n              <p className=\"m-0 truncate text-xs leading-body text-ink-3\">\n                {activeNode.path.slice(0, -1).join(\" / \") || root.label}\n              </p>\n              <p className=\"m-0 flex items-baseline justify-between gap-3 text-sm leading-[1.35]\">\n                <span className=\"min-w-0 truncate text-ink\">{activeNode.label}</span>\n                <span className=\"flex-none font-medium text-ink tabular-nums\">{formatValue(activeNode.value)}</span>\n              </p>\n              <p className=\"m-0 text-xs leading-body text-ink-3 tabular-nums\">\n                {percent.format(share(activeNode, parentOf(activeNode)))} of {parentOf(activeNode).label}\n              </p>\n              {colored && activeNode.color !== null && (\n                <p className=\"m-0 text-xs leading-body text-ink-3 tabular-nums\">\n                  {colorLabel} {formatColor(activeNode.color)}\n                </p>\n              )}\n            </>\n          )}\n        </motion.div>\n      </div>\n      {/* The color scale: stepped swatches from light to dark, never a gradient. */}\n      {colored && (\n        <div\n          className=\"flex min-w-0 flex-wrap items-center gap-2 text-xs leading-body text-ink-3 tabular-nums\"\n          aria-hidden=\"true\"\n        >\n          <span className=\"mr-1 text-ink-2\">{colorLabel}</span>\n          <span>{formatColor(lo)}</span>\n          <span className=\"inline-flex gap-0.5\">\n            {Array.from({ length: STEPS }, (_, i) => (\n              <span\n                key={i}\n                className=\"h-[10px] w-[18px] rounded-[3px] bg-(--fill)\"\n                style={\n                  {\n                    \"--fill\": `color-mix(in oklab, ${center.depth === 0 ? \"var(--ink)\" : hueOf(branchOf.get(center.id) ?? -1)} ${Math.round(14 + (i / (STEPS - 1)) * 44)}%, var(--surface))`,\n                  } as CSSProperties\n                }\n              />\n            ))}\n          </span>\n          <span>{formatColor(hi)}</span>\n        </div>\n      )}\n      <p className=\"sr-only\" aria-live=\"polite\" aria-atomic=\"true\">\n        {activeNode ? describe(activeNode) : `${center.label}, ${formatValue(center.value)}`}\n      </p>\n      {!empty && (\n        <div className=\"sr-only\">\n          <table>\n            <caption>{label}</caption>\n            <thead>\n              <tr>\n                <th scope=\"col\">Path</th>\n                <th scope=\"col\">Value</th>\n                <th scope=\"col\">Share of parent</th>\n                {colored && <th scope=\"col\">{colorLabel}</th>}\n              </tr>\n            </thead>\n            <tbody>\n              {nodes.map((node) => (\n                <tr key={node.id}>\n                  <th scope=\"row\">{node.path.join(\" / \")}</th>\n                  <td>{formatValue(node.value)}</td>\n                  <td>{percent.format(share(node, parentOf(node)))}</td>\n                  {colored && <td>{node.color === null ? \"\" : formatColor(node.color)}</td>}\n                </tr>\n              ))}\n            </tbody>\n          </table>\n        </div>\n      )}\n    </figure>\n  );\n}\n\nexport default Treemap;\n",
      "type": "registry:component",
      "target": "@components/iris/treemap/treemap.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/treemap",
  "meta": {
    "kind": "component",
    "group": "Charts",
    "export": "Treemap",
    "tags": [
      "data",
      "chart",
      "new"
    ],
    "docs": "https://irisui.dev/components/treemap"
  }
}
