Widgets
Bubble chart
Blasendiagramm nach Wert: auf zwei Achsen, wenn die Punkte x und y haben, sonst gepackt mit den größten zuerst, mit Beschriftung wo Platz ist, einem Tooltip und einem Tab-Stopp.
Vorschau
Verwendung
bubble-chart-demo.tsx
import { useState } from 'react'
import { BubbleChart, type BubbleDatum } from '@/components/bubble-chart'
import { Pills } from '@/components/chart-kit'
import { formatValue } from '@/components/chart-utils'
import { DataTile, TileLegend } from '@/components/data-tile'
// Example figures for the demo, not real data.
const CATS = ['Groceries', 'Transport', 'Eating out', 'Shopping', 'Bills']
const MERCHANTS = [
{ name: 'Harbour Market', cat: 0, visits: 6, avg: 48.2 },
{ name: 'City Transit', cat: 1, visits: 18, avg: 3.1 },
{ name: 'Corner Bakery', cat: 2, visits: 12, avg: 6.4 },
{ name: 'Green Leaf Café', cat: 2, visits: 9, avg: 9.8 },
{ name: 'Northwind Books', cat: 3, visits: 2, avg: 31.5 },
{ name: 'Pixel & Plug', cat: 3, visits: 1, avg: 58.9 },
{ name: 'Lumen Energy', cat: 4, visits: 1, avg: 74 },
{ name: 'Fresh Fields', cat: 0, visits: 4, avg: 27.35 },
]
const EUR = { currency: 'EUR' }
// Each merchant's colour follows its category, in the order of CATS.
const color = (cat: number) => `var(--chart-${cat + 1})`
const merchants: BubbleDatum[] = MERCHANTS.map((m) => ({
label: m.name,
value: Math.round(m.visits * m.avg * 100) / 100,
x: m.visits,
y: m.avg,
group: CATS[m.cat],
color: color(m.cat),
}))
const PERIODS = [
{ id: 'month', label: 'Month', values: [398.6, 55.8, 165, 121.9, 74] },
{ id: 'year', label: 'Year', values: [5023.4, 1944.54, 2592.72, 2268.63, 4375.23] },
]
export default function BubbleChartDemo() {
const [period, setPeriod] = useState('month')
const p = PERIODS.find((x) => x.id === period) ?? PERIODS[0]
return (
<div className="grid w-full gap-4">
<DataTile
title="Where you shop"
action={<TileLegend items={CATS.map((label, i) => ({ label, color: color(i), shape: 'dot' as const }))} />}
footer={<span>Position by visits and average spend, size by the month's total. Example data.</span>}
>
<p className="text-sm leading-[1.3] font-medium text-muted-foreground">How often you go, what you spend each time</p>
<BubbleChart
className="mt-4"
data={merchants}
label="Merchants by visits and average spend, sized by total this month"
format={{ ...EUR, decimals: 2 }}
yFormat={{ ...EUR, decimals: 2 }}
labels={{
item: 'Merchant',
group: 'Category',
value: 'Total',
valueNote: 'this month',
x: 'Visits',
y: 'Average',
xUnit: 'visits',
describe: (d, t) => `${d.label}: ${t.x} visits, average ${t.y}, total ${t.value}`,
}}
/>
</DataTile>
<div className="grid gap-4 sm:grid-cols-2">
<DataTile
title="By category"
density="compact"
action={<Pills label="Period" options={PERIODS} value={period} onChange={setPeriod} />}
footer={<span>Packed, largest first. Example data.</span>}
>
<BubbleChart
data={CATS.map((label, i) => ({ label, value: p.values[i] }))}
label={`Spending by category, last ${period}`}
format={EUR}
labels={{ item: 'Category', value: 'Spent' }}
/>
</DataTile>
<DataTile title="Wallet" density="compact" inverted footer={<span>{formatValue(1383.2, EUR)} this month. Example data.</span>}>
<BubbleChart
data={merchants.slice(0, 5)}
label="Wallet merchants by visits and average spend"
format={{ ...EUR, decimals: 2 }}
yFormat={EUR}
labels={{ item: 'Merchant', group: 'Category', value: 'Total', x: 'Visits', y: 'Average', xUnit: 'visits' }}
/>
</DataTile>
</div>
</div>
)
}Quellcode
bubble-chart.tsx
// Bubble chart: one circle per item, sized by its value. Give every item an `x` and a `y` and the bubbles sit on
// two axes (how often, how much each time) with dashed gridlines and values at the right; leave them out and the
// bubbles pack together, largest in the middle. Each bubble is named by a label beside it (on axes) or inside it
// (packed) wherever the label fits; the rest are named by the tooltip.
//
// How it works: the layout is worked out in pixels from the measured size, so labels, radii and ticks stay readable
// from 280 to 1200 px. Labels on axes try eight spots around their bubble and keep the one that hits nothing; packed
// bubbles are placed largest first, each in the free spot nearest the middle. The plot is one Tab stop: arrow keys,
// Home and End step through the bubbles (left to right on axes, largest first when packed), Escape lets go. Hover
// and a finger scrubbing across the plot pick the bubble under it and show the same tooltip, and the page still
// scrolls up and down under a finger.
//
// Screen readers: the plot is an image named by `label` with a short hint; each step is read once as one sentence
// ("Harbour Market: 6 visits, average €48.20, total €289.20"), and the whole data set is in a hidden table.
//
// Motion: on first view the bubbles grow from their centres one after another (all done in 600 ms). Bubbles only
// move by transform and fade by opacity, so new data glides them to their new places. Under reduced motion
// everything is drawn in place at once and nothing moves.
import { useCallback, useEffect, useId, useMemo, useState, type HTMLAttributes, type KeyboardEvent, type PointerEvent } from 'react'
import { cn } from '@/lib/utils'
import { ChartTooltip, SrTable } from '@/registry/manniche/chart-kit/chart-kit'
import {
BASE,
EASE_CSS,
GRID,
formatTick,
formatValue,
niceScale,
seriesColor,
stepIndex,
valueParts,
type ValueFormat,
} from '@/registry/manniche/chart-kit/chart-utils'
import { useAnnounce, useChartFrame } from '@/registry/manniche/chart-kit/use-chart'
export type BubbleDatum = {
/** The item's name, written beside or inside its bubble and read aloud. Also its key, so keep it unique. */
label: string
/** Sets the bubble's area. Negative values count as zero. */
value: number
/** Position along the bottom axis. Give every item an `x` and a `y` to draw axes; leave both out to pack. */
x?: number
/** Position up the side axis. */
y?: number
/** A group name, shown in the tooltip and the table, such as "Groceries". Items in one group share a colour. */
group?: string
/** Any CSS colour. Defaults to a series colour per group (or per item without groups). */
color?: string
}
export type BubbleLabels = {
/** The first column of the hidden table. Default "Item". */
item?: string
/** What `value` measures, in the table and the default sentence. Default "Value". */
value?: string
/** Muted words after the value in the tooltip, such as "this month". Default none. */
valueNote?: string
/** What `x` measures, in the tooltip, the table and the default sentence. Default "X". */
x?: string
/** What `y` measures. Default "Y". */
y?: string
/** A unit after the last bottom tick, such as "visits". Default none. */
xUnit?: string
/** The group column of the hidden table. Default "Group". */
group?: string
/** Read once when the plot gets focus. Default "Arrow keys step through the bubbles; Escape lets go." */
hint?: string
/** The sentence read for a bubble. Gets the item and its formatted figures. */
describe?: (d: BubbleDatum, text: { value: string; x: string; y: string }) => string
}
export type BubbleChartProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
/** One entry per bubble: `{ label, value, x?, y?, group?, color? }`. */
data: BubbleDatum[]
/** What the chart shows, such as "Merchants by visits and average spend". Names the plot and the hidden table. */
label: string
/** How `value` is written. Default: a plain whole number in en-GB. */
format?: ValueFormat
/** How `x` is written. Default: a plain number. */
xFormat?: ValueFormat
/** How `y` is written, on the side axis too. Default: a plain number. */
yFormat?: ValueFormat
/** The plot's height in px. Default 280 below 560 px wide and 320 above (240/280 in compact density). */
height?: number
/** The largest bubble's radius in px. Default 40 (30 below 560 px wide, 22 below 400), and never more than an eighth of the height. */
maxRadius?: number
/** The bubble that is active (its tooltip shows), as an index into `data`, or null. Makes the selection controlled. */
activeIndex?: number | null
/** The bubble active on first render when uncontrolled. Default null. */
defaultActiveIndex?: number | null
/** Called with the new index (or null) when the pointer or the keyboard moves the selection. */
onActiveIndexChange?: (index: number | null) => void
/** Words for i18n. */
labels?: BubbleLabels
/** "compact" makes the plot shorter. Inside a DataTile it follows the tile's density on its own. */
density?: 'comfortable' | 'compact'
}
type Placed = { i: number; cx: number; cy: number; r: number; color: string }
type Tag = { x: number; y: number; anchor: 'start' | 'middle' | 'end'; inside: boolean } | null
const GUT = 46 // right gutter for the side axis values
const PB = 30 // bottom room for the x ticks
const PT = 14
const LABEL_H = 14
/** A label's width in px at 12 px, guessed from its letters so layout is the same on the server and the client. */
function textWidth(s: string, px = 12) {
let em = 0
for (const ch of s) {
if (/[il.,'|!:;ɪj]/.test(ch)) em += 0.27
else if (ch === ' ') em += 0.27
else if (/[mwMW@]/.test(ch)) em += 0.86
else if (/[A-Z&]/.test(ch)) em += 0.66
else if (/[ft r]/.test(ch)) em += 0.36
else em += 0.56
}
return em * px
}
function useControllable(controlled: number | null | undefined, initial: number | null, onChange?: (v: number | null) => void) {
const [inner, setInner] = useState(initial)
const isControlled = controlled !== undefined
const value = isControlled ? controlled : inner
const set = useCallback(
(v: number | null) => {
if (v === value) return
if (!isControlled) setInner(v)
onChange?.(v)
},
[value, isControlled, onChange],
)
return [value, set] as const
}
/** Packs circles (radii in any unit) largest first, each in the free tangent spot nearest the middle. */
function pack(radii: number[], aspect: number) {
const order = radii.map((_, i) => i).sort((a, b) => radii[b] - radii[a])
const pos: { x: number; y: number; r: number }[] = new Array(radii.length)
const done: number[] = []
const pad = Math.max(...radii, 0) * 0.06
const free = (x: number, y: number, r: number) =>
done.every((j) => (pos[j].x - x) ** 2 + (pos[j].y - y) ** 2 >= (pos[j].r + r + pad - 1e-6) ** 2)
// Wider plots spread sideways: distance counts less along x.
const cost = (x: number, y: number) => (x / aspect) ** 2 + y ** 2
for (const i of order) {
const r = radii[i]
if (!done.length) {
pos[i] = { x: 0, y: 0, r }
done.push(i)
continue
}
let best: { x: number; y: number } | null = null
let bc = Infinity
const tryAt = (x: number, y: number) => {
const c = cost(x, y)
if (c < bc && free(x, y, r)) {
bc = c
best = { x, y }
}
}
for (const a of done) {
const A = pos[a]
for (let k = 0; k < 12; k++) {
const t = (k / 12) * Math.PI * 2
tryAt(A.x + Math.cos(t) * (A.r + r + pad), A.y + Math.sin(t) * (A.r + r + pad))
}
for (const b of done) {
if (b <= a) continue
const B = pos[b]
const ra = A.r + r + pad
const rb = B.r + r + pad
const dx = B.x - A.x
const dy = B.y - A.y
const d = Math.hypot(dx, dy)
if (d > ra + rb || d < Math.abs(ra - rb) || d === 0) continue
const l = (ra * ra - rb * rb + d * d) / (2 * d)
const h = Math.sqrt(Math.max(0, ra * ra - l * l))
const mx = A.x + (dx * l) / d
const my = A.y + (dy * l) / d
tryAt(mx + (h * dy) / d, my - (h * dx) / d)
tryAt(mx - (h * dy) / d, my + (h * dx) / d)
}
}
pos[i] = best ? { ...(best as { x: number; y: number }), r } : { x: 0, y: 0, r }
done.push(i)
}
return pos
}
/**
* A bubble chart: bubbles sized by value, on two axes when the data has `x` and `y`, packed together when it does
* not. Direct labels where they fit, one tooltip for the rest, one Tab stop with arrow keys.
*/
export function BubbleChart({
data,
label,
format,
xFormat,
yFormat,
height,
maxRadius,
activeIndex,
defaultActiveIndex = null,
onActiveIndexChange,
labels: labelsProp,
density,
className,
style,
...rest
}: BubbleChartProps) {
const labels = {
item: 'Item',
value: 'Value',
x: 'X',
y: 'Y',
group: 'Group',
hint: 'Arrow keys step through the bubbles; Escape lets go.',
...labelsProp,
}
const { ref: frameRef, width: frameW, height: frameH, drawn, reduced } = useChartFrame<HTMLDivElement>()
const { say, region } = useAnnounce()
const hintId = useId()
const [active, setActive] = useControllable(activeIndex, defaultActiveIndex, onActiveIndexChange)
const [keyboard, setKeyboard] = useState(false)
const n = data.length
const axes = n > 0 && data.every((d) => Number.isFinite(d.x) && Number.isFinite(d.y))
const W = Math.max(280, frameW)
const H = frameH
const ready = frameW > 0 && H > 0
// Colours: one per group, in the order groups first appear; otherwise one per item.
const groups = useMemo(() => [...new Set(data.map((d) => d.group).filter((g): g is string => !!g))], [data])
const colorOf = useCallback(
(d: BubbleDatum, i: number) => d.color ?? (d.group ? seriesColor(groups.indexOf(d.group)) : seriesColor(i)),
[groups],
)
const layout = useMemo(() => {
if (!ready || !n) return null
const maxV = Math.max(...data.map((d) => Math.max(0, d.value)), Number.EPSILON)
if (axes) {
const pw = W - GUT
// The axes start at 0, or lower when a value is negative.
const xv = data.map((d) => d.x as number)
const yv = data.map((d) => d.y as number)
const xs = niceScale(Math.max(...xv), 4, Math.min(0, ...xv))
const ys = niceScale(Math.max(...yv), 4, Math.min(0, ...yv))
const rmax = Math.min(maxRadius ?? (W < 400 ? 22 : W < 560 ? 30 : 40), H / 8)
const rmin = Math.min(6, rmax / 2)
const X = (v: number) => 16 + ((v - xs.min) / (xs.max - xs.min || 1)) * (pw - 32)
const Y = (v: number) => PT + (1 - (v - ys.min) / (ys.max - ys.min || 1)) * (H - PT - PB)
const dots: Placed[] = data.map((d, i) => ({
i,
cx: X(d.x as number),
cy: Y(d.y as number),
r: rmin + Math.sqrt(Math.max(0, d.value) / maxV) * (rmax - rmin),
color: colorOf(d, i),
}))
// Direct labels: eight spots around each bubble, biggest bubbles first; the cheapest spot wins.
type Box = { x: number; y: number; w: number; h: number }
const placed: Box[] = []
const hitsCircle = (b: Box, o: Placed) => {
const nx = Math.max(b.x, Math.min(o.cx, b.x + b.w))
const ny = Math.max(b.y, Math.min(o.cy, b.y + b.h))
return (nx - o.cx) ** 2 + (ny - o.cy) ** 2 < (o.r + 2) ** 2
}
const hitsBox = (a: Box, b: Box) => a.x < b.x + b.w + 4 && b.x < a.x + a.w + 4 && a.y < b.y + b.h + 2 && b.y < a.y + a.h + 2
const tags: Tag[] = new Array(n).fill(null)
for (const o of [...dots].sort((p, q) => q.r - p.r)) {
const w = textWidth(data[o.i].label)
const h = LABEL_H
const d = o.r * 0.7
const spots: (Box & { a: 'start' | 'middle' | 'end' })[] = [
{ x: o.cx + o.r + 7, y: o.cy - h / 2, a: 'start', w, h },
{ x: o.cx - o.r - 7 - w, y: o.cy - h / 2, a: 'end', w, h },
{ x: o.cx - w / 2, y: o.cy - o.r - 6 - h, a: 'middle', w, h },
{ x: o.cx - w / 2, y: o.cy + o.r + 6, a: 'middle', w, h },
{ x: o.cx + d + 3, y: o.cy - d - h, a: 'start', w, h },
{ x: o.cx - d - 3 - w, y: o.cy - d - h, a: 'end', w, h },
{ x: o.cx + d + 3, y: o.cy + d, a: 'start', w, h },
{ x: o.cx - d - 3 - w, y: o.cy + d, a: 'end', w, h },
]
const cost = (b: Box) =>
(b.x < 0 || b.x + b.w > pw || b.y < 0 || b.y + b.h > H - PB ? 10 : 0) +
dots.filter((q) => q !== o && hitsCircle(b, q)).length * 3 +
placed.filter((q) => hitsBox(b, q)).length * 4
let best = spots[0]
let bc = Infinity
for (const s of spots) {
const c = cost(s)
if (c < bc) {
bc = c
best = s
}
if (!c) break
}
// A label that would hit something is left to the tooltip.
if (bc > 0) continue
placed.push(best)
const ax = best.a === 'start' ? best.x : best.a === 'end' ? best.x + w : best.x + w / 2
tags[o.i] = { x: ax, y: best.y + 11, anchor: best.a, inside: false }
}
const xTicks = xs.ticks.map((v) => ({ v, at: X(v) }))
const yTicks = ys.ticks.map((v) => ({ v, at: Y(v) }))
// Left to right, then bottom to top: the order the eye reads the plot.
const order = dots
.map((o) => o.i)
.sort((a, b) => (data[a].x as number) - (data[b].x as number) || (data[a].y as number) - (data[b].y as number))
return { dots, tags, order, pw, xTicks, yTicks }
}
// Packed: unit radii by area, packed, then scaled to fit the plot with room for the focus ring.
const radii = data.map((d) => Math.sqrt(Math.max(0, d.value) / maxV))
const pos = pack(radii, Math.max(1, W / H))
let x0 = Infinity
let x1 = -Infinity
let y0 = Infinity
let y1 = -Infinity
for (const p of pos) {
x0 = Math.min(x0, p.x - p.r)
x1 = Math.max(x1, p.x + p.r)
y0 = Math.min(y0, p.y - p.r)
y1 = Math.max(y1, p.y + p.r)
}
const m = 8
let s = Math.min((W - 2 * m) / (x1 - x0 || 1), (H - 2 * m) / (y1 - y0 || 1))
if (maxRadius) s = Math.min(s, maxRadius)
const ox = W / 2 - ((x0 + x1) / 2) * s
const oy = H / 2 - ((y0 + y1) / 2) * s
const dots: Placed[] = data.map((d, i) => ({ i, cx: ox + pos[i].x * s, cy: oy + pos[i].y * s, r: pos[i].r * s, color: colorOf(d, i) }))
const tags: Tag[] = dots.map((o) => (textWidth(data[o.i].label) <= o.r * 1.7 - 8 && o.r >= 20 ? { x: o.cx, y: o.cy + 4, anchor: 'middle', inside: true } : null))
const order = dots.map((o) => o.i).sort((a, b) => data[b].value - data[a].value || a - b)
return { dots, tags, order, pw: W, xTicks: [], yTicks: [] }
}, [ready, n, data, axes, W, H, maxRadius, colorOf])
// First view: the bubbles grow in once; afterwards moves glide without a delay.
const [settled, setSettled] = useState(false)
useEffect(() => {
if (!drawn || settled) return
const t = window.setTimeout(() => setSettled(true), 650)
return () => window.clearTimeout(t)
}, [drawn, settled])
const grown = drawn && ready
// A bubble that no longer exists lets go.
useEffect(() => {
if (active !== null && (active < 0 || active >= n)) setActive(null)
}, [active, n, setActive])
const at = active !== null && active >= 0 && active < n ? active : -1
const order = useMemo(() => layout?.order ?? [], [layout])
const texts = (d: BubbleDatum) => ({
value: formatValue(d.value, format),
x: d.x === undefined ? '' : formatValue(d.x, xFormat),
y: d.y === undefined ? '' : formatValue(d.y, yFormat),
})
const describe = (i: number) => {
const d = data[i]
const t = texts(d)
if (labels.describe) return labels.describe(d, t)
const parts = [`${labels.value} ${t.value}`]
if (axes) parts.unshift(`${labels.x} ${t.x}`, `${labels.y} ${t.y}`)
return `${d.label}${d.group ? `, ${d.group}` : ''}: ${parts.join(', ')}`
}
const pick = (i: number, announce: boolean) => {
setActive(i)
if (announce) say(describe(i))
}
const fromPointer = (e: PointerEvent<HTMLDivElement>) => {
if (!layout) return
setKeyboard(false)
const r = e.currentTarget.getBoundingClientRect()
const px = e.clientX - r.left
const py = e.clientY - r.top
// The bubble under the pointer, smallest first so a small one in front of a big one can be reached; a finger
// gets 10 px of slack.
const slack = e.pointerType === 'touch' ? 10 : 2
let hit = -1
let hr = Infinity
for (const o of layout.dots) {
if ((o.cx - px) ** 2 + (o.cy - py) ** 2 <= (o.r + slack) ** 2 && o.r < hr) {
hit = o.i
hr = o.r
}
}
if (hit >= 0) {
if (hit !== at) pick(hit, false)
} else if (e.pointerType === 'mouse' && !e.currentTarget.matches(':focus-visible')) setActive(null)
}
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (!n || !order.length) return
if (e.key === 'Escape') {
if (at < 0) return
e.preventDefault()
setActive(null)
return
}
const p = order.indexOf(at)
const to = p < 0 && /^Arrow/.test(e.key) ? 0 : stepIndex(e.key, p, order.length, { loop: false })
if (to === null) return
e.preventDefault()
setKeyboard(true)
pick(order[to], true)
}
const isCompact = density === 'compact'
const heightClass = isCompact
? 'h-[240px] @[35rem]:h-[280px]'
: 'h-[280px] @[35rem]:h-[320px] group-data-[density=compact]/tile:h-[240px] @[35rem]:group-data-[density=compact]/tile:h-[280px]'
const stagger = Math.min(50, 300 / Math.max(1, n - 1))
const motionFor = (k: number) =>
reduced
? { transition: 'none' }
: settled
? { transitionProperty: 'transform, opacity', transitionDuration: '300ms, 200ms', transitionTimingFunction: EASE_CSS }
: {
transitionProperty: 'transform, opacity',
transitionDuration: '300ms, 200ms',
transitionTimingFunction: EASE_CSS,
transitionDelay: `${Math.round(k * stagger)}ms, 0ms`,
}
const rankOf = useMemo(() => {
const m = new Map<number, number>()
order.forEach((i, k) => m.set(i, k))
return m
}, [order])
const tip = at >= 0 && layout ? layout.dots[at] : null
const tipD = at >= 0 ? data[at] : null
const tipRows = tipD && axes ? [
{ label: labels.x, value: texts(tipD).x },
{ label: labels.y, value: texts(tipD).y },
] : []
return (
<div className={cn('@container relative min-w-0', className)} style={style} {...rest}>
<div
ref={frameRef}
tabIndex={n ? 0 : undefined}
role="img"
aria-label={label}
aria-describedby={n ? hintId : undefined}
onPointerDown={fromPointer}
onPointerMove={fromPointer}
onPointerLeave={(e) => {
if (e.pointerType !== 'mouse') return
if (!e.currentTarget.matches(':focus-visible')) setActive(null)
}}
onFocus={(e) => {
// A press focuses the plot too; only keyboard focus picks the first bubble.
if (!e.currentTarget.matches(':focus-visible')) return
setKeyboard(true)
if (at >= 0) say(describe(at))
else if (order.length) pick(order[0], true)
}}
onBlur={() => {
setKeyboard(false)
setActive(null)
}}
onKeyDown={onKeyDown}
className={cn('relative w-full rounded-xl outline-offset-4 select-none [touch-action:pan-y]', heightClass)}
style={height ? { height } : undefined}
>
{layout && (
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="absolute inset-0 block overflow-visible" aria-hidden>
{axes && (
<g>
{layout.yTicks.map((t, k) => (
<g key={`y${t.v}`}>
<line
x1={0}
x2={layout.pw}
y1={Math.round(t.at) + 0.5}
y2={Math.round(t.at) + 0.5}
stroke={k === 0 ? BASE : GRID}
strokeDasharray={k === 0 ? undefined : '2 4'}
/>
<text x={layout.pw + 10} y={t.at + 4} className="fill-muted-foreground font-mono text-[11px] tabular-nums">
{formatTick(t.v, yFormat)}
</text>
</g>
))}
{layout.xTicks.map((t, k) => (
<text
key={`x${t.v}`}
x={t.at}
y={H - 8}
textAnchor="middle"
className="fill-muted-foreground font-mono text-[11px] tabular-nums"
>
{formatTick(t.v, xFormat)}
{k === layout.xTicks.length - 1 && labels.xUnit ? ` ${labels.xUnit}` : ''}
</text>
))}
</g>
)}
{layout.dots.map((o) => {
const k = rankOf.get(o.i) ?? o.i
const dim = at >= 0 && at !== o.i
return (
<circle
key={data[o.i].label}
cx={0}
cy={0}
r={o.r}
fill={axes ? o.color : `color-mix(in oklab, ${o.color} 30%, var(--card))`}
stroke={axes ? 'var(--card)' : o.color}
strokeWidth={axes ? 2.5 : 1.5}
style={{
transform: `translate(${o.cx}px, ${o.cy}px) scale(${grown ? 1 : 0})`,
opacity: dim ? 0.28 : 1,
...motionFor(k),
}}
/>
)
})}
{layout.dots.map((o) => {
const t = layout.tags[o.i]
if (!t) return null
const k = rankOf.get(o.i) ?? o.i
const dim = at >= 0 && at !== o.i
return (
<text
key={data[o.i].label}
x={0}
y={0}
textAnchor={t.anchor}
className="fill-foreground text-[12px] font-medium"
style={{
transform: `translate(${t.x}px, ${t.y}px)`,
opacity: grown ? (dim ? 0.35 : 1) : 0,
...motionFor(k),
}}
>
{data[o.i].label}
</text>
)
})}
{tip && (
<circle
cx={0}
cy={0}
r={tip.r + 4}
fill="none"
stroke="var(--ring)"
strokeWidth={2}
style={{ transform: `translate(${tip.cx}px, ${tip.cy}px)`, opacity: keyboard ? 1 : 0, transition: 'none' }}
/>
)}
</svg>
)}
<ChartTooltip
open={!!tip}
x={tip ? tip.cx : 0}
y={tip ? (tip.cy - tip.r < 70 ? tip.cy + tip.r : tip.cy - tip.r) : 0}
below={tip ? tip.cy - tip.r < 70 : false}
bounds={W}
title={tipD ? (tipD.group ? `${tipD.label} · ${tipD.group}` : tipD.label) : ''}
value={tipD ? <TipValue value={tipD.value} format={format} note={labels.valueNote} /> : ''}
rows={tipRows}
/>
</div>
<span id={hintId} hidden>
{labels.hint}
</span>
<SrTable
caption={label}
head={[labels.item, ...(groups.length ? [labels.group] : []), ...(axes ? [labels.x, labels.y] : []), labels.value]}
rows={data.map((d) => {
const t = texts(d)
return [d.label, ...(groups.length ? [d.group ?? ''] : []), ...(axes ? [t.x, t.y] : []), t.value]
})}
/>
{region}
</div>
)
}
/** The tooltip figure with its unit and note set muted. */
function TipValue({ value, format, note }: { value: number; format?: ValueFormat; note?: string }) {
const p = valueParts(value, format)
const muted = 'font-medium text-[color-mix(in_oklab,var(--card)_60%,var(--foreground))]'
return (
<>
{p.sign}
{!p.unitAfter && <span className={muted}>{p.unit}</span>}
{p.whole}
{p.fraction}
{p.unitAfter && <span className={muted}> {p.unit}</span>}
{p.suffix}
{note && <span className={cn(muted, 'text-[13px] font-normal')}> {note}</span>}
</>
)
}
export default BubbleChartInstallieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/bubble-chart.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/bubble-chart