Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Quadratisch

Eine Ebene fast quadratischer Kacheln, die größte im Akzent, Beschriftung nur wo sie passt.

Im Studio öffnen

Gruppiert

Kacheln in vier benannten Gruppen mit ihren Summen, jede Gruppe in eigenem Ton, die Legende trägt die Summen.

Im Studio öffnen

Hineinzoomen

Zuerst die Gruppen. Ein Tippen öffnet eine, ihre Kacheln gleiten herein, der Pfad darüber und Escape führen zurück.

Im Studio öffnen

Nach Veränderung getönt

Die Fläche ist die Größe, die Tönung die Veränderung zum Vormonat. Pfeile und Vorzeichen tragen die Richtung mit, nicht die Farbe allein.

Im Studio öffnen

Zwei Zeiträume

Ein Umschalter zwischen zwei Jahren. Die Kacheln behalten ihre Reihenfolge und gleiten auf ihre neue Größe, die Anzeige nennt beide Werte.

Im Studio öffnen

Details

Was Treemap mitbringt, aus Registry und Quellcode gelesen.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    TreemapVariant

    squarifiedgroupeddrilldownchangecompare

  • itemsPflicht
    TreemapItem[]
  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }

  • periodsoptional
    [string, string]

    compare: names of the two periods, the current first

  • highlightoptional
    string

    squarified: the tile in the accent at the start. Default the largest.

  • heightoptional
    number

    Height 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
totalEnglisch Total {v}Deutsch Gesamt {v}
hintEnglisch Open a group to see its partsDeutsch Eine Gruppe öffnen, um ihre Teile zu sehen
shareEnglisch {share} of the totalDeutsch {share} vom Ganzen
shareColEnglisch ShareDeutsch Anteil
allEnglisch AllDeutsch Alle
backEnglisch Back to allDeutsch Zurück zu allen
openEnglisch Open {label}Deutsch {label} öffnen
periodsEnglisch PeriodDeutsch Zeitraum
changeEnglisch ChangeDeutsch Veränderung
itemEnglisch ItemDeutsch Eintrag
groupEnglisch GroupDeutsch Gruppe
valueEnglisch ValueDeutsch Wert
gainEnglisch GainDeutsch Plus
lossEnglisch LossDeutsch Minus
flatEnglisch About the sameDeutsch Etwa gleich
vsEnglisch 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
components/ui/treemap.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert