Treemap
Teil vom Ganzen als Kacheln, deren Fläche der Wert ist, flach, in Gruppen, zum Hineinzoomen, nach Veränderung getönt oder über zwei Zeiträume mit gleitenden Kacheln. Pfeiltasten gehen von Kachel zu Kachel.
Eine von 4 Components in Statistik-Charts
Variants
Details
Was Treemap mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Statistik-Charts
- Bereich
- Elements
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/treemap.tsx
- Bringt mit
- utilskiticonchartchart-figure
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 16 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle TreemapProps aus components/ui/treemap.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'label'>, ChartFigureProps an.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalTreemapVariantsquarifiedgroupeddrilldownchangecompare
itemsPflichtTreemapItem[]formatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }
periodsoptional[string, string]compare: names of the two periods, the current first
highlightoptionalstringsquarified: the tile in the accent at the start. Default the largest.
heightoptionalnumberHeight of the box in px. Default: about 56 % of the width, taller on phones.
Eingebaute Texte
Diese Texte schreibt Treemap selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 16 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| total | Englisch Total {v} | Deutsch Gesamt {v} |
| hint | Englisch Open a group to see its parts | Deutsch Eine Gruppe öffnen, um ihre Teile zu sehen |
| share | Englisch {share} of the total | Deutsch {share} vom Ganzen |
| shareCol | Englisch Share | Deutsch Anteil |
| all | Englisch All | Deutsch Alle |
| back | Englisch Back to all | Deutsch Zurück zu allen |
| open | Englisch Open {label} | Deutsch {label} öffnen |
| periods | Englisch Period | Deutsch Zeitraum |
| change | Englisch Change | Deutsch Veränderung |
| item | Englisch Item | Deutsch Eintrag |
| group | Englisch Group | Deutsch Gruppe |
| value | Englisch Value | Deutsch Wert |
| gain | Englisch Gain | Deutsch Plus |
| loss | Englisch Loss | Deutsch Minus |
| flat | Englisch About the same | Deutsch Etwa gleich |
| vs | Englisch vs. {period} | Deutsch ggü. {period} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Treemap reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-current, aria-haspopup, aria-label, aria-live und aria-roledescription, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen group, radio und radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 471 Zeilen
'use client';
import { useMemo, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { CHART_TEXTS, ChartRoot, ChartTable, fill, numberFormatter, textWidth, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, pairColor, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Part to whole as tiles whose area is the value.
* squarified one level, the picked tile in the accent
* grouped tiles inside up to four named groups
* drilldown the groups first, a tap opens one, the trail leads back
* change area is the size, tint is the change (gain or loss)
* compare two periods, the tiles glide to their new size
*
* Tiles are HTML buttons in one positioned box: they wrap text, take the
* keyboard (arrow keys move to the nearest tile in that direction) and
* glide when the layout changes. A 2 px gap in the surface color parts them.
* Labels only show where they fit, every value is in the tooltip and table.
*/
export type TreemapVariant = 'squarified' | 'grouped' | 'drilldown' | 'change' | 'compare';
export interface TreemapItem {
label: string;
/** Size of the tile */
value: number;
/** change: change against the last period as a fraction, 0.12 is +12 % */
change?: number;
/** compare: the value of the second period */
previous?: number;
/** grouped, drilldown: the tiles inside this group (value of the group is their sum) */
children?: TreemapItem[];
}
export interface TreemapProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: TreemapVariant;
items: TreemapItem[];
/** Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 } */
format?: Intl.NumberFormatOptions;
/** compare: names of the two periods, the current first */
periods?: [string, string];
/** squarified: the tile in the accent at the start. Default the largest. */
highlight?: string;
/** Height of the box in px. Default: about 56 % of the width, taller on phones. */
height?: number;
className?: string;
}
const TEXTS = {
en: { total: 'Total {v}', hint: 'Open a group to see its parts', share: '{share} of the total', shareCol: 'Share', all: 'All', back: 'Back to all', open: 'Open {label}', periods: 'Period', change: 'Change', item: 'Item', group: 'Group', value: 'Value', gain: 'Gain', loss: 'Loss', flat: 'About the same', vs: 'vs. {period}' },
de: { total: 'Gesamt {v}', hint: 'Eine Gruppe öffnen, um ihre Teile zu sehen', share: '{share} vom Ganzen', shareCol: 'Anteil', all: 'Alle', back: 'Zurück zu allen', open: '{label} öffnen', periods: 'Zeitraum', change: 'Veränderung', item: 'Eintrag', group: 'Gruppe', value: 'Wert', gain: 'Plus', loss: 'Minus', flat: 'Etwa gleich', vs: 'ggü. {period}' },
};
interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** Squarified treemap (Bruls, Huizing, van Wijk): rows that keep tiles close to square */
export function squarify(values: number[], box: Rect): Rect[] {
const total = values.reduce((a, v) => a + Math.max(0, v), 0);
const out: Rect[] = new Array(values.length);
if (!total || box.w <= 0 || box.h <= 0) return values.map(() => ({ x: box.x, y: box.y, w: 0, h: 0 }));
const order = values.map((v, i) => ({ v: (Math.max(0, v) / total) * box.w * box.h, i })).sort((a, b) => b.v - a.v);
let { x, y, w, h } = box;
let row: { v: number; i: number }[] = [];
const worst = (r: { v: number }[], side: number) => {
const s = r.reduce((a, t) => a + t.v, 0);
const max = Math.max(...r.map((t) => t.v));
const min = Math.min(...r.map((t) => t.v));
return Math.max((side * side * max) / (s * s), (s * s) / (side * side * min));
};
const layout = (r: { v: number; i: number }[]) => {
const s = r.reduce((a, t) => a + t.v, 0);
if (w >= h) {
const cw = s / h;
let cy = y;
for (const t of r) {
const th = t.v / cw;
out[t.i] = { x, y: cy, w: cw, h: th };
cy += th;
}
x += cw;
w -= cw;
} else {
const rh = s / w;
let cx = x;
for (const t of r) {
const tw = t.v / rh;
out[t.i] = { x: cx, y, w: tw, h: rh };
cx += tw;
}
y += rh;
h -= rh;
}
};
for (const t of order) {
const side = Math.min(w, h);
if (!row.length || worst([...row, t], side) <= worst(row, side)) row.push(t);
else {
layout(row);
row = [t];
}
}
if (row.length) layout(row);
return out;
}
const GAP = 2;
const sumOf = (it: TreemapItem): number => (it.children?.length ? it.children.reduce((a, c) => a + sumOf(c), 0) : it.value);
interface Tile {
key: string;
label: string;
value: number;
rect: Rect;
fill: string;
hover: string;
text: string;
group?: string;
groupIndex?: number;
change?: number;
previous?: number;
/** drilldown: opens this group */
opens?: number;
}
/**
* Tiles whose area is the value: flat, in groups, with drill down, tinted by change or across two periods.
* <Treemap variant="grouped" items={[{ label: 'Schools', value: 0, children: [{ label: 'Primary', value: 4.2 }] }]} />
*/
export function Treemap(props: TreemapProps) {
const { variant = 'squarified', items, format, periods, highlight, height: heightProp, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const ct = useTexts(CHART_TEXTS, labels);
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 1 });
const signed = (n: number) => (n > 0 ? '+' : n < 0 ? '−' : '') + pct(Math.abs(n));
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<string | null>(null);
// One tab stop for all tiles, the arrow keys move between them
const [focusKey, setFocusKey] = useState<string | null>(null);
const [open, setOpen] = useState<number | null>(null);
const [period, setPeriod] = useState(0);
const boxRef = useRef<HTMLDivElement>(null);
const phone = width < 520;
const height = heightProp ?? Math.round(Math.max(300, Math.min(520, phone ? width * 1.15 : width * 0.56)));
const box: Rect = { x: 0, y: 0, w: width, h: height };
const name = label ?? title ?? '';
const tiles: Tile[] = useMemo(() => {
const flat = (list: TreemapItem[], b: Rect, mk: (it: TreemapItem, i: number, r: Rect) => Tile) => {
const rects = squarify(list.map((it) => sumOf(it)), b);
return list.map((it, i) => mk(it, i, rects[i]));
};
if (variant === 'grouped' || (variant === 'drilldown' && open === null)) {
const groups = squarify(items.map(sumOf), box);
if (variant === 'drilldown')
return items.map((g, gi) => ({
key: g.label,
label: g.label,
value: sumOf(g),
rect: groups[gi],
fill: 'color-mix(in oklab, var(--primary) 22%, var(--card))',
hover: 'color-mix(in oklab, var(--primary) 34%, var(--card))',
text: 'var(--foreground)',
opens: gi,
}));
return items.flatMap((g, gi) => {
const r = groups[gi];
const head = r.h > 64 && r.w > 70 ? 24 : 0;
const inner: Rect = { x: r.x + 6, y: r.y + head + 6, w: Math.max(0, r.w - 12), h: Math.max(0, r.h - head - 12) };
const color = pairColor(gi);
const kids = g.children?.length ? g.children : [g];
const groupTile: Tile = { key: `__g${gi}`, label: g.label, value: sumOf(g), rect: r, fill: `color-mix(in oklab, ${color} 9%, var(--card))`, hover: `color-mix(in oklab, ${color} 9%, var(--card))`, text: 'var(--foreground)', groupIndex: gi };
return [groupTile, ...flat(kids, inner, (it, i, rect) => ({ key: `${g.label}/${it.label}`, label: it.label, value: sumOf(it), rect, fill: `color-mix(in oklab, ${color} ${gi === 0 ? 30 : 26}%, var(--card))`, hover: `color-mix(in oklab, ${color} ${gi === 0 ? 46 : 40}%, var(--card))`, text: 'var(--foreground)', group: g.label, groupIndex: gi }))];
});
}
if (variant === 'drilldown' && open !== null) {
const g = items[open];
return flat(g?.children ?? [], box, (it, i, rect) => ({ key: `${g.label}/${it.label}`, label: it.label, value: sumOf(it), rect, fill: 'color-mix(in oklab, var(--primary) 22%, var(--card))', hover: 'color-mix(in oklab, var(--primary) 34%, var(--card))', text: 'var(--foreground)', group: g.label }));
}
if (variant === 'change')
return flat(items, box, (it, _i, rect) => {
const c = it.change ?? 0;
const tone = Math.abs(c) < 0.02 ? 'var(--muted-foreground)' : c > 0 ? 'var(--kitfg-up)' : 'var(--kitfg-down)';
const k = Math.abs(c) < 0.02 ? 12 : Math.abs(c) < 0.1 ? 20 : 34;
return { key: it.label, label: it.label, value: it.value, rect, fill: `color-mix(in oklab, ${tone} ${k}%, var(--card))`, hover: `color-mix(in oklab, ${tone} ${k + 12}%, var(--card))`, text: 'var(--foreground)', change: c };
});
if (variant === 'compare') {
// Same order in both periods, so tiles keep their neighbours and only glide
const vals = items.map((it) => (period === 0 ? it.value : it.previous ?? it.value));
const rects = squarify(vals, box);
return items.map((it, i) => ({ key: it.label, label: it.label, value: vals[i], rect: rects[i], fill: 'color-mix(in oklab, var(--primary) 22%, var(--card))', hover: 'color-mix(in oklab, var(--primary) 34%, var(--card))', text: 'var(--foreground)', previous: period === 0 ? it.previous : it.value }));
}
const top = highlight ?? [...items].sort((a, b) => sumOf(b) - sumOf(a))[0]?.label;
return flat(items, box, (it, _i, rect) => ({ key: it.label, label: it.label, value: sumOf(it), rect, fill: it.label === top ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 18%, var(--card))', hover: it.label === top ? 'color-mix(in oklab, var(--primary) 88%, var(--foreground))' : 'color-mix(in oklab, var(--primary) 30%, var(--card))', text: it.label === top ? 'var(--primary-foreground)' : 'var(--foreground)' }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variant, items, width, height, open, period, highlight]);
const leaves = tiles.filter((x) => !x.key.startsWith('__g'));
const total = leaves.reduce((a, x) => a + x.value, 0);
const activeTile = leaves.find((x) => x.key === active) ?? null;
// Keyboard: arrows go to the nearest tile in that direction
const move = (from: Tile, key: string) => {
const c = { x: from.rect.x + from.rect.w / 2, y: from.rect.y + from.rect.h / 2 };
let best: Tile | null = null;
let score = Infinity;
for (const o of leaves) {
if (o === from) continue;
const d = { x: o.rect.x + o.rect.w / 2 - c.x, y: o.rect.y + o.rect.h / 2 - c.y };
const ok = key === 'ArrowRight' ? d.x > 1 : key === 'ArrowLeft' ? d.x < -1 : key === 'ArrowDown' ? d.y > 1 : d.y < -1;
if (!ok) continue;
const along = key === 'ArrowRight' || key === 'ArrowLeft' ? Math.abs(d.x) : Math.abs(d.y);
const across = key === 'ArrowRight' || key === 'ArrowLeft' ? Math.abs(d.y) : Math.abs(d.x);
const s = along + across * 2;
if (s < score) {
score = s;
best = o;
}
}
return best;
};
const onTileKey = (e: React.KeyboardEvent, tile: Tile) => {
if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Escape'].includes(e.key)) return;
e.preventDefault();
if (e.key === 'Escape') {
if (variant === 'drilldown' && open !== null) setOpen(null);
return;
}
const next = move(tile, e.key);
if (next) boxRef.current?.querySelector<HTMLElement>(`[data-tile="${CSS.escape(next.key)}"]`)?.focus();
};
const tableRows: string[][] =
variant === 'grouped' || variant === 'drilldown'
? items.flatMap((g) => (g.children?.length ? g.children : [g]).map((c) => [c.label, g.label, fmt(sumOf(c)), pct(sumOf(c) / Math.max(1, items.reduce((a, x) => a + sumOf(x), 0)))]))
: variant === 'change'
? items.map((it) => [it.label, fmt(it.value), signed(it.change ?? 0)])
: variant === 'compare'
? items.map((it) => [it.label, fmt(it.value), fmt(it.previous ?? it.value)])
: items.map((it) => [it.label, fmt(sumOf(it)), pct(sumOf(it) / Math.max(1, total))]);
const tableHead =
variant === 'grouped' || variant === 'drilldown'
? [t.item, t.group, t.value, t.shareCol]
: variant === 'change'
? [t.item, t.value, t.change]
: variant === 'compare'
? [t.item, periods?.[0] ?? '', periods?.[1] ?? '']
: [t.item, t.value, t.shareCol];
const tipFor = (x: Tile) => {
const lines = [fmt(x.value), fill(t.share, { share: pct(x.value / Math.max(1, total)) })];
if (x.change !== undefined) lines.push(`${t.change} ${signed(x.change)}`);
if (x.previous !== undefined && periods) lines.push(`${fill(t.vs, { period: periods[period === 0 ? 1 : 0] })} ${fmt(x.previous)}`);
return lines;
};
const actions =
variant === 'compare' && periods ? (
<div role="radiogroup" aria-label={t.periods} className="inline-flex h-9 items-center rounded-md bg-muted p-[3px] shadow-[inset_0_0_0_1px_var(--border)]">
{periods.map((p, i) => (
<button
key={p}
type="button"
role="radio"
aria-checked={period === i}
tabIndex={period === i ? 0 : -1}
onClick={() => setPeriod(i)}
onKeyDown={(e) => {
// Radio group after the APG: arrows move and choose, one tab stop
const step = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!step) return;
e.preventDefault();
const k = (period + step + periods.length) % periods.length;
setPeriod(k);
(e.currentTarget.parentElement?.children[k] as HTMLElement | undefined)?.focus();
}}
className={cn("relative h-full rounded-[5px] px-3 text-[13px] font-medium transition-[background-color,color,box-shadow] duration-300 after:absolute after:-inset-y-[7px] after:inset-x-0 after:content-['']", period === i ? 'bg-background text-foreground shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))]' : 'text-muted-foreground hover:text-foreground')}
>
{p}
</button>
))}
</div>
) : undefined;
const legend =
variant === 'change' ? (
<ul className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
{[
{ label: t.gain, tone: 'var(--kitfg-up)', icon: 'arrow-up-right' as const },
{ label: t.flat, tone: 'var(--muted-foreground)', icon: null },
{ label: t.loss, tone: 'var(--kitfg-down)', icon: 'arrow-down-right' as const },
].map((k) => (
<li key={k.label} className="flex items-center gap-1.5">
<span aria-hidden className="flex h-3 overflow-hidden rounded-[3px]">
<span className="w-2.5" style={{ background: `color-mix(in oklab, ${k.tone} 20%, var(--card))` }} />
{k.icon && <span className="w-2.5" style={{ background: `color-mix(in oklab, ${k.tone} 34%, var(--card))` }} />}
</span>
{k.icon && <Icon name={k.icon} size={13} strokeWidth={2} />}
{k.label}
</li>
))}
</ul>
) : variant === 'grouped' ? (
<ul className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
{items.map((g, gi) => (
<li key={g.label} className="flex items-center gap-1.5">
<span aria-hidden className="size-2.5 rounded-[3px]" style={{ background: `color-mix(in oklab, ${pairColor(gi)} 40%, var(--card))` }} />
{g.label}
<span className="tabular-nums text-foreground">{fmt(sumOf(g))}</span>
</li>
))}
</ul>
) : null;
const tileDelay = (i: number) => (anim ? ({ '--kitch-d': `${round(80 + i * 45, 0)}ms` } as React.CSSProperties) : undefined);
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} actions={actions} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{legend}
{variant === 'drilldown' && (
<nav aria-label={name} className="mb-3 flex min-h-11 flex-wrap items-center gap-1 text-[13px]">
<button type="button" onClick={() => setOpen(null)} disabled={open === null} className={cn('relative inline-flex min-h-11 items-center gap-1 rounded-md px-2 font-medium transition-colors sm:min-h-9', open === null ? 'text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')} aria-label={open === null ? undefined : t.back}>
{open !== null && <Icon name="arrow-left" size={15} strokeWidth={1.8} />}
{t.all}
</button>
{open === null && <span className="px-1 text-muted-foreground">{t.hint}</span>}
{open !== null && (
<>
<Icon name="chevron-right" size={14} className="text-subtle-foreground" />
<span aria-current="page" className="px-2 font-medium text-foreground">
{items[open]?.label}
</span>
</>
)}
</nav>
)}
<div ref={wrapRef} className="relative">
<div ref={boxRef} role="group" aria-roledescription={ct.chart} aria-label={summary ? `${name}. ${summary}` : name} className="relative overflow-hidden rounded-lg" style={{ height }}>
{tiles.map((x, i) => {
const isGroup = x.key.startsWith('__g');
const r = x.rect;
const w = Math.max(0, r.w - GAP);
const h = Math.max(0, r.h - GAP);
// Text only where it fits whole: padding 12, label line 15 or 19, value line 16 or 26
const pad = 24;
const valueText = fmt(x.value);
const big = w > 170 && h > 110 && textWidth(x.label, 15) <= w - pad && textWidth(valueText, 20) <= w - pad;
const oneLine = textWidth(x.label, big ? 15 : 12) <= w - pad;
// A label too long for one line may take two, when every word fits and the tile is tall enough
const words = x.label.split(' ');
const twoLines = !oneLine && !big && words.length > 1 && words.every((wd) => textWidth(wd, 12) <= w - pad) && (() => {
let line = '';
let count = 1;
for (const wd of words) {
const next = line ? `${line} ${wd}` : wd;
if (textWidth(next, 12) > w - pad) {
count++;
line = wd;
} else line = next;
}
return count <= 2;
})() && h >= pad + 32;
const showLabel = (oneLine && h >= pad + (big ? 19 : 15)) || twoLines;
const showValue = showLabel && textWidth(valueText, big ? 20 : 12) + (x.change !== undefined ? 64 : 0) <= w - pad && h >= pad + (big ? 19 + 6 + 26 : (twoLines ? 32 : 15) + 4 + 16);
const style: React.CSSProperties = {
left: round(r.x),
top: round(r.y),
width: round(w),
height: round(h),
background: active === x.key ? x.hover : x.fill,
color: x.text,
transition: "left .7s var(--ease-kit), top .7s var(--ease-kit), width .7s var(--ease-kit), height .7s var(--ease-kit), background-color .2s var(--ease-kit)",
...tileDelay(i),
};
if (isGroup)
return (
<div key={x.key} aria-hidden className={cn('absolute rounded-md transition-[left,top,width,height] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none', anim && 'kitch-fade')} style={style}>
{r.h > 64 && r.w > 70 && (
<p className="flex items-baseline justify-between gap-2 truncate px-2.5 pt-1.5 text-[11px] font-semibold uppercase tracking-[0.07em] text-muted-foreground">
<span className="truncate">{x.label}</span>
<span className="tabular-nums normal-case tracking-normal">{fmt(x.value)}</span>
</p>
)}
</div>
);
const tip = tipFor(x);
return (
<button
key={x.key}
type="button"
data-tile={x.key}
aria-label={`${x.label}, ${tip.join(', ')}`}
aria-haspopup={x.opens !== undefined ? 'true' : undefined}
onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(x.key)}
onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
tabIndex={(tiles.some((y) => y.key === focusKey) ? focusKey : tiles[0]?.key) === x.key ? 0 : -1}
onFocus={() => {
setActive(x.key);
setFocusKey(x.key);
}}
onBlur={() => setActive(null)}
onKeyDown={(e) => onTileKey(e, x)}
onClick={() => {
if (x.opens !== undefined) {
setOpen(x.opens);
setActive(null);
requestAnimationFrame(() => boxRef.current?.querySelector<HTMLElement>('[data-tile]')?.focus());
} else setActive(active === x.key ? null : x.key);
}}
className={cn(
'absolute flex flex-col items-start justify-between overflow-hidden rounded-md p-2.5 text-left outline-offset-[-2px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground motion-reduce:!transition-none sm:p-3',
anim && 'kitch-cell',
x.opens !== undefined && 'cursor-zoom-in',
)}
style={style}
>
{showLabel && (
<span className={cn('block max-w-full font-medium leading-tight', big ? 'text-[15px]' : 'text-[12px]', !big && !twoLines && 'truncate')}>
{x.label}
</span>
)}
{showValue && (
<span className="flex w-full flex-wrap items-baseline justify-between gap-x-2 gap-y-0.5">
<span className={cn('tabular-nums', big ? 'text-[20px] font-semibold tracking-[-0.01em]' : 'text-[12px] opacity-80')}>{fmt(x.value)}</span>
{x.change !== undefined && (
<span className="inline-flex items-center gap-0.5 text-[12px] font-medium tabular-nums">
{Math.abs(x.change) >= 0.02 && <Icon name={x.change > 0 ? 'arrow-up-right' : 'arrow-down-right'} size={13} strokeWidth={2} />}
{signed(x.change)}
</span>
)}
{x.opens !== undefined && big && <Icon name="zoom-in" size={16} className="opacity-60" />}
</span>
)}
</button>
);
})}
</div>
{/* Readout under the box: works for every tile, also the ones too small for a label */}
<p aria-live="polite" className="mt-3 flex min-h-5 flex-wrap items-baseline gap-x-2 text-[13px]">
{activeTile ? (
<>
<span className="font-medium text-foreground">{activeTile.label}</span>{' '}
{activeTile.group && <span className="text-muted-foreground">{activeTile.group} </span>}
<span className="tabular-nums text-foreground">{fmt(activeTile.value)}</span>{' '}
<span className="tabular-nums text-muted-foreground">{fill(t.share, { share: pct(activeTile.value / Math.max(1, total)) })} </span>
{activeTile.change !== undefined && <span className="tabular-nums text-muted-foreground">{`${t.change} ${signed(activeTile.change)}`}</span>}
{activeTile.previous !== undefined && periods && <span className="tabular-nums text-muted-foreground">{`${fill(t.vs, { period: periods[period === 0 ? 1 : 0] })} ${fmt(activeTile.previous)}`}</span>}
</>
) : (
<span className="text-muted-foreground">{variant === 'compare' && periods ? `${periods[period]} · ${fill(t.total, { v: fmt(total) })}` : fill(t.total, { v: fmt(total) })}</span>
)}
</p>
</div>
<ChartTable caption={name} head={tableHead} rows={tableRows} />
</ChartRoot>
</ChartFigure>
);
}Häufige Fragen
Wie installiere ich Treemap?
Mit aiw add @ai-website/treemap. Der Befehl legt die Datei components/ui/treemap.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, chart und chart-figure. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Treemap?
5 Varianten, Quadratisch, Gruppiert, Hineinzoomen, Nach Veränderung getönt und Zwei Zeiträume. Du wählst sie mit der Prop variant, zum Beispiel variant="grouped".
Kann ich die Texte von Treemap übersetzen?
Ja. Treemap bringt 16 eigene Texte auf Deutsch und Englisch mit, etwa „Eine Gruppe öffnen, um ihre Teile zu sehen“, „Anteil“ und „Alle“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Treemap einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Treemap mit aiw_components und installieren sie mit aiw_add @ai-website/treemap. Nenn im Prompt die Variante, etwa variant="squarified", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Treemap ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Treemap-Code zum Kopieren.