BubblePacking
Force-directed circle packing view.
Live demo
01Technology market overview
Colour schemes
Static placement (force simulation off)
Implementation
02<script>
import BubblePacking from '$lib/components/BubblePacking.svelte';
β
const bubbleData = [
{ id: 'aws', label: 'AWS', value: 80, group: 'Cloud' },
{ id: 'azure', label: 'Azure', value: 65, group: 'Cloud' }
];
</script>
β
<BubblePacking
data={bubbleData}
width={700}
height={500}
onBubbleClick={(bubble) => console.log(bubble)}
/>BubblePacking lays bubbles out with iterative collision resolution: bubbles are pulled toward the centre while colliding pairs are pushed apart. Sizes use square-root scaling so bubble area (not radius) is proportional to value. Disable force simulation via useForce={false} for instant placement when you have a small dataset and need a static layout.
Logic explainer
03What Does It Do? (Plain English)
A bowl of bubbles where each bubble's size encodes a value. Pass it [{ id, label, value, group? }, β¦] and the component runs a small physics simulation to settle the bubbles into a tidy, non-overlapping cluster inside a circular container. Hover any bubble for a tooltip, click for a callback, and watch them colour-code automatically by their group field.
It's the same family of visualisation as D3's pack layout, except this implementation is a 150-iteration force simulation rather than a recursive geometric pack. The trade-off: the result isn't perfectly tightly packed (small gaps remain), but the algorithm is dependency-free, tolerates incremental data updates, and produces an organic-looking cluster that reads as "data" rather than "diagram".
Use cases: budget breakdowns, vocabulary frequency, market-share snapshots, "skills I have" portfolio plots, anything where relative magnitudes matter more than precise comparisons.
How It Works (Pseudo-Code)
state:
tooltip = { visible: false, x: 0, y: 0, text: '' }
hoveredBubble = null
derive packedBubbles (from data, width, height, padding, useForce):
if data is empty: return []
// 1. assign colours by group
groups = unique(data.map(d => d.group))
groupColorMap = groups.map((g, i) => [g, colorScheme[i % colorScheme.length]])
// 2. value β radius using sqrt scaling (area is proportional to value)
maxValue = max(data.map(d => d.value))
containerRadius = min(width, height) / 2
maxBubbleRadius = containerRadius * 0.35
for each item:
rawR = max(10, sqrt(value / maxValue) Γ maxBubbleRadius)
// 3. global scale-to-fit so total area β€ 85% of container area
totalArea = sum(Ο Γ rΒ²)
availableArea = Ο Γ containerRadiusΒ² Γ 0.85
scaleFactor = sqrt(availableArea / totalArea)
for each bubble:
r = max(10, rawR Γ min(1, scaleFactor))
// 4. seed positions near centre with small random jitter
// 5. sort largest-first; place largest at exact centre
// 6. force simulation
iterations = useForce ? 150 : 50
for iter from 0 to iterations - 1:
alpha = 1 - iter / iterations
for each bubble i:
// attractive force toward centre
fx, fy += unit_vector_to_centre Γ 0.5 Γ alpha
// collision avoidance against every other bubble
for each other j β i:
if dist(i, j) < r_i + r_j + padding:
push apart along the iβj vector by (overlap Γ 0.5 Γ alpha)
// soft container walls
if bubble would cross boundary: push back inward
bubble.x += fx; bubble.y += fy
return bubbles
render:
<svg>
{#each packedBubbles}
<g transform="translate(x, y)">
<circle r fill role="button" with hover/click/keyboard handlers />
{#if showLabels && r >= labelThreshold} <text>truncated label</text> {/if}
</g>
</svg>
{#if tooltip.visible} absolute-positioned div {/if}
{#if groups.length > 1} <legend> {/if}The force simulation is deterministic-ish: the random initial jitter means two runs can produce slightly different final positions. If you need bit-stable layouts (e.g., for visual regression tests), seed Math.random upstream or set useForce: false to get a near-static placement.
Core Concept: Why βvalue, And Why A Force Simulation
Two design choices define how this component reads.
Square-root scaling: area, not diameter
Humans compare circle sizes by area, not radius. If you want a bubble with value 4 to look "four times" the bubble with value 1, you need:
area(4) = 4 Γ area(1)
Ο Γ r4Β² = 4 Γ Ο Γ r1Β²
r4 = 2 Γ r1 β square root of the value ratioSo the radius formula is:
radius = β(value / maxValue) Γ maxBubbleRadiusThe naive radius = (value / maxValue) Γ maxBubbleRadius gives 4-vs-1 a sixteen-times area ratio, dramatically over-emphasising the larger bubble. The square root keeps the visual ratio honest.
There's also a max(10, ...) floor β bubbles smaller than 10 px are unreadable, so we lift everything to that minimum even if their values would otherwise produce something tiny. This sacrifices strict proportionality at the bottom of the range in exchange for hover-targetable circles.
Force-directed packing
D3's d3-pack is a recursive enclosure algorithm: it computes the smallest enclosing circle for groups of bubbles and nests the result. It's perfect for hierarchical data and produces tight packing. We don't use it for two reasons: (a) the implementation is ~600 lines of geometry; (b) the result looks too tidy for non-hierarchical data.
The force-directed approach treats each bubble as a particle with three forces:
F_centre = 0.5 Γ alpha Γ unit_vector(bubble β centre)
F_collision = 0.5 Γ alpha Γ overlap Γ unit_vector(bubble β other) // for each overlapping neighbour
F_walls = 0.5 Γ distance_outside_bounds // when crossing the container edgeThe alpha = 1 - iter / iterations annealing factor decays the forces over the simulation: early iterations make big moves to break gross overlaps; later iterations make small adjustments to settle into local stability. This is the same idea as simulated annealing β start hot, cool down.
The collision pass is O(nΒ²) per iteration: every bubble checks every other. With iterations = 150 and n = 50 bubbles, that's 150 Γ 50 Γ 50 = 375 000 distance calculations. Each is a sqrt and a few additions β trivial. At n = 200 it's 6 million; still fine on a modern machine but you can feel the layout pause. See Performance for guidance.
The 0.85 area-fit factor (availableArea = Ο Γ RΒ² Γ 0.85) leaves ~15 % whitespace inside the container, which is the magic number where bubbles look "comfortably arranged" rather than "sardine-packed". Tighter packing produces visual stress; looser packing makes the container feel empty.
Performance
Computational cost dominates rendering cost β DOM is cheap, simulation is not.
- n β€ 50 bubbles: Layout completes in <16 ms (one frame). No perceptible lag even on
datachange. - n = 50β200: Layout takes 50β300 ms. Set
useForce: false(drops iterations to 50) if you can tolerate a less-settled look. - n > 200: The O(nΒ²) collision pass becomes the bottleneck. The component blocks the main thread during layout. If you need this scale, consider quad-tree spatial indexing (BarnesβHut style) or move the simulation off-thread.
The render is a single <svg> with n <g> elements. SVG handles a few hundred elements comfortably; for >1 000 elements you'd want to switch to canvas, but you'd hit the simulation wall first.
There is no animation between layouts β the simulation runs synchronously when data, width, or height changes, and the new positions appear immediately. Bubbles that were on screen don't smoothly transition to their new spots; they teleport. This is intentional: animating an unstable physics result would flicker. If you want enter/exit animations on the bubbles themselves, wrap the <g> elements in a Svelte transition.
prefers-reduced-motion disables the hover stroke transition (the only CSS animation in the component). The simulation itself runs the same regardless β it's a layout calculation, not an animation.
State Flow Diagram
βββββββββββββββββββββββββ
β empty (data === []) β
β packedBubbles = [] β
ββββββββββββ¬βββββββββββββ
β data prop set
βΌ
βββββββββββββββββββββββββ
β layout running β $derived block runs
β 150 iterations β (synchronous, blocking)
ββββββββββββ¬βββββββββββββ
β
βΌ
βββββββββββββββββββββββββ
β rendered β
β bubbles in DOM β
ββββββββββββ¬βββββββββββββ
β
βββββββββββββββββββΌββββββββββββββββββ¬ββββββββββββββββββββ
β hover β click β touchstart β data prop changes
βΌ βΌ βΌ βΌ
ββββββββββββ ββββββββββββββββββ βββββββββββββββββββ ββββββββββββββββ
β tooltip β β onBubbleClick β β first tap: β β re-layout β
β visible β β fired β β show tooltip β β runs again β
ββββββ¬ββββββ ββββββββββββββββββ β second tap: β β bubbles β
β mouseleave β click + hide β β teleport β
βΌ βββββββββββββββββββ ββββββββββββββββ
ββββββββββββ
β tooltip β
β hidden β
ββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
data |
BubbleItem[] |
[] |
Each item: { id, label, value, color?, group? }. value drives radius; group drives default colour. |
width |
number |
600 |
SVG width in pixels. |
height |
number |
600 |
SVG height in pixels. |
padding |
number |
3 |
Pixel gap between bubble edges in the collision pass. |
colorScheme |
string[] |
BUBBLE_COLOR_SCHEME |
Palette assigned to groups in encounter order. Used when item.color is not set. |
showLabels |
boolean |
true |
Render the bubble label inside large bubbles. |
labelThreshold |
number |
20 |
Minimum radius (px) for a label to render. Smaller bubbles stay quiet. |
useForce |
boolean |
true |
true = 150 iterations (settled); false = 50 iterations (faster, less tidy). |
onBubbleClick |
(bubble: BubbleItem) => void |
undefined |
Fires on click and Enter/Space on a focused bubble. |
onBubbleHover |
(bubble: BubbleItem | null) => void |
undefined |
Fires with the hovered bubble's data, or null on leave. |
tooltipFormatter |
(bubble: BubbleItem) => string |
undefined |
Custom tooltip text. Defaults to "{label}: {value.toLocaleString()}". |
class |
string |
'' |
Extra classes on the container. |
Edge Cases
| Situation | Behaviour |
|---|---|
data === [] |
Renders empty SVG and no legend. No errors. |
| Single bubble | Placed at the exact centre at maxBubbleRadius (with 10 px floor). |
| All values identical | Every bubble gets the same radius; force simulation produces a hexagonal-packing-like pattern. |
value <= 0 for one item |
Square-root of zero is zero; the 10 px floor kicks in. The bubble is small but visible. |
Duplicate ids |
Svelte's {#each (id)} will warn and may misbehave on re-render. Always provide unique IDs. |
| Total bubble area exceeds container | Global scaleFactor shrinks every bubble proportionally so they fit at 85 % container area. Relative sizes are preserved. |
width or height zero |
containerRadius is 0, all radii floor to 10 px, simulation runs but looks broken. Provide non-zero dimensions. |
n > 200 bubbles |
O(nΒ²) collision pass makes layout perceptibly slow. Use useForce: false to drop to 50 iterations. |
| Window resize | Parent must pass new width/height; the $derived re-runs the simulation and bubbles teleport to new positions. |
| Mobile touch | First tap shows the tooltip and fires onBubbleHover; second tap on the same bubble fires onBubbleClick and clears the tooltip. Tapping the SVG background dismisses the tooltip. |
prefers-reduced-motion: reduce |
CSS hover transition disabled. Simulation still runs. |
Dependencies
- Svelte 5.x β
$state,$derivedfor the layout pipeline. svelte/reactivityβSvelteSet,SvelteMapfor group enumeration and colour mapping.- Zero external dependencies. The packing algorithm, colour assignment, label truncation, and tooltip positioning are all hand-rolled.
The decision to skip D3 here is deliberate: D3's d3-hierarchy adds ~50 KB and its API expects a hierarchical input. For flat lists with optional grouping, the force simulation in this component is simpler and ships smaller.
File Structure
src/lib/components/BubblePacking.svelte # implementation
src/lib/components/BubblePacking.test.ts # unit tests
src/lib/components/BubblePacking.md # this file
src/routes/bubblepacking/+page.svelte # demo page
src/lib/types.ts # BubbleItem, BubblePackingProps
src/lib/constants.ts # BUBBLE_COLOR_SCHEME, sample fixturesAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
data | BubbleItem[] | required | Bubbles with id, label, value and optional group/color. |
width / height | number | 600 | SVG dimensions in pixels. |
padding | number | 3 | Gap between bubbles. |
colorScheme | string[] | Tableau10 | Hex palette mapped per group. |
showLabels / labelThreshold | boolean / number | true / 20 | Hide labels on bubbles smaller than the threshold radius. |
useForce | boolean | true | Disable to use the static initial placement. |
onBubbleClick / onBubbleHover | (b) => void | β | Interaction callbacks. |
tooltipFormatter | (b) => string | β | Custom function returning the tooltip body text. |