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.
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
Details
Was Blasendiagramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Statistik-Charts
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalBubbleVariantgroupspackedtimelinematrix
dataPflichtBubbleDatum[]valueLabelPflichtstringName of the size, e.g. "Revenue"
xLabeloptionalstringgroups: names of the x and y measures. matrix: names of the columns and rows.
yLabeloptionalstringformatoptionalIntl.NumberFormatOptionsxFormatoptionalIntl.NumberFormatOptionsyFormatoptionalIntl.NumberFormatOptionsgroupsoptionalstring[]Order of the groups (and so their colors). At most three.
columnsoptionalstring[]matrix: order of the columns and rows
rowsoptionalstring[]highlightoptionalstring[]Bubbles named directly. Default: the five largest (groups) or every one with room.
heightoptionalnumberPlot 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| item | Englisch Item | Deutsch Eintrag |
| group | Englisch Group | Deutsch Gruppe |
| sizeKey | Englisch {label} as area | Deutsch {label} als Fläche |
| total | Englisch Total | Deutsch Summe |
| summary | Englisch {n} bubbles, {label} from {min} to {max}, largest {top}. | Deutsch {n} Blasen, {label} von {min} bis {max}, am größten {top}. |
| date | Englisch Date | Deutsch 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
'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.