BubbleChart
Points placed by two numbers and sized by a third, in coloured groups, labelled when big enough, with all three numbers on hover.
Demo
| Group | Point | GDP per person | Life expectancy | Population |
|---|---|---|---|---|
| Europe | France | $46,300 | 82.3 years | 68.2M |
| Europe | Germany | $52,700 | 81.2 years | 84.5M |
| Europe | Spain | $33,300 | 83.1 years | 48.4M |
| Europe | Poland | $22,100 | 78.5 years | 36.7M |
| Americas | United States | $80,400 | 77.5 years | 334.9M |
| Americas | Brazil | $10,000 | 75.8 years | 216.4M |
| Americas | Mexico | $13,900 | 75.1 years | 128.5M |
| Americas | Canada | $53,400 | 81.6 years | 40.1M |
| Asia | China | $12,600 | 78.6 years | 1,410.7M |
| Asia | India | $2,500 | 72 years | 1,428.6M |
| Asia | Japan | $33,800 | 84.7 years | 124.5M |
| Asia | Indonesia | $4,900 | 68.3 years | 277.5M |
| Point | Weeks late | Budget used | Team size |
|---|---|---|---|
| Atlas | 0 | 64% | 4 |
| Beacon | 2 | 91% | 9 |
| Comet | 5 | 118% | 14 |
| Delta | 1 | 48% | 3 |
| Ember | 3 | 102% | 6 |
A bubble's area, not its width, is in proportion to its size; the largest are drawn first.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/bubble_chart.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/bubble_chart.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/bubble_chart.qwe
view BubbleChartDemo() = <div.demo-stack> <BubbleChart label="Countries in 2023" show_label={true} x_label="GDP per person" x_prefix="$" y_label="Life expectancy" suffix=" years" size_label="Population" size_suffix="M" height={260} groups={[ { name: "Europe", points: [{ x: 46300, y: 82.3, size: 68.2, label: "France" }, { x: 52700, y: 81.2, size: 84.5, label: "Germany" }, { x: 33300, y: 83.1, size: 48.4, label: "Spain" }, { x: 22100, y: 78.5, size: 36.7, label: "Poland" }] }, { name: "Americas", points: [{ x: 80400, y: 77.5, size: 334.9, label: "United States" }, { x: 10000, y: 75.8, size: 216.4, label: "Brazil" }, { x: 13900, y: 75.1, size: 128.5, label: "Mexico" }, { x: 53400, y: 81.6, size: 40.1, label: "Canada" }] }, { name: "Asia", points: [{ x: 12600, y: 78.6, size: 1410.7, label: "China" }, { x: 2500, y: 72, size: 1428.6, label: "India" }, { x: 33800, y: 84.7, size: 124.5, label: "Japan" }, { x: 4900, y: 68.3, size: 277.5, label: "Indonesia" }] }]}/> <BubbleChart label="Projects" show_label={true} x_label="Weeks late" y_label="Budget used" suffix="%" size_label="Team size" zero={true} max_radius={20} height={180} points={[ { x: 0, y: 64, size: 4, label: "Atlas" }, { x: 2, y: 91, size: 9, label: "Beacon" }, { x: 5, y: 118, size: 14, label: "Comet" }, { x: 1, y: 48, size: 3, label: "Delta" }, { x: 3, y: 102, size: 6, label: "Ember" }]}/> <p.demo-note>A bubble's area, not its width, is in proportion to its size; the largest are drawn first.</p> </div>Source
ui/bubble_chart.qwe
// BubbleChart: points placed by two numbers, x across and y up, each a circle whose// area shows a third (population, revenue, team size); in groups, each in a colour// of its own. CSS only: hovering a bubble brings it forward and shows its name and// three numbers, and screen readers get them as a table.//// <BubbleChart label="Countries" x_label="GDP per person" y_label="Life expectancy" size_label="Population"// points={[{ x: 48000, y: 81, size: 67, label: "France" }, { x: 12000, y: 76, size: 213, label: "Brazil" }]}/>//// points: a list of { x, y, size, label } (label optional; shown on hover, and on the// bubble when it is big enough); or groups: a list of { name, points } (up to eight,// each in a colour of its own, with a legend). Points with a null x, y or size are// left out. The biggest bubble's radius is max_radius pixels (28), and the others'// areas are in proportion to their sizes; bubbles are drawn largest first so the small// ones stay on top. bubble_labels: a bubble's label written on it when it fits (true).// x_label, y_label and size_label: what the three numbers are ("x", "y", "Size").// x_prefix and x_suffix, prefix and suffix, size_prefix and size_suffix: around the x,// y and size values shown; decimals: digits after the point, always, instead of up to// two as needed. zero: starts both axes at 0. height: the plot's height in pixels// (280). label: what the chart shows; it names it for screen readers and captions// their table. show_label also writes it above the chart.import ui/theme.qwe use ui_clslet bubble_chart_css = css`.ui-bubc { --ui-bubc-1: light-dark(#2a78d6, #3987e5); --ui-bubc-2: light-dark(#eb6834, #d95926); --ui-bubc-3: light-dark(#1baf7a, #199e70); --ui-bubc-4: light-dark(#eda100, #c98500); --ui-bubc-5: light-dark(#e87ba4, #d55181); --ui-bubc-6: light-dark(#008300, #008300); --ui-bubc-7: light-dark(#6250d6, #9085e9); --ui-bubc-8: light-dark(#e34948, #e66767); display: grid; gap: 10px; align-content: start; min-width: 0; margin: 0; font: 12px/1.3 var(--ui-font); color: var(--ui-fg); }.ui-bubc-title { margin: 0; font-size: 14px; font-weight: 600; }.ui-bubc-s1 { --c: var(--ui-bubc-1); } .ui-bubc-s2 { --c: var(--ui-bubc-2); } .ui-bubc-s3 { --c: var(--ui-bubc-3); }.ui-bubc-s4 { --c: var(--ui-bubc-4); } .ui-bubc-s5 { --c: var(--ui-bubc-5); } .ui-bubc-s6 { --c: var(--ui-bubc-6); }.ui-bubc-s7 { --c: var(--ui-bubc-7); } .ui-bubc-s8 { --c: var(--ui-bubc-8); }.ui-bubc-legend { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; padding: 0; list-style: none; color: var(--ui-muted); font-size: 13px; }.ui-bubc-legend li { display: inline-flex; align-items: center; gap: 6px; }.ui-bubc-key { width: 10px; height: 10px; border-radius: 50%; background: var(--c); flex: none; }.ui-bubc-skey { width: 12px; height: 12px; border: 1.5px solid var(--ui-muted); border-radius: 50%; flex: none; }.ui-bubc-frame { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 8px; }.ui-bubc-ylabel { grid-column: 1 / -1; margin-bottom: 8px; color: var(--ui-muted); font-size: 11px; font-weight: 600; }.ui-bubc-y { position: relative; min-width: 24px; }.ui-bubc-y span { position: absolute; right: 0; transform: translateY(50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-bubc-plot { position: relative; }.ui-bubc-grid { position: absolute; background: color-mix(in srgb, var(--ui-border) 70%, transparent); }.ui-bubc-h { left: 0; right: 0; height: 1px; }.ui-bubc-v { top: 0; bottom: 0; width: 1px; }.ui-bubc-zero { background: var(--ui-strong); }.ui-bubc-pt { position: absolute; display: grid; place-items: center; width: var(--d); height: var(--d); border-radius: 50%; background: color-mix(in srgb, var(--c) 55%, transparent); box-shadow: inset 0 0 0 1.5px var(--c), 0 0 0 1px var(--ui-bg); transform: translate(-50%, -50%); transition: background-color .12s ease; }.ui-bubc-pt:hover { z-index: 10; background: var(--c); }.ui-bubc-name { max-width: 100%; overflow: hidden; color: var(--ui-fg); font-size: 11px; font-weight: 600; text-overflow: clip; white-space: nowrap; pointer-events: none; }.ui-bubc-tip { position: absolute; z-index: 20; bottom: calc(100% + 6px); left: 50%; display: none; min-width: 130px; padding: 7px 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); box-shadow: var(--ui-shadow); color: var(--ui-fg); font-size: 12px; transform: translateX(-50%); pointer-events: none; }.ui-bubc-pt:hover .ui-bubc-tip { display: block; }.ui-bubc-left .ui-bubc-tip { left: 0; transform: none; }.ui-bubc-right .ui-bubc-tip { left: auto; right: 0; transform: none; }.ui-bubc-tip strong { display: block; margin-bottom: 3px; font-weight: 600; white-space: nowrap; }.ui-bubc-tip p { display: flex; gap: 6px; margin: 2px 0 0; color: var(--ui-muted); white-space: nowrap; }.ui-bubc-tip b { margin-left: auto; padding-left: 12px; color: var(--ui-fg); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-bubc-x { position: relative; height: 16px; margin-top: 6px; }.ui-bubc-x span { position: absolute; top: 0; transform: translateX(-50%); color: var(--ui-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }.ui-bubc-x span:first-child { transform: none; }.ui-bubc-x span:last-child { transform: translateX(-100%); }.ui-bubc-xlabel { grid-column: 2; margin-top: 4px; color: var(--ui-muted); font-size: 11px; font-weight: 600; text-align: right; }@media (prefers-reduced-motion: reduce) { .ui-bubc-pt { transition: none; } }`// n (a whole number, 0 or more) with at least `width` digits.fn bubble_chart_pad(n: int, width: int) -> str { let s = str(n) len(s) < width ? repeat("0", width - len(s)) + s : s}// A whole number with commas: 1234567 is "1,234,567".fn bubble_chart_group(n: int) -> str = n < 1000 ? str(n) : bubble_chart_group(floor(n / 1000)) + "," + bubble_chart_pad(n % 1000, 3)// A value as it reads, with commas: `places` digits after the point, or up to two as// needed when that is null: 48210 is "48,210", 6350.5 is "6,350.5" ("6,350.50" with 2).// Worked out in whole numbers, so the server and the browser write the same text.fn bubble_chart_num(v: num, places: int? = null) -> str { let digits = places == null ? 2 : (places < 0 ? 0 : (places > 4 ? 4 : places)) let scale = [1, 10, 100, 1000, 10000][digits] let units = round(abs(v) * scale) let frac = units % scale let tail = digits == 0 || (places == null && frac == 0) ? "" : (places == null && frac % 10 == 0 ? `.${floor(frac / 10)}` : `.${bubble_chart_pad(frac, digits)}`) `${v < 0 && units > 0 ? "-" : ""}${bubble_chart_group(floor(units / scale))}${tail}`}// An axis tick, short: 1500 is "1.5k", 2000000 is "2M", 0.25 is "0.25".fn bubble_chart_short(v: num) -> str { let a = abs(v) let unit = a >= 1000000000 ? 1000000000 : (a >= 1000000 ? 1000000 : (a >= 1000 ? 1000 : 1)) let tenths = round(a * 10 / unit) let mark = unit == 1000000000 ? "B" : (unit == 1000000 ? "M" : "k") unit == 1 ? bubble_chart_num(v) : `${v < 0 ? "-" : ""}${bubble_chart_group(floor(tenths / 10))}${tenths % 10 != 0 ? `.${tenths % 10}` : ""}${mark}`}// A percentage for CSS (or a coordinate out of 100), to two decimals.fn bubble_chart_pct(n: num) -> str { let r = round(n * 100) / 100 r == floor(r) ? str(floor(r)) : str(r)}// The axis from lo to hi, widened to round ticks: about four steps of 1, 2, 2.5 or 5 x 10^n.fn bubble_chart_scale(lo: num, hi: num) -> map { let span = hi - lo == 0 ? (lo == 0 ? 1 : abs(lo)) : hi - lo let raw = span / 4 let mag = 1 while mag * 10 <= raw { mag = mag * 10 } while mag > raw { mag = mag / 10 } let r = raw / mag let step = (r <= 1 ? 1 : (r <= 2 ? 2 : (r <= 2.5 ? 2.5 : (r <= 5 ? 5 : 10)))) * mag let a = floor(lo / step + 0.000001) let b0 = ceil(hi / step - 0.000001) let b = b0 == a ? a + 1 : b0 { lo: a * step, hi: b * step, ticks: for i in range(b - a + 1) => (a + i) * step }}view BubbleChart(points: list? = null, groups: list? = null, x_label: str = "x", y_label: str = "y", size_label: str = "Size", x_prefix: str = "", x_suffix: str = "", prefix: str = "", suffix: str = "", size_prefix: str = "", size_suffix: str = "", decimals: int? = null, max_radius: int = 28, bubble_labels: bool = true, zero: bool = false, height: int = 280, label: str = "Bubble chart", show_label: bool = false, class: str? = null) { style global bubble_chart_css let sets0 = groups ?? [{ name: label, points: points ?? [] }] let sets = for g in sets0 => ({ name: g.name, points: g.points | filter((p) => p.x != null && p.y != null && p.size != null) }) let m = len(sets) let all_pts = flatten(for [j, g] in enumerate(sets) => g.points | map((p) => ({ p: p, j: j }))) let xs = all_pts | map((e) => e.p.x) let ys = all_pts | map((e) => e.p.y) let biggest = len(all_pts) == 0 ? 0 : max(all_pts | map((e) => abs(e.p.size))) let xlo = len(xs) == 0 ? 0 : min(xs) let xhi = len(xs) == 0 ? 0 : max(xs) let ylo = len(ys) == 0 ? 0 : min(ys) let yhi = len(ys) == 0 ? 0 : max(ys) let sx = bubble_chart_scale(zero && xlo > 0 ? 0 : xlo, zero && xhi < 0 ? 0 : xhi) let sy = bubble_chart_scale(zero && ylo > 0 ? 0 : ylo, zero && yhi < 0 ? 0 : yhi) let px = (v) => (v - sx.lo) * 100 / (sx.hi - sx.lo) let py = (v) => (v - sy.lo) * 100 / (sy.hi - sy.lo) // The area in proportion to the size: the radius to its square root. let radius = (v) => biggest > 0 ? max_radius * sqrt(abs(v) / biggest) : 0 let xf = (v) => `${v < 0 ? "-" : ""}${x_prefix}${bubble_chart_num(abs(v), decimals)}${x_suffix}` let yf = (v) => `${v < 0 ? "-" : ""}${prefix}${bubble_chart_num(abs(v), decimals)}${suffix}` let zf = (v) => `${v < 0 ? "-" : ""}${size_prefix}${bubble_chart_num(abs(v), decimals)}${size_suffix}` <figure class={ui_cls(["ui-bubc", class])} aria-label={label}> {show_label && <figcaption.ui-bubc-title>{label}</figcaption>} <ul.ui-bubc-legend aria-hidden="true"> {m > 1 && (for [j, g] in enumerate(sets) => <li><span class={`ui-bubc-key ui-bubc-s${j % 8 + 1}`}></span>{g.name}</li>)} <li><span.ui-bubc-skey></span>Area: {lower(size_label)}</li> </ul> <div.ui-bubc-frame aria-hidden="true"> <span.ui-bubc-ylabel>{y_label}</span> <div.ui-bubc-y style={`height: ${height}px`}> {for t in sy.ticks => <span style={`bottom: ${bubble_chart_pct(py(t))}%`}>{t < 0 ? "-" : ""}{prefix}{bubble_chart_short(abs(t))}</span>} </div> <div.ui-bubc-plot style={`height: ${height}px`}> {for t in sy.ticks => <span class={ui_cls(["ui-bubc-grid ui-bubc-h", t == 0 && "ui-bubc-zero"])} style={`bottom: ${bubble_chart_pct(py(t))}%`}></span>} {for t in sx.ticks => <span class={ui_cls(["ui-bubc-grid ui-bubc-v", t == 0 && "ui-bubc-zero"])} style={`left: ${bubble_chart_pct(px(t))}%`}></span>} {for e in sort_by(all_pts, (e) => -abs(e.p.size)) => { let p = e.p let r = radius(p.size) let left = px(p.x) <span class={ui_cls(["ui-bubc-pt", `ui-bubc-s${e.j % 8 + 1}`, left < 20 && "ui-bubc-left", left > 80 && "ui-bubc-right"])} style={`left: ${bubble_chart_pct(left)}%; top: ${bubble_chart_pct(100 - py(p.y))}%; --d: ${bubble_chart_pct(2 * (r < 3 ? 3 : r))}px`} data-size={str(p.size)}> {bubble_labels && p.label != null && len(str(p.label)) * 6.5 <= 2 * r - 6 && <span.ui-bubc-name>{p.label}</span>} <span.ui-bubc-tip> {(p.label != null || m > 1) && <strong>{p.label ?? sets[e.j].name}{p.label != null && m > 1 ? ` · ${sets[e.j].name}` : ""}</strong>} <p>{x_label}<b>{xf(p.x)}</b></p> <p>{y_label}<b>{yf(p.y)}</b></p> <p>{size_label}<b>{zf(p.size)}</b></p> </span> </span> }} </div> <span></span> <div.ui-bubc-x>{for t in sx.ticks => <span style={`left: ${bubble_chart_pct(px(t))}%`}>{t < 0 ? "-" : ""}{x_prefix}{bubble_chart_short(abs(t))}</span>}</div> <span.ui-bubc-xlabel>{x_label}</span> </div> <table.ui-sr> <caption>{label}</caption> <thead><tr> {m > 1 && <th scope="col">Group</th>} <th scope="col">Point</th><th scope="col">{x_label}</th><th scope="col">{y_label}</th><th scope="col">{size_label}</th> </tr></thead> <tbody> {for g in sets => for [k, p] in enumerate(g.points) => <tr> {m > 1 && <td>{g.name}</td>} <th scope="row">{p.label ?? str(k + 1)}</th><td>{xf(p.x)}</td><td>{yf(p.y)}</td><td>{zf(p.size)}</td> </tr>} </tbody> </table> </figure>}Related
AreaChartFilled areas for one or more series, overlapping or stacked into a total, with a hover crosshair listing every value.6 views · 8 downloadsBarChartBars up or across, one series or several grouped or stacked, on round ticks, with values on hover and a table for screen readers.7 views · 7 downloadsBollingerChartPrices with Bollinger bands of a period and a number of deviations, closes outside them marked, as a line or candles.7 views · 5 downloadsBoxPlotQuartiles, median, whiskers and outliers for each group, worked out from the raw values, with the five numbers on hover.5 views · 7 downloadsBrushChartA large chart of a range with an overview below, where a window dragged or moved by keys picks the range shown.6 views · 5 downloadsBulletChartMeasures as thin bars against a target mark over grey bands from poor to good, a row each, with the band named on hover.5 views · 7 downloads
Thread
Thread 0 posts
On BubbleChart: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.