scripts/verify-heatmap.py
A supporting file of the diagram-design skill, shown as source.
#!/usr/bin/env python3
"""Verify that a heatmap's drawn cells match the values they declare.
A heatmap makes one claim: **fill opacity is a monotone ramp on the declared
data value, and nothing else encodes quantity**. Row and column are categories;
their visual positions are only "which row" and "which column", not quantities
measured against a scale. Only the fill encodes "how much" — and the ramp must
be non-decreasing.
Four invariants:
1. COMPLETE GRID — every (row, col) pair in the declared row-and-column
vocabulary is present. A missing cell is a gap in the reading that looks
like "zero" but was never drawn.
2. MONOTONE FILL — non-focal cells whose data-value is higher must have a fill
opacity that is >= the opacity of any cell with a lower data-value. The ramp
must be non-decreasing: a lighter cell at a higher value inverts the visual
scale and reverses every comparison. Cells with the same value must have the
same opacity (tolerance ±0.03 to absorb floating-point representation).
3. ONE FOCAL MAX — at most one cell carries data-focal="true" or uses accent
fill (R >= 200, G <= 150, B <= 100). The focal accent is an editorial marker
for the single cell whose story the figure is about; using it on more than one
inverts the ink-ramp contract the rest of the figure depends on.
4. FAIL CLOSED — zero parseable cells is a finding. A heatmap with no cells is
not an empty-data edge case; it is an authoring error or a parse failure, and
a checker that says OK because it found nothing to compare is the bug.
WHAT THIS DOES NOT CHECK, deliberately:
- **Cell geometry (x, y, width, height).** Both axes are categorical, so position
encodes "which row/column", carried by axis labels — not a quantitative scale
this checker can verify. A cell drawn at a wrong x,y is a layout error, not a
data error; the label is what binds the mark to its identity.
- **The scale formula.** Monotone, not a specific slope or formula. Linear, sqrt,
and log scales all pass as long as opacity is non-decreasing with value.
- **Text values inside cells.** Optional annotation; not part of the geometric
contract.
Usage:
python3 scripts/verify-heatmap.py --all
python3 scripts/verify-heatmap.py skills/diagram-design/assets/example-heatmap.html
Exit: 0 clean, 1 findings, 2 usage.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills/diagram-design/assets"
RECT_RE = re.compile(r"<rect\b(?P<attrs>[^>]*?)/?>", re.IGNORECASE)
TEXT_RE = re.compile(r"<text\b(?P<attrs>[^>]*)>", re.IGNORECASE)
COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
STYLE_RE = re.compile(r"<style\b[^>]*>(?P<body>.*?)</style>", re.IGNORECASE | re.DOTALL)
RGBA_RE = re.compile(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)")
HEX_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
# CSS properties that can silently move a verified rect without changing its
# x/y/width/height attributes. Declaration-start anchoring avoids false matches
# on text-transform, custom-property values, etc.
# Mirrors verify-bump.py: checks transform/translate/rotate/scale and SVG
# presentation attributes that CSS can override (cx, cy, x, y, d, offset-*).
# Does NOT flag width/height since `svg { width: 100%; }` is standard responsive
# boilerplate and does not affect the verified rect attributes.
CSS_MOVES_MARK_RE = re.compile(
r"(?:^|[{;}\n])\s*(?:-(?:webkit|moz|ms|o)-)?"
r"(?P<prop>transform|translate|rotate|scale"
r"|cx|cy|x|y|d"
r"|offset(?:-(?:path|distance|position|anchor|rotate))?)"
r"\s*:",
re.IGNORECASE,
)
def _attr(attrs_str: str, name: str) -> str | None:
m = re.search(r"\b" + re.escape(name) + r'\s*=\s*"([^"]*)"', attrs_str)
return m.group(1) if m else None
def _is_accent(r: int, g: int, b: int) -> bool:
"""Detect accent fill — warm reddish-orange. Works for both light and dark skins."""
return r >= 200 and g <= 150 and b <= 100
def _parse_color(
color: str, underlay: tuple[int, int, int] | None = None
) -> tuple[int, int, int] | None:
"""Parse a CSS hex or rgb/rgba color into the visible 8-bit RGB components.
For rgba() values with alpha < 1, blend the color against the actual canvas
underlay rather than assuming the light paper background. In dark heatmaps, the
underlay is the dark paper color (#2d3142), not #f5f5f5.
"""
value = color.strip()
if not value:
return None
if underlay is None:
underlay = (245, 245, 245)
def blend(rgb: tuple[int, int, int], alpha: float) -> tuple[int, int, int]:
if alpha >= 1.0:
return rgb
return tuple(
round(c * alpha + underlay[idx] * (1.0 - alpha))
for idx, c in enumerate(rgb)
)
if HEX_RE.match(value):
hex_value = value[1:]
if len(hex_value) == 3:
hex_value = "".join(ch * 2 for ch in hex_value)
try:
rgb = (
int(hex_value[0:2], 16),
int(hex_value[2:4], 16),
int(hex_value[4:6], 16),
)
return rgb
except ValueError:
return None
rgba_match = RGBA_RE.match(value)
if rgba_match:
try:
rgb = (
int(rgba_match.group(1)),
int(rgba_match.group(2)),
int(rgba_match.group(3)),
)
alpha = float(rgba_match.group(4))
return blend(rgb, alpha)
except ValueError:
return None
rgb_match = re.match(
r"rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", value, re.IGNORECASE
)
if rgb_match:
try:
return (
int(rgb_match.group(1)),
int(rgb_match.group(2)),
int(rgb_match.group(3)),
)
except ValueError:
return None
return None
def _relative_luminance(rgb: tuple[int, int, int]) -> float:
r, g, b = (channel / 255 for channel in rgb)
def channel_to_linear(channel: float) -> float:
if channel <= 0.03928:
return channel / 12.92
return ((channel + 0.055) / 1.055) ** 2.4
r_lin = channel_to_linear(r)
g_lin = channel_to_linear(g)
b_lin = channel_to_linear(b)
return 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin
def _contrast_ratio(
foreground: tuple[int, int, int], background: tuple[int, int, int]
) -> float:
lum_fg = _relative_luminance(foreground)
lum_bg = _relative_luminance(background)
lighter, darker = max(lum_fg, lum_bg), min(lum_fg, lum_bg)
return (lighter + 0.05) / (darker + 0.05)
def parse_axis_labels(source: str) -> tuple[list[str], list[str], set[str], set[str]]:
"""Return (declared_rows, declared_cols, duplicate_rows, duplicate_cols).
Rows are declared with data-row-label="name" on <text> elements (left gutter).
Columns are declared with data-col="name" on <text> elements (top header row).
Duplicate declarations are treated as a verifier failure because they silently
shrink the grid vocabulary and make a malformed heatmap pass completeness.
"""
source_clean = COMMENT_RE.sub("", source)
declared_rows: list[str] = []
declared_cols: list[str] = []
duplicate_rows: set[str] = set()
duplicate_cols: set[str] = set()
seen_rows: set[str] = set()
seen_cols: set[str] = set()
for m in TEXT_RE.finditer(source_clean):
attrs_str = m.group("attrs")
row_label = _attr(attrs_str, "data-row-label")
col_label = _attr(attrs_str, "data-col")
if row_label:
if row_label in seen_rows:
duplicate_rows.add(row_label)
else:
declared_rows.append(row_label)
seen_rows.add(row_label)
if col_label:
if col_label in seen_cols:
duplicate_cols.add(col_label)
else:
declared_cols.append(col_label)
seen_cols.add(col_label)
return declared_rows, declared_cols, duplicate_rows, duplicate_cols
def parse_cells(source: str) -> list[dict]:
"""Return list of cell dicts: row, col, value, opacity, focal."""
source_clean = COMMENT_RE.sub("", source)
cells: list[dict] = []
for m in RECT_RE.finditer(source_clean):
attrs_str = m.group("attrs")
row = _attr(attrs_str, "data-row")
col = _attr(attrs_str, "data-col")
val_str = _attr(attrs_str, "data-value")
if not (row and col and val_str is not None):
continue
try:
value = float(val_str)
except ValueError:
continue
if not (0.0 <= value < 1e9) or value != value: # reject nan/inf/negative
continue
fill = _attr(attrs_str, "fill") or ""
focal = _attr(attrs_str, "data-focal") == "true"
opacity: float | None = None
x = _attr(attrs_str, "x")
y = _attr(attrs_str, "y")
width = _attr(attrs_str, "width")
height = _attr(attrs_str, "height")
rm = RGBA_RE.search(fill)
if rm:
try:
r_ch = int(rm.group(1))
g_ch = int(rm.group(2))
b_ch = int(rm.group(3))
opacity = float(rm.group(4))
except ValueError:
opacity = None
else:
if not (0.0 <= opacity <= 1.0):
opacity = None
elif _is_accent(r_ch, g_ch, b_ch):
focal = True
cells.append(
{
"row": row,
"col": col,
"value": value,
"opacity": opacity,
"focal": focal,
"fill": fill,
"x": float(x) if x is not None else None,
"y": float(y) if y is not None else None,
"width": float(width) if width is not None else None,
"height": float(height) if height is not None else None,
}
)
return cells
def parse_text_labels(source: str) -> list[dict]:
"""Return SVG text labels with their fill, x, y, and rendered text."""
source_clean = COMMENT_RE.sub("", source)
texts: list[dict] = []
for m in TEXT_RE.finditer(source_clean):
attrs = m.group("attrs")
fill = _attr(attrs, "fill")
if not fill:
continue
x = _attr(attrs, "x")
y = _attr(attrs, "y")
text_content = re.search(r">(.*?)</text>", m.group(0), re.DOTALL)
texts.append(
{
"fill": fill,
"x": float(x) if x is not None else None,
"y": float(y) if y is not None else None,
"text": text_content.group(1).strip() if text_content else "",
}
)
return texts
def check_file(path: Path) -> list[str]:
errors: list[str] = []
try:
source = path.read_text(encoding="utf-8")
except OSError as exc:
return [f"{path.name}: cannot read: {exc}"]
# CSS-moves-geometry check.
for style_body in STYLE_RE.findall(source):
m = CSS_MOVES_MARK_RE.search(style_body)
if m:
errors.append(
f"{path.name}: CSS property '{m.group('prop')}' can move verified "
"cell geometry without changing the attributes this checker reads"
)
cells = parse_cells(source)
if not cells:
errors.append(
f"{path.name}: no parseable heatmap cells found "
"(need <rect data-row=... data-col=... data-value=...>)"
)
return errors
focal_cells = [c for c in cells if c["focal"]]
non_focal = [c for c in cells if not c["focal"]]
# Invariant 3: at most one focal cell.
if len(focal_cells) > 1:
focal_ids = ", ".join(f"({c['row']},{c['col']})" for c in focal_cells)
errors.append(
f"{path.name}: found {len(focal_cells)} focal cells ({focal_ids}); "
"at most 1 allowed — the focal accent marks one editorial cell, "
"not a second data range"
)
# Invariant 1: require a declared axis vocabulary and reject any cell whose
# row/column are outside that vocabulary. Cell-derived fallback is intentionally
# not allowed here: a malformed heatmap can otherwise pass when a row or column
# is missing from the label axis but the remaining cells still produce a count.
declared_rows, declared_cols, duplicate_rows, duplicate_cols = parse_axis_labels(source)
if not declared_rows or not declared_cols:
errors.append(
f"{path.name}: heatmap is missing declared row/column axis labels; "
"every row and column must be named via data-row-label and data-col"
)
return errors
if duplicate_rows or duplicate_cols:
dup_msg = ", ".join(sorted(duplicate_rows | duplicate_cols))
errors.append(
f"{path.name}: duplicate axis declarations for {dup_msg}; "
"repeated data-row-label/data-col entries silently shrink the grid and must be rejected"
)
return errors
rows = declared_rows
cols = declared_cols
seen_rows: set[str] = set()
seen_cols: set[str] = set()
for c in cells:
if c["row"] not in rows:
errors.append(
f"{path.name}: cell uses undeclared row '{c['row']}' — "
"every cell must map to a declared row label"
)
if c["col"] not in cols:
errors.append(
f"{path.name}: cell uses undeclared column '{c['col']}' — "
"every cell must map to a declared column label"
)
seen_rows.add(c["row"])
seen_cols.add(c["col"])
expected = len(rows) * len(cols)
actual = len(cells)
if actual != expected:
errors.append(
f"{path.name}: expected {expected} cells "
f"({len(rows)} rows × {len(cols)} cols) but found {actual}; "
"every (row, col) pair must be present"
)
else:
seen: set[tuple[str, str]] = set()
for c in cells:
key = (c["row"], c["col"])
if key in seen:
errors.append(
f"{path.name}: duplicate cell ({c['row']}, {c['col']}); "
"each (row, col) pair must appear exactly once"
)
break
seen.add(key)
# Invariant 2: monotone fill ramp for non-focal cells.
if non_focal:
opaque_missing = [c for c in non_focal if c["opacity"] is None]
if opaque_missing:
for c in opaque_missing:
errors.append(
f"{path.name}: non-focal cell ({c['row']}, {c['col']}) "
"has no parseable rgba fill — every non-focal cell must "
"declare its opacity through rgba(R,G,B,opacity)"
)
by_value: dict[float, list[float]] = {}
for c in non_focal:
if c["opacity"] is not None:
by_value.setdefault(c["value"], []).append(c["opacity"])
# Within each value, all opacities must be identical (±TOLERANCE).
TOLERANCE = 0.03
for val, opacities in sorted(by_value.items()):
spread = max(opacities) - min(opacities)
if spread > TOLERANCE:
errors.append(
f"{path.name}: cells with data-value={val} have inconsistent "
f"opacities (spread {spread:.3f} > {TOLERANCE}); "
"every cell with the same value must use the same fill opacity"
)
# Across values, mean opacity must be non-decreasing.
sorted_vals = sorted(by_value.keys())
prev_opacity = -1.0
prev_val = None
for val in sorted_vals:
mean_opacity = sum(by_value[val]) / len(by_value[val])
if mean_opacity < prev_opacity - TOLERANCE:
errors.append(
f"{path.name}: value {val} has mean fill opacity "
f"{mean_opacity:.3f}, which is less than value "
f"{prev_val}'s opacity {prev_opacity:.3f}; "
"the fill ramp must be non-decreasing — higher values "
"must use >= opacity than lower values"
)
prev_opacity = mean_opacity
prev_val = val
# Require focal cell value text to maintain WCAG AA against the rendered focal fill.
# The fill is composited against the heatmap canvas background, which is light paper in
# light variants and dark paper in dark variants.
canvas_underlay = None
for rect_match in RECT_RE.finditer(source):
attrs = rect_match.group("attrs")
fill = _attr(attrs, "fill")
width = _attr(attrs, "width")
height = _attr(attrs, "height")
if fill and width and height and (width in {"100%", "100"} or height in {"100%", "100"}):
parsed = _parse_color(fill)
if parsed is not None:
canvas_underlay = parsed
break
if canvas_underlay is None:
canvas_underlay = (245, 245, 245)
text_labels = parse_text_labels(source)
for focal in focal_cells:
bg_rgb = _parse_color(focal["fill"], canvas_underlay)
if bg_rgb is None:
continue
x0 = focal["x"] if focal["x"] is not None else 0.0
y0 = focal["y"] if focal["y"] is not None else 0.0
width = focal["width"] if focal["width"] is not None else 0.0
height = focal["height"] if focal["height"] is not None else 0.0
for text in text_labels:
if text["x"] is None or text["y"] is None:
continue
if not (x0 <= text["x"] <= x0 + width and y0 <= text["y"] <= y0 + height):
continue
text_rgb = _parse_color(text["fill"])
if text_rgb is None:
continue
ratio = _contrast_ratio(text_rgb, bg_rgb)
if ratio < 4.5:
errors.append(
f"{path.name}: focal cell ({focal['row']}, {focal['col']}) has text "
f"{text['text']!r} in {text['fill']} on fill {focal['fill']} "
f"({ratio:.2f}:1 contrast, below WCAG AA 4.5:1)"
)
return errors
def _heatmap_examples(asset_dir: Path) -> list[Path]:
"""Return all heatmap example HTML files from the asset directory."""
return sorted(asset_dir.glob("example-heatmap*.html"))
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Verify heatmap chart files.",
epilog="Exit 0 = clean, 1 = findings, 2 = usage error.",
)
parser.add_argument(
"files",
nargs="*",
metavar="FILE",
help="Heatmap HTML files to check. Default: all example-heatmap*.html.",
)
parser.add_argument(
"--all",
action="store_true",
help="Check all example-heatmap*.html in the assets directory.",
)
args = parser.parse_args(argv)
if args.all or not args.files:
paths = _heatmap_examples(ASSET_DIR)
if not paths:
print("No heatmap examples found.", file=sys.stderr)
return 1
else:
paths = [Path(f) for f in args.files]
all_errors: list[str] = []
for path in paths:
all_errors.extend(check_file(path))
if all_errors:
for err in all_errors:
print(err, file=sys.stderr)
return 1
print(f"OK — {len(paths)} file(s) checked, no findings.")
return 0
if __name__ == "__main__":
sys.exit(main())