컴포넌트
트리맵
사각형 크기로 계층 데이터의 비중을 보여 주고 눌러서 안으로 들어가는 차트예요.
- #arc
- #component
- #chart
- #treemap
- #hierarchy
- #data
- #drilldown
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/treemap.json파일
components/arc/treemap/treemap.module.csscomponents/arc/treemap/treemap.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
treemap.module.csstreemap.module.csstreemap.module.csscss
.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); }
/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */
/* Breadcrumb on the left, the total of what fills the view on the right. */
.bar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--space-3); }
/* Pull the trail back by the crumb padding, so the first label lines up with the tiles and the title. */
.crumbs { min-width: 0; margin-left: -4px; }
.crumbList { position: relative; display: flex; min-width: 0; min-height: 32px; flex-wrap: wrap; align-items: center; margin: 0; padding: 0; list-style: none; }
.crumb { display: inline-flex; align-items: center; min-width: 0; }
.crumbSep { padding: 0 4px; color: var(--border-strong); font-size: var(--text-sm); }
.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); }
.crumbButton:active { transform: scale(.97); }
.crumbCurrent { padding: 0 4px; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; }
@media (hover: hover) and (pointer: fine) { .crumbButton:hover { background: color-mix(in oklab, var(--foreground) 5%, transparent); color: var(--foreground); } }
.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; }
.totalValue { display: block; }
/* Tiles are absolutely placed and sized each frame, so text never scales while a tile grows or shrinks. */
.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; }
.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); }
.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; }
.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); }
.tileLabel { font-weight: 500; }
/* Hidden ruler for the width of a total, in the same type as the tiles. */
.ruler { position: absolute; top: 0; left: 0; visibility: hidden; pointer-events: none; white-space: nowrap; font-size: var(--text-xs); line-height: 16px; font-variant-numeric: tabular-nums; }
/* A total that does not fit whole is left out instead of cut short. */
.tile[data-fit="no"] .tileValue { display: none; }
.tileValue { opacity: 0; font-variant-numeric: tabular-nums; }
.tile[data-room="name"] .tileLabel, .tile[data-room="full"] .tileLabel, .tile[data-room="full"] .tileValue { opacity: 1; }
.tile[data-room="full"] .tileValue { opacity: .72; }
/* A branch on the top level prints its name and total on one header line. */
.tile[data-branch][data-level="1"] { flex-direction: row; align-items: baseline; justify-content: space-between; gap: 8px; }
.tile[data-branch][data-level="1"] .tileValue { flex: none; opacity: .6; }
.tile[data-branch][data-level="1"][data-room="none"] .tileLabel, .tile[data-branch][data-level="1"][data-room="none"] .tileValue { opacity: 0; }
.tile[data-branch][data-level="1"][data-room="name"] .tileValue { opacity: .6; }
.tile[data-level="2"] { padding: 5px 7px; }
.tile[data-active] { box-shadow: inset 0 0 0 2px var(--foreground); }
.tile[data-lineage] { box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--hue) 70%, transparent); }
.stage[data-active] .tile[data-dim] { filter: opacity(.5); }
.message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }
.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(--float-edge, var(--surface-raised)); 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); }
.stage[data-active] .tooltip { opacity: 1; scale: 1; }
.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; }
.tipValue { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 0; font-size: var(--text-sm); line-height: 1.35; }
.tipName { min-width: 0; overflow: hidden; color: var(--foreground); text-overflow: ellipsis; white-space: nowrap; }
.tipAmount { flex: none; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
.tipNote { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
/* The color scale: stepped swatches from light to dark, never a gradient. */
.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; }
.scaleLabel { margin-right: 4px; color: var(--text-secondary); }
.steps { display: inline-flex; gap: 2px; }
.step { width: 18px; height: 10px; border-radius: 3px; background: var(--fill); }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.tile, .tileLabel, .tileValue, .tooltip, .crumbButton { transition: none; }
}
treemap.tsxtreemap.tsxtreemap.tsxtsx
"use client";
import { useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useSpring } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./treemap.module.css";
export interface TreemapNode {
/** Stable identity, unique in the tree. A tile that survives a data change morphs to its new size. */
id: string;
label: string;
/** Leaf size. A branch's size is the sum of its children. */
value?: number;
/** Optional second measure for shading, such as growth. A branch uses the size weighted mean of its children. */
color?: number;
children?: TreemapNode[];
}
/** Use a treemap for a hierarchy of parts, such as revenue by region, country, and plan, when sizes matter more than exact comparisons. */
export interface TreemapProps {
/** The root. Its label names the whole in the first breadcrumb. */
data: TreemapNode;
/** What the whole is, such as "전체 매출". Names the chart for assistive technology. */
label: string;
formatValue?: (value: number) => string;
/** Names the shading measure in the tooltip and scale, such as "성장률". 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. */
colorLabel?: string;
formatColor?: (value: number) => string;
/** Range of the color measure. Defaults to the range of the leaves. */
colorDomain?: [number, number];
/** Controlled id of the node that fills the view. */
focus?: string;
defaultFocus?: string;
onFocusChange?: (id: string) => void;
/** Height of the tiles in pixels. */
height?: number;
emptyLabel?: string;
ref?: Ref<HTMLElement>;
className?: string;
}
type Rect = { x: number; y: number; w: number; h: number };
type Flat = { id: string; label: string; value: number; color: number | null; depth: number; parent: string | null; children: string[]; path: string[] };
type Drawn = Rect & { o: number };
const { spring, ease, blur, duration } = motionTokens;
const 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 }; };
const zoom = physical({ visualDuration: .62, bounce: .06 });
const reveal = physical({ visualDuration: .9, bounce: 0 });
const glide = physical(spring.snappy, .01);
const follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };
/** Top level branches take the four series hues in order; a fifth and beyond stay neutral rather than reuse a hue. */
const hueOf = (index: number) => index < 0 ? "var(--series-1)" : index < 4 ? `var(--series-${index + 1})` : "var(--foreground)";
const GAP = 2, PAD = 3, HEADER = 22, TIP = 14, STEPS = 5;
const grouped = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 1 });
const percent = new Intl.NumberFormat("ko-KR", { style: "percent", maximumFractionDigits: 1 });
const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
const 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) });
function flatten(root: TreemapNode) {
const flat = new Map<string, Flat>();
const visit = (node: TreemapNode, depth: number, parent: string | null, path: string[]): { value: number; weighted: number; colored: number } => {
const kids = node.children ?? [];
let value = 0, weighted = 0, colored = 0;
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; }
else { value = Math.max(0, node.value ?? 0); if (node.color !== undefined && Number.isFinite(node.color)) { weighted = node.color * value; colored = value; } }
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] });
return { value, weighted, colored };
};
visit(root, 0, null, []);
return flat;
}
/** Squarified layout: rows of tiles along the short side, each row closed when adding a tile would make the worst aspect ratio worse. */
function squarify(items: { id: string; value: number }[], rect: Rect, out: Map<string, Rect>) {
const list = items.filter(item => item.value > 0).sort((a, b) => b.value - a.value);
const total = list.reduce((sum, item) => sum + item.value, 0);
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; }
const scale = (rect.w * rect.h) / total;
let { x, y, w, h } = rect, i = 0;
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)); };
while (i < list.length) {
const side = Math.min(w, h), row: number[] = [list[i]!.value * scale];
let j = i + 1;
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++; }
const sum = row.reduce((a, b) => a + b, 0);
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; }
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; }
i = j;
}
for (const item of items) if (!out.has(item.id)) out.set(item.id, { x: rect.x, y: rect.y, w: 0, h: 0 });
}
/** Rects of every descendant when `focus` fills the view: its children fill the view, and each branch holds its own children under a header. */
function layoutFrom(flat: Map<string, Flat>, focus: string, view: Rect) {
const out = new Map<string, Rect>();
const nest = (id: string, rect: Rect, top: boolean) => {
const node = flat.get(id)!;
if (!node.children.length) return;
const room = top ? rect : inset(rect, rect.h > 48 && rect.w > 64 ? HEADER : PAD, PAD);
const placed = new Map<string, Rect>();
squarify(node.children.map(child => ({ id: child, value: flat.get(child)!.value })), room, placed);
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); }
};
nest(focus, view, true);
return out;
}
/** The affine map that carries rect `from` onto rect `to`, applied to `r`. */
const 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 }; };
const subscribeNothing = () => () => {};
function useReducedMotionSafe() {
const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);
return !!useReducedMotion() && hydrated;
}
export function Treemap({ data, label, formatValue = value => grouped.format(value), colorLabel, formatColor = value => grouped.format(value), colorDomain, focus, defaultFocus, onFocusChange, height = 420, emptyLabel = "아직 데이터가 없어요", ref, className }: TreemapProps) {
const reduced = useReducedMotionSafe();
const figure = useRef<HTMLElement>(null);
const stage = useRef<HTMLDivElement>(null);
const tip = useRef<HTMLDivElement>(null);
const tiles = useRef(new Map<string, HTMLDivElement>());
useImperativeHandle(ref, () => figure.current as HTMLElement);
const inView = useInView(figure, { once: true, amount: .25 });
const signature = JSON.stringify(data);
const flat = useMemo(() => flatten(data), [signature]); // eslint-disable-line react-hooks/exhaustive-deps
const root = flat.get(data.id)!;
const nodes = useMemo(() => [...flat.values()].filter(node => node.depth > 0), [flat]);
const [ownFocus, setOwnFocus] = useState(defaultFocus ?? data.id);
const wanted = flat.get(focus ?? ownFocus);
const center = wanted && wanted.children.length ? wanted : root;
const zoomTo = (id: string) => { if (focus === undefined) setOwnFocus(id); onFocusChange?.(id); };
const [active, setActive] = useState<string | null>(null);
const [pointer, setPointer] = useState<{ x: number; y: number } | null>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const node = stage.current;
if (!node || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setWidth(node.clientWidth));
observer.observe(node);
setWidth(node.clientWidth);
return () => observer.disconnect();
}, []);
const view = useMemo<Rect>(() => ({ x: 0, y: 0, w: width, h: height }), [width, height]);
const target = useMemo(() => layoutFrom(flat, center.id, view), [flat, center.id, view]);
const levelOf = (node: Flat) => node.depth - center.depth;
// Color scale: a few neutral steps from light to dark, so shade reads as more without adding a hue.
const leaves = nodes.filter(node => !node.children.length && node.color !== null);
const [lo, hi] = colorDomain ?? (leaves.length ? [Math.min(...leaves.map(node => node.color!)), Math.max(...leaves.map(node => node.color!))] : [0, 1]);
const colored = colorLabel !== undefined && leaves.length > 0;
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); };
const branchOf = useMemo(() => { const map = new Map<string, number>(); 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]);
// 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.
const drawn = useRef(new Map<string, Drawn>());
const levels = useRef(new Map<string, number>());
const lastFocus = useRef<{ id: string; layout: Map<string, Rect> } | null>(null);
const valueWidths = useRef(new Map<string, number>());
const ruler = useRef<HTMLSpanElement>(null);
const paint = () => {
for (const node of nodes) {
const el = tiles.current.get(node.id), d = drawn.current.get(node.id);
if (!el) continue;
if (!d || d.o < .01 || d.w < .5 || d.h < .5) { el.style.visibility = "hidden"; el.style.pointerEvents = "none"; continue; }
el.style.visibility = "visible";
el.style.transform = `translate3d(${d.x.toFixed(2)}px, ${d.y.toFixed(2)}px, 0)`;
el.style.width = `${d.w.toFixed(2)}px`; el.style.height = `${d.h.toFixed(2)}px`;
el.style.opacity = d.o.toFixed(3);
el.style.pointerEvents = d.o > .6 ? "auto" : "none";
const level = levels.current.get(node.id) ?? 0;
el.dataset.level = String(level);
el.dataset.room = d.w > 64 && d.h > 38 ? (d.h > 54 ? "full" : "name") : "none";
// A total that would not fit whole is left out; a number cut short reads as a different number. The tooltip still has it.
const need = (valueWidths.current.get(node.id) ?? 0) + (node.children.length && level === 1 ? 48 : 16);
el.dataset.fit = d.w >= need ? "yes" : "no";
}
};
const paintRef = useRef(paint);
useLayoutEffect(() => { paintRef.current = paint; });
const shown = (inView || reduced) && width > 0;
useEffect(() => {
if (!shown) return;
const prev = lastFocus.current;
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; };
const visible = (node: Flat) => { const l = node.depth - center.depth; return l >= 1 && l <= 2 && isUnder(node.id, center.id); };
const nextVisible = new Set(nodes.filter(visible).map(node => node.id));
const before = new Map(drawn.current);
const wasVisible = new Set([...before].filter(([, d]) => d.o > .5).map(([id]) => id));
// The pivot: the tile that grows to fill the view, or the view that shrinks back into its tile.
let toOld: ((r: Rect) => Rect) | null = null, toNew: ((r: Rect) => Rect) | null = null;
if (prev && prev.id !== center.id && before.size) {
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); } }
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); } }
}
const from = new Map<string, Drawn>(), to = new Map<string, Drawn>();
const first = !before.size;
for (const node of nodes) {
const goal = target.get(node.id), now = before.get(node.id);
const inNext = nextVisible.has(node.id), inPrev = wasVisible.has(node.id);
if (inNext && goal) {
to.set(node.id, { ...goal, o: 1 });
if (inPrev && now) from.set(node.id, now);
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 });
else from.set(node.id, { ...(toOld ? toOld(goal) : goal), o: 0 });
levels.current.set(node.id, levelOf(node));
} else if (inPrev && now) {
from.set(node.id, now);
to.set(node.id, { ...(toNew ? toNew(now) : now), o: 0 });
}
}
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 });
lastFocus.current = { id: center.id, layout: target };
const finish = () => { for (const [id, d] of to) drawn.current.set(id, d); paintRef.current(); };
if (reduced) { finish(); return; }
const controls = animate(0, 1, { ...(first ? reveal : zoom), onUpdate: t => {
for (const [id, a] of from) {
const b = to.get(id)!;
// On the first reveal, tiles grow in a quick wave from the largest.
const local = first ? clamp((t - Math.min(.3, (1 - b.w * b.h / (view.w * view.h || 1)) * .3)) / .7, 0, 1) : t;
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) });
}
paintRef.current();
}, onComplete: finish });
return () => controls.stop();
}, [shown, target, reduced]); // eslint-disable-line react-hooks/exhaustive-deps
// Natural width of every total, measured once per dataset on a hidden ruler that shares the tile type.
useLayoutEffect(() => {
const node = ruler.current;
if (!node) return;
const widths = new Map<string, number>();
for (const item of nodes) { node.textContent = formatValue(item.value); widths.set(item.id, node.offsetWidth); }
valueWidths.current = widths;
paintRef.current();
}, [signature, flat]); // eslint-disable-line react-hooks/exhaustive-deps
useLayoutEffect(() => { paintRef.current(); });
const activeNode = active ? flat.get(active) ?? null : null;
const parentOf = (node: Flat) => node.parent ? flat.get(node.parent)! : node;
const share = (node: Flat, of: Flat) => of.value ? node.value / of.value : 0;
// Tooltip: trails the pointer, or sits by the tile in focus, and stays inside the chart.
const tipX = useMotionValue(0), tipY = useMotionValue(0);
const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);
const wasOn = useRef(false);
useLayoutEffect(() => {
const bubble = tip.current;
if (!bubble || !activeNode || !width) { wasOn.current = false; return; }
const r = target.get(activeNode.id);
const ax = pointer?.x ?? (r ? r.x + r.w / 2 : 0), ay = pointer?.y ?? (r ? r.y + Math.min(r.h / 2, 28) : 0);
const tw = bubble.offsetWidth, th = bubble.offsetHeight;
let left = ax + TIP;
if (left + tw > width) left = ax - TIP - tw;
let top = ay + TIP;
if (top + th > height) top = ay - TIP - th;
tipX.set(clamp(left, 0, Math.max(0, width - tw))); tipY.set(clamp(top, 0, Math.max(0, height - th)));
if (!wasOn.current || reduced) { tipSpringX.jump(tipX.get()); tipSpringY.jump(tipY.get()); }
wasOn.current = true;
});
// Keyboard: arrows hop to the nearest tile in that direction on the top level, Enter zooms in, Escape zooms out.
const topTiles = center.children.map(id => flat.get(id)!).filter(node => node.value > 0);
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; };
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const current = topOf(activeNode);
const dirs: Record<string, [number, number]> = { ArrowRight: [1, 0], ArrowLeft: [-1, 0], ArrowDown: [0, 1], ArrowUp: [0, -1] };
if (event.key in dirs) {
event.preventDefault(); setPointer(null);
if (!current) { setActive(topTiles[0]?.id ?? null); return; }
const [dx, dy] = dirs[event.key]!, o = target.get(current.id)!, ox = o.x + o.w / 2, oy = o.y + o.h / 2;
let best: Flat | null = null, score = Infinity;
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; } }
if (best) setActive(best.id);
} else if (event.key === "Home" || event.key === "End") { event.preventDefault(); setPointer(null); setActive((event.key === "Home" ? topTiles[0] : topTiles[topTiles.length - 1])?.id ?? null); }
else if (event.key === "Enter" || event.key === " ") { if (current?.children.length) { event.preventDefault(); zoomTo(current.id); setActive(current.children[0]!); } }
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); } }
};
const onPointerMove = (event: PointerEvent<HTMLDivElement>, id: string) => {
if (event.pointerType !== "mouse") return;
const rect = stage.current?.getBoundingClientRect();
if (rect) setPointer({ x: event.clientX - rect.left, y: event.clientY - rect.top });
setActive(id);
};
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); };
const trail: Flat[] = [];
for (let at: Flat | undefined = center; at; at = at.parent ? flat.get(at.parent) : undefined) trail.unshift(at);
const empty = !root.value;
const describe = (node: Flat) => `${node.path.join(", ")}, ${formatValue(node.value)}, ${parentOf(node).label}의 ${percent.format(share(node, parentOf(node)))}${colored && node.color !== null ? `, ${colorLabel} ${formatColor(node.color)}` : ""}`;
const focusTop = topOf(activeNode);
return <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(" ")} aria-label={label}>
<div className={styles.bar}>
<nav className={styles.crumbs} aria-label={`${label} 경로`}>
<ol className={styles.crumbList}>
<AnimatePresence initial={false} mode="popLayout">
{trail.map((node, index) => <motion.li key={node.id} className={styles.crumb} layout={reduced ? false : "position"} initial={{ opacity: 0, x: reduced ? 0 : -6, filter: reduced ? "none" : `blur(${blur.subtle}px)` }} animate={{ opacity: 1, x: 0, filter: "blur(0px)" }} exit={{ opacity: 0, x: reduced ? 0 : -6, transition: { duration: duration.instant, ease: [...ease.exit] } }} transition={{ ...spring.smooth }}>
{index > 0 && <span className={styles.crumbSep} aria-hidden="true">/</span>}
{index < trail.length - 1 ? <button type="button" className={styles.crumbButton} onClick={() => { zoomTo(node.id); setActive(null); }}>{node.label}</button> : <span className={styles.crumbCurrent} aria-current="location">{node.label}</span>}
</motion.li>)}
</AnimatePresence>
</ol>
</nav>
<p className={styles.total}><AnimatePresence mode="popLayout" initial={false}>
<motion.span key={formatValue(center.value)} className={styles.totalValue} initial={{ opacity: 0, y: reduced ? 0 : 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduced ? 0 : -6 }} transition={{ duration: duration.standard, ease: [...ease.enter] }}>{formatValue(center.value)}</motion.span>
</AnimatePresence></p>
</div>
<div ref={stage} className={styles.stage} style={{ height }} data-active={activeNode ? true : undefined} role="group" tabIndex={empty ? -1 : 0} aria-roledescription="트리맵"
aria-label={`${label}, ${center.label}. 화살표 키로 칸을 옮기고, Enter로 확대하고, Esc로 축소해요.`}
onKeyDown={onKeyDown} onBlur={() => setActive(null)} onPointerLeave={() => { setActive(null); setPointer(null); }}>
{nodes.map(node => { const top = topOf(node); return <div key={node.id} ref={element => { if (element) tiles.current.set(node.id, element); else tiles.current.delete(node.id); }} className={styles.tile}
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}
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}
onPointerMove={event => { event.stopPropagation(); onPointerMove(event, node.id); }} onClick={event => { event.stopPropagation(); onTileClick(node); }}>
<span className={styles.tileLabel}>{node.label}</span>
<span className={styles.tileValue}>{formatValue(node.value)}</span>
</div>; })}
<span ref={ruler} className={styles.ruler} aria-hidden="true" />
{empty && <p className={styles.message}>{emptyLabel}</p>}
<motion.div ref={tip} className={styles.tooltip} style={{ x: reduced ? tipX : tipSpringX, y: reduced ? tipY : tipSpringY }} aria-hidden="true">
{activeNode && <>
<p className={styles.tipTitle}>{activeNode.path.slice(0, -1).join(" / ") || root.label}</p>
<p className={styles.tipValue}><span className={styles.tipName}>{activeNode.label}</span><span className={styles.tipAmount}>{formatValue(activeNode.value)}</span></p>
<p className={styles.tipNote}>{parentOf(activeNode).label}의 {percent.format(share(activeNode, parentOf(activeNode)))}</p>
{colored && activeNode.color !== null && <p className={styles.tipNote}>{colorLabel} {formatColor(activeNode.color)}</p>}
</>}
</motion.div>
</div>
{colored && <div className={styles.scale} aria-hidden="true">
<span className={styles.scaleLabel}>{colorLabel}</span>
<span className={styles.scaleEnd}>{formatColor(lo)}</span>
<span className={styles.steps}>{Array.from({ length: STEPS }, (_, i) => <span key={i} className={styles.step} style={{ "--fill": `color-mix(in oklab, ${center.depth === 0 ? "var(--foreground)" : hueOf(branchOf.get(center.id) ?? -1)} ${Math.round(14 + (i / (STEPS - 1)) * 44)}%, var(--surface))` } as CSSProperties} />)}</span>
<span className={styles.scaleEnd}>{formatColor(hi)}</span>
</div>}
<p className={styles.srOnly} aria-live="polite" aria-atomic="true">{activeNode ? describe(activeNode) : `${center.label}, ${formatValue(center.value)}`}</p>
{!empty && <div className={styles.srOnly}><table>
<caption>{label}</caption>
<thead><tr><th scope="col">경로</th><th scope="col">값</th><th scope="col">상위 대비 비율</th>{colored && <th scope="col">{colorLabel}</th>}</tr></thead>
<tbody>{nodes.map(node => <tr key={node.id}><th scope="row">{node.path.join(" / ")}</th><td>{formatValue(node.value)}</td><td>{percent.format(share(node, parentOf(node)))}</td>{colored && <td>{node.color === null ? "" : formatColor(node.color)}</td>}</tr>)}</tbody>
</table></div>}
</figure>;
}
export default Treemap;