feat(scatter): add bubble variant for three-value comparisons (#141)

* feat(scatter): add bubble variant for three-value comparisons

Bubble chart as a Scatter variant per the issue #62 triage: x and y on
shared zero-based linear scales, bubble AREA proportional to the third
value (r = K*sqrt(size)), one accent bubble, ink-opacity ramp as ink-mass
compensation, largest-first paint order.

scripts/verify-bubble.py derives both axis scales and the area constant
from the set itself (Theil-Sen, leave-one-out) and holds every bound
label and tick against the mark it describes; test-verify-bubble.py
exercises both polarities. Bindings are bubble-specific (data-size,
data-name, data-tick) rather than data-series, so verify-slopegraph.py
never claims a bubble file and neither checker needs scope carve-outs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bubble): report a bubble whose peers cannot describe a scale

A bubble holding the only distinct value on an axis was skipped by the
leave-one-out residual test — its own fit is degenerate, and the full-set
fit passes through wherever it was drawn, so a wrong coordinate shipped
clean. Report it as unverifiable instead of skipping it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump to 2.6.3 and register bubble gates

Ridgeline (#143) landed on 2.6.2, colliding with this branch's bump, so
re-bump past it. Also add the bubble gate commands to the maintainer
policy's local_commands, matching what ridgeline registered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: bump to 2.6.5

The mermaid fix (#131) landed on 2.6.4, colliding with this branch's
bump again. Re-bump past it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
0xDarkMatter
2026-08-21 17:26:23 -05:00
committed by GitHub
co-authored by 0xDarkMatter Claude Fable 5
parent c79585edb4
commit 648c2a5978
14 changed files with 1922 additions and 6 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ The pattern owns semantic primitives and its tighter budget; the type owns layou
| Part-of-whole where the relative sizes are the story | **Treemap** | [type-treemap.md](references/type-treemap.md) |
| Continuous trends over time, change between exactly two states (slopegraph), or one distribution per series (ridgeline) | **Line chart** | [type-line.md](references/type-line.md) |
| Tasks and phases on a timeline | **Gantt** | [type-gantt.md](references/type-gantt.md) |
| Distribution and correlation between two variables | **Scatter plot** | [type-scatter.md](references/type-scatter.md) |
| Distribution and correlation between two variables, or three with area-sized marks (bubble) | **Scatter plot** | [type-scatter.md](references/type-scatter.md) |
| End-to-end data stack on a container cluster | **High-Level** | [type-high-level.md](references/type-high-level.md) |
| Multi-actor sequential process with data handoffs | **Process** | [type-process.md](references/type-process.md) |
| Multi-tier data storage with quality levels and access policies | **Medallion** | [type-medallion.md](references/type-medallion.md) |
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>p95 vs error rate, sized by traffic · Bubble · dark</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-paper: #2d3142;
--color-ink: #f5f5f5;
--color-muted: #bfc0c0;
--color-accent: #f08a59;
--font-sans: 'Geist', system-ui, sans-serif;
--font-serif: 'Instrument Serif', serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
}
body { font-family: var(--font-sans); background: var(--color-paper); color: var(--color-ink); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; }
.frame { max-width: 1200px; width: 100%; }
.eyebrow { font-family: var(--font-mono); font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.5rem; }
h1 { font-family: var(--font-serif); font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; }
svg { width: 100%; min-width: 760px; display: block; }
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Bubble · Diagram Design</p>
<h1>The biggest risk is not the slowest service</h1>
<!-- X: 0-500ms p95 over 880px, x = 80 + 1.76·ms. Y: 0-4% error rate over
380px, y = 420 - 95·pct. AREA encodes requests per second: r = 1.4·√v,
so area = π·1.96·v, exactly proportional. scripts/verify-bubble.py
derives all three maps from the values each bubble declares and flags
any bubble that disagrees with its peers. -->
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="bubble-dark-title bubble-dark-desc">
<title id="bubble-dark-title">The biggest risk is not the slowest service</title>
<desc id="bubble-dark-desc">Bubble chart of nine services by p95 latency and error rate on zero-based axes, bubble area proportional to requests per second; Payments carries near-peak traffic at the highest error rate.</desc>
<defs>
<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" fill="rgba(245,245,245,0.10)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="#2d3142"/>
<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/>
<!-- Axis captions -->
<text transform="rotate(-90 24 230)" x="24" y="230" fill="#bfc0c0" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">ERROR RATE, %</text>
<text x="520" y="492" fill="#bfc0c0" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">P95 LATENCY, MS</text>
<!-- Gridlines, same positions as the parent scatter -->
<line x1="80" y1="325" x2="960" y2="325" stroke="rgba(245,245,245,0.08)" stroke-width="0.8"/>
<line x1="80" y1="230" x2="960" y2="230" stroke="rgba(245,245,245,0.08)" stroke-width="0.8"/>
<line x1="80" y1="135" x2="960" y2="135" stroke="rgba(245,245,245,0.08)" stroke-width="0.8"/>
<line x1="80" y1="40" x2="960" y2="40" stroke="rgba(245,245,245,0.06)" stroke-width="0.8"/>
<line x1="256" y1="40" x2="256" y2="420" stroke="rgba(245,245,245,0.08)" stroke-width="0.8"/>
<line x1="432" y1="40" x2="432" y2="420" stroke="rgba(245,245,245,0.08)" stroke-width="0.8"/>
<line x1="608" y1="40" x2="608" y2="420" stroke="rgba(245,245,245,0.08)" stroke-width="0.8"/>
<line x1="784" y1="40" x2="784" y2="420" stroke="rgba(245,245,245,0.08)" stroke-width="0.8"/>
<!-- Axes: both start at zero. A bubble's position is read against the
origin, so a truncated axis here shifts every bubble's story. -->
<line x1="80" y1="40" x2="80" y2="420" stroke="rgba(245,245,245,0.25)" stroke-width="1"/>
<line x1="80" y1="420" x2="960" y2="420" stroke="rgba(245,245,245,0.25)" stroke-width="1"/>
<!-- Tick labels are BOUND: data-tick names the axis, data-value the number
the tick claims to sit at. verify-bubble.py cross-checks each against
the scale the bubbles themselves describe, so the printed axis and
the drawn positions cannot drift apart. -->
<text data-tick="y" data-value="1" x="72" y="329" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">1</text>
<text data-tick="y" data-value="2" x="72" y="234" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">2</text>
<text data-tick="y" data-value="3" x="72" y="139" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">3</text>
<text data-tick="y" data-value="4" x="72" y="44" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">4</text>
<text data-tick="y" data-value="0" x="72" y="424" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">0</text>
<text data-tick="x" data-value="0" x="80" y="440" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">0</text>
<text data-tick="x" data-value="100" x="256" y="440" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">100</text>
<text data-tick="x" data-value="200" x="432" y="440" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">200</text>
<text data-tick="x" data-value="300" x="608" y="440" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">300</text>
<text data-tick="x" data-value="400" x="784" y="440" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">400</text>
<text data-tick="x" data-value="500" x="960" y="440" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">500</text>
<!-- ─── BUBBLES, drawn LARGEST FIRST so a small bubble can never be buried
under a big one. AREA carries the third value, never radius: a radius
proportional to traffic would show a 6x service as 36x the ink. The
fill ramp runs faintest-on-largest — it compensates ink mass so small
bubbles stay visible, it is NOT a fourth encoding, and the legend says
which end is which in skin-neutral terms. Each bubble sits on a paper
underlay so gridlines do not show through the translucent fill. -->
<circle cx="229.6" cy="372.5" r="42" fill="#2d3142"/>
<circle data-name="Gateway" data-x="85" data-y="0.5" data-size="900" cx="229.6" cy="372.5" r="42" fill="rgba(245,245,245,0.14)" stroke="#bfc0c0" stroke-width="1"/>
<!-- Payments FOCAL — and it is the risk, not the winner: near-peak traffic
riding the worst error rate. One accent bubble max; every other
service stays on the ink ramp. -->
<circle cx="537.6" cy="154" r="38.6" fill="#2d3142"/>
<circle data-name="Payments" data-x="260" data-y="2.8" data-size="760" cx="537.6" cy="154" r="38.6" fill="rgba(240,138,89,0.15)" stroke="#f08a59" stroke-width="1.2"/>
<circle cx="326.4" cy="334.5" r="34.9" fill="#2d3142"/>
<circle data-name="Auth" data-x="140" data-y="0.9" data-size="620" cx="326.4" cy="334.5" r="34.9" fill="rgba(245,245,245,0.17)" stroke="#bfc0c0" stroke-width="1"/>
<circle cx="396.8" cy="353.5" r="32.5" fill="#2d3142"/>
<circle data-name="Catalog" data-x="180" data-y="0.7" data-size="540" cx="396.8" cy="353.5" r="32.5" fill="rgba(245,245,245,0.20)" stroke="#bfc0c0" stroke-width="1"/>
<circle cx="643.2" cy="315.5" r="30.7" fill="#2d3142"/>
<circle data-name="Search" data-x="320" data-y="1.1" data-size="480" cx="643.2" cy="315.5" r="30.7" fill="rgba(245,245,245,0.23)" stroke="#bfc0c0" stroke-width="1"/>
<circle cx="484.8" cy="287" r="25.4" fill="#2d3142"/>
<circle data-name="Media" data-x="230" data-y="1.4" data-size="330" cx="484.8" cy="287" r="25.4" fill="rgba(245,245,245,0.26)" stroke="#bfc0c0" stroke-width="1"/>
<circle cx="819.2" cy="268" r="20.3" fill="#2d3142"/>
<circle data-name="Recommender" data-x="420" data-y="1.6" data-size="210" cx="819.2" cy="268" r="20.3" fill="rgba(245,245,245,0.29)" stroke="#bfc0c0" stroke-width="1"/>
<circle cx="247.2" cy="211" r="17.1" fill="#2d3142"/>
<circle data-name="Notifications" data-x="95" data-y="2.2" data-size="150" cx="247.2" cy="211" r="17.1" fill="rgba(245,245,245,0.32)" stroke="#bfc0c0" stroke-width="1"/>
<circle cx="889.6" cy="334.5" r="10.8" fill="#2d3142"/>
<circle data-name="Reports" data-x="460" data-y="0.9" data-size="60" cx="889.6" cy="334.5" r="10.8" fill="rgba(245,245,245,0.35)" stroke="#bfc0c0" stroke-width="1"/>
<!-- Labels: the focal bubble plus the two poles of the area story — the
giant that is healthy and the sliver that is slow. Each label is
bound to its bubble by data-name, so verify-bubble.py can prove it
sits nearer its own bubble than anyone else's. -->
<rect x="506" y="99" width="64" height="12" rx="2" fill="#2d3142"/>
<text data-name="Payments" data-role="label" x="538" y="108" fill="#f5f5f5" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">PAYMENTS</text>
<rect x="198" y="316" width="64" height="12" rx="2" fill="#2d3142"/>
<text data-name="Gateway" data-role="label" x="230" y="325" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">GATEWAY</text>
<rect x="858" y="347" width="64" height="12" rx="2" fill="#2d3142"/>
<text data-name="Reports" data-role="label" x="890" y="356" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">REPORTS</text>
<!-- Legend -->
<line x1="40" y1="462" x2="960" y2="462" stroke="rgba(245,245,245,0.10)" stroke-width="0.8"/>
<text x="40" y="478" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.18em">LEGEND</text>
<text x="960" y="478" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.06em" text-anchor="end">P95 VS ERROR RATE, BUBBLE AREA ∝ REQUESTS PER SECOND · BOTH AXES START AT ZERO · ILLUSTRATIVE FIGURES, NOT MEASURED SERVICES</text>
<circle cx="52" cy="490" r="6" fill="rgba(240,138,89,0.15)" stroke="#f08a59" stroke-width="1.2"/>
<text x="68" y="494" fill="#bfc0c0" font-size="8.5" font-family="'Geist', sans-serif">Payments · focal, highest error rate at near-peak traffic</text>
<circle cx="660" cy="490" r="5" fill="rgba(245,245,245,0.23)" stroke="#bfc0c0" stroke-width="1"/>
<text x="676" y="494" fill="#bfc0c0" font-size="8.5" font-family="'Geist', sans-serif">Service · faintest fill is the largest bubble</text>
</svg>
</div>
</body>
</html>
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>p95 vs error rate, sized by traffic · Bubble</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --color-paper:#f5f5f5; --color-paper-2:#ececec; --color-ink:#2d3142; --color-muted:#4f5d75; --color-soft:#7a8399; --color-rule:rgba(45,49,66,0.12); --color-accent:#eb6c36; --font-sans:'Geist',system-ui,sans-serif; --font-serif:'Instrument Serif',serif; --font-mono:'Geist Mono',ui-monospace,monospace; }
body { font-family: var(--font-sans); background: var(--color-paper); min-height: 100vh; padding: 3rem 2rem; color: var(--color-ink); }
.container { max-width: 1200px; margin: 0 auto; }
.header { margin-bottom: 2.5rem; }
.header-eyebrow { font-family: var(--font-mono); font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.75rem; }
h1 { font-family: var(--font-serif); font-size: clamp(1.75rem, 3vw + 1rem, 2.5rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 0.5rem; }
.subtitle { font-size: 1rem; line-height: 1.55; color: var(--color-muted); max-width: 58ch; }
.diagram-container { background: var(--color-paper-2); border-radius: 8px; border: 1px solid var(--color-rule); padding: 1.5rem; overflow-x: auto; }
svg { width: 100%; min-width: 760px; display: block; }
.cards { display: grid; grid-template-columns: 1.1fr 1fr 0.9fr; gap: 1rem; margin-top: 1.5rem; }
@media (max-width: 720px) { .cards { grid-template-columns: 1fr; } }
.card { background: #fff; border-radius: 6px; border: 1px solid var(--color-rule); padding: 1.25rem; }
.card .eyebrow { font-family: var(--font-mono); font-size: 0.5rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.5rem; }
.card-header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.875rem; padding-bottom: 0.875rem; border-bottom: 1px solid rgba(45,49,66,0.08); }
.card-dot { width: 7px; height: 7px; border-radius: 50%; }
.card-dot.coral { background: var(--color-accent); }
.card-dot.ink-strong { background: rgba(45,49,66,0.80); }
.card-dot.ink-light { background: rgba(45,49,66,0.62); }
.card h3 { font-size: 0.875rem; font-weight: 600; }
.card p { color: var(--color-muted); font-size: 0.8125rem; line-height: 1.55; }
.footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid rgba(45,49,66,0.10); font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.06em; color: var(--color-muted); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<p class="header-eyebrow">Bubble · Diagram Design</p>
<h1>The biggest risk is not the slowest service</h1>
<p class="subtitle">Nine services by p95 latency and error rate, bubble area proportional to requests per second, both axes from zero. Reports is the slowest thing on the board and barely matters; Payments runs near-peak traffic at the highest error rate, and that is where the incident will come from.</p>
</div>
<div class="diagram-container">
<!-- X: 0-500ms p95 over 880px, x = 80 + 1.76·ms. Y: 0-4% error rate over
380px, y = 420 - 95·pct. AREA encodes requests per second: r = 1.4·√v,
so area = π·1.96·v, exactly proportional. scripts/verify-bubble.py
derives all three maps from the values each bubble declares and flags
any bubble that disagrees with its peers. -->
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="bubble-full-title bubble-full-desc">
<title id="bubble-full-title">The biggest risk is not the slowest service</title>
<desc id="bubble-full-desc">Bubble chart of nine services by p95 latency and error rate on zero-based axes, bubble area proportional to requests per second; Payments carries near-peak traffic at the highest error rate.</desc>
<defs>
<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" fill="rgba(45,49,66,0.10)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="#f5f5f5"/>
<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/>
<!-- Axis captions -->
<text transform="rotate(-90 24 230)" x="24" y="230" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">ERROR RATE, %</text>
<text x="520" y="492" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">P95 LATENCY, MS</text>
<!-- Gridlines, same positions as the parent scatter -->
<line x1="80" y1="325" x2="960" y2="325" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="80" y1="230" x2="960" y2="230" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="80" y1="135" x2="960" y2="135" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="80" y1="40" x2="960" y2="40" stroke="rgba(45,49,66,0.06)" stroke-width="0.8"/>
<line x1="256" y1="40" x2="256" y2="420" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="432" y1="40" x2="432" y2="420" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="608" y1="40" x2="608" y2="420" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="784" y1="40" x2="784" y2="420" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<!-- Axes: both start at zero. A bubble's position is read against the
origin, so a truncated axis here shifts every bubble's story. -->
<line x1="80" y1="40" x2="80" y2="420" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line x1="80" y1="420" x2="960" y2="420" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<!-- Tick labels are BOUND: data-tick names the axis, data-value the number
the tick claims to sit at. verify-bubble.py cross-checks each against
the scale the bubbles themselves describe, so the printed axis and
the drawn positions cannot drift apart. -->
<text data-tick="y" data-value="1" x="72" y="329" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">1</text>
<text data-tick="y" data-value="2" x="72" y="234" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">2</text>
<text data-tick="y" data-value="3" x="72" y="139" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">3</text>
<text data-tick="y" data-value="4" x="72" y="44" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">4</text>
<text data-tick="y" data-value="0" x="72" y="424" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">0</text>
<text data-tick="x" data-value="0" x="80" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">0</text>
<text data-tick="x" data-value="100" x="256" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">100</text>
<text data-tick="x" data-value="200" x="432" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">200</text>
<text data-tick="x" data-value="300" x="608" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">300</text>
<text data-tick="x" data-value="400" x="784" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">400</text>
<text data-tick="x" data-value="500" x="960" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">500</text>
<!-- ─── BUBBLES, drawn LARGEST FIRST so a small bubble can never be buried
under a big one. AREA carries the third value, never radius: a radius
proportional to traffic would show a 6x service as 36x the ink. The
fill ramp runs faintest-on-largest — it compensates ink mass so small
bubbles stay visible, it is NOT a fourth encoding, and the legend says
which end is which in skin-neutral terms. Each bubble sits on a paper
underlay so gridlines do not show through the translucent fill. -->
<circle cx="229.6" cy="372.5" r="42" fill="#f5f5f5"/>
<circle data-name="Gateway" data-x="85" data-y="0.5" data-size="900" cx="229.6" cy="372.5" r="42" fill="rgba(45,49,66,0.14)" stroke="#4f5d75" stroke-width="1"/>
<!-- Payments FOCAL — and it is the risk, not the winner: near-peak traffic
riding the worst error rate. One accent bubble max; every other
service stays on the ink ramp. -->
<circle cx="537.6" cy="154" r="38.6" fill="#f5f5f5"/>
<circle data-name="Payments" data-x="260" data-y="2.8" data-size="760" cx="537.6" cy="154" r="38.6" fill="rgba(235,108,54,0.15)" stroke="#eb6c36" stroke-width="1.2"/>
<circle cx="326.4" cy="334.5" r="34.9" fill="#f5f5f5"/>
<circle data-name="Auth" data-x="140" data-y="0.9" data-size="620" cx="326.4" cy="334.5" r="34.9" fill="rgba(45,49,66,0.17)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="396.8" cy="353.5" r="32.5" fill="#f5f5f5"/>
<circle data-name="Catalog" data-x="180" data-y="0.7" data-size="540" cx="396.8" cy="353.5" r="32.5" fill="rgba(45,49,66,0.20)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="643.2" cy="315.5" r="30.7" fill="#f5f5f5"/>
<circle data-name="Search" data-x="320" data-y="1.1" data-size="480" cx="643.2" cy="315.5" r="30.7" fill="rgba(45,49,66,0.23)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="484.8" cy="287" r="25.4" fill="#f5f5f5"/>
<circle data-name="Media" data-x="230" data-y="1.4" data-size="330" cx="484.8" cy="287" r="25.4" fill="rgba(45,49,66,0.26)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="819.2" cy="268" r="20.3" fill="#f5f5f5"/>
<circle data-name="Recommender" data-x="420" data-y="1.6" data-size="210" cx="819.2" cy="268" r="20.3" fill="rgba(45,49,66,0.29)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="247.2" cy="211" r="17.1" fill="#f5f5f5"/>
<circle data-name="Notifications" data-x="95" data-y="2.2" data-size="150" cx="247.2" cy="211" r="17.1" fill="rgba(45,49,66,0.32)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="889.6" cy="334.5" r="10.8" fill="#f5f5f5"/>
<circle data-name="Reports" data-x="460" data-y="0.9" data-size="60" cx="889.6" cy="334.5" r="10.8" fill="rgba(45,49,66,0.35)" stroke="#4f5d75" stroke-width="1"/>
<!-- Labels: the focal bubble plus the two poles of the area story — the
giant that is healthy and the sliver that is slow. Each label is
bound to its bubble by data-name, so verify-bubble.py can prove it
sits nearer its own bubble than anyone else's. -->
<rect x="506" y="99" width="64" height="12" rx="2" fill="#f5f5f5"/>
<text data-name="Payments" data-role="label" x="538" y="108" fill="#2d3142" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">PAYMENTS</text>
<rect x="198" y="316" width="64" height="12" rx="2" fill="#f5f5f5"/>
<text data-name="Gateway" data-role="label" x="230" y="325" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">GATEWAY</text>
<rect x="858" y="347" width="64" height="12" rx="2" fill="#f5f5f5"/>
<text data-name="Reports" data-role="label" x="890" y="356" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">REPORTS</text>
<!-- Legend -->
<line x1="40" y1="462" x2="960" y2="462" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="40" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.18em">LEGEND</text>
<text x="960" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.06em" text-anchor="end">P95 VS ERROR RATE, BUBBLE AREA ∝ REQUESTS PER SECOND · BOTH AXES START AT ZERO · ILLUSTRATIVE FIGURES, NOT MEASURED SERVICES</text>
<circle cx="52" cy="490" r="6" fill="rgba(235,108,54,0.15)" stroke="#eb6c36" stroke-width="1.2"/>
<text x="68" y="494" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Payments · focal, highest error rate at near-peak traffic</text>
<circle cx="660" cy="490" r="5" fill="rgba(45,49,66,0.23)" stroke="#4f5d75" stroke-width="1"/>
<text x="676" y="494" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Service · faintest fill is the largest bubble</text>
</svg>
</div>
<div class="cards">
<div class="card">
<p class="eyebrow">FOCAL · PAYMENTS</p>
<div class="card-header"><span class="card-dot coral"></span><h3>Payments burns the error budget fastest</h3></div>
<p>2.8% of 760 requests per second — the highest error rate on the board applied to nearly the biggest volume. The accent marks the risk, not the worst number in any one column: Reports has worse latency and Gateway has more traffic, but neither multiplies out to as many failing requests.</p>
</div>
<div class="card">
<div class="card-header"><span class="card-dot ink-strong"></span><h3>Gateway shows where mass should sit</h3></div>
<p>The largest bubble sits in the bottom-left corner — 900 requests per second at 85ms and half a percent of errors. Area is what keeps that reading honest: 900 is fifteen times Reports' 60, and the bubble carries fifteen times the ink. Radius-proportional sizing would square that into a 225-to-one blot.</p>
</div>
<div class="card">
<div class="card-header"><span class="card-dot ink-light"></span><h3>Reports is slow and it does not matter</h3></div>
<p>460ms p95 — the worst latency here — on 60 requests per second. The x-position raises the alarm and the tiny area answers it. Cropping the size encoding out of this chart turns Reports into the headline; that is the lie of every latency-only ranking.</p>
</div>
</div>
<div class="footer">
<span>p95 latency vs error rate, area ∝ requests per second · illustrative</span>
<span>example · diagram design</span>
</div>
</div>
</body>
</html>
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>p95 vs error rate, sized by traffic · Bubble</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-paper: #f5f5f5;
--color-ink: #2d3142;
--color-muted: #4f5d75;
--color-accent: #eb6c36;
--font-sans: 'Geist', system-ui, sans-serif;
--font-serif: 'Instrument Serif', serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
}
body { font-family: var(--font-sans); background: var(--color-paper); color: var(--color-ink); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; }
.frame { max-width: 1200px; width: 100%; }
.eyebrow { font-family: var(--font-mono); font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.5rem; }
h1 { font-family: var(--font-serif); font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; }
svg { width: 100%; min-width: 760px; display: block; }
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Bubble · Diagram Design</p>
<h1>The biggest risk is not the slowest service</h1>
<!-- X: 0-500ms p95 over 880px, x = 80 + 1.76·ms. Y: 0-4% error rate over
380px, y = 420 - 95·pct. AREA encodes requests per second: r = 1.4·√v,
so area = π·1.96·v, exactly proportional. scripts/verify-bubble.py
derives all three maps from the values each bubble declares and flags
any bubble that disagrees with its peers. -->
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="bubble-title bubble-desc">
<title id="bubble-title">The biggest risk is not the slowest service</title>
<desc id="bubble-desc">Bubble chart of nine services by p95 latency and error rate on zero-based axes, bubble area proportional to requests per second; Payments carries near-peak traffic at the highest error rate.</desc>
<defs>
<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" fill="rgba(45,49,66,0.10)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="#f5f5f5"/>
<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/>
<!-- Axis captions -->
<text transform="rotate(-90 24 230)" x="24" y="230" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">ERROR RATE, %</text>
<text x="520" y="492" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">P95 LATENCY, MS</text>
<!-- Gridlines, same positions as the parent scatter -->
<line x1="80" y1="325" x2="960" y2="325" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="80" y1="230" x2="960" y2="230" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="80" y1="135" x2="960" y2="135" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="80" y1="40" x2="960" y2="40" stroke="rgba(45,49,66,0.06)" stroke-width="0.8"/>
<line x1="256" y1="40" x2="256" y2="420" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="432" y1="40" x2="432" y2="420" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="608" y1="40" x2="608" y2="420" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<line x1="784" y1="40" x2="784" y2="420" stroke="rgba(45,49,66,0.08)" stroke-width="0.8"/>
<!-- Axes: both start at zero. A bubble's position is read against the
origin, so a truncated axis here shifts every bubble's story. -->
<line x1="80" y1="40" x2="80" y2="420" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line x1="80" y1="420" x2="960" y2="420" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<!-- Tick labels are BOUND: data-tick names the axis, data-value the number
the tick claims to sit at. verify-bubble.py cross-checks each against
the scale the bubbles themselves describe, so the printed axis and
the drawn positions cannot drift apart. -->
<text data-tick="y" data-value="1" x="72" y="329" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">1</text>
<text data-tick="y" data-value="2" x="72" y="234" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">2</text>
<text data-tick="y" data-value="3" x="72" y="139" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">3</text>
<text data-tick="y" data-value="4" x="72" y="44" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">4</text>
<text data-tick="y" data-value="0" x="72" y="424" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">0</text>
<text data-tick="x" data-value="0" x="80" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">0</text>
<text data-tick="x" data-value="100" x="256" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">100</text>
<text data-tick="x" data-value="200" x="432" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">200</text>
<text data-tick="x" data-value="300" x="608" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">300</text>
<text data-tick="x" data-value="400" x="784" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">400</text>
<text data-tick="x" data-value="500" x="960" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">500</text>
<!-- ─── BUBBLES, drawn LARGEST FIRST so a small bubble can never be buried
under a big one. AREA carries the third value, never radius: a radius
proportional to traffic would show a 6x service as 36x the ink. The
fill ramp runs faintest-on-largest — it compensates ink mass so small
bubbles stay visible, it is NOT a fourth encoding, and the legend says
which end is which in skin-neutral terms. Each bubble sits on a paper
underlay so gridlines do not show through the translucent fill. -->
<circle cx="229.6" cy="372.5" r="42" fill="#f5f5f5"/>
<circle data-name="Gateway" data-x="85" data-y="0.5" data-size="900" cx="229.6" cy="372.5" r="42" fill="rgba(45,49,66,0.14)" stroke="#4f5d75" stroke-width="1"/>
<!-- Payments FOCAL — and it is the risk, not the winner: near-peak traffic
riding the worst error rate. One accent bubble max; every other
service stays on the ink ramp. -->
<circle cx="537.6" cy="154" r="38.6" fill="#f5f5f5"/>
<circle data-name="Payments" data-x="260" data-y="2.8" data-size="760" cx="537.6" cy="154" r="38.6" fill="rgba(235,108,54,0.15)" stroke="#eb6c36" stroke-width="1.2"/>
<circle cx="326.4" cy="334.5" r="34.9" fill="#f5f5f5"/>
<circle data-name="Auth" data-x="140" data-y="0.9" data-size="620" cx="326.4" cy="334.5" r="34.9" fill="rgba(45,49,66,0.17)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="396.8" cy="353.5" r="32.5" fill="#f5f5f5"/>
<circle data-name="Catalog" data-x="180" data-y="0.7" data-size="540" cx="396.8" cy="353.5" r="32.5" fill="rgba(45,49,66,0.20)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="643.2" cy="315.5" r="30.7" fill="#f5f5f5"/>
<circle data-name="Search" data-x="320" data-y="1.1" data-size="480" cx="643.2" cy="315.5" r="30.7" fill="rgba(45,49,66,0.23)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="484.8" cy="287" r="25.4" fill="#f5f5f5"/>
<circle data-name="Media" data-x="230" data-y="1.4" data-size="330" cx="484.8" cy="287" r="25.4" fill="rgba(45,49,66,0.26)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="819.2" cy="268" r="20.3" fill="#f5f5f5"/>
<circle data-name="Recommender" data-x="420" data-y="1.6" data-size="210" cx="819.2" cy="268" r="20.3" fill="rgba(45,49,66,0.29)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="247.2" cy="211" r="17.1" fill="#f5f5f5"/>
<circle data-name="Notifications" data-x="95" data-y="2.2" data-size="150" cx="247.2" cy="211" r="17.1" fill="rgba(45,49,66,0.32)" stroke="#4f5d75" stroke-width="1"/>
<circle cx="889.6" cy="334.5" r="10.8" fill="#f5f5f5"/>
<circle data-name="Reports" data-x="460" data-y="0.9" data-size="60" cx="889.6" cy="334.5" r="10.8" fill="rgba(45,49,66,0.35)" stroke="#4f5d75" stroke-width="1"/>
<!-- Labels: the focal bubble plus the two poles of the area story — the
giant that is healthy and the sliver that is slow. Each label is
bound to its bubble by data-name, so verify-bubble.py can prove it
sits nearer its own bubble than anyone else's. -->
<rect x="506" y="99" width="64" height="12" rx="2" fill="#f5f5f5"/>
<text data-name="Payments" data-role="label" x="538" y="108" fill="#2d3142" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">PAYMENTS</text>
<rect x="198" y="316" width="64" height="12" rx="2" fill="#f5f5f5"/>
<text data-name="Gateway" data-role="label" x="230" y="325" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">GATEWAY</text>
<rect x="858" y="347" width="64" height="12" rx="2" fill="#f5f5f5"/>
<text data-name="Reports" data-role="label" x="890" y="356" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">REPORTS</text>
<!-- Legend -->
<line x1="40" y1="462" x2="960" y2="462" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="40" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.18em">LEGEND</text>
<text x="960" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.06em" text-anchor="end">P95 VS ERROR RATE, BUBBLE AREA ∝ REQUESTS PER SECOND · BOTH AXES START AT ZERO · ILLUSTRATIVE FIGURES, NOT MEASURED SERVICES</text>
<circle cx="52" cy="490" r="6" fill="rgba(235,108,54,0.15)" stroke="#eb6c36" stroke-width="1.2"/>
<text x="68" y="494" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Payments · focal, highest error rate at near-peak traffic</text>
<circle cx="660" cy="490" r="5" fill="rgba(45,49,66,0.23)" stroke="#4f5d75" stroke-width="1"/>
<text x="676" y="494" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Service · faintest fill is the largest bubble</text>
</svg>
</div>
</body>
</html>
+3
View File
@@ -274,6 +274,9 @@
<button class="tab new" data-type="scatter">
<span class="eyebrow">22</span>Scatter
</button>
<button class="tab new" data-type="bubble">
<span class="eyebrow">22</span>Scatter · bubble
</button>
<button class="tab new" data-type="medallion">
<span class="eyebrow">23</span>Medallion
</button>
@@ -29,11 +29,93 @@
- More than 30 points without clustering (jitter/mush).
- Forced trend line when the data is genuinely scattered — dishonest.
- Point labels on every point (label the focal and 1–2 notable outliers only).
- Bubble size encoding (use a third axis label or color instead; bubble area perception is unreliable).
- Ad-hoc bubble size encoding on a plain scatter. Size perception is unreliable enough that it earns its own contract: when the third value genuinely matters, use the **bubble variant** below, which pins area to the value and gates it with `scripts/verify-bubble.py`; when it doesn't, a third axis label or a focal choice says it cheaper.
- Axes that don't include zero when the absolute position matters; axes that do include zero when the range is tiny and far from zero.
### Bubble
**Best for:** three quantities per item — x, y, and a magnitude — where the *joint* reading is the story: which items combine a bad position with a big footprint. The shipped example plots services by p95 latency and error rate with area as request volume, and the point of the figure is exactly the multiplication a two-variable scatter cannot do — the slowest service barely matters and the risky one is not the slowest.
Not for: a third value that is really a category (use the focal accent or facet it); two variables (that is the parent scatter — size on everything is decoration); or magnitudes spanning several orders (the small bubbles vanish; bin, or plot the magnitude on an axis of its own).
#### Layout conventions
- **Same plot frame as the parent:** margins left 80, bottom 60, top 40, right 40 inside `0 0 1000 500`; X rule at `y=420`, Y rule at `x=80`; gridlines at the parent's positions; legend on the house rhythm (rule `y=462`, `LEGEND` at `478`, keys at `490`).
- **Item count:** 5–15. Below 5 the leave-one-out scale check has nothing to hold onto and a table says it better; above 15 the areas start stacking and the reading degrades to a density cloud — which is the parent's contour territory, not this.
- **Radius from area:** `r = K·√value` for one constant K across the figure, sized so the largest bubble stays inside the plot (the shipped example uses `K = 1.4` on requests-per-second, giving 10.8–42px). State the area scale in the source line.
- **Bound axis ticks:** every tick carries `data-tick` (axis) and `data-value` (the number it prints). 4–6 per axis at equal intervals, Geist Mono 8px, same placement as the parent.
- **Paper underlay per bubble**, same radius, painted immediately beneath — the translucent fill must not show gridlines through itself, because the fill's job is to read as one solid area.
- **Draw order: largest first.** A small bubble painted early is buried under a later giant and its area is unreadable. `verify-bubble.py` checks paint order on every overlapping pair.
- **Labels:** the focal bubble plus at most 2–3 outliers a reader will look for, Geist Mono 8px small-caps on a paper mask, each bound to its bubble with `data-name`. Never all of them.
- **4px grid** applies to the designed constants — axis rules, gridlines, tick baselines, legend rows. Bubble centres and radii are data-scaled and exempt; snapping them would move the data.
#### Colour
- **One accent bubble, and an `ink` opacity ramp for everything else** — never a hue per item. Every labelled bubble is named where it sits, so hue would re-encode what the labels already carry.
- **The accent marks the editorially focal item, not the biggest or the worst single number.** In the shipped example it marks the service whose *combination* is the risk: near-peak volume on the worst error rate.
- **The ramp runs faintest-on-largest** (0.14 on the largest fill up to 0.35 on the smallest in the shipped example). This is ink-mass compensation, not an encoding: a giant bubble at the same opacity as a small one dominates the page by area alone, so opacity scales down as area scales up and every bubble ends up with comparable visual weight. Tone is **not** a fourth variable — the legend must say which end of the ramp is which, in skin-neutral terms ("faintest fill is the largest bubble" survives both skins; "darkest" ships false on one of them).
- **Every bubble keeps a `muted` stroke** (6.11:1 on light paper, 7.07:1 on dark) — the fills sit at opacities well under 3:1, so the stroke is what carries WCAG 1.4.11 for the mark's edge. The focal bubble's accent stroke measures 2.86:1 on light paper; as with the focal bar, line and slopegraph, its data is carried redundantly — position, label, and the legend naming it in words — and the accent adds only *which bubble is focal*.
- **Labels stay `ink` or `muted`**, including the focal one. Accent text at 8px misses AA on light paper.
#### Honest-data rule
**Area encodes the third value — never radius.** Radius-proportional sizing squares the claim: a 6× value reads as 36× the ink. `scripts/verify-bubble.py` gates it, along with the two axis scales.
- **One linear scale per axis, every bubble on it.** A bubble nudged aside because two crowd each other reads as a different number; crowded bubbles are data, and the honest fixes are a hairline of separation (which the largest-first rule provides) or fewer items — never a moved centre.
- **Axes include zero, or the source line states the bounds.** A bubble's position is read against the origin in a way a slopegraph's is not. No log scale without saying so — and area next to a log axis is a reading most audiences get wrong, so prefer not at all.
- **Omitted items are counted in the footnote.** A bubble chart with the inconvenient giant quietly missing is the same lie as a truncated axis.
- **A non-positive magnitude cannot be a bubble.** Area has no sign; omit the item and say so.
- **Round once, then draw from the rounded number**, so the declared value and the drawn geometry are two statements of one number rather than two chances to disagree.
#### Declaring the values
**Every drawn quantity is bound to an attribute stating the value it encodes.** The data circle carries all three values; the paper underlay is scenery and carries nothing.
```svg
<!-- A bubble: position from two shared linear scales, area from the size.
x = 80 + 1.76·ms, y = 420 - 95·pct, r = 1.4·√(req/s) -->
<circle cx="537.6" cy="154" r="38.6" fill="#f5f5f5"/>
<circle data-name="Payments" data-x="260" data-y="2.8" data-size="760"
cx="537.6" cy="154" r="38.6"
fill="rgba(235,108,54,0.15)" stroke="#eb6c36" stroke-width="1.2"/>
<!-- Its label, bound to the bubble it names -->
<text data-name="Payments" data-role="label" x="538" y="108" fill="#2d3142" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.06em">PAYMENTS</text>
<!-- An axis tick, bound to the number it prints -->
<text data-tick="x" data-value="300" x="608" y="440" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle">300</text>
```
What each binding buys, and what it costs to omit:
| Binding | Without it |
|---|---|
| `data-x` / `data-y` on the circle | A nudged centre could not be caught — the drawn position would be the only statement of the value. |
| `data-size` on the circle | Nothing pins area to the value, and radius-proportional sizing renders identically plausible. |
| `data-name` on the circle | An unnamed bubble cannot be labelled or cross-checked, and drops out of every comparison silently. |
| `data-name` on a label | Two labels could be exchanged between bubbles, renaming both, with every number still correct in isolation. |
| `data-tick` / `data-value` on a tick | The printed axis could be relabelled wholesale — every bubble honestly placed on a scale the axis lies about. |
`scripts/verify-bubble.py` derives both axis scales and the area constant from the set itself (Theil–Sen, leave-one-out, so one dishonest bubble cannot drag the line it is measured against), requires at most one accent bubble, checks paint order on overlaps, and holds every bound label and tick against the mark it describes. Deliberately **not** `data-series`: that attribute is the slopegraph contract, and using it here would put every bubble file inside `verify-slopegraph.py`'s scope.
**No `transform` on any of it.** The checker reads raw `cx`/`cy`/`r` and `x`/`y` attributes, so a transform on a bubble, a bound label, an ancestor `<g>`, or in a CSS rule moves the rendered mark away from the number that was verified. Bake the offset into the coordinates. The rotated value-axis caption is fine — it is neither verified geometry nor a bound label.
#### Anti-patterns
- Radius proportional to the value — the type's one unforgivable error.
- Moving a bubble to stop it overlapping a neighbour, or drawing a small bubble under a large one.
- A hue per item instead of the ink ramp plus a single accent, or a legend that reads the ramp as data.
- A log axis the source line never states, or a truncated axis with no stated bounds.
- Labelling every bubble (the focal plus 2–3 outliers, no more).
- A `transform` on a bubble, a bound label, an ancestor group, or in CSS.
- An unbound visible string: a label or an axis tick with no attribute stating the same thing.
- Size on a value nobody will read — if the areas do not change the story, this is the parent scatter wearing a costume.
## Examples
- `assets/example-scatter.html` — minimal light
- `assets/example-scatter-dark.html` — minimal dark
- `assets/example-scatter-full.html` — full editorial
- `assets/example-bubble.html` — bubble, minimal light
- `assets/example-bubble-dark.html` — bubble, minimal dark
- `assets/example-bubble-full.html` — bubble, full editorial