Skip to main content

Packed Bubble Chart

reporting-custom-chart/packed_bubble

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.

FieldLabelTypeRole
dimensionDimensiondimensionCategory each bubble represents; shown as the bubble label. Sorted ascending (apply_order: 1).
valueValuemeasureMagnitude 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:

dimensionvalue
Search4200
Direct3100
Social2400
Email1500
Referral900

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.

OptionDefaultEffect
gravity_x0.1Horizontal pull toward the center. Higher values pack bubbles tighter left-to-right.
gravity_y0.2Vertical pull toward the center. Higher values pack bubbles tighter top-to-bottom.
show_labelstrueWhether category labels appear inside bubbles. Labels still only show on bubbles large enough to fit them.
color_schemetableau10Ordinal 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_labels on, 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.


Open Markdown
Let us know what you think about this document :)