Zum Inhalt springen
UI Kit
Accent

Blasendiagramm

Werte als Fläche von Kreisen. Auf zwei Achsen in bis zu drei Gruppen mit direkten Namen, dicht gepackt nach Gruppe, entlang einer Zeitachse in Bahnen oder als Matrix zweier Kategorien mit Zeilensummen.

4 VariantsEine von 5 Components in Statistik-Charts

Ein dritter Wert als Größe eines Kreises

Ein Blasendiagramm fügt einem Diagramm einen dritten Wert als Fläche jedes Kreises hinzu, Kosten, Kaufquote und Umsatz lesen sich so in einem Bild. Die Beschriftung sitzt direkt an den Blasen, ohne sich zu überlappen, und die Component geht über den einfachen Blasenmodus des Streudiagramms hinaus.

„Gruppen auf zwei Achsen“ stellt Kosten je Klick gegen die Kaufquote, Umsatz als Fläche, drei Kanäle in Farbe und eine Legende, die einen wählt. „Gepackte Kreise“ packt Hilfeartikel nach Thema, die meistgelesenen am größten, mit Name und Aufrufen in jedem Kreis mit Platz. Die „Blasen-Zeitachse“ gibt jeder Veranstaltungsart eine Bahn und jeder Veranstaltung eine Blase an ihrem Datum, die Fläche ist die Zahl der Gäste. Die „Blasenmatrix“ legt Artikel nach Wochentag als Raster aus Blasen aus, mit dem Wert in jeder Blase und der Summe jeder Zeile am Ende.

Skaliere Blasen nach Fläche, nicht nach Durchmesser, sonst wirken große Werte viel größer, als sie sind. Halte Gruppen bei drei Farben. Beschrifte die größten Blasen direkt und lass den Zeiger den Rest zeigen.

Variants

Gruppen auf zwei AchsenNeu in 1.2

Kosten je Klick gegen Kaufquote, Umsatz als Fläche, drei Kanäle in Farbe und eine Legende, die einen wählt.

Gepackte KreiseNeu in 1.2

Hilfeartikel nach Thema gepackt, die meistgelesenen am größten, Name und Aufrufe in jedem Kreis mit Platz.

Blasen-ZeitachseNeu in 1.2

Eine Bahn je Veranstaltungsart, jede Veranstaltung eine Blase an ihrem Datum, die Fläche ist die Zahl der Gäste.

BlasenmatrixNeu in 1.2

Artikel nach Wochentag als Raster aus Blasen, der Wert in jeder Blase mit Platz und die Summe jeder Zeile am Ende.

Details

Was Blasendiagramm mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/bubble-chart.tsx
Bringt mit
utilskitchartchart-figure
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 6 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle BubbleChartProps aus components/ui/bubble-chart.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
    BubbleVariant

    groupspackedtimelinematrix

  • dataPflicht
    BubbleDatum[]
  • valueLabelPflicht
    string

    Name of the size, e.g. "Revenue"

  • xLabeloptional
    string

    groups: names of the x and y measures. matrix: names of the columns and rows.

  • yLabeloptional
    string
  • formatoptional
    Intl.NumberFormatOptions
  • xFormatoptional
    Intl.NumberFormatOptions
  • yFormatoptional
    Intl.NumberFormatOptions
  • groupsoptional
    string[]

    Order of the groups (and so their colors). At most three.

  • columnsoptional
    string[]

    matrix: order of the columns and rows

  • rowsoptional
    string[]
  • highlightoptional
    string[]

    Bubbles named directly. Default: the five largest (groups) or every one with room.

  • heightoptional
    number

    Plot height in px. Default per variant.

Eingebaute Texte

Diese Texte schreibt Blasendiagramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 6 Texte anzeigen
itemEnglisch ItemDeutsch Eintrag
groupEnglisch GroupDeutsch Gruppe
sizeKeyEnglisch {label} as areaDeutsch {label} als Fläche
totalEnglisch TotalDeutsch Summe
summaryEnglisch {n} bubbles, {label} from {min} to {max}, largest {top}.Deutsch {n} Blasen, {label} von {min} bis {max}, am größten {top}.
dateEnglisch DateDeutsch Datum

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Blasendiagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1 und Ende.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 556 Zeilen
components/ui/bubble-chart.tsx
'use client';

import { useMemo, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, fill, niceTicks, numberFormatter, parseDate, textWidth, tickFormatter, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, HALO, PickLegend, PlotAxes, ShapeKey, linear, pairColor, placeLabels, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Values as the area of circles.
 *   groups    x, y and size, up to three groups with color and shape, the
 *             largest bubbles named directly where they cover nothing
 *   packed    circles packed tightly by group, no axes, name and value
 *             inside every circle that has room
 *   timeline  one lane per group, bubbles along a time axis, the size is
 *             the value of that moment
 *   matrix    a grid of two categories, one bubble per cell, the row
 *             totals at the end
 *
 * Area, not radius, carries the value. Smaller bubbles are drawn on top
 * and every bubble has a 2 px ring in the surface color, so overlaps stay
 * readable. scatter-chart has one plain bubble mode, this chart is about
 * the sizes themselves.
 */

export type BubbleVariant = 'groups' | 'packed' | 'timeline' | 'matrix';

export interface BubbleDatum {
  label: string;
  /** The size */
  value: number;
  /** groups: a number. timeline: a date ("2026-03" or "2026-03-14"). matrix: the column. */
  x?: number | string;
  /** groups: a number. matrix: the row. */
  y?: number | string;
  /** groups, packed, timeline: the group (timeline: the lane) */
  group?: string;
}

export interface BubbleChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: BubbleVariant;
  data: BubbleDatum[];
  /** Name of the size, e.g. "Revenue" */
  valueLabel: string;
  /** groups: names of the x and y measures. matrix: names of the columns and rows. */
  xLabel?: string;
  yLabel?: string;
  format?: Intl.NumberFormatOptions;
  xFormat?: Intl.NumberFormatOptions;
  yFormat?: Intl.NumberFormatOptions;
  /** Order of the groups (and so their colors). At most three. */
  groups?: string[];
  /** matrix: order of the columns and rows */
  columns?: string[];
  rows?: string[];
  /** Bubbles named directly. Default: the five largest (groups) or every one with room. */
  highlight?: string[];
  /** Plot height in px. Default per variant. */
  height?: number;
  className?: string;
}

const TEXTS = {
  en: { item: 'Item', group: 'Group', sizeKey: '{label} as area', total: 'Total', summary: '{n} bubbles, {label} from {min} to {max}, largest {top}.', date: 'Date' },
  de: { item: 'Eintrag', group: 'Gruppe', sizeKey: '{label} als Fläche', total: 'Summe', summary: '{n} Blasen, {label} von {min} bis {max}, am größten {top}.', date: 'Datum' },
};

type Circle = { x: number; y: number; r: number };

/*
 * Server and browser must pack to the same layout, or hydration fails. Math.hypot, sin and cos
 * may differ in the last bit between engines, sqrt is exact everywhere. So: sqrt only, and the
 * twelve directions as fixed numbers.
 */
const dist = (dx: number, dy: number) => Math.sqrt(dx * dx + dy * dy);
const H = 0.8660254037844386;
const DIRS: [number, number][] = [[1, 0], [H, 0.5], [0.5, H], [0, 1], [-0.5, H], [-H, 0.5], [-1, 0], [-H, -0.5], [-0.5, -H], [0, -1], [0.5, -H], [H, -0.5]];

/**
 * Packs circles tightly, largest first: each new circle touches two placed
 * ones (or one, for the second circle) at the free spot nearest the middle.
 * `aspect` above 1 lets the pack grow wider than tall. Deterministic.
 */
function pack(radii: number[], gap: number, aspect = 1): Circle[] {
  const out: Circle[] = [];
  const score = (x: number, y: number) => (x / aspect) ** 2 + y ** 2;
  const free = (x: number, y: number, r: number) => out.every((c) => dist(c.x - x, c.y - y) >= c.r + r + gap - 1e-6);
  radii.forEach((r, i) => {
    if (i === 0) return out.push({ x: 0, y: 0, r });
    let best: { x: number; y: number } | null = null;
    let bestS = Infinity;
    const consider = (x: number, y: number) => {
      const s = score(x, y);
      if (s < bestS && free(x, y, r)) {
        bestS = s;
        best = { x, y };
      }
    };
    // Touching one circle, at twelve angles
    out.forEach((a) => {
      DIRS.forEach(([cx, cy]) => consider(a.x + cx * (a.r + r + gap), a.y + cy * (a.r + r + gap)));
    });
    // Touching two circles
    for (let p = 0; p < out.length; p++)
      for (let q = p + 1; q < out.length; q++) {
        const A = out[p];
        const B = out[q];
        const ra = A.r + r + gap;
        const rb = B.r + r + gap;
        const dx = B.x - A.x;
        const dy = B.y - A.y;
        const d = dist(dx, dy);
        if (d === 0 || d > ra + rb || d < Math.abs(ra - rb)) continue;
        const a = (ra * ra - rb * rb + d * d) / (2 * d);
        const h = Math.sqrt(Math.max(0, ra * ra - a * a));
        const mx = A.x + (a * dx) / d;
        const my = A.y + (a * dy) / d;
        consider(mx + (h * dy) / d, my - (h * dx) / d);
        consider(mx - (h * dy) / d, my + (h * dx) / d);
      }
    const b = best as { x: number; y: number } | null;
    out.push(b ? { x: b.x, y: b.y, r } : { x: 0, y: 0, r });
  });
  // Center on the enclosing box
  const x0 = Math.min(...out.map((c) => c.x - c.r));
  const x1 = Math.max(...out.map((c) => c.x + c.r));
  const y0 = Math.min(...out.map((c) => c.y - c.r));
  const y1 = Math.max(...out.map((c) => c.y + c.r));
  return out.map((c) => ({ x: c.x - (x0 + x1) / 2, y: c.y - (y0 + y1) / 2, r: c.r }));
}

const enclose = (cs: Circle[]) => Math.max(...cs.map((c) => dist(c.x, c.y) + c.r), 0);

/**
 * Bubbles: on two axes in groups, packed, along a timeline or in a matrix.
 *   <BubbleChart variant="packed" valueLabel="Visits" data={[{ label: 'Blog', value: 1840, group: 'Content' }, …]} />
 */
/** A tick formatter with as many decimals as the ticks need (0.25 stays 0.25), at most three */
function exactTicks(intl: string, ticks: number[], format?: Intl.NumberFormatOptions) {
  const scale = format?.style === 'percent' ? 100 : 1;
  let digits = 0;
  while (digits < 3 && !ticks.every((v) => Math.abs(Math.round(v * scale * 10 ** digits) - v * scale * 10 ** digits) < 1e-6)) digits++;
  const nf = new Intl.NumberFormat(intl, { useGrouping: true, ...format, notation: 'compact', minimumFractionDigits: 0, maximumFractionDigits: Math.max(1, digits) });
  return (n: number) => nf.format(n);
}

export function BubbleChart(props: BubbleChartProps) {
  const { variant = 'groups', data, valueLabel, xLabel, yLabel, format, xFormat, yFormat, highlight, height: heightProp, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
  const t = useTexts(TEXTS, undefined);
  useTexts(CHART_TEXTS, labels);
  const intl = useIntlLocale();
  const fmt = numberFormatter(intl, format);
  const tick = tickFormatter(intl, format);
  const fx = numberFormatter(intl, xFormat);
  const fy = numberFormatter(intl, yFormat);
  const [wrapRef, width] = useWidth<HTMLDivElement>(760);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<number | null>(null);
  const { shown, open, jump } = useShown(active);
  const [focusGroup, setFocusGroup] = useState<number | null>(null);
  const phone = width < 520;
  const name = label ?? title ?? valueLabel;
  const n = data.length;
  const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);

  const groups = (props.groups ?? [...new Set(data.map((d) => d.group ?? ''))].filter(Boolean)).slice(0, variant === 'timeline' ? 12 : 3);
  const gi = (d: BubbleDatum) => Math.max(0, groups.indexOf(d.group ?? ''));
  const colorOf = (d: BubbleDatum) => (variant === 'timeline' || variant === 'matrix' ? 'var(--kitch-1)' : pairColor(gi(d)));
  const dimmed = (d: BubbleDatum) => focusGroup !== null && variant !== 'timeline' && variant !== 'matrix' && gi(d) !== focusGroup;
  const vMax = Math.max(1e-9, ...data.map((d) => Math.max(0, d.value)));
  const dateF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'long', year: 'numeric' });
  const dayF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' });
  const xName = (d: BubbleDatum) => {
    if (variant === 'timeline') {
      const dt = parseDate(d.x);
      return dt ? (String(d.x).length === 7 ? dateF : dayF).format(dt) : String(d.x ?? '');
    }
    return String(d.x ?? '');
  };

  // Packing is the expensive part, it runs once per data set, not on every pointer move
  const groupKey = groups.join('|');
  const [packsMemo, outerMemo] = useMemo(() => {
    if (variant !== 'packed') return [null, null];
    const keys = groups.length ? groups : [''];
    const packs = keys.map((g) => {
      const idx = data.map((d, i) => (groups.length ? ((d.group ?? '') === g ? i : -1) : i)).filter((i) => i >= 0).sort((x, y) => data[y].value - data[x].value);
      const cs = pack(idx.map((i) => Math.sqrt(Math.max(0, data[i].value) / vMax) * 10), 0.3);
      return { g, idx, cs, R: enclose(cs) };
    });
    // Groups side by side on wide screens, more stacked on phones; room above each for its name
    return [packs, pack(packs.map((p) => p.R + 1.2), 3.2, phone ? 0.8 : 2.4)] as const;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [variant, data, groupKey, vMax, phone]);

  /* ---------------- geometry per variant ---------------- */
  let height = heightProp ?? 360;
  let pos: Circle[] = [];
  let axes: React.ReactNode = null;
  let order: number[] = data.map((_, i) => i);
  let groupRings: { x: number; y: number; r: number; label: string; k: number }[] = [];
  let rowTotals: { y: number; text: string; label: string }[] = [];
  let cols = 0;

  if (variant === 'groups') {
    height = heightProp ?? (phone ? 320 : 380);
    const xs = data.map((d) => Number(d.x ?? 0));
    const ys = data.map((d) => Number(d.y ?? 0));
    const rMax = Math.max(14, Math.min(34, width * 0.042));
    const padX = (Math.max(...xs) - Math.min(...xs) || 1) * 0.1;
    const padY = (Math.max(...ys) - Math.min(...ys) || 1) * 0.14;
    const nx = niceTicks(Math.min(...xs) >= 0 ? Math.max(0, Math.min(...xs) - padX) : Math.min(...xs) - padX, Math.max(...xs) + padX, Math.max(3, Math.min(7, Math.floor((width - 60) / 80))));
    const ny = niceTicks(Math.min(...ys) >= 0 ? Math.max(0, Math.min(...ys) - padY) : Math.min(...ys) - padY, Math.max(...ys) + padY, 5);
    // Ticks like 0.25 need two decimals, the shared compact format keeps one and would print 0.3
    const txs = exactTicks(intl, nx.ticks, xFormat);
    const yLabels = ny.ticks.map(exactTicks(intl, ny.ticks, yFormat));
    const left = Math.max(...yLabels.map((s) => textWidth(s, 11)), 14) + 14;
    const right = width - Math.max(10, textWidth(txs(nx.ticks[nx.ticks.length - 1]), 11) / 2 + 4);
    const top = 30;
    const bottom = height - 40;
    const X = linear(nx.min, nx.max, left, right);
    const Y = linear(ny.min, ny.max, bottom, top);
    pos = data.map((d, i) => ({ x: X(xs[i]), y: Y(ys[i]), r: Math.max(4, round(Math.sqrt(Math.max(0, d.value) / vMax) * rMax)) }));
    order = data.map((_, i) => i).sort((a, b) => xs[a] - xs[b] || ys[a] - ys[b]);
    axes = <PlotAxes left={left} right={right} top={top} bottom={bottom} height={height} yTicks={ny.ticks.map((v, i) => ({ at: Y(v), label: yLabels[i] }))} xTicks={nx.ticks.map((v) => ({ at: X(v), label: txs(v) }))} xGrid yTitle={yLabel} xTitle={xLabel} />;
  } else if (variant === 'packed') {
    // Pack each group, then pack the groups (memoised above)
    const packs = packsMemo!;
    const outer = outerMemo!;
    const bx0 = Math.min(...outer.map((c) => c.x - c.r));
    const bx1 = Math.max(...outer.map((c) => c.x + c.r));
    const by0 = Math.min(...outer.map((c) => c.y - c.r));
    const by1 = Math.max(...outer.map((c) => c.y + c.r));
    const labelRoom = groups.length ? 22 : 0;
    const fitW = (width - 8) / Math.max(1e-6, bx1 - bx0);
    height = heightProp ?? round(Math.min(phone ? 520 : 480, Math.max(260, fitW * (by1 - by0) + labelRoom + 8)), 0);
    const s = Math.min(fitW, (height - labelRoom - 8) / Math.max(1e-6, by1 - by0));
    const cx = width / 2 - ((bx0 + bx1) / 2) * s;
    const cy = labelRoom + (height - labelRoom) / 2 - ((by0 + by1) / 2) * s;
    pos = new Array(n);
    packs.forEach((p, k) => {
      const o = outer[k];
      p.idx.forEach((i, j) => {
        pos[i] = { x: round(cx + (o.x + p.cs[j].x) * s), y: round(cy + (o.y + p.cs[j].y) * s), r: round(Math.max(2, p.cs[j].r * s)) };
      });
      if (p.g) groupRings.push({ x: round(cx + o.x * s), y: round(cy + o.y * s), r: round((p.R + 0.8) * s), label: p.g, k: groups.indexOf(p.g) });
    });
    order = data.map((_, i) => i).sort((a, b) => data[b].value - data[a].value);
  } else if (variant === 'timeline') {
    const lanes = groups.length ? groups : [''];
    const laneH = phone ? 58 : 66;
    const top = 26;
    height = heightProp ?? top + lanes.length * laneH + 30;
    const labelW = phone ? 0 : Math.min(180, Math.max(...lanes.map((g) => textWidth(g, 12)), 40) + 22);
    const times = data.map((d) => parseDate(d.x)?.getTime() ?? 0);
    const lo = Math.min(...times);
    const hi = Math.max(...times);
    const rMax = Math.min(laneH * 0.48, Math.max(12, (width - labelW) / Math.max(8, new Set(times).size) * 0.55));
    const left = labelW + rMax + 4;
    const right = width - rMax - 4;
    const X = (v: number) => round(hi === lo ? (left + right) / 2 : left + ((v - lo) / (hi - lo)) * (right - left));
    const laneY = (k: number) => round(top + k * laneH + laneH / 2 + (phone ? 8 : 0));
    pos = data.map((d, i) => ({ x: X(times[i]), y: laneY(Math.max(0, lanes.indexOf(d.group ?? ''))), r: Math.max(3, round(Math.sqrt(Math.max(0, d.value) / vMax) * rMax)) }));
    order = data.map((_, i) => i).sort((a, b) => Math.max(0, lanes.indexOf(data[a].group ?? '')) - Math.max(0, lanes.indexOf(data[b].group ?? '')) || times[a] - times[b]);
    // Month ticks
    const monthF = new Intl.DateTimeFormat(intl, { timeZone: 'UTC', month: 'short' });
    const d0 = new Date(lo);
    const tks: { at: number; label: string }[] = [];
    for (let m = 0; m < 400; m++) {
      const at = Date.UTC(d0.getUTCFullYear(), d0.getUTCMonth() + m, 1);
      if (at > hi) break;
      if (at >= lo) tks.push({ at: X(at), label: monthF.format(new Date(at)) });
    }
    const every = Math.max(1, Math.ceil(30 / Math.max(1, (right - left) / Math.max(1, tks.length))));
    axes = (
      <g className="text-[11px]" fill="var(--muted-foreground)">
        {lanes.map((g, k) => (
          <g key={g || k}>
            <line x1={phone ? 0 : labelW} x2={width} y1={laneY(k)} y2={laneY(k)} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />
            {g && (
              <text x={0} y={phone ? laneY(k) - laneH / 2 + 4 : laneY(k)} dy="0.34em" className="text-[12px] font-medium" fill="var(--foreground)">
                {g}
              </text>
            )}
          </g>
        ))}
        {tks.map((tk, i) =>
          i % every === 0 ? (
            <text key={tk.at} x={tk.at} y={height - 6} textAnchor="middle">
              {tk.label}
            </text>
          ) : null,
        )}
      </g>
    );
  } else {
    // matrix
    const colNames = props.columns ?? [...new Set(data.map((d) => String(d.x ?? '')))];
    const rowNames = props.rows ?? [...new Set(data.map((d) => String(d.y ?? '')))];
    cols = colNames.length;
    const labelW = Math.min(phone ? 96 : 170, Math.max(...rowNames.map((s) => textWidth(s, 12)), 30) + 16);
    const totalW = phone ? 0 : 70;
    const cellW = (width - labelW - totalW) / Math.max(1, colNames.length);
    const cellH = Math.max(40, Math.min(64, cellW * 0.9));
    const top = 30;
    height = heightProp ?? round(top + rowNames.length * cellH + 8, 0);
    const rMax = Math.min(cellW, cellH) / 2 - 3;
    pos = data.map((d) => {
      const c = colNames.indexOf(String(d.x ?? ''));
      const r = rowNames.indexOf(String(d.y ?? ''));
      return { x: round(labelW + cellW * (c + 0.5)), y: round(top + cellH * (r + 0.5)), r: Math.max(2, round(Math.sqrt(Math.max(0, d.value) / vMax) * rMax)) };
    });
    order = data.map((_, i) => i).sort((a, b) => rowNames.indexOf(String(data[a].y)) - rowNames.indexOf(String(data[b].y)) || colNames.indexOf(String(data[a].x)) - colNames.indexOf(String(data[b].x)));
    const colLabelFit = Math.max(...colNames.map((s) => textWidth(s, 11))) <= cellW - 4;
    rowTotals = rowNames.map((rn, r) => ({ y: round(top + cellH * (r + 0.5)), label: rn, text: fmt(data.filter((d) => String(d.y) === rn).reduce((a, d) => a + d.value, 0)) }));
    axes = (
      <g className="text-[11px]" fill="var(--muted-foreground)">
        {rowNames.map((rn, r) => (
          <g key={rn}>
            {r > 0 && <line x1={0} x2={width} y1={round(top + cellH * r)} y2={round(top + cellH * r)} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />}
            <text x={0} y={round(top + cellH * (r + 0.5))} dy="0.34em" className="text-[12px]" fill="var(--foreground)">
              {rn}
            </text>
          </g>
        ))}
        {colNames.map((cn2, c) => (
          <text key={cn2} x={round(labelW + cellW * (c + 0.5))} y={14} textAnchor="middle">
            {colLabelFit ? cn2 : cn2.slice(0, Math.max(1, Math.floor((cellW - 4) / 6.4)))}
          </text>
        ))}
        {!phone && (
          <text x={width} y={14} textAnchor="end" className="font-medium">
            {t.total}
          </text>
        )}
        {!phone &&
          rowTotals.map((rt) => (
            <text key={rt.label} x={width} y={rt.y} dy="0.34em" textAnchor="end" className="text-[12px] font-semibold tabular-nums" fill="var(--foreground)">
              {rt.text}
            </text>
          ))}
      </g>
    );
  }

  /* ---------------- labels ---------------- */
  const named = (() => {
    if (variant === 'packed' || variant === 'matrix') return [];
    const list = highlight ?? [...data].sort((a, b) => b.value - a.value).slice(0, phone ? 3 : 5).map((d) => d.label);
    return data.map((_, i) => i).filter((i) => list.includes(data[i].label));
  })();
  const spots = placeLabels(
    named.map((i) => ({ x: pos[i].x, y: pos[i].y, r: pos[i].r, text: data[i].label })),
    pos.map((p) => ({ x: p.x, y: p.y, r: p.r })),
    { left: 0, right: width, top: 0, bottom: height - 20 },
  );

  /* ---------------- interaction ---------------- */
  const pick = (clientX: number, clientY: number, el: HTMLElement, mouse: boolean) => {
    const b = el.getBoundingClientRect();
    const px = (clientX - b.left) * (width / (b.width || width));
    const py = clientY - b.top;
    let best = -1;
    let near = Infinity;
    pos.forEach((p, i) => {
      if (!p || dimmed(data[i])) return;
      // Timeline: inside its lane only the distance along the time axis counts
      const inLane = variant === 'timeline' && Math.abs(p.y - py) < 30;
      const d = inLane ? Math.max(0, Math.abs(p.x - px) - p.r) : dist(p.x - px, p.y - py) - p.r;
      // Inside a bubble the smallest one wins, it is drawn on top
      const score = d <= 0 ? -1000 + p.r : d;
      if (score < near) {
        near = score;
        best = i;
      }
    });
    setActive(best >= 0 && (near <= (variant === 'timeline' ? 80 : 24) || !mouse) ? best : null);
  };
  const onKeyDown = (e: React.KeyboardEvent) => {
    const at = active === null ? -1 : order.indexOf(active);
    let next = at;
    if (variant === 'matrix' && cols && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) next = at < 0 ? 0 : Math.max(0, Math.min(order.length - 1, at + (e.key === 'ArrowDown' ? cols : -cols)));
    else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = Math.min(order.length - 1, at + 1);
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = Math.max(0, at - 1);
    else if (e.key === 'Home') next = 0;
    else if (e.key === 'End') next = order.length - 1;
    else if (e.key === 'Escape') return setActive(null);
    else return;
    e.preventDefault();
    setActive(order[next]);
  };
  const total = data.reduce((a, d) => a + d.value, 0);
  const rowsOf = (i: number): TooltipRow[] => {
    const d = data[i];
    const out: TooltipRow[] = [{ key: 'v', label: valueLabel, value: fmt(d.value) }];
    if (variant === 'groups') {
      if (xLabel) out.push({ key: 'x', label: xLabel, value: fx(Number(d.x ?? 0)) });
      if (yLabel) out.push({ key: 'y', label: yLabel, value: fy(Number(d.y ?? 0)) });
    }
    if (variant === 'packed') out.push({ key: 's', label: t.total, value: new Intl.NumberFormat(intl, { style: 'percent', maximumFractionDigits: 1 }).format(d.value / (total || 1)) });
    if (d.group && variant !== 'timeline') out.unshift({ key: 'g', label: d.group, value: '', color: colorOf(d), shape: 'dot' });
    return out;
  };
  const titleOf = (i: number) => {
    const d = data[i];
    if (variant === 'timeline') return `${d.label} · ${xName(d)}`;
    if (variant === 'matrix') return `${d.y}, ${d.x}`;
    return d.label;
  };
  const live = active !== null && data[active] ? `${titleOf(active)}. ${rowsOf(active).filter((r) => r.value).map((r) => `${r.label} ${r.value}`).join(', ')}` : '';
  const top1 = [...data].sort((a, b) => b.value - a.value)[0];
  const auto = fill(t.summary, { n: String(n), label: valueLabel, min: fmt(Math.min(...data.map((d) => d.value))), max: fmt(vMax), top: top1?.label ?? '' });
  const head = variant === 'matrix' ? [yLabel ?? '', xLabel ?? '', valueLabel] : variant === 'timeline' ? [t.item, t.group, t.date, valueLabel] : [t.item, ...(groups.length ? [t.group] : []), ...(variant === 'groups' ? [xLabel ?? 'x', yLabel ?? 'y'] : []), valueLabel];
  const table = (
    <ChartTable
      caption={name}
      head={head}
      rows={order.map((i) => {
        const d = data[i];
        if (variant === 'matrix') return [String(d.y ?? ''), String(d.x ?? ''), fmt(d.value)];
        if (variant === 'timeline') return [d.label, d.group ?? '', xName(d), fmt(d.value)];
        return [d.label, ...(groups.length ? [d.group ?? ''] : []), ...(variant === 'groups' ? [fx(Number(d.x ?? 0)), fy(Number(d.y ?? 0))] : []), fmt(d.value)];
      })}
    />
  );

  // Size key: two round values
  const keyValues = (() => {
    if (variant === 'packed') return [];
    const steps = niceTicks(0, vMax, 4).ticks.filter((v) => v > 0 && v <= vMax);
    const big = steps[steps.length - 1];
    const small = steps.find((v) => v >= vMax * 0.15);
    return big === undefined ? [] : small !== undefined && small !== big ? [small, big] : [big];
  })();
  const rScale = (() => {
    const i = data.findIndex((d) => d.value === vMax);
    return i >= 0 && pos[i] ? pos[i].r : 10;
  })();

  const drawOrder = data.map((_, i) => i).sort((a, b) => data[b].value - data[a].value);

  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef} className="[--bub-ink2:var(--background)] dark:[--bub-ink2:var(--foreground)]">
        {(variant === 'groups' || variant === 'packed') && groups.length > 1 && <PickLegend className="mb-3" active={focusGroup} onActive={setFocusGroup} items={groups.map((g, k) => ({ label: g, key: <ShapeKey shape={0} color={pairColor(k)} /> }))} />}
        <div ref={wrapRef}>
          <ChartSurface
            label={name}
            summary={summary ?? auto}
            labels={labels}
            live={live}
            table={table}
            onKeyDown={onKeyDown}
            onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(order[0] ?? null)}
            onBlur={() => setActive(null)}
            onPointerMove={(e) => pick(e.clientX, e.clientY, e.currentTarget, e.pointerType === 'mouse')}
            onPointerDown={(e) => pick(e.clientX, e.clientY, e.currentTarget, e.pointerType === 'mouse')}
            onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
          >
            <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
              {axes}
              {groupRings.map((g) => (
                <g key={g.label} opacity={focusGroup !== null && focusGroup !== g.k ? 0.3 : 1} className={cn('transition-opacity duration-300', anim && 'kitch-fade')} style={delay(60)}>
                  <circle cx={g.x} cy={g.y} r={g.r} fill="color-mix(in oklab, var(--foreground) 3%, transparent)" stroke="var(--kitch-grid)" strokeWidth="1" strokeDasharray="3 3" />
                  <text x={g.x} y={g.y - g.r - 8} textAnchor="middle" className="text-[12px] font-medium" fill="var(--muted-foreground)">
                    {g.label}
                  </text>
                </g>
              ))}
              {drawOrder.map((i) => {
                const d = data[i];
                const p = pos[i];
                if (!p) return null;
                const dim = dimmed(d);
                const k = order.indexOf(i);
                return (
                  <g key={i} opacity={dim ? 0.18 : 1} className="kitch-series">
                    <circle cx={p.x} cy={p.y} r={p.r} fill={colorOf(d)} fillOpacity={variant === 'packed' ? 0.9 : variant === 'matrix' ? 1 : 0.78} stroke="var(--kitch-surface)" strokeWidth="2" className={cn(anim && 'kitch-pop')} style={delay(100 + (k / Math.max(1, n)) * 700)} />
                    {active === i && <circle cx={p.x} cy={p.y} r={p.r + 4} fill="none" stroke="var(--foreground)" strokeWidth="1.5" />}
                  </g>
                );
              })}
              {/* Names inside packed bubbles and values inside matrix bubbles */}
              {(variant === 'packed' || variant === 'matrix') && (
                <g className="pointer-events-none">
                  {data.map((d, i) => {
                    const p = pos[i];
                    if (!p) return null;
                    const ink = variant === 'packed' ? (gi(d) === 0 ? 'var(--primary-foreground)' : gi(d) === 1 ? 'var(--background)' : 'var(--bub-ink2)') : 'var(--primary-foreground)';
                    const valueText = variant === 'matrix' ? tick(d.value) : fmt(d.value);
                    const nameFits = variant === 'packed' && textWidth(d.label, 12) + 10 <= p.r * 2 && p.r >= 20;
                    const valueFits = textWidth(valueText, 11) + 8 <= p.r * 2 && p.r >= 11;
                    if (!nameFits && !valueFits) return null;
                    return (
                      <g key={i} opacity={dimmed(d) ? 0.2 : 1} className={cn(anim && 'kitch-fade')} style={delay(700 + (order.indexOf(i) / Math.max(1, n)) * 400)}>
                        {nameFits && (
                          <text x={p.x} y={valueFits ? p.y - 7 : p.y} dy="0.34em" textAnchor="middle" className="text-[12px] font-semibold" fill={ink}>
                            {d.label}
                          </text>
                        )}
                        {valueFits && (
                          <text x={p.x} y={nameFits ? p.y + 8 : p.y} dy="0.34em" textAnchor="middle" className="text-[11px] font-medium tabular-nums" fill={ink} opacity={0.9}>
                            {valueText}
                          </text>
                        )}
                      </g>
                    );
                  })}
                </g>
              )}
              {/* Direct labels beside the bubbles */}
              <g className="pointer-events-none text-[11px] font-medium" fill="var(--foreground)">
                {named.map((i, k) =>
                  dimmed(data[i]) ? null : (
                    <text key={i} x={spots[k].x} y={spots[k].y} dy="0.34em" textAnchor={spots[k].anchor} style={{ ...HALO, ...delay(800) }} className={anim ? 'kitch-rise' : undefined}>
                      {data[i].label}
                    </text>
                  ),
                )}
              </g>
            </svg>
            <ChartTooltip open={open} jump={jump} x={shown !== null && pos[shown] ? pos[shown].x : 0} y={shown !== null && pos[shown] ? Math.max(0, pos[shown].y - 30) : 0} width={width} offset={shown !== null && pos[shown] ? pos[shown].r + 10 : 12} title={shown !== null && data[shown] ? titleOf(shown) : ''} rows={shown !== null && data[shown] ? rowsOf(shown).filter((r) => r.value !== '' || r.color) : []} />
          </ChartSurface>
        </div>
        {keyValues.length > 0 && (
          <div className={cn('mt-4 flex flex-wrap items-end justify-between gap-x-5 gap-y-2 text-[12px] text-muted-foreground', anim && 'kitch-rise')} style={delay(900)}>
            <span className="flex flex-wrap items-end gap-x-4 gap-y-2">
              <span>{fill(t.sizeKey, { label: valueLabel })}</span>
              {keyValues.map((v) => {
                const r = Math.max(3, round(Math.sqrt(v / vMax) * rScale));
                return (
                  <span key={v} className="inline-flex items-end gap-1.5">
                    <svg width={round(2 * r + 2)} height={round(2 * r + 2)} viewBox={`0 0 ${round(2 * r + 2)} ${round(2 * r + 2)}`} aria-hidden>
                      <circle cx={r + 1} cy={r + 1} r={r} fill="none" stroke="var(--muted-foreground)" strokeWidth="1.25" />
                    </svg>
                    <span className="tabular-nums">{fmt(v)}</span>
                  </span>
                );
              })}
            </span>
            {variant === 'matrix' && phone && (
              <span className="flex flex-wrap gap-x-3 gap-y-1">
                {rowTotals.map((rt) => (
                  <span key={rt.label} className="tabular-nums">
                    <span className="text-foreground">{rt.label}</span> {rt.text}
                  </span>
                ))}
              </span>
            )}
          </div>
        )}
      </ChartRoot>
    </ChartFigure>
  );
}

Häufige Fragen

Wie installiere ich Blasendiagramm?

Mit aiw add @ai-website/bubble-chart. Der Befehl legt die Datei components/ui/bubble-chart.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind chart und chart-figure. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Blasendiagramm?

4 Varianten, Gruppen auf zwei Achsen, Gepackte Kreise, Blasen-Zeitachse und Blasenmatrix. Du wählst sie mit der Prop variant, zum Beispiel variant="packed".

Kann ich die Texte von Blasendiagramm übersetzen?

Ja. Blasendiagramm bringt 6 eigene Texte auf Deutsch und Englisch mit, etwa „Eintrag“, „Gruppe“ und „Summe“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Blasendiagramm einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Blasendiagramm mit aiw_components und installieren sie mit aiw_add @ai-website/bubble-chart. Nenn im Prompt die Variante, etwa variant="groups", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert