Packed Bubble Chart
A packed bubble chart shows category magnitudes as circles sized by value, where bubbles gravitate toward the center with a physics simulation and settle into place. Labels sit inside the larger bubbles, the layout adapts continuously to the container, and cluster tightness is tunable via the gravity options. Hovering a bubble highlights it and fades the others.
- Good for: comparing magnitudes across a single set of categories, showing relative size at a glance (revenue by product, headcount by team, traffic by source).
- Not great for: precise value comparison (use a bar chart), part-to-whole hierarchy (use a sunburst or treemap), or data that needs x/y coordinates (use a bubble plot).
Syntax
Use the following AML definition to add the Packed Bubble Chart to your custom chart library.
CustomChartDef packed_bubble_force {
label: 'Packed Bubble (Force)'
description: 'To show category magnitudes as gravity-clustered bubbles, with labels inside the larger bubbles.'
fields {
field dimension {
label: 'Dimension'
type: 'dimension'
sort {
apply_order: 1
direction: 'asc'
}
}
field value {
label: 'Value'
type: 'measure'
sort {
apply_order: 2
direction: 'desc'
}
}
}
options {
option gravity_x {
label: 'Horizontal gravity'
type: 'select'
options: [0.05, 0.1, 0.2, 0.3, 0.5]
default_value: 0.1
}
option gravity_y {
label: 'Vertical gravity'
type: 'select'
options: [0.05, 0.1, 0.2, 0.3, 0.5]
default_value: 0.2
}
option show_labels {
label: 'Show labels in bubbles'
type: 'toggle'
default_value: true
}
option color_scheme {
label: 'Color scheme'
type: 'select'
options: ['tableau10', 'category10', 'accent', 'dark2', 'paired', 'set2']
default_value: 'tableau10'
}
}
template: @vg {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"signals": [
{
"name": "width",
"init": "containerSize()[0] - 10",
"on": [{ "events": "window:resize", "update": "containerSize()[0] - 10" }]
},
{
"name": "height",
"init": "containerSize()[1] - 10",
"on": [{ "events": "window:resize", "update": "containerSize()[1] - 10" }]
},
{"name": "cx", "update": "width / 2"},
{"name": "cy", "update": "height / 2"},
{"name": "gravityX", "update": "@{options.gravity_x.value}"},
{"name": "gravityY", "update": "@{options.gravity_y.value}"},
{"name": "maxSize", "update": "(width * height * 0.55) / max(1, length(data('table')))"},
{
"name": "hovered",
"value": null,
"on": [
{"events": "@nodes:mouseover", "update": "datum.category"},
{"events": "@nodes:mouseout", "update": "null"}
]
}
],
"data": [
{
"name": "table",
"values": @{values},
"transform": [
{"type": "formula", "expr": "datum['@{fields.dimension.name}']", "as": "category"},
{"type": "formula", "expr": "datum['@{fields.value.name}']", "as": "amount"},
{"type": "filter", "expr": "datum.amount != null && datum.amount > 0"},
{
"type": "aggregate",
"groupby": ["category"],
"fields": ["amount"],
"ops": ["sum"],
"as": ["amount"]
}
]
}
],
"scales": [
{
"name": "size",
"type": "linear",
"zero": true,
"domain": {"data": "table", "field": "amount"},
"range": [16, {"signal": "maxSize"}]
},
{
"name": "color",
"type": "ordinal",
"domain": {"data": "table", "field": "category", "sort": true},
"range": {"scheme": @{options.color_scheme.value}}
}
],
"marks": [
{
"name": "nodes",
"type": "symbol",
"from": {"data": "table"},
"encode": {
"enter": {
"xfocus": {"signal": "cx"},
"yfocus": {"signal": "cy"}
},
"update": {
"size": {"scale": "size", "field": "amount"},
"fill": {"scale": "color", "field": "category"},
"fillOpacity": {
"signal": "hovered === null || hovered === datum.category ? 1 : 0.3"
},
"stroke": {"value": "white"},
"strokeWidth": {"value": 1.5},
"tooltip": {
"signal": "{'Category': datum.category, 'Value': format(datum.amount, ',')}"
}
}
},
"transform": [
{
"type": "force",
"iterations": 100,
"static": false,
"forces": [
{
"force": "collide",
"iterations": 2,
"radius": {"expr": "sqrt(datum.size) / 2 + 1"}
},
{"force": "center", "x": {"signal": "cx"}, "y": {"signal": "cy"}},
{"force": "x", "x": "xfocus", "strength": {"signal": "gravityX"}},
{"force": "y", "y": "yfocus", "strength": {"signal": "gravityY"}}
]
}
]
},
{
"type": "text",
"interactive": false,
"from": {"data": "nodes"},
"encode": {
"update": {
"x": {"field": "x"},
"y": {"field": "y"},
"align": {"value": "center"},
"baseline": {"value": "middle"},
"text": {"field": "datum.category"},
"fontSize": {"signal": "clamp(sqrt(datum.size) / 5.5, 8, 14)"},
"fontWeight": {"value": 500},
"fill": {"value": "white"},
"fillOpacity": {
"signal": "hovered === null || hovered === datum.datum.category ? 1 : 0.3"
},
"limit": {"signal": "sqrt(datum.size) * 0.95"},
"opacity": {
"signal": "@{options.show_labels.value} && sqrt(datum.size) / 2 > 16 ? 1 : 0"
}
}
}
}
]
};;
}
Required fields
A Packed Bubble Chart expects exactly two fields. Each row of input is one category whose value sets the bubble size.
| Field | Label | Type | Role |
|---|---|---|---|
dimension | Dimension | dimension | Category each bubble represents; shown as the bubble label. Sorted ascending (apply_order: 1). |
value | Value | measure | Magnitude that sets the bubble area. Sorted descending (apply_order: 2). |
Data requirements: The template sums duplicate categories and filters out null and non-positive values, so you don't need to pre-aggregate. Use positive values, since the template drops zero and negative rows before rendering.
Sample data:
| dimension | value |
|---|---|
| Search | 4200 |
| Direct | 3100 |
| Social | 2400 |
| 1500 | |
| Referral | 900 |
Options
Set these options to adjust the layout without editing the Vega template. The CustomChartDef block above declares each option's type and allowed values.
| Option | Default | Effect |
|---|---|---|
gravity_x | 0.1 | Horizontal pull toward the center. Higher values pack bubbles tighter left-to-right. |
gravity_y | 0.2 | Vertical pull toward the center. Higher values pack bubbles tighter top-to-bottom. |
show_labels | true | Whether category labels appear inside bubbles. Labels still only show on bubbles large enough to fit them. |
color_scheme | tableau10 | Ordinal color palette applied across categories. |
Known limitations
-
Positions are not quantitative. Bubbles settle by physics simulation, so their x/y location carries no meaning. Use a bubble plot when you need two numeric axes.
-
Labels show only on larger bubbles. Small bubbles omit their label even with
show_labelson, since the text would not fit inside the circle. -
Magnitudes are hard to compare precisely. Area-based sizing reads relative scale well but not exact differences. Use a bar chart when precise comparison matters.