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
+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())