feat(types): add treemap for part-of-whole by area (#87)
Add Treemap as the 28th visual type with light, dark, and editorial variants; area, label, and information-marker verification; synchronized documentation and manifests; and plugin version 2.5.0.
This commit is contained in:
@@ -402,7 +402,7 @@ def main() -> int:
|
||||
missing_guide = directory / "missing-guide.md"
|
||||
missing_guide.write_text(
|
||||
original_skill.read_text(encoding="utf-8").replace(
|
||||
"### Visual-type guide (27)", "### Visual guide"
|
||||
"### Visual-type guide (28)", "### Visual guide"
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -410,7 +410,7 @@ def main() -> int:
|
||||
markdown_errors = semantic_module.verify_markdown()
|
||||
finally:
|
||||
semantic_module.SKILL = original_skill
|
||||
if not any("must contain the 27-row visual-type guide" in error for error in markdown_errors):
|
||||
if not any("must contain the 28-row visual-type guide" in error for error in markdown_errors):
|
||||
raise AssertionError(f"missing visual-guide anchor was accepted: {markdown_errors}")
|
||||
print("OK: missing visual-type guide anchor is rejected")
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Adversarial tests for verify-treemap.py — both polarities.
|
||||
|
||||
Per ADR 0005, a geometric contract in this repo is a checker plus fixtures that
|
||||
prove it fires when it should and stays quiet when it shouldn't. The mutations
|
||||
below are the two defects that actually shipped in review: a sliver cell drawn a
|
||||
third too small, and a label longer than the cell holding it.
|
||||
|
||||
Usage: python3 scripts/test-verify-treemap.py
|
||||
Exit: 0 all pass, 1 a case failed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CHECKER = ROOT / "scripts/verify-treemap.py"
|
||||
GOOD = ROOT / "skills/diagram-design/assets/example-treemap.html"
|
||||
|
||||
|
||||
def run(path: Path) -> tuple[int, str]:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(CHECKER), str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
return result.returncode, (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def write(directory: Path, name: str, source: str) -> Path:
|
||||
path = directory / name
|
||||
path.write_text(source, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
source = GOOD.read_text(encoding="utf-8")
|
||||
failures: list[str] = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
directory = Path(raw)
|
||||
|
||||
# 1. The shipped example must pass untouched.
|
||||
code, output = run(write(directory, "clean.html", source))
|
||||
if code != 0:
|
||||
failures.append(f"clean example was rejected: {output.strip()}")
|
||||
else:
|
||||
print("OK: the shipped treemap passes")
|
||||
|
||||
# 2. Shrink a labelled cell below the share it prints. This is the
|
||||
# defect that shipped in review — a cell drawn smaller than it claims
|
||||
# — caught here on Africa, which carries a label to contradict.
|
||||
shrunk = source.replace(
|
||||
'<rect x="576" y="40" width="252" height="252"',
|
||||
'<rect x="576" y="40" width="200" height="252"',
|
||||
)
|
||||
if shrunk == source:
|
||||
failures.append("could not build the undersized-cell fixture (anchor moved)")
|
||||
else:
|
||||
code, output = run(write(directory, "undersized.html", shrunk))
|
||||
if code == 0:
|
||||
failures.append("cell drawn under its stated share was accepted")
|
||||
elif "relative" not in output:
|
||||
failures.append(f"undersized cell reported without an area finding: {output.strip()}")
|
||||
else:
|
||||
print("OK: a cell drawn smaller than its label is rejected")
|
||||
|
||||
# 3. Inflate Asia instead — the opposite direction of the same defect.
|
||||
# Both rects move: a cell is painted as a paper mask then a body, and
|
||||
# mutating only one leaves the original size still present, which the
|
||||
# smallest-host rule would quietly measure instead.
|
||||
inflated = source.replace(
|
||||
'<rect x="40" y="40" width="532" height="380"',
|
||||
'<rect x="40" y="40" width="700" height="380"',
|
||||
)
|
||||
if inflated == source:
|
||||
failures.append("could not build the oversized-cell fixture (anchor moved)")
|
||||
else:
|
||||
code, _ = run(write(directory, "oversized.html", inflated))
|
||||
if code == 0:
|
||||
failures.append("oversized focal cell was accepted")
|
||||
else:
|
||||
print("OK: a cell drawn larger than its label is rejected")
|
||||
|
||||
# 4. Overrun a label past its own cell.
|
||||
overflowing = source.replace(
|
||||
">South America</text>",
|
||||
">South America and the Caribbean Basin</text>",
|
||||
)
|
||||
if overflowing == source:
|
||||
failures.append("could not build the overflowing-label fixture (anchor moved)")
|
||||
else:
|
||||
code, output = run(write(directory, "overflow.html", overflowing))
|
||||
if code == 0:
|
||||
failures.append("label overflowing its cell was accepted")
|
||||
elif "overflows" not in output:
|
||||
failures.append(f"overflow reported without a fit finding: {output.strip()}")
|
||||
else:
|
||||
print("OK: a label wider than its cell is rejected")
|
||||
|
||||
# 4b. Overflow off the LEFT edge, via an end-anchored label. Checking
|
||||
# only the right and bottom edges would pass this silently, and the
|
||||
# text would read as belonging to the cell next door.
|
||||
left_over = source.replace(
|
||||
'<text x="804" y="324" fill="#2d3142" font-size="12" font-weight="600"',
|
||||
'<text x="804" y="324" text-anchor="end" fill="#2d3142" font-size="12" font-weight="600"',
|
||||
)
|
||||
if left_over == source:
|
||||
failures.append("could not build the left-overflow fixture (anchor moved)")
|
||||
else:
|
||||
code, output = run(write(directory, "leftoverflow.html", left_over))
|
||||
if code == 0:
|
||||
failures.append("label hanging off the left of its cell was accepted")
|
||||
elif "left" not in output:
|
||||
failures.append(f"left overflow not named in the finding: {output.strip()}")
|
||||
else:
|
||||
print("OK: a label overflowing the left edge is rejected")
|
||||
|
||||
# 4c. Overflow off the TOP edge — the label's ascent clears the cell.
|
||||
top_over = source.replace(
|
||||
'<text x="592" y="68" fill="#2d3142" font-size="13"',
|
||||
'<text x="592" y="44" fill="#2d3142" font-size="13"',
|
||||
)
|
||||
if top_over == source:
|
||||
failures.append("could not build the top-overflow fixture (anchor moved)")
|
||||
else:
|
||||
code, output = run(write(directory, "topoverflow.html", top_over))
|
||||
if code == 0:
|
||||
failures.append("label overflowing the top of its cell was accepted")
|
||||
elif "top" not in output:
|
||||
failures.append(f"top overflow not named in the finding: {output.strip()}")
|
||||
else:
|
||||
print("OK: a label overflowing the top edge is rejected")
|
||||
|
||||
# 4d. A sliver can be narrower than the fixed 10px information-mark
|
||||
# disc. The marker must never cross into the neighbouring cell.
|
||||
marker_over = source.replace(
|
||||
'<rect x="940" y="296" width="16" height="124"',
|
||||
'<rect x="940" y="296" width="8" height="124"',
|
||||
).replace('cx="948" cy="358"', 'cx="944" cy="358"').replace(
|
||||
'<text x="948" y="361"', '<text x="944" y="361"'
|
||||
)
|
||||
if marker_over == source:
|
||||
failures.append("could not build the overflowing-marker fixture (anchor moved)")
|
||||
else:
|
||||
code, output = run(write(directory, "markeroverflow.html", marker_over))
|
||||
if code == 0:
|
||||
failures.append("information mark overflowing a sliver cell was accepted")
|
||||
elif "information marker" not in output:
|
||||
failures.append(f"marker overflow not named in the finding: {output.strip()}")
|
||||
else:
|
||||
print("OK: an information mark overflowing its cell is rejected")
|
||||
|
||||
# 5. The shipped example leaves its smallest cell deliberately
|
||||
# unlabelled. That must not switch the area check off for the cells
|
||||
# that ARE labelled — the failure mode where a checker reports clean
|
||||
# because it found nothing to compare.
|
||||
unlabelled_shrunk = source.replace(
|
||||
'<rect x="576" y="296" width="208" height="124"',
|
||||
'<rect x="576" y="296" width="140" height="124"',
|
||||
)
|
||||
if unlabelled_shrunk == source:
|
||||
failures.append("could not build the still-checks-labelled-cells fixture")
|
||||
else:
|
||||
code, _ = run(write(directory, "unlabelled.html", unlabelled_shrunk))
|
||||
if code == 0:
|
||||
failures.append(
|
||||
"area check went silent when a cell was unlabelled — a labelled "
|
||||
"cell was resized and no finding was reported"
|
||||
)
|
||||
else:
|
||||
print("OK: an unlabelled sliver does not disable the area check")
|
||||
|
||||
for failure in failures:
|
||||
print(f"FAIL: {failure}")
|
||||
if failures:
|
||||
print(f"\n{len(failures)} case(s) failed.")
|
||||
return 1
|
||||
print("\nOK verify-treemap: both polarities behave")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -11,10 +11,15 @@ Five drift classes, each of which has shipped before:
|
||||
3. Every concrete file named in README.md's architecture tree must exist.
|
||||
4. Every relative references/*.md link in SKILL.md must resolve.
|
||||
5. Claude and Pi profile surfaces must both route to the profile reference.
|
||||
6. The plugin manifests repeat the SKILL.md description verbatim. They are the
|
||||
text a user reads *before installing*, so by ADR 0004's own argument they
|
||||
need every type's lexical hook too - and nothing else notices when they
|
||||
drift, because they are four separate copies of one sentence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -67,8 +72,8 @@ def check_description(errors: list[str]) -> None:
|
||||
errors.append("SKILL.md frontmatter description is missing")
|
||||
return
|
||||
types = selection_table_types(markdown)
|
||||
if len(types) != 27:
|
||||
errors.append(f"expected 27 visual types in the selection table; found {len(types)}")
|
||||
if len(types) != 28:
|
||||
errors.append(f"expected 28 visual types in the selection table; found {len(types)}")
|
||||
for name in types:
|
||||
key = normalized(name)
|
||||
key = DESCRIPTION_ALIASES.get(key, key)
|
||||
@@ -151,9 +156,62 @@ def check_profile_surfaces(errors: list[str], root: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
MANIFEST_DESCRIPTIONS = (
|
||||
(Path(".claude-plugin/plugin.json"), ("description",)),
|
||||
(Path(".claude-plugin/marketplace.json"), ("description",)),
|
||||
(Path(".codex-plugin/plugin.json"), ("description", "longDescription")),
|
||||
)
|
||||
|
||||
|
||||
def find_key(node: object, key: str) -> str | None:
|
||||
"""First value for *key* anywhere in a nested JSON document."""
|
||||
if isinstance(node, dict):
|
||||
if isinstance(node.get(key), str):
|
||||
return node[key]
|
||||
for value in node.values():
|
||||
found = find_key(value, key)
|
||||
if found is not None:
|
||||
return found
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
found = find_key(value, key)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def check_manifest_descriptions(errors: list[str], root: Path) -> None:
|
||||
markdown = SKILL.read_text(encoding="utf-8")
|
||||
description = normalized(frontmatter_description(markdown))
|
||||
if not description:
|
||||
return
|
||||
types = selection_table_types(markdown)
|
||||
for relative, keys in MANIFEST_DESCRIPTIONS:
|
||||
path = root / relative
|
||||
if not path.exists():
|
||||
errors.append(f"missing plugin manifest: {relative.as_posix()}")
|
||||
continue
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
for key in keys:
|
||||
value = find_key(document, key)
|
||||
if value is None:
|
||||
errors.append(f"{relative.as_posix()} has no {key!r}")
|
||||
continue
|
||||
text = normalized(value)
|
||||
for name in types:
|
||||
hook = DESCRIPTION_ALIASES.get(normalized(name), normalized(name))
|
||||
if hook not in text:
|
||||
errors.append(
|
||||
f"{relative.as_posix()} {key!r} lost the lexical hook for "
|
||||
f"type {name!r} (expected {hook!r}) — it must name every "
|
||||
f"type the SKILL.md description names"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
check_description(errors)
|
||||
check_manifest_descriptions(errors, ROOT)
|
||||
check_gallery(errors)
|
||||
check_readme_tree(errors)
|
||||
check_skill_reference_links(
|
||||
@@ -169,7 +227,7 @@ def main() -> int:
|
||||
return 1
|
||||
print(
|
||||
"OK docs sync: description hooks, gallery reachability, README tree, "
|
||||
"reference links, profile surfaces"
|
||||
"reference links, profile surfaces, manifest descriptions"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
@@ -191,18 +191,18 @@ def verify_markdown() -> list[str]:
|
||||
if "Selection: semantic pattern, then visual type" not in skill:
|
||||
errors.append("SKILL.md must choose semantic pattern before visual type")
|
||||
router_position = skill.find("semantic-patterns.md")
|
||||
guide_position = skill.find("### Visual-type guide (27)")
|
||||
guide_position = skill.find("### Visual-type guide (28)")
|
||||
if router_position < 0:
|
||||
errors.append("SKILL.md must link to semantic-patterns.md")
|
||||
if guide_position < 0:
|
||||
errors.append("SKILL.md must contain the 27-row visual-type guide")
|
||||
errors.append("SKILL.md must contain the 28-row visual-type guide")
|
||||
if router_position >= 0 and guide_position >= 0 and router_position > guide_position:
|
||||
errors.append("semantic-pattern router must precede the visual-type guide")
|
||||
|
||||
visual_guide = section(skill, "### Visual-type guide (27)", "Rules of thumb:")
|
||||
visual_guide = section(skill, "### Visual-type guide (28)", "Rules of thumb:")
|
||||
visual_rows = re.findall(r"^\|.*\[type-[^)]+\.md\]", visual_guide, re.MULTILINE)
|
||||
if len(visual_rows) != 27:
|
||||
errors.append(f"visual-type guide must preserve 27 rows; found {len(visual_rows)}")
|
||||
if len(visual_rows) != 28:
|
||||
errors.append(f"visual-type guide must preserve 28 rows; found {len(visual_rows)}")
|
||||
|
||||
for index, name in enumerate(PATTERN_NAMES, 1):
|
||||
if name not in skill:
|
||||
@@ -419,7 +419,7 @@ def main() -> int:
|
||||
print(f"- {error}")
|
||||
return 1
|
||||
if not args.example_only:
|
||||
print("OK: 7 semantic patterns route independently to the preserved 27 visual types")
|
||||
print("OK: 7 semantic patterns route independently to the preserved 28 visual types")
|
||||
print("OK: animation modes, primitives, static fallback, and accessibility contract")
|
||||
if not args.markdown_only:
|
||||
print("OK: policy-trace example controls, structure, reduced motion, and unique IDs")
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify the two invariants a treemap can silently break.
|
||||
|
||||
A treemap's whole claim is that **area is the encoding**. Both ways of breaking
|
||||
that claim render perfectly - nothing errors, nothing disappears, and neither
|
||||
`lint-skin.py` (colors, fonts, a11y) nor `verify-geometry.py` (label masks vs
|
||||
later-painted nodes) reads a cell against the number printed inside it.
|
||||
|
||||
1. AREA FIDELITY - a cell's share of the drawn area must match the share its
|
||||
own label claims. This is checked as **relative** error, not percentage
|
||||
points: a 0.13pp slip is noise on a 59% cell and a quarter of a 0.5% cell,
|
||||
so an absolute threshold passes exactly the cell most likely to be wrong.
|
||||
The failure is real - the smallest cell absorbs the whole 4px-grid residue,
|
||||
because 4px is a third of a sliver and a rounding error on a giant.
|
||||
|
||||
2. LABEL AND MARKER FIT - a label or information marker must stay inside the
|
||||
cell it names. Cells have no clip path, so overflow bleeds into a neighbour
|
||||
or off the viewBox and reads as belonging to the wrong thing. A cell too
|
||||
small for its annotation is not a nudge-the-coordinates problem: drop the
|
||||
annotation (the cell keeps its honest area) or merge the tail into a named
|
||||
"Other".
|
||||
|
||||
Text extent is estimated from font metrics rather than measured in a browser,
|
||||
deliberately: every other gate here is pure Python and runs in CI with no
|
||||
browser. The advances below are calibrated against Chromium renderings of the
|
||||
shipped Geist / Geist Mono faces and rounded UP, so the estimate reports
|
||||
overflow slightly before real overflow, never after.
|
||||
|
||||
Usage:
|
||||
python3 scripts/verify-treemap.py --all
|
||||
python3 scripts/verify-treemap.py skills/diagram-design/assets/example-treemap.html
|
||||
|
||||
Exit: 0 clean, 1 findings, 2 usage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ASSET_DIR = ROOT / "skills/diagram-design/assets"
|
||||
|
||||
CELL_RE = re.compile(
|
||||
r"<rect\b[^>]*?"
|
||||
r'\bx="(?P<x>-?[\d.]+)"\s+y="(?P<y>-?[\d.]+)"\s+'
|
||||
r'width="(?P<w>[\d.]+)"\s+height="(?P<h>[\d.]+)"[^>]*?'
|
||||
r'rx="2"',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TEXT_RE = re.compile(r"<text\b(?P<attrs>[^>]*)>(?P<body>.*?)</text>", re.IGNORECASE | re.DOTALL)
|
||||
CIRCLE_RE = re.compile(r"<circle\b(?P<attrs>[^>]*)/?>", re.IGNORECASE)
|
||||
ATTR_RE = re.compile(r'(?P<name>[\w:-]+)="(?P<value>[^"]*)"')
|
||||
ROTATE_RE = re.compile(r"rotate\(\s*(?P<deg>-?[\d.]+)")
|
||||
TAG_RE = re.compile(r"<[^>]+>")
|
||||
# A cell states its size one of two ways, and the two must not be confused: a
|
||||
# magnitude ("4.78B") is a value to be normalised against the other cells, while
|
||||
# a percentage ("59%", "0.6%") is already the claimed share. Reading "0.6%" as
|
||||
# the number 0.6 against cells measured in billions produces nonsense, so the
|
||||
# percentage pattern is tried first and anchored on the sign.
|
||||
PCT_RE = re.compile(r"(?P<num>\d[\d,]*(?:\.\d+)?)\s*%")
|
||||
VALUE_RE = re.compile(r"(?P<num>\d[\d,]*(?:\.\d+)?)\s*(?P<unit>[BMK])\b")
|
||||
|
||||
MONO_ADVANCE = 0.62
|
||||
SANS_ADVANCE = 0.60
|
||||
ASCENT = 0.74
|
||||
UNITS = {None: 1.0, "K": 1e3, "M": 1e6, "B": 1e9}
|
||||
|
||||
# A treemap cell is large enough to hold a label and small enough not to be a
|
||||
# page background or diagram container.
|
||||
CELL_MIN_AREA = 400.0
|
||||
CELL_MAX_W = 900.0
|
||||
CELL_MAX_H = 460.0
|
||||
EPSILON = 1.0
|
||||
# Relative area tolerance. Grid snapping on a 1000x500 viewBox cannot do better
|
||||
# than a couple of percent on a sub-1% cell; 8% is loose enough never to fire on
|
||||
# honest rounding and tight enough to have caught the 30%-undersized sliver this
|
||||
# check exists for.
|
||||
AREA_TOLERANCE = 8.0
|
||||
MARKER_EPSILON = 0.01
|
||||
|
||||
|
||||
class Box:
|
||||
__slots__ = ("x", "y", "w", "h", "offset")
|
||||
|
||||
def __init__(self, x: float, y: float, w: float, h: float, offset: int = 0) -> None:
|
||||
self.x, self.y, self.w, self.h, self.offset = x, y, w, h, offset
|
||||
|
||||
@property
|
||||
def right(self) -> float:
|
||||
return self.x + self.w
|
||||
|
||||
@property
|
||||
def bottom(self) -> float:
|
||||
return self.y + self.h
|
||||
|
||||
@property
|
||||
def area(self) -> float:
|
||||
return self.w * self.h
|
||||
|
||||
def contains_point(self, x: float, y: float) -> bool:
|
||||
return (
|
||||
self.x - EPSILON <= x <= self.right + EPSILON
|
||||
and self.y - EPSILON <= y <= self.bottom + EPSILON
|
||||
)
|
||||
|
||||
|
||||
def line_of(source: str, offset: int) -> int:
|
||||
return source.count("\n", 0, offset) + 1
|
||||
|
||||
|
||||
def plain(body: str) -> str:
|
||||
return html.unescape(TAG_RE.sub("", body)).strip()
|
||||
|
||||
|
||||
def parse_cells(source: str) -> list[Box]:
|
||||
"""Deduped cell rects. Each cell is painted twice: paper mask, then body."""
|
||||
seen: list[Box] = []
|
||||
for m in CELL_RE.finditer(source):
|
||||
box = Box(
|
||||
float(m.group("x")),
|
||||
float(m.group("y")),
|
||||
float(m.group("w")),
|
||||
float(m.group("h")),
|
||||
m.start(),
|
||||
)
|
||||
if box.area < CELL_MIN_AREA or box.w > CELL_MAX_W or box.h > CELL_MAX_H:
|
||||
continue
|
||||
if any(
|
||||
abs(s.x - box.x) < 0.01
|
||||
and abs(s.y - box.y) < 0.01
|
||||
and abs(s.w - box.w) < 0.01
|
||||
and abs(s.h - box.h) < 0.01
|
||||
for s in seen
|
||||
):
|
||||
continue
|
||||
seen.append(box)
|
||||
return seen
|
||||
|
||||
|
||||
def label_box(attrs: dict[str, str], body: str) -> Box | None:
|
||||
"""Estimated rendered box of a <text>, in root user space."""
|
||||
try:
|
||||
x = float(attrs["x"])
|
||||
y = float(attrs["y"])
|
||||
size = float(attrs.get("font-size", "12"))
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
chars = len(plain(body))
|
||||
if not chars:
|
||||
return None
|
||||
mono = "mono" in attrs.get("font-family", "").lower()
|
||||
width = chars * size * (MONO_ADVANCE if mono else SANS_ADVANCE)
|
||||
anchor = attrs.get("text-anchor", "start")
|
||||
|
||||
transform = attrs.get("transform", "")
|
||||
if transform:
|
||||
m = ROTATE_RE.search(transform)
|
||||
# Only the quarter-turn cases are modelled; anything else is skipped
|
||||
# rather than guessed at.
|
||||
if m is None or abs(abs(float(m.group("deg"))) - 90.0) > 0.01:
|
||||
return None
|
||||
if anchor == "middle":
|
||||
top = y - width / 2
|
||||
elif anchor == "end":
|
||||
top = y
|
||||
else:
|
||||
top = y - width
|
||||
return Box(x - size * ASCENT, top, size, width)
|
||||
|
||||
if anchor == "middle":
|
||||
left = x - width / 2
|
||||
elif anchor == "end":
|
||||
left = x - width
|
||||
else:
|
||||
left = x
|
||||
return Box(left, y - size * ASCENT, width, size)
|
||||
|
||||
|
||||
def _number(match: re.Match[str] | None) -> float | None:
|
||||
if match is None:
|
||||
return None
|
||||
try:
|
||||
return float(match.group("num").replace(",", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def parse_claim(text: str) -> tuple[float | None, float | None]:
|
||||
"""(percentage, value) claimed by a label. Either may be absent."""
|
||||
percentage = _number(PCT_RE.search(text))
|
||||
value_match = VALUE_RE.search(text)
|
||||
value = _number(value_match)
|
||||
if value is not None and value_match is not None:
|
||||
value *= UNITS[value_match.group("unit")]
|
||||
return percentage, value
|
||||
|
||||
|
||||
def check(path: Path) -> list[str]:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
cells = parse_cells(source)
|
||||
if len(cells) < 3:
|
||||
return []
|
||||
|
||||
findings: list[str] = []
|
||||
labels: dict[int, list[str]] = {index: [] for index in range(len(cells))}
|
||||
|
||||
for m in TEXT_RE.finditer(source):
|
||||
attrs = {a.group("name"): a.group("value") for a in ATTR_RE.finditer(m.group("attrs"))}
|
||||
box = label_box(attrs, m.group("body"))
|
||||
if box is None:
|
||||
continue
|
||||
# Host is resolved from the text's ANCHOR, not its box corner. An
|
||||
# end- or middle-anchored label extends left of its anchor, so a box
|
||||
# corner can land outside the very cell the label belongs to - which
|
||||
# would silently attribute it to a neighbour, or to nothing at all.
|
||||
anchor_x, anchor_y = float(attrs["x"]), float(attrs["y"])
|
||||
host_index = None
|
||||
for index, cell in enumerate(cells):
|
||||
if not cell.contains_point(anchor_x, anchor_y):
|
||||
continue
|
||||
if host_index is None or cell.area < cells[host_index].area:
|
||||
host_index = index
|
||||
if host_index is None:
|
||||
continue
|
||||
host = cells[host_index]
|
||||
labels[host_index].append(plain(m.group("body")))
|
||||
|
||||
# All four edges. Checking only right and bottom passes an end-anchored
|
||||
# label hanging off the left, or a label whose ascent clears the top -
|
||||
# both of which read as belonging to the neighbouring cell.
|
||||
overflow = {
|
||||
"left": host.x - box.x,
|
||||
"top": host.y - box.y,
|
||||
"right": box.right - host.right,
|
||||
"bottom": box.bottom - host.bottom,
|
||||
}
|
||||
breached = {side: over for side, over in overflow.items() if over > EPSILON}
|
||||
if breached:
|
||||
detail = " / ".join(f"{over:.1f} {side}" for side, over in breached.items())
|
||||
findings.append(
|
||||
f"{path.name}:{line_of(source, m.start())}: label "
|
||||
f"{plain(m.group('body'))[:38]!r} overflows its {host.w:g}x{host.h:g} cell "
|
||||
f"by {detail} — drop the label or merge the cell into a named Other"
|
||||
)
|
||||
|
||||
# Information marks are 8–12px discs placed inside sliver cells. Their
|
||||
# one-letter text can fit while the surrounding disc still crosses a cell
|
||||
# boundary, so circles need their own containment check.
|
||||
for m in CIRCLE_RE.finditer(source):
|
||||
attrs = {a.group("name"): a.group("value") for a in ATTR_RE.finditer(m.group("attrs"))}
|
||||
try:
|
||||
cx = float(attrs["cx"])
|
||||
cy = float(attrs["cy"])
|
||||
radius = float(attrs["r"])
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
if not 4.0 <= radius <= 6.0:
|
||||
continue
|
||||
host_index = None
|
||||
for index, cell in enumerate(cells):
|
||||
if not cell.contains_point(cx, cy):
|
||||
continue
|
||||
if host_index is None or cell.area < cells[host_index].area:
|
||||
host_index = index
|
||||
if host_index is None:
|
||||
continue
|
||||
host = cells[host_index]
|
||||
overflow = {
|
||||
"left": host.x - (cx - radius),
|
||||
"top": host.y - (cy - radius),
|
||||
"right": (cx + radius) - host.right,
|
||||
"bottom": (cy + radius) - host.bottom,
|
||||
}
|
||||
breached = {
|
||||
side: over for side, over in overflow.items() if over > MARKER_EPSILON
|
||||
}
|
||||
if breached:
|
||||
detail = " / ".join(f"{over:.1f} {side}" for side, over in breached.items())
|
||||
findings.append(
|
||||
f"{path.name}:{line_of(source, m.start())}: information marker "
|
||||
f"overflows its {host.w:g}x{host.h:g} cell by {detail} — "
|
||||
"drop the marker and identify the sliver in the legend"
|
||||
)
|
||||
|
||||
claims = {index: parse_claim(" ".join(texts)) for index, texts in labels.items()}
|
||||
# Only compare on a basis every cell states, so the shares share a
|
||||
# denominator. Percentages win when present: they are the claim itself.
|
||||
percentages = {i: p for i, (p, _) in claims.items() if p}
|
||||
magnitudes = {i: v for i, (_, v) in claims.items() if v}
|
||||
known = percentages if len(percentages) >= len(magnitudes) else magnitudes
|
||||
|
||||
# Normalise both sides over the labelled cells only. A sliver too small to
|
||||
# label is legal - it keeps its honest area and is named in the source line
|
||||
# - and it must not switch this check off for the cells that do carry a
|
||||
# claim, which is what comparing against the full drawn area would do.
|
||||
if len(known) >= 3:
|
||||
drawn_total = sum(cells[index].area for index in known)
|
||||
value_total = sum(known.values())
|
||||
for index, value in sorted(known.items()):
|
||||
cell = cells[index]
|
||||
area_share = cell.area / drawn_total * 100
|
||||
value_share = value / value_total * 100
|
||||
relative = (area_share - value_share) / value_share * 100
|
||||
if abs(relative) > AREA_TOLERANCE:
|
||||
findings.append(
|
||||
f"{path.name}:{line_of(source, cell.offset)}: cell "
|
||||
f"{cell.w:g}x{cell.h:g} labelled {' '.join(labels[index])[:30]!r} draws "
|
||||
f"{area_share:.2f}% of the area for a {value_share:.2f}% value — "
|
||||
f"{relative:+.1f}% relative. Area is the only encoding; resize the cell"
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
def targets(args: argparse.Namespace) -> list[Path]:
|
||||
if args.all:
|
||||
return sorted(ASSET_DIR.glob("example-treemap*.html"))
|
||||
return [Path(p) for p in args.paths]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Verify treemap area fidelity and label/marker fit."
|
||||
)
|
||||
parser.add_argument("paths", nargs="*", help="HTML files to check")
|
||||
parser.add_argument("--all", action="store_true", help="check every shipped treemap example")
|
||||
args = parser.parse_args()
|
||||
if not args.all and not args.paths:
|
||||
parser.print_help()
|
||||
return 2
|
||||
|
||||
findings: list[str] = []
|
||||
checked = 0
|
||||
for path in targets(args):
|
||||
if not path.exists():
|
||||
print(f"error: {path} does not exist", file=sys.stderr)
|
||||
return 2
|
||||
findings.extend(check(path))
|
||||
checked += 1
|
||||
|
||||
for finding in findings:
|
||||
print(finding)
|
||||
if findings:
|
||||
print(f"\n{len(findings)} treemap finding(s) across {checked} file(s).")
|
||||
return 1
|
||||
print(
|
||||
f"OK treemap: {checked} file(s), area matches labels and every label/marker fits its cell"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user