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
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.6.4",
"version": "2.6.5",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.6.4",
"version": "2.6.5",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.6.4",
"version": "2.6.5",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
+12
View File
@@ -259,6 +259,16 @@ jobs:
id: ridgeline_tests
run: python scripts/test-verify-ridgeline.py
- name: Verify bubble positions and area encoding
if: always()
id: bubble
run: python scripts/verify-bubble.py --all
- name: Verify bubble checker
if: always()
id: bubble_tests
run: python scripts/test-verify-bubble.py
# lint-render.py's oracle is pixels, so the browser is part of the contract:
# pin Playwright exactly, and let that pin choose the Chromium build. Bump
# both together, deliberately, by editing PLAYWRIGHT_VERSION.
@@ -337,6 +347,8 @@ jobs:
echo "| Sankey Checker | ${{ steps.sankey_tests.outcome == 'success' && '✅ Passed' || (steps.sankey_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Ridgeline Shared Amplitude | ${{ steps.ridgeline.outcome == 'success' && '✅ Passed' || (steps.ridgeline.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Ridgeline Checker | ${{ steps.ridgeline_tests.outcome == 'success' && '✅ Passed' || (steps.ridgeline_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Bubble Position & Area | ${{ steps.bubble.outcome == 'success' && '✅ Passed' || (steps.bubble.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Bubble Checker | ${{ steps.bubble_tests.outcome == 'success' && '✅ Passed' || (steps.bubble_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Primitive Icons & Documentation | ${{ steps.icons.outcome == 'success' && '✅ Passed' || (steps.icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Render Linter Self-Test | ${{ steps.render_selftest.outcome == 'success' && '✅ Passed' || (steps.render_selftest.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Rendered Layout (paint oracle) | ${{ steps.render.outcome == 'success' && '✅ Passed' || (steps.render.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
+2
View File
@@ -53,6 +53,8 @@
"python3 scripts/test-verify-slopegraph.py",
"python3 scripts/verify-ridgeline.py --all",
"python3 scripts/test-verify-ridgeline.py",
"python3 scripts/verify-bubble.py --all",
"python3 scripts/test-verify-bubble.py",
"python3 scripts/build-icons.py && git diff --ignore-space-at-eol --exit-code -- skills/diagram-design/assets/icons.html skills/diagram-design/references/primitive-icons.md"
],
"required_check_names": [],
+6 -1
View File
@@ -71,6 +71,8 @@ The helper refuses to run if the Claude and Codex versions already differ. If an
| Ridgeline checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-ridgeline.py` |
| Sankey flow conservation and ribbon geometry | `python3 scripts/verify-sankey.py --all` |
| Sankey checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-sankey.py` |
| Bubble positions sit on shared axis scales and area encodes every declared size | `python3 scripts/verify-bubble.py --all` |
| Bubble checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-bubble.py` |
| Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files |
The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 bytes so the installed skill remains practical to load. If that gate fails, reduce duplication or move detail into a routed reference; do not remove routing vocabulary from frontmatter.
@@ -112,7 +114,9 @@ python3 scripts/test-plugin-package.py \
&& python3 scripts/verify-ridgeline.py --all \
&& python3 scripts/test-verify-ridgeline.py \
&& python3 scripts/verify-sankey.py --all \
&& python3 scripts/test-verify-sankey.py
&& python3 scripts/test-verify-sankey.py \
&& python3 scripts/verify-bubble.py --all \
&& python3 scripts/test-verify-bubble.py
```
### If a gate fails
@@ -123,6 +127,7 @@ python3 scripts/test-plugin-package.py \
- **`verify-screenshot-freshness.py`:** a canonical minimal-light example or its committed PNG changed without a synchronized catalog refresh. Before the first regeneration, install the renderer with `python3 -m pip install playwright && python3 -m playwright install chromium`. Then run `python3 scripts/render-canonical-screenshots.py`, inspect all 39 renders, and commit the updated PNGs plus `docs/screenshots/manifest.json`.
- **`verify-slopegraph.py`:** the two axes disagree about scale or origin, or an endpoint is drawn somewhere other than where its own declared value belongs. Fix the coordinate, never the label — and never move a point to stop two endpoint labels colliding, because crowded labels mean the values really are close.
- **`verify-ridgeline.py`:** a ridge is drawn on its own amplitude, a baseline sits off the stack's pitch or away from its drawn rule, a ridge is sampled on its own x positions, or a printed range is wider than the bins it claims. Fix the geometry or the declaration so they state one thing — never renormalise a single ridge to make it readable, and never move a baseline to buy one ridge headroom.
- **`verify-bubble.py`:** a bubble is drawn off the shared axis scale its peers describe, its radius disagrees with the one area constant (`r = K·√size`), a second bubble wears the accent, an overlapping smaller bubble is painted under a larger one, or a bound label/tick disagrees with the mark it names. Fix the geometry, never the binding — and never nudge a bubble to open up space, because crowded bubbles mean the values really are close.
- **`verify-geometry.py`:** a label mask overlaps a node declared later in the document, so the node fill clips the label at render time. Move the label to a free segment of its connector — keep the 6–10px gap from the stroke required by SKILL.md §6, and do not shrink the mask to sneak under the check.
- **Icon assets:** you changed `scripts/vendor/icons/` or `scripts/build-icons.py` and the generated files went stale. Rerun `python3 scripts/build-icons.py` and commit the regenerated files.
+572
View File
@@ -0,0 +1,572 @@
#!/usr/bin/env python3
"""Adversarial cases for verify-bubble.py, both polarities.
Every case is named for exactly what it asserts — a name that overclaims is
itself a defect. The negative half matters as much as the positive: a checker
that fires on an honest bubble chart, on the parent scatter, or on the shipped
slopegraph gets widened or switched off, and then it guards nothing.
Fixtures live in a per-process temporary directory, never under the repository
root, and two cases at the end hold that isolation in place.
Exit: 0 all pass, 1 any failure.
"""
from __future__ import annotations
import math
import re
import shutil
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
verify = __import__("verify-bubble")
ASSETS = ROOT / "skills/diagram-design/assets"
SHIPPED = [ASSETS / name for name in (
"example-bubble.html", "example-bubble-dark.html", "example-bubble-full.html",
)]
HEAD = """<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>t</title></head><body>
<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">t</title>
<desc id="bubble-desc">Bubble chart fixture.</desc>
"""
TAIL = "</svg></body></html>\n"
# The fixture scale, matching the shipped example: x = 80 + 1.76*v over a
# 0-500 domain, y = 420 - 95*v over 0-4, and r = 1.4*sqrt(size).
def cx(value: float) -> float:
return round(80 + 1.76 * value, 1)
def cy(value: float) -> float:
return round(420 - 95 * value, 1)
def radius(size: float) -> float:
return round(1.4 * math.sqrt(size), 1)
def bubble(name: str, x: float, y: float, size: float, drawn_cx=None,
drawn_cy=None, drawn_r=None, stroke: str = "#4f5d75",
omit: str = "", extra: str = "") -> str:
parts = []
if omit != "data-name":
parts.append('data-name="%s"' % name)
parts.append('data-x="%s"' % x)
parts.append('data-y="%s"' % y)
parts.append('data-size="%s"' % size)
if omit != "cx":
parts.append('cx="%s"' % ("%g" % cx(x) if drawn_cx is None else drawn_cx))
parts.append('cy="%s"' % ("%g" % cy(y) if drawn_cy is None else drawn_cy))
parts.append('r="%s"' % ("%g" % radius(size) if drawn_r is None else drawn_r))
if extra:
parts.append(extra)
return (' <circle %s fill="rgba(45,49,66,0.20)" stroke="%s" '
'stroke-width="1"/>\n' % (" ".join(parts), stroke))
def label(name: str, x: float, y: float, text=None, extra: str = "") -> str:
return (' <text data-name="%s" data-role="label" x="%g" y="%g"%s>%s</text>\n'
% (name, x, y, (" " + extra) if extra else "",
name.upper() if text is None else text))
def tick(axis: str, value: float, position=None, shown=None,
declared=None, extra: str = "") -> str:
if position is None:
position = cx(value) if axis == "x" else cy(value) + 4
coords = ('x="%g" y="440"' % position) if axis == "x" else \
('x="72" y="%g"' % position)
bind = "" if declared == "omit" else \
' data-value="%s"' % (value if declared is None else declared)
return (' <text data-tick="%s"%s %s%s>%s</text>\n'
% (axis, bind, coords, (" " + extra) if extra else "",
("%g" % value) if shown is None else shown))
TICKS = tick("x", 0) + tick("x", 500) + tick("y", 0) + tick("y", 4)
# Five honest bubbles with distinct values on every axis; none overlap.
BUBBLES = [("Edge", 100, 1.0, 400), ("Queue", 200, 2.0, 100),
("Batch", 300, 0.5, 225), ("Webhook", 400, 3.0, 25),
("Stream", 150, 1.5, 900)]
def honest_block(rows=None, focal: str = "Edge") -> str:
body = ""
for name, x, y, size in sorted(BUBBLES if rows is None else rows,
key=lambda row: -row[3]):
stroke = "#eb6c36" if name == focal else "#4f5d75"
body += bubble(name, x, y, size, stroke=stroke)
return body
def document(*blocks: str) -> str:
return HEAD + "".join(blocks) + TAIL
def honest(rows=None, focal: str = "Edge") -> str:
return document(honest_block(rows, focal), TICKS)
class Harness:
def __init__(self) -> None:
self.failures = 0
self.count = 0
# A private temp dir per instance: fixture names are meaningful to the
# checker (it keys detection off the filename) but their directory is
# not, so there is no reason to put them anywhere a real file lives.
self.dir = Path(tempfile.mkdtemp(prefix="bubble-fixtures-"))
def close(self) -> None:
shutil.rmtree(self.dir, ignore_errors=True)
def path_for(self, name: str) -> Path:
return self.dir / name
def run(self, source: str, name: str = "example-bubble-fixture.html") -> list:
path = self.path_for(name)
path.write_text(source, encoding="utf-8")
try:
return verify.check(path)
finally:
path.unlink()
def expect_clean(self, case: str, source: str, name: str | None = None) -> None:
self.count += 1
found = self.run(source, name) if name else self.run(source)
if found:
self.failures += 1
print("FAIL %s" % case)
for item in found:
print(" unexpected: %s" % item)
else:
print("ok %s" % case)
def expect_finding(self, case: str, source: str, pattern: str,
name: str | None = None) -> None:
self.count += 1
found = self.run(source, name) if name else self.run(source)
if not found:
self.failures += 1
print("FAIL %s\n expected a finding, got none" % case)
return
if not any(re.search(pattern, item) for item in found):
self.failures += 1
print("FAIL %s\n no finding matched %r" % (case, pattern))
for item in found:
print(" got: %s" % item)
return
print("ok %s" % case)
def expect_only_one(self, case: str, source: str, pattern: str) -> None:
"""A single defect must produce a single finding, not a cascade."""
self.count += 1
found = self.run(source)
if len(found) == 1 and re.search(pattern, found[0]):
print("ok %s" % case)
return
self.failures += 1
print("FAIL %s\n expected exactly one finding matching %r, got %d"
% (case, pattern, len(found)))
for item in found:
print(" got: %s" % item)
def expect_out_of_scope(self, case: str, source: str, name: str) -> None:
"""Not merely finding-free — genuinely outside this checker's scope.
expect_clean cannot tell the two apart: a detected-and-clean file also
produces no findings, which is not what "out of scope" claims.
"""
self.count += 1
path = self.path_for(name)
path.write_text(source, encoding="utf-8")
try:
detected = verify.looks_like_bubble(path, source)
found = verify.check(path)
finally:
path.unlink()
if detected or found:
self.failures += 1
print("FAIL %s\n detected=%s findings=%d"
% (case, detected, len(found)))
return
print("ok %s" % case)
def check(self, case: str, condition: bool, detail: str = "") -> None:
self.count += 1
if condition:
print("ok %s" % case)
return
self.failures += 1
print("FAIL %s%s" % (case, ("\n " + detail) if detail else ""))
def run_cases(h: Harness) -> int:
# ── Positive polarity: honest figures pass ────────────────────────────
for path in SHIPPED:
found = verify.check(path)
h.check("shipped %s verifies clean" % path.name, found == [],
"; ".join(found))
h.expect_clean("an honest synthetic bubble chart passes", honest())
h.expect_clean("a chart with no accent bubble at all passes",
honest(focal="nobody"))
h.expect_clean(
"single-quoted attributes are parsed, not dropped",
document(honest_block().replace('"', "'"), TICKS),
)
h.expect_clean(
"a lying bubble inside a comment is markup that never renders",
document(honest_block(), TICKS,
" <!-- %s -->\n" % bubble("Ghost", 100, 1.0, 400,
drawn_cx=900).strip()),
)
# An inverted y (value grows downward) is a legitimate design; the checker
# verifies a SHARED scale, not a direction.
inverted = ""
for name, x, y, size in sorted(BUBBLES, key=lambda row: -row[3]):
inverted += bubble(name, x, y, size, drawn_cy=round(40 + 95 * y, 1))
inverted_ticks = (tick("x", 0) + tick("x", 500)
+ tick("y", 0, position=44) + tick("y", 4, position=424))
h.expect_clean("an inverted y axis with a shared scale passes",
document(inverted, inverted_ticks))
# Overlap honesty. Pair that genuinely overlaps: (256, 325, r28) and
# (273.6, 315.5, r14) are 20px apart against 42px of radius.
big = bubble("Big", 100, 1.0, 400)
small = bubble("Small", 110, 1.1, 100)
h.expect_clean("overlapping bubbles drawn largest-first pass",
document(big, small, TICKS,
honest_block([("A", 300, 0.5, 225),
("B", 400, 3.0, 25)], focal="none")))
h.expect_finding(
"a small bubble buried under a later larger one is reported",
document(small, big, TICKS,
honest_block([("A", 300, 0.5, 225), ("B", 400, 3.0, 25)],
focal="none")),
r"painted\s+first — draw overlapping bubbles largest-first",
)
twin_a = bubble("TwinA", 200, 2.0, 100)
twin_b = bubble("TwinB", 205, 2.05, 100)
h.expect_clean(
"equal-radius overlap passes in either order — order cannot bury a tie",
document(twin_a, twin_b, TICKS,
honest_block([("A", 300, 0.5, 225), ("B", 400, 3.0, 25)],
focal="none")),
)
# ── Axis scale lies ───────────────────────────────────────────────────
# Seven honest peers, so the leave-one-out Theil-Sen fit for each of them
# stays clean even with the one liar included: with four bubbles a single
# nudge contaminated the median and dragged a second, honest bubble past
# tolerance — a cascade blaming the wrong mark.
HONEST_PEERS = [("Edge", 100, 1.0, 400), ("Queue", 200, 2.0, 100),
("Batch", 300, 0.5, 225), ("Webhook", 400, 3.0, 25),
("Cache", 250, 0.8, 300), ("Ingest", 350, 2.4, 150),
("Ledger", 450, 1.2, 60)]
nudged = honest_block(HONEST_PEERS, focal="none")
nudged += bubble("Stream", 150, 1.5, 900, drawn_cx=cx(150) + 8)
h.expect_only_one(
"one bubble nudged 8px in x is one finding, not a cascade",
document(nudged, TICKS),
r"bubble 'Stream' declares x=150 .* never nudge",
)
nudged_y = honest_block(HONEST_PEERS, focal="none")
nudged_y += bubble("Stream", 150, 1.5, 900, drawn_cy=cy(1.5) - 6)
h.expect_finding(
"one bubble nudged 6px in y is reported against its peers' scale",
document(nudged_y, TICKS),
r"bubble 'Stream' declares y=1\.5",
)
h.expect_finding(
"an axis where every bubble declares the same value is unverifiable",
document(honest_block([("A", 100, 1.0, 400), ("B", 100, 2.0, 100),
("C", 100, 0.5, 225), ("D", 100, 3.0, 25)],
focal="none"), TICKS),
r"the x axis has no two distinct declared values",
)
# The lone bubble whose peers all share one value: its leave-one-out fit is
# degenerate, so the residual test skips it and the full-set fit passes
# through wherever it was drawn. Skipping silently let a bubble at a wrong
# coordinate ship clean; it must be reported as unverifiable instead.
lone = honest_block([("A", 100, 1.0, 400), ("B", 100, 2.0, 100),
("C", 100, 0.5, 225)], focal="none")
lone += bubble("Lone", 300, 3.0, 25, drawn_cx=cx(300) + 40)
h.expect_finding(
"a bubble holding the only distinct x value is reported, not skipped",
document(lone, TICKS),
r"bubble 'Lone' holds the only distinct x value",
)
# ── Area lies ─────────────────────────────────────────────────────────
proportional = ""
for name, x, y, size in sorted(BUBBLES, key=lambda row: -row[3]):
proportional += bubble(name, x, y, size, drawn_r=round(size / 22.0, 1))
h.expect_finding(
"radius-proportional sizing is reported — r^2/size is not constant",
document(proportional, TICKS),
r"area must be proportional to the value",
)
inflated = honest_block([("Edge", 100, 1.0, 400), ("Queue", 200, 2.0, 100),
("Batch", 300, 0.5, 225), ("Webhook", 400, 3.0, 25)],
focal="none")
inflated += bubble("Stream", 150, 1.5, 900, drawn_r=radius(900) + 6)
h.expect_finding(
"one bubble drawn 6px too large is reported by name",
document(inflated, TICKS),
r"bubble 'Stream' declares size 900",
)
h.expect_finding(
"a non-positive size is reported, not scaled",
document(honest_block(focal="none"),
bubble("Void", 250, 2.5, 0), TICKS),
r"area cannot encode a non-positive value",
)
h.expect_finding(
"a zero radius is reported — a bubble with no area states no value",
document(honest_block(focal="none"),
bubble("Flat", 250, 2.5, 300, drawn_r=0), TICKS),
r"a bubble with no area states no value",
)
# ── Focal discipline ──────────────────────────────────────────────────
two_accents = honest_block() + bubble("Rogue", 250, 2.5, 300,
stroke="#eb6c36")
h.expect_finding(
"a second accent bubble is reported — one focal claim per figure",
document(two_accents, TICKS),
r"one accent bubble max",
)
dark_accent = honest_block() + bubble("Rogue", 250, 2.5, 300,
stroke="#f08a59")
h.expect_finding(
"the dark-skin accent counts toward the same limit",
document(dark_accent, TICKS),
r"one accent bubble max",
)
# ── Label lies ────────────────────────────────────────────────────────
h.expect_clean(
"a small-caps label matches its mixed-case binding",
document(honest_block(), TICKS, label("Edge", cx(100), cy(1.0) - 34)),
)
h.expect_finding(
"a label naming an undeclared bubble is reported",
document(honest_block(), TICKS, label("Phantom", 500, 250)),
r"which no circle declares",
)
h.expect_finding(
"a label whose text disagrees with its binding is reported",
document(honest_block(), TICKS,
label("Edge", cx(100), cy(1.0) - 34, text="QUEUE")),
r"the visible text and the binding must agree",
)
h.expect_finding(
"a label drawn nearer another bubble renames the mark",
document(honest_block(), TICKS,
label("Edge", cx(200), cy(2.0) - 4)),
r"nearer 'Queue'",
)
h.expect_finding(
"a second label for one bubble is reported",
document(honest_block(), TICKS,
label("Edge", cx(100), cy(1.0) - 34),
label("Edge", cx(100), cy(1.0) + 42)),
r"one bubble, one label",
)
# ── Tick lies ─────────────────────────────────────────────────────────
h.expect_finding(
"a tick printing a different number than it declares is reported",
document(honest_block(), tick("x", 0), tick("x", 500, shown="400"),
tick("y", 0), tick("y", 4)),
r"prints '400' but declares 500",
)
h.expect_finding(
"a tick drawn off the scale the bubbles set is reported",
document(honest_block(), tick("x", 0), tick("x", 500, position=760),
tick("y", 0), tick("y", 4)),
r"the printed axis and the drawn positions disagree",
)
h.expect_finding(
"an axis with fewer than two bound ticks is reported",
document(honest_block(), tick("x", 0), tick("x", 500), tick("y", 0)),
r"the y axis binds 1 distinct tick value",
)
h.expect_finding(
"a tick with no data-value is reported as unbound",
document(honest_block(), tick("x", 0), tick("x", 500, declared="omit"),
tick("y", 0), tick("y", 4)),
r"no readable data-value",
)
h.expect_finding(
"a tick printing two numeric tokens is ambiguous, not first-token-parsed",
document(honest_block(), tick("x", 0),
tick("x", 500, shown="500 (512,000)"),
tick("y", 0), tick("y", 4)),
r"prints more than one numeric token",
)
# ── Transforms ────────────────────────────────────────────────────────
h.expect_finding(
"a transform on a bubble is rejected, not resolved",
document(honest_block(),
bubble("Slid", 250, 2.5, 300,
extra='transform="translate(0 80)"'), TICKS),
r"bubble 'Slid' carries transform=",
)
h.expect_finding(
"an ancestor <g> transform moves every mark inside it",
document(' <g transform="translate(0 40)">\n', honest_block(),
" </g>\n", TICKS),
r"an ancestor <g>/<svg> transform",
)
h.expect_finding(
"an UNCLOSED transformed group covers everything after it",
document(' <g transform="translate(0 40)">\n', honest_block(), TICKS),
r"an ancestor <g>/<svg> transform",
)
h.expect_finding(
"a transform on a bound label is rejected",
document(honest_block(), TICKS,
label("Edge", cx(100), cy(1.0) - 34,
extra='transform="translate(40 0)"')),
r"a bound label .* carries transform=",
)
h.expect_finding(
"a CSS transform declaration is rejected — no telling what it moves",
"<style>.bubble { transform: translate(0, 40px); }</style>"
+ document(honest_block(), TICKS),
r"a CSS `transform` declaration",
)
h.expect_clean(
"text-transform in CSS is styling, not movement",
"<style>.label { text-transform: uppercase; }</style>"
+ document(honest_block(), TICKS),
)
# ── Malformed markup: findings, never tracebacks, never silence ───────
h.expect_finding(
"a data-size circle whose attributes cannot be parsed is reported",
document(' <circle data-size=broken cx="100" cy="100" r="10"/>\n',
honest_block(), TICKS),
r"could not be parsed",
)
h.expect_finding(
"a bubble without data-name is reported",
document(honest_block(),
bubble("X", 250, 2.5, 300, omit="data-name"), TICKS),
r"data-size but no data-name",
)
h.expect_finding(
"a bubble missing cx is reported with the missing attribute named",
document(honest_block(), bubble("Hole", 250, 2.5, 300, omit="cx"),
TICKS),
r"bubble 'Hole' is missing cx",
)
h.expect_finding(
"a NaN coordinate is unreadable, not silently within every tolerance",
document(honest_block(),
bubble("Nan", 250, 2.5, 300, drawn_cx="NaN"), TICKS),
r"not a finite\s+number",
)
# ── Fail closed ───────────────────────────────────────────────────────
h.expect_finding(
"three bubbles are too few for leave-one-out — refused, not passed",
document(honest_block([("A", 100, 1.0, 400), ("B", 200, 2.0, 100),
("C", 300, 0.5, 225)], focal="none"), TICKS),
r"declares 3 verifiable bubble\(s\)",
)
h.expect_finding(
"a file that claims the type by filename but parses nothing is a finding",
HEAD + TAIL,
r"declares 0 verifiable bubble",
name="example-bubble-empty.html",
)
h.expect_finding(
"a file that claims the type in its description is held to the contract",
"<svg role='img'><title>t</title><desc>A bubble chart of things."
"</desc></svg>",
r"declares 0 verifiable bubble",
name="fixture.html",
)
# ── Scope: the neighbours stay unclaimed ──────────────────────────────
scatter = (ASSETS / "example-scatter.html").read_text(encoding="utf-8")
h.expect_out_of_scope(
"the parent scatter is out of scope — points are not bubbles",
scatter, "example-scatter-fixture.html",
)
slopegraph = (ASSETS / "example-slopegraph.html").read_text(encoding="utf-8")
h.expect_out_of_scope(
"the shipped slopegraph is out of scope — data-series is not data-size",
slopegraph, "example-slopegraph-fixture.html",
)
h.check(
"no shipped bubble file declares data-series, so verify-slopegraph "
"never claims one",
all("data-series" not in path.read_text(encoding="utf-8")
for path in SHIPPED),
)
# ── The fixture-isolation guarantees, held in place ───────────────────
sentinel = ROOT / "example-bubble-fixture.html"
existed = sentinel.exists()
if not existed:
sentinel.write_text("KEEP ME\n", encoding="utf-8")
try:
h.run(honest())
h.check(
"a pre-existing file sharing a fixture name is never touched",
sentinel.exists() and sentinel.read_text(encoding="utf-8") == "KEEP ME\n",
"%s was overwritten or deleted by the harness" % sentinel,
)
finally:
if not existed and sentinel.exists():
sentinel.unlink()
other = Harness()
try:
h.check(
"two harnesses use different directories, so parallel runs cannot collide",
other.dir != h.dir and not str(other.dir).startswith(str(ROOT)),
"dirs %s and %s" % (h.dir, other.dir),
)
finally:
other.close()
h.check(
"fixtures are written outside the repository",
not str(h.dir).startswith(str(ROOT)),
"fixture dir %s is inside %s" % (h.dir, ROOT),
)
print()
if h.failures:
print("%d of %d case(s) failed." % (h.failures, h.count))
return 1
print("OK bubble checker: %d case(s), both polarities" % h.count)
return 0
def main() -> int:
h = Harness()
try:
return run_cases(h)
finally:
h.close()
if __name__ == "__main__":
raise SystemExit(main())
+769
View File
@@ -0,0 +1,769 @@
#!/usr/bin/env python3
"""Verify that a bubble chart's drawn geometry matches the values it declares.
A bubble chart makes three claims at once: x and y positions come off two
shared linear scales, and AREA — never radius — carries the third value. All
three can be broken without anything erroring: the figure renders, every label
is present, and the lie is in the geometry. `lint-skin.py` reads colors and
fonts, `verify-geometry.py` reads label masks against later-painted nodes, and
neither compares a drawn coordinate or radius against the value bound to it.
Seven invariants:
1. SHARED AXIS SCALES - every bubble's cx must sit where one linear x scale
puts its declared x value, and cy likewise for y. A single bubble nudged
aside "to stop it overlapping" reads as a different number, and crowded
bubbles are data, not a coordinate problem.
2. AREA, NEVER RADIUS - r^2 must be proportional to the declared size across
the whole set, i.e. r = K*sqrt(size) for one shared K. This is the type's
one unforgivable error: a radius proportional to the value shows a 6x
service as 36x the ink. Per-bubble consistency catches the global version
too, because under r = c*size the ratio r^2/size is not constant.
3. ONE ACCENT BUBBLE - at most one bound circle may carry the accent stroke,
on either skin. A second accent bubble is a second "focal" claim, and the
one-accent rule is the whole colour system of the house style.
4. LARGEST FIRST - where two bubbles overlap, the one painted earlier must be
the larger, or the smaller is buried under it and its area cannot be read.
Ties pass: equal radii cannot bury each other in a way paint order fixes.
5. BOUND LABELS ON THEIR OWN BUBBLE - a label names the bubble it is bound to,
its visible text matches the binding (case aside - labels ship small-caps),
and it sits nearer its own bubble's centre than any other's. Two labels
exchanged between bubbles rename both while every number stays correct.
6. BOUND TICKS ON THE SAME SCALE - each axis needs at least two tick labels
bound with data-tick/data-value, each printing exactly the number it
declares and drawn where the bubbles' own scale puts that value. Unbound
axis numbers are the cheapest way to relabel a whole chart.
7. FAIL CLOSED - a file that presents as a bubble chart but yields fewer than
four parseable bubbles is a finding, never a pass. Four because the outlier
test is leave-one-out and each fit needs three points; below that nothing
here is verifiable, and a checker that reports OK because it found nothing
to compare is the bug, not the gate. The same rule covers a bubble whose
peers all share one value on an axis: its leave-one-out fit is degenerate,
so it would define the scale rather than be checked against it, and an
unmeasurable mark is reported rather than skipped.
The basis for geometry is the `data-x` / `data-y` / `data-size` triple each
bubble circle declares, never the rendered text. The scales are derived from
the set itself (Theil-Sen, leave-one-out), so one dishonest bubble cannot drag
the line it is measured against.
Deliberately NOT `data-series`: that attribute belongs to the slopegraph
contract, and `verify-slopegraph.py` claims any file that uses it. A bubble
file is detected by `data-size`, its filename, or its accessible description.
WHAT THIS DOES NOT CHECK, deliberately:
- **Absolute truth.** Every check is internal consistency; a figure wrong by
one constant everywhere is self-consistent. The source line states the
domain and the area scale to the reader, and prose is not parsed.
- **Axis direction.** A y that grows downward with the value is accepted; an
inverted axis is a legitimate design (error rate reading "up is worse").
- **Zero-based axes.** Whether the domain includes zero is stated in the
source line, which this checker cannot read. The ticks it CAN read are
pinned to the same scale as the bubbles, which is the checkable half.
Usage:
python3 scripts/verify-bubble.py --all
python3 scripts/verify-bubble.py skills/diagram-design/assets/example-bubble.html
Exit: 0 clean, 1 findings, 2 usage.
"""
from __future__ import annotations
import argparse
import html
import math
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills/diagram-design/assets"
CIRCLE_RE = re.compile(r"<circle\b(?P<attrs>[^>]*?)/?>", re.IGNORECASE)
TEXT_RE = re.compile(r"<text\b(?P<attrs>[^>]*)>(?P<body>.*?)</text>", re.IGNORECASE | re.DOTALL)
COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
NAMED_RE = re.compile(r"<(?P<tag>title|desc)\b[^>]*>(?P<body>.*?)</(?P=tag)>",
re.IGNORECASE | re.DOTALL)
GROUP_OPEN_RE = re.compile(r"<(?:g|svg)\b(?P<attrs>[^>]*?)(?P<selfclose>/?)>", re.IGNORECASE)
GROUP_CLOSE_RE = re.compile(r"</(?:g|svg)\s*>", re.IGNORECASE)
STYLE_RE = re.compile(r"<style\b[^>]*>(?P<body>.*?)</style>", re.IGNORECASE | re.DOTALL)
# `transform:` but not `text-transform:`. The full-editorial template uses the
# latter, so an unguarded pattern would report every editorial variant.
CSS_TRANSFORM_RE = re.compile(r"(?<![\w-])transform\s*:", re.IGNORECASE)
TAG_RE = re.compile(r"<[^>]+>")
# The COMPLETE numeric token an author may print: sign, comma-grouped
# thousands, decimals, leading-dot decimals, exponents. Matching only the
# first fragment is how "512,000" once agreed with metadata that said 512.
NUMBER_RE = re.compile(
r"[-+]?(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?"
)
DIGIT_RE = re.compile(r"\d")
# Both quote styles: matching only double quotes drops a single-quoted circle
# from the verified set without a word, which is exactly how a lie ships.
ATTR_RE = re.compile(
r"""(?P<name>[\w:-]+)\s*=\s*(?P<quote>["'])(?P<value>.*?)(?P=quote)""",
re.DOTALL,
)
DECLARES_BUBBLE_RE = re.compile(r"\bdata-size\s*=", re.IGNORECASE)
# The accent stroke on either skin, hex or rgba. The focal count keys on the
# STROKE because the fill is a translucent tint the paper shows through; the
# stroke is the mark's edge and the thing a reader identifies the accent by.
ACCENT_RE = re.compile(
r"#eb6c36\b|#f08a59\b|rgba\(\s*235\s*,\s*108\s*,\s*54\b|rgba\(\s*240\s*,\s*138\s*,\s*89\b",
re.IGNORECASE,
)
# Coordinates ship rounded to one decimal, so an honest point sits within
# 0.05px of true; whole-pixel rounding sits within 0.5px. 1.0px clears both
# and still catches the smallest dishonest nudge worth making.
RESIDUAL_TOLERANCE = 1.0 # px, drawn centre vs the shared axis scale
RADIUS_TOLERANCE = 1.0 # px, drawn radius vs the shared area scale
VALUE_TOLERANCE = 0.001 # printed tick label vs declared attribute
# Tick text is placed beside its gridline, not on it: an x tick's anchor sits
# on the tick, but a y tick's BASELINE hangs ~4px below the line it names.
# 6px absorbs that offset; a swapped tick pair is off by a full gridline gap.
TICK_TOLERANCE = 6.0 # px, tick position vs the scale the bubbles set
LABEL_TOLERANCE = 0.5 # px, slack before a label counts as another bubble's
OVERLAP_SLACK = 0.5 # px, separation below which two bubbles overlap
class Bubble:
__slots__ = ("name", "x", "y", "size", "cx", "cy", "r", "accent", "offset")
def __init__(self, name, x, y, size, cx, cy, r, accent, offset):
self.name = name
self.x, self.y, self.size = x, y, size
self.cx, self.cy, self.r = cx, cy, r
self.accent = accent
self.offset = offset
def line_of(source: str, offset: int) -> int:
return source.count("\n", 0, offset) + 1
def blank_comments(source: str) -> str:
"""Comments out, length and line numbers preserved.
Markup inside a comment is not rendered, so treating it as data reports a
commented-out old draft as a live defect. Replacing each comment with
spaces of the same length keeps every later offset and line number honest.
"""
return COMMENT_RE.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), source)
def attrs_of(raw: str) -> dict:
return {m.group("name"): m.group("value") for m in ATTR_RE.finditer(raw)}
def plain(body: str) -> str:
return html.unescape(TAG_RE.sub("", body)).strip()
def number(value):
"""A finite float from a complete numeric token, or None.
`float("nan")` succeeds and then every `abs(x) > tolerance` comparison is
False, so a NaN coordinate silently satisfies every check in the file.
Any non-finite value is treated as unreadable instead.
"""
if value is None:
return None
try:
parsed = float(str(value).replace(",", ""))
except (TypeError, ValueError):
return None
return parsed if math.isfinite(parsed) else None
def printed_number(body: str):
"""(value, reason) for a label's visible text.
Returns a value only when the text carries exactly one complete numeric
token. Trailing units are fine ("500ms"); a second number, or digits the
token did not consume, is ambiguous and reported rather than guessed at.
"""
match = NUMBER_RE.search(body)
if match is None:
return None, "prints no number"
outside = body[:match.start()] + body[match.end():]
if DIGIT_RE.search(outside):
return None, "prints more than one numeric token"
value = number(match.group())
if value is None:
return None, "prints a number this checker cannot read"
return value, None
def median(values: list) -> float:
ordered = sorted(values)
middle = len(ordered) // 2
if len(ordered) % 2:
return ordered[middle]
return (ordered[middle - 1] + ordered[middle]) / 2.0
def fit(points: list) -> tuple:
"""Robust (slope, intercept) for coordinate = slope * value + intercept.
Theil-Sen - the median of all pairwise slopes - rather than least squares,
so one dishonest bubble does not drag the line it is measured against.
Returns (None, None) when every value is the same, which the caller
reports: an axis with one distinct value has no derivable scale.
"""
slopes = [
(cb - ca) / (vb - va)
for index, (va, ca) in enumerate(points)
for vb, cb in points[index + 1:]
if vb != va
]
if not slopes:
return None, None
slope = median(slopes)
return slope, median([c - slope * v for v, c in points])
def outliers(points: list, tolerance: float) -> list:
"""Points that disagree with the line their PEERS describe.
Leave-one-out, because measuring a point against a fit that includes it
lets it hide inside its own influence. Needs four points, so each fit is
made from at least three - which is why fewer than four bubbles is the
fail-closed case rather than a smaller check.
Returns [(index, drawn, expected)].
"""
if len(points) < 4:
return []
found = []
for index, (value, drawn) in enumerate(points):
peers = points[:index] + points[index + 1:]
slope, intercept = fit(peers)
if slope is None:
continue
expected = slope * value + intercept
if abs(drawn - expected) > tolerance:
found.append((index, drawn, expected))
return found
def named_text(source: str) -> str:
"""The accessible name and description, where a figure says what it is."""
return " ".join(plain(m.group("body")) for m in NAMED_RE.finditer(source)).casefold()
def looks_like_bubble(path: Path, source: str) -> bool:
"""Does this file present itself as a bubble chart?
Deliberately generous - anything that claims the type in its name, its
accessible description, or its markup is held to the contract, and a file
that claims it while declaring nothing parseable is the fail-closed case,
not a pass. Scoped to bubble-specific signals only: `data-size` rather
than `data-series`, so this checker and `verify-slopegraph.py` never claim
one another's files.
"""
if path.name.startswith("example-bubble"):
return True
if DECLARES_BUBBLE_RE.search(source):
return True
described = named_text(source)
return "bubble chart" in described or "bubble plot" in described
def transformed_spans(source: str) -> list:
"""Offset ranges enclosed by a <g>/<svg> that carries a transform.
Element-level transforms are easy to see; an ancestor's is not, and shifts
everything inside it identically - exactly the change that leaves all
internal consistency intact while moving every mark on the page.
"""
events = []
for match in GROUP_OPEN_RE.finditer(source):
if match.group("selfclose"):
continue
events.append((match.start(), 0, "transform" in attrs_of(match.group("attrs"))))
for match in GROUP_CLOSE_RE.finditer(source):
events.append((match.start(), 1, False))
events.sort()
stack, spans = [], []
for position, kind, transformed in events:
if kind == 0:
stack.append((position, transformed))
elif stack:
start, was_transformed = stack.pop()
if was_transformed:
spans.append((start, position))
# An unclosed transformed group covers everything after it.
for start, was_transformed in stack:
if was_transformed:
spans.append((start, len(source)))
return spans
def parse_bubbles(source: str, findings: list, name: str) -> list:
"""Bubble circles, with anything unparseable reported rather than dropped."""
bubbles = []
for match in CIRCLE_RE.finditer(source):
raw = match.group("attrs")
attrs = attrs_of(raw)
if "data-size" not in attrs:
# A <circle> with no data-size is scenery - a paper underlay, a
# legend swatch, a dot-grid cell - and skipping it is correct. But
# one whose raw text DOES declare data-size and still parsed to
# nothing is markup this checker cannot read, and dropping it
# silently is how a lie ships.
if DECLARES_BUBBLE_RE.search(raw):
findings.append(
"%s:%d: a <circle> declares data-size but its attributes could "
"not be parsed — the checker will not silently skip markup it "
"cannot read. Use plain quoted attributes"
% (name, line_of(source, match.start()))
)
continue
label = attrs.get("data-name")
if label is None:
findings.append(
"%s:%d: a bubble declares data-size but no data-name — an unnamed "
"bubble cannot be labelled or cross-checked"
% (name, line_of(source, match.start()))
)
continue
missing = [key for key in ("data-x", "data-y", "cx", "cy", "r")
if key not in attrs]
if missing:
findings.append(
"%s:%d: bubble %r is missing %s — a bubble must declare all three "
"values and all three drawn quantities or it cannot be verified"
% (name, line_of(source, match.start()), label, ", ".join(missing))
)
continue
parsed = [number(attrs[key])
for key in ("data-x", "data-y", "data-size", "cx", "cy", "r")]
if any(value is None for value in parsed):
findings.append(
"%s:%d: bubble %r has a value or coordinate that is not a finite "
"number — cannot verify its position or area"
% (name, line_of(source, match.start()), label)
)
continue
if parsed[2] <= 0:
# Area cannot encode a non-positive value; the honest treatment of
# a zero or missing measurement is to omit the bubble and count the
# omission in the source line, exactly as the reference says.
findings.append(
"%s:%d: bubble %r declares data-size=%g — area cannot encode a "
"non-positive value. Omit the item and note the omission in the "
"source line" % (name, line_of(source, match.start()), label,
parsed[2])
)
continue
if parsed[5] <= 0:
findings.append(
"%s:%d: bubble %r has r=%g — a bubble with no area states no value"
% (name, line_of(source, match.start()), label, parsed[5])
)
continue
accent = bool(ACCENT_RE.search(attrs.get("stroke", "")))
bubbles.append(Bubble(label, parsed[0], parsed[1], parsed[2], parsed[3],
parsed[4], parsed[5], accent, match.start()))
return bubbles
def check_transforms(source: str, findings: list, name: str) -> None:
"""No transform may move verified geometry or a bound label."""
spans = transformed_spans(source)
def enclosed(offset):
return any(start <= offset <= end for start, end in spans)
def report(offset, what, how):
findings.append(
"%s:%d: %s carries %s — this checker validates raw cx/cy/r and x/y "
"attributes, so a transform moves the rendered mark away from the "
"number it was checked against. Bake the offset into the coordinates "
"instead" % (name, line_of(source, offset), what, how)
)
for match in CIRCLE_RE.finditer(source):
attrs = attrs_of(match.group("attrs"))
if "data-size" not in attrs:
continue
what = "bubble %r" % attrs.get("data-name", "?")
if "transform" in attrs:
report(match.start(), what, "transform=%r" % attrs["transform"])
elif enclosed(match.start()):
report(match.start(), what, "an ancestor <g>/<svg> transform")
for match in TEXT_RE.finditer(source):
attrs = attrs_of(match.group("attrs"))
if "data-name" not in attrs and "data-tick" not in attrs:
continue
what = "a bound label (%s)" % plain(match.group("body"))[:20]
if "transform" in attrs:
report(match.start(), what, "transform=%r" % attrs["transform"])
elif enclosed(match.start()):
report(match.start(), what, "an ancestor <g>/<svg> transform")
for match in STYLE_RE.finditer(source):
found = CSS_TRANSFORM_RE.search(match.group("body"))
if found:
findings.append(
"%s:%d: a CSS `transform` declaration — this checker cannot tell "
"which marks it applies to, and a transform on verified geometry "
"invalidates every coordinate here. Remove it, or bake the offset "
"into the coordinates"
% (name, line_of(source, match.start("body") + found.start()))
)
def check_axis(bubbles: list, axis: str, findings: list, source: str,
name: str):
"""One shared linear scale per axis, and no bubble may drift off it.
Returns the (slope, intercept) fit for the axis so the tick check can
measure against the same scale, or (None, None) when none is derivable.
"""
if axis == "x":
points = [(b.x, b.cx) for b in bubbles]
drawn_attr = "cx"
else:
points = [(b.y, b.cy) for b in bubbles]
drawn_attr = "cy"
line = line_of(source, bubbles[0].offset)
slope, intercept = fit(points)
if slope is None:
findings.append(
"%s:%d: the %s axis has no two distinct declared values, so its scale "
"cannot be derived and no position on it is verifiable"
% (name, line, axis)
)
return None, None
# A bubble whose PEERS all share one value cannot be measured: the
# leave-one-out fit for it is degenerate, so `outliers` skips it, and the
# full-set fit passes exactly through wherever it was drawn — its position
# would define the scale rather than be checked by it. Fail closed: that
# is an unverifiable mark, not a pass. (Greptile flagged the silent skip.)
for index, (value, _drawn) in enumerate(points):
peer_values = {points[i][0] for i in range(len(points)) if i != index}
if len(peer_values) < 2:
b = bubbles[index]
findings.append(
"%s:%d: bubble %r holds the only distinct %s value (%g) — its "
"peers cannot describe a scale to check it against, so its "
"position would define the axis instead of being verified by "
"it. An axis needs two distinct values among the other bubbles"
% (name, line_of(source, b.offset), b.name, axis, value)
)
for index, drawn, expected in outliers(points, RESIDUAL_TOLERANCE):
b = bubbles[index]
findings.append(
"%s:%d: bubble %r declares %s=%g but draws %s=%g where the shared %s "
"scale its peers describe puts it at %.1f — off by %.1f px. Crowded "
"bubbles are data; never nudge one aside"
% (name, line_of(source, b.offset), b.name, axis,
points[index][0], drawn_attr, drawn, axis, expected,
abs(drawn - expected))
)
return slope, intercept
def check_area(bubbles: list, findings: list, source: str, name: str) -> None:
"""r must equal K*sqrt(size) for one K shared by the whole set.
The ratio r^2/size is constant under honest area encoding and varies under
every dishonest alternative - radius-proportional sizing makes it grow
linearly with the value, and a single inflated bubble moves only its own
ratio. Leave-one-out again, so the offender is measured against its peers.
"""
if len(bubbles) < 4:
return
ratios = [b.r * b.r / b.size for b in bubbles]
for index, b in enumerate(bubbles):
peers = ratios[:index] + ratios[index + 1:]
expected = math.sqrt(median(peers) * b.size)
if abs(b.r - expected) > RADIUS_TOLERANCE:
findings.append(
"%s:%d: bubble %r declares size %g but draws r=%g where the area "
"scale its peers share puts %.1f — area must be proportional to "
"the value (r = K*sqrt(size)), and radius-proportional sizing "
"shows a 6x value as 36x the ink"
% (name, line_of(source, b.offset), b.name, b.size, b.r, expected)
)
def check_focal(bubbles: list, findings: list, source: str, name: str) -> None:
"""At most one bubble wears the accent."""
accented = [b for b in bubbles if b.accent]
for extra in accented[1:]:
findings.append(
"%s:%d: bubble %r also carries the accent stroke — one accent bubble "
"max (%r already has it). A second focal mark is a second editorial "
"claim, and the ink ramp exists so everything else stays ranked "
"without spending the accent"
% (name, line_of(source, extra.offset), extra.name, accented[0].name)
)
def check_paint_order(bubbles: list, findings: list, source: str, name: str) -> None:
"""Where two bubbles overlap, the larger must be painted first.
Painted later means painted on top: a small bubble under a large one is
invisible and its area unreadable. Ties pass - equal radii cannot bury
each other in a way order fixes - and separation by at least a hairline
is the honest fix for near-touching bubbles, so only true overlap counts.
"""
for index, a in enumerate(bubbles):
for b in bubbles[index + 1:]:
gap = math.hypot(a.cx - b.cx, a.cy - b.cy) - (a.r + b.r)
if gap < -OVERLAP_SLACK and a.r < b.r:
findings.append(
"%s:%d: bubble %r (r=%g) overlaps %r (r=%g) but is painted "
"first — draw overlapping bubbles largest-first so the small "
"one stays on top and both areas stay readable"
% (name, line_of(source, a.offset), a.name, a.r, b.name, b.r)
)
def check_labels(bubbles: list, source: str, findings: list, name: str) -> None:
"""Bound labels must name a real bubble, match it, and sit on it."""
centres = {b.name: (b.cx, b.cy) for b in bubbles}
seen = set()
for match in TEXT_RE.finditer(source):
attrs = attrs_of(match.group("attrs"))
label = attrs.get("data-name")
if label is None:
continue
offset = match.start()
body = plain(match.group("body"))
if label not in centres:
findings.append(
"%s:%d: a label names bubble %r, which no circle declares — a "
"label with no mark is not verifiable and reads as data"
% (name, line_of(source, offset), label)
)
continue
if label in seen:
findings.append(
"%s:%d: a second label for bubble %r — one bubble, one label, or "
"the figure states two things about one mark"
% (name, line_of(source, offset), label)
)
continue
seen.add(label)
# Case-insensitive: labels ship small-caps ("PAYMENTS" for a bubble
# named "Payments"), and casing is presentation, not identity.
if body.casefold() != label.casefold():
findings.append(
"%s:%d: a label bound to bubble %r reads %r — the visible text "
"and the binding must agree"
% (name, line_of(source, offset), label, body[:28])
)
continue
x, y = number(attrs.get("x")), number(attrs.get("y"))
if x is None or y is None:
findings.append(
"%s:%d: the label for bubble %r has no readable x/y, so its "
"placement cannot be checked"
% (name, line_of(source, offset), label)
)
continue
own_cx, own_cy = centres[label]
own = math.hypot(x - own_cx, y - own_cy)
nearest = min(
(other for other in centres if other != label),
key=lambda other: math.hypot(x - centres[other][0],
y - centres[other][1]),
default=None,
)
if nearest is not None:
near = math.hypot(x - centres[nearest][0], y - centres[nearest][1])
if near < own - LABEL_TOLERANCE:
findings.append(
"%s:%d: the label for bubble %r is drawn at (%g, %g), nearer "
"%r's centre than its own — a label on the wrong bubble "
"renames the mark"
% (name, line_of(source, offset), label, x, y, nearest)
)
def check_ticks(x_fit, y_fit, source: str, findings: list, name: str) -> None:
"""Bound axis ticks must print their value and sit on the bubbles' scale."""
ticks = {"x": [], "y": []}
for match in TEXT_RE.finditer(source):
attrs = attrs_of(match.group("attrs"))
axis = attrs.get("data-tick")
if axis is None:
continue
offset = match.start()
if axis not in ("x", "y"):
findings.append(
"%s:%d: a tick label has data-tick=%r, which is neither 'x' nor "
"'y'" % (name, line_of(source, offset), axis)
)
continue
declared = number(attrs.get("data-value"))
if declared is None:
findings.append(
"%s:%d: a %s tick has no readable data-value — an unbound axis "
"number is the cheapest way to relabel a whole chart"
% (name, line_of(source, offset), axis)
)
continue
body = plain(match.group("body"))
shown, reason = printed_number(body)
if shown is None:
findings.append(
"%s:%d: the %s tick for %g %s (%r) — print one complete number "
"per tick" % (name, line_of(source, offset), axis, declared,
reason, body[:28])
)
continue
if abs(shown - declared) > VALUE_TOLERANCE:
findings.append(
"%s:%d: a %s tick prints %r but declares %g — the label and the "
"binding must state one number"
% (name, line_of(source, offset), axis, body[:28], declared)
)
continue
position = number(attrs.get("x" if axis == "x" else "y"))
if position is None:
findings.append(
"%s:%d: the %s tick for %g has no readable position"
% (name, line_of(source, offset), axis, declared)
)
continue
ticks[axis].append((declared, position, offset))
for axis, fit_pair in (("x", x_fit), ("y", y_fit)):
entries = ticks[axis]
if len({value for value, _pos, _off in entries}) < 2:
findings.append(
"%s: the %s axis binds %d distinct tick value(s) — an axis needs "
"at least two bound ticks (data-tick/data-value) or its printed "
"scale is unverifiable against the drawn one"
% (name, axis, len({v for v, _p, _o in entries}))
)
continue
slope, intercept = fit_pair
if slope is None:
continue # already reported by check_axis
for value, position, offset in entries:
expected = slope * value + intercept
if abs(position - expected) > TICK_TOLERANCE:
findings.append(
"%s:%d: the %s tick for %g is drawn at %g but the scale the "
"bubbles themselves describe puts that value at %.1f — the "
"printed axis and the drawn positions disagree, so every "
"reading off this axis is wrong"
% (name, line_of(source, offset), axis, value, position,
expected)
)
def check_source(path: Path, raw: str) -> list:
"""Findings for one already-read document."""
source = blank_comments(raw)
findings: list = []
bubbles = parse_bubbles(source, findings, path.name)
if len(bubbles) < 4:
findings.append(
"%s: presents as a bubble chart but declares %d verifiable bubble(s) "
"— each needs data-name, data-x, data-y, data-size, cx, cy and r, "
"and the leave-one-out scale test needs four. Refusing to report OK "
"on a file this checker could not read" % (path.name, len(bubbles))
)
return findings
check_transforms(source, findings, path.name)
x_fit = check_axis(bubbles, "x", findings, source, path.name)
y_fit = check_axis(bubbles, "y", findings, source, path.name)
check_area(bubbles, findings, source, path.name)
check_focal(bubbles, findings, source, path.name)
check_paint_order(bubbles, findings, source, path.name)
check_labels(bubbles, source, findings, path.name)
check_ticks(x_fit, y_fit, source, findings, path.name)
return findings
def check(path: Path) -> list:
"""Findings for one file on disk, or [] if it is not a bubble chart."""
raw = path.read_text(encoding="utf-8")
if not looks_like_bubble(path, raw):
return []
return check_source(path, raw)
def targets(args: argparse.Namespace) -> list:
if args.all:
return sorted(ASSET_DIR.glob("example-*.html"))
return [Path(p) for p in args.paths]
def main() -> int:
parser = argparse.ArgumentParser(
description="Verify bubble positions and areas against the values they declare."
)
parser.add_argument("paths", nargs="*", help="HTML files to check")
parser.add_argument(
"--all", action="store_true",
help="check every shipped example that presents as a bubble chart",
)
args = parser.parse_args()
if not args.all and not args.paths:
parser.print_help()
return 2
findings: list = []
checked = 0
skipped = 0
for path in targets(args):
if not path.is_file():
print("error: %s is not a readable file" % path, file=sys.stderr)
return 2
try:
raw = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as error:
print("error: cannot read %s: %s" % (path, error), file=sys.stderr)
return 2
# Read once, then decide. Scope is reported separately from
# verification in both modes: printing "1 file(s) verified" for a
# scatter that was skipped is a claim the run never made good on.
if not looks_like_bubble(path, raw):
skipped += 1
continue
findings.extend(check_source(path, raw))
checked += 1
for finding in findings:
print(finding)
tail = " (%d file(s) skipped as out of scope)" % skipped if skipped else ""
if findings:
print("\n%d bubble finding(s) across %d file(s).%s"
% (len(findings), checked, tail))
return 1
if not checked:
print("OK bubble: no bubble chart found to check%s" % tail)
return 0
print("OK bubble: %d file(s), shared x and y scales, area proportional to every "
"declared size, at most one accent bubble, largest-first paint order, and "
"every bound label and tick agreeing with the mark it describes%s"
% (checked, tail))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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