Charts

BubbleChart

Points placed by two numbers and sized by a third, in coloured groups, labelled when big enough, with all three numbers on hover.

#chart#data#no-js

Demo

Preview
Countries in 2023
Countries in 2023
GroupPointGDP per personLife expectancyPopulation
EuropeFrance$46,30082.3 years68.2M
EuropeGermany$52,70081.2 years84.5M
EuropeSpain$33,30083.1 years48.4M
EuropePoland$22,10078.5 years36.7M
AmericasUnited States$80,40077.5 years334.9M
AmericasBrazil$10,00075.8 years216.4M
AmericasMexico$13,90075.1 years128.5M
AmericasCanada$53,40081.6 years40.1M
AsiaChina$12,60078.6 years1,410.7M
AsiaIndia$2,50072 years1,428.6M
AsiaJapan$33,80084.7 years124.5M
AsiaIndonesia$4,90068.3 years277.5M
Projects
Projects
PointWeeks lateBudget usedTeam size
Atlas064%4
Beacon291%9
Comet5118%14
Delta148%3
Ember3102%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.qwe
main.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>}
Thread
78Useful? Like it, or add to its thread below.

Thread 0 posts

On BubbleChart: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.