{"$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://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/treemap/treemap.tsx","type":"registry:component","target":"@components/arc/treemap/treemap.tsx","content":"\"use client\";\n\nimport { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./treemap.module.css\";\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;\n className?: string;\n}\n\ntype Rect = { x: number; y: number; w: number; h: number };\ntype Flat = { id: string; label: string; value: number; color: number | null; depth: number; parent: string | null; children: string[]; path: string[] };\ntype Drawn = Rect & { o: number };\n\nconst { spring, ease, blur, duration } = motionTokens;\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = .0005) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };\nconst zoom = physical({ visualDuration: .62, bounce: .06 });\nconst reveal = physical({ visualDuration: .9, bounce: 0 });\nconst glide = physical(spring.snappy, .01);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };\n/** Top level branches take the four series hues in order; a fifth and beyond stay neutral rather than reuse a hue. */\nconst hueOf = (index: number) => index < 0 ? \"var(--series-1)\" : index < 4 ? `var(--series-${index + 1})` : \"var(--foreground)\";\nconst GAP = 2, PAD = 3, HEADER = 22, TIP = 14, 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 => ({ x: r.x + side, y: r.y + top, w: Math.max(0, r.w - side * 2), h: Math.max(0, r.h - top - side) });\n\nfunction flatten(root: TreemapNode) {\n const flat = new Map();\n const visit = (node: TreemapNode, depth: number, parent: string | null, path: string[]): { value: number; weighted: number; colored: number } => {\n const kids = node.children ?? [];\n let value = 0, weighted = 0, colored = 0;\n if (kids.length) for (const kid of kids) { const r = visit(kid, depth + 1, node.id, depth === 0 ? [] : [...path, node.label]); value += r.value; weighted += r.weighted; colored += r.colored; }\n else { value = Math.max(0, node.value ?? 0); if (node.color !== undefined && Number.isFinite(node.color)) { weighted = node.color * value; colored = value; } }\n flat.set(node.id, { id: node.id, label: node.label, value, color: colored ? weighted / colored : null, depth, parent, children: kids.map(kid => kid.id), path: depth === 0 ? [node.label] : [...path, node.label] });\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) {\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) { for (const item of items) out.set(item.id, { x: rect.x, y: rect.y, w: 0, h: 0 }); return; }\n const scale = (rect.w * rect.h) / total;\n let { x, y, w, h } = rect, i = 0;\n const worst = (row: number[], side: number) => { const sum = row.reduce((a, b) => a + b, 0), max = Math.max(...row), min = Math.min(...row); return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min)); };\n while (i < list.length) {\n const side = Math.min(w, h), row: number[] = [list[i]!.value * scale];\n let j = i + 1;\n while (j < list.length) { const next = [...row, list[j]!.value * scale]; if (worst(next, side) > worst(row, side)) break; row.push(list[j]!.value * scale); j++; }\n const sum = row.reduce((a, b) => a + b, 0);\n if (w >= h) { const cw = sum / h; let cy = y; for (let k = i; k < j; k++) { const th = (list[k]!.value * scale) / cw; out.set(list[k]!.id, { x, y: cy, w: cw, h: th }); cy += th; } x += cw; w -= cw; }\n else { const rh = sum / w; let cx = x; for (let k = i; k < j; k++) { const tw = (list[k]!.value * scale) / rh; out.set(list[k]!.id, { x: cx, y, w: tw, h: rh }); cx += tw; } y += rh; h -= rh; }\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, focus: string, view: Rect) {\n const out = new Map();\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();\n squarify(node.children.map(child => ({ id: child, value: flat.get(child)!.value })), room, placed);\n for (const [child, r] of placed) { 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) }; out.set(child, gapped); nest(child, gapped, false); }\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 => { const sx = to.w / (from.w || 1), sy = to.h / (from.h || 1); 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\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n return !!useReducedMotion() && hydrated;\n}\n\nexport function Treemap({ data, label, formatValue = value => grouped.format(value), colorLabel, formatColor = value => grouped.format(value), colorDomain, focus, defaultFocus, onFocusChange, height = 420, emptyLabel = \"No data yet\", ref, className }: TreemapProps) {\n const reduced = useReducedMotionSafe();\n const figure = useRef(null);\n const stage = useRef(null);\n const tip = useRef(null);\n const tiles = useRef(new Map());\n useImperativeHandle(ref, () => figure.current as HTMLElement);\n const inView = useInView(figure, { once: true, amount: .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) => { if (focus === undefined) setOwnFocus(id); onFocusChange?.(id); };\n const [active, setActive] = useState(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(() => ({ 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] = colorDomain ?? (leaves.length ? [Math.min(...leaves.map(node => node.color!)), Math.max(...leaves.map(node => node.color!))] : [0, 1]);\n const colored = colorLabel !== undefined && leaves.length > 0;\n const shade = (value: number | null) => { if (!colored || value === null) return 34; const t = clamp((value - lo) / (hi - lo || 1), 0, 1); return Math.round(14 + Math.round(t * (STEPS - 1)) / (STEPS - 1) * 44); };\n const branchOf = useMemo(() => { const map = new Map(); root.children.forEach((id, index) => { const walk = (at: string) => { map.set(at, index); flat.get(at)?.children.forEach(walk); }; walk(id); }); return map; }, [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());\n const levels = useRef(new Map());\n const lastFocus = useRef<{ id: string; layout: Map } | null>(null);\n const paint = () => {\n for (const node of nodes) {\n const el = tiles.current.get(node.id), d = drawn.current.get(node.id);\n if (!el) continue;\n if (!d || d.o < .01 || d.w < .5 || d.h < .5) { el.style.visibility = \"hidden\"; el.style.pointerEvents = \"none\"; continue; }\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`; el.style.height = `${d.h.toFixed(2)}px`;\n el.style.opacity = d.o.toFixed(3);\n el.style.pointerEvents = d.o > .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(() => { paintRef.current = paint; });\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) => { for (let at: Flat | undefined = flat.get(id); at; at = at.parent ? flat.get(at.parent) : undefined) if (at.id === ancestor) return true; return false; };\n const visible = (node: Flat) => { const l = node.depth - center.depth; return l >= 1 && l <= 2 && isUnder(node.id, center.id); };\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 > .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, toNew: ((r: Rect) => Rect) | null = null;\n if (prev && prev.id !== center.id && before.size) {\n if (isUnder(center.id, prev.id)) { const inOld = prev.layout.get(center.id); if (inOld) { toOld = r => carry(r, view, inOld); toNew = r => carry(r, inOld, view); } }\n else if (isUnder(prev.id, center.id)) { const inNew = target.get(prev.id); if (inNew) { toOld = r => carry(r, inNew, view); toNew = r => carry(r, view, inNew); } }\n }\n const from = new Map(), to = new Map();\n const first = !before.size;\n for (const node of nodes) {\n const goal = target.get(node.id), now = before.get(node.id);\n const inNext = nextVisible.has(node.id), 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 = () => { for (const [id, d] of to) drawn.current.set(id, d); paintRef.current(); };\n if (reduced) { finish(); return; }\n const controls = animate(0, 1, { ...(first ? reveal : zoom), 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 ? clamp((t - Math.min(.3, (1 - b.w * b.h / (view.w * view.h || 1)) * .3)) / .7, 0, 1) : t;\n drawn.current.set(id, { x: mix(a.x, b.x, local), y: mix(a.y, b.y, local), w: mix(a.w, b.w, local), h: mix(a.h, b.h, local), o: mix(a.o, b.o, local) });\n }\n paintRef.current();\n }, onComplete: finish });\n return () => controls.stop();\n }, [shown, target, reduced]); // eslint-disable-line react-hooks/exhaustive-deps\n useLayoutEffect(() => { paintRef.current(); });\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), tipY = useMotionValue(0);\n const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);\n const wasOn = useRef(false);\n useLayoutEffect(() => {\n const bubble = tip.current;\n if (!bubble || !activeNode || !width) { wasOn.current = false; return; }\n const r = target.get(activeNode.id);\n const ax = pointer?.x ?? (r ? r.x + r.w / 2 : 0), ay = pointer?.y ?? (r ? r.y + Math.min(r.h / 2, 28) : 0);\n const tw = bubble.offsetWidth, 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))); tipY.set(clamp(top, 0, Math.max(0, height - th)));\n if (!wasOn.current || reduced) { tipSpringX.jump(tipX.get()); tipSpringY.jump(tipY.get()); }\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) => { let at = node; while (at && at.parent && at.parent !== center.id) at = flat.get(at.parent) ?? null; return at && at.parent === center.id ? at : null; };\n const onKeyDown = (event: KeyboardEvent) => {\n const current = topOf(activeNode);\n const dirs: Record = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] };\n if (event.key in dirs) {\n event.preventDefault(); setPointer(null);\n if (!current) { setActive(topTiles[0]?.id ?? null); return; }\n const [dx, dy] = dirs[event.key]!, o = target.get(current.id)!, ox = o.x + o.w / 2, oy = o.y + o.h / 2;\n let best: Flat | null = null, score = Infinity;\n for (const node of topTiles) { if (node.id === current.id) continue; const r = target.get(node.id); if (!r) continue; const vx = r.x + r.w / 2 - ox, vy = r.y + r.h / 2 - oy, along = vx * dx + vy * dy, across = Math.abs(vx * dy - vy * dx); if (along <= 1) continue; const s = along + across * 1.5; if (s < score) { score = s; best = node; } }\n if (best) setActive(best.id);\n } else if (event.key === \"Home\" || event.key === \"End\") { event.preventDefault(); setPointer(null); setActive((event.key === \"Home\" ? topTiles[0] : topTiles[topTiles.length - 1])?.id ?? null); }\n else if (event.key === \"Enter\" || event.key === \" \") { if (current?.children.length) { event.preventDefault(); zoomTo(current.id); setActive(current.children[0]!); } }\n else if (event.key === \"Escape\" || event.key === \"Backspace\") { if (center.parent) { event.preventDefault(); zoomTo(center.parent); setActive(center.id); } else if (active) { event.preventDefault(); setActive(null); } }\n };\n const onPointerMove = (event: PointerEvent, 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) => { const top = topOf(node); if (top?.children.length) zoomTo(top.id); else if (!activeNode || activeNode.id !== node.id) setActive(node.id); };\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) => `${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
\n \n

\n {formatValue(center.value)}\n

\n
\n
setActive(null)} onPointerLeave={() => { setActive(null); setPointer(null); }}>\n {nodes.map(node => { const top = topOf(node); return
{ if (element) tiles.current.set(node.id, element); else tiles.current.delete(node.id); }} className={styles.tile}\n data-branch={node.children.length > 0 || undefined} data-active={active === node.id || undefined} data-lineage={focusTop?.id === node.id && active !== node.id || undefined} data-dim={focusTop !== null && top !== null && focusTop.id !== top.id || undefined}\n style={{ \"--hue\": hueOf(branchOf.get(node.id) ?? -1), \"--fill\": `color-mix(in oklab, ${hueOf(branchOf.get(node.id) ?? -1)} ${shade(node.color)}%, var(--surface))`, zIndex: node.depth } as CSSProperties}\n onPointerMove={event => { event.stopPropagation(); onPointerMove(event, node.id); }} onClick={event => { event.stopPropagation(); onTileClick(node); }}>\n {node.label}\n {formatValue(node.value)}\n
; })}\n {empty &&

{emptyLabel}

}\n \n {activeNode && <>\n

{activeNode.path.slice(0, -1).join(\" / \") || root.label}

\n

{activeNode.label}{formatValue(activeNode.value)}

\n

{percent.format(share(activeNode, parentOf(activeNode)))} of {parentOf(activeNode).label}

\n {colored && activeNode.color !== null &&

{colorLabel} {formatColor(activeNode.color)}

}\n }\n
\n
\n {colored &&
\n {colorLabel}\n {formatColor(lo)}\n {Array.from({ length: STEPS }, (_, i) => )}\n {formatColor(hi)}\n
}\n

{activeNode ? describe(activeNode) : `${center.label}, ${formatValue(center.value)}`}

\n {!empty &&
\n \n {colored && }\n {nodes.map(node => {colored && })}\n
{label}
PathValueShare of parent{colorLabel}
{node.path.join(\" / \")}{formatValue(node.value)}{percent.format(share(node, parentOf(node)))}{node.color === null ? \"\" : formatColor(node.color)}
}\n
;\n}\n\nexport default Treemap;\n"},{"path":"registry/components/treemap/treemap.module.css","type":"registry:component","target":"@components/arc/treemap/treemap.module.css","content":".figure { display: grid; min-width: 0; gap: var(--space-3); margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */\n\n/* Breadcrumb on the left, the total of what fills the view on the right. */\n.bar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--space-3); }\n/* Pull the trail back by the crumb padding, so the first label lines up with the tiles and the title. */\n.crumbs { min-width: 0; margin-left: -4px; }\n.crumbList { position: relative; display: flex; min-width: 0; min-height: 32px; flex-wrap: wrap; align-items: center; margin: 0; padding: 0; list-style: none; }\n.crumb { display: inline-flex; align-items: center; min-width: 0; }\n.crumbSep { padding: 0 4px; color: var(--border-strong); font-size: var(--text-sm); }\n.crumbButton { min-height: 32px; padding: 0 6px; margin: 0 -2px; border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); white-space: nowrap; -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); }\n.crumbButton:active { transform: scale(.97); }\n.crumbCurrent { padding: 0 4px; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; }\n@media (hover: hover) and (pointer: fine) { .crumbButton:hover { background: color-mix(in oklab, var(--foreground) 5%, transparent); color: var(--foreground); } }\n.total { position: relative; display: inline-grid; flex: none; justify-items: end; overflow: hidden; margin: 0; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n.totalValue { display: block; }\n\n/* Tiles are absolutely placed and sized each frame, so text never scales while a tile grows or shrinks. */\n.stage { position: relative; min-width: 0; overflow: hidden; border-radius: 12px; outline: none; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }\n.tile { position: absolute; top: 0; left: 0; display: flex; flex-direction: column; gap: 1px; overflow: hidden; padding: 6px 8px; border-radius: 6px; background: var(--fill); color: var(--foreground); cursor: pointer; visibility: hidden; 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); }\n.tile[data-branch][data-level=\"1\"] { padding: 3px 8px; border-radius: 8px; background: color-mix(in oklab, var(--hue) 9%, var(--surface)); color: var(--foreground); cursor: zoom-in; }\n.tileLabel, .tileValue { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-xs); line-height: 16px; opacity: 0; transition: opacity var(--duration-fast) var(--ease-standard); }\n.tileLabel { font-weight: 500; }\n.tileValue { opacity: 0; font-variant-numeric: tabular-nums; }\n.tile[data-room=\"name\"] .tileLabel, .tile[data-room=\"full\"] .tileLabel, .tile[data-room=\"full\"] .tileValue { opacity: 1; }\n.tile[data-room=\"full\"] .tileValue { opacity: .72; }\n/* A branch on the top level prints its name and total on one header line. */\n.tile[data-branch][data-level=\"1\"] { flex-direction: row; align-items: baseline; justify-content: space-between; gap: 8px; }\n.tile[data-branch][data-level=\"1\"] .tileValue { flex: none; opacity: .6; }\n.tile[data-branch][data-level=\"1\"][data-room=\"none\"] .tileLabel, .tile[data-branch][data-level=\"1\"][data-room=\"none\"] .tileValue { opacity: 0; }\n.tile[data-branch][data-level=\"1\"][data-room=\"name\"] .tileValue { opacity: .6; }\n.tile[data-level=\"2\"] { padding: 5px 7px; }\n\n.tile[data-active] { box-shadow: inset 0 0 0 2px var(--foreground); }\n.tile[data-lineage] { box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--hue) 70%, transparent); }\n.stage[data-active] .tile[data-dim] { filter: opacity(.5); }\n\n.message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }\n\n.tooltip { position: absolute; top: 0; left: 0; z-index: 50; display: grid; min-width: 160px; max-width: min(240px, 100%); grid-template-columns: minmax(0, 1fr); gap: 2px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }\n.stage[data-active] .tooltip { opacity: 1; scale: 1; }\n.tipTitle { margin: 0; overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); text-overflow: ellipsis; white-space: nowrap; }\n.tipValue { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0; font-size: var(--text-sm); line-height: 1.35; }\n.tipName { min-width: 0; overflow: hidden; color: var(--foreground); text-overflow: ellipsis; white-space: nowrap; }\n.tipAmount { flex: none; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }\n.tipNote { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n\n/* The color scale: stepped swatches from light to dark, never a gradient. */\n.scale { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n.scaleLabel { margin-right: 4px; color: var(--text-secondary); }\n.steps { display: inline-flex; gap: 2px; }\n.step { width: 18px; height: 10px; border-radius: 3px; background: var(--fill); }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n .tile, .tileLabel, .tileValue, .tooltip, .crumbButton { transition: none; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/treemap","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/treemap","markdown":"https://uiarc.dev/components/treemap/markdown","tags":["data","chart","new"]}}