From ddaeae936e1ce24083a9fa1deecea6ed2826464b Mon Sep 17 00:00:00 2001 From: 0xDarkMatter <143511969+0xDarkMatter@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:23:44 +1000 Subject: [PATCH] feat(line): add ridgeline as a Line variant (#143) * feat(line): add ridgeline as a Line variant One distribution per series, stacked at a fixed pitch on a single shared amplitude. Ships three static examples (light, dark, full editorial), the type-line.md spec, and a geometric gate. verify-ridgeline.py derives the figure's one amplitude and its baseline pitch from the figure itself, then holds every vertex to both. It also checks the drawn baseline rules, the shared bin scale, absolute M/L/Z only, the overlap ceiling, the focus pairing and every label binding. test-verify-ridgeline.py proves each check in both polarities and pins the scope treaty with the sibling Line gates. The contract binds data-bins on a , which no other chart in the repo declares, so verify-slopegraph and this gate never claim the same file. Co-Authored-By: Claude Fable 5 * fix(verify-ridgeline): read exponent coordinates as numbers, not commands parse_d scanned for [A-Za-z] independently of the numbers, so the `e` in a valid scientific-notation coordinate such as 4.4e2 was counted as a path command. The explicit-command rule then rejected a correct ridgeline with a message about command counts. Tokenise the d attribute left to right with the number branch tried first, so an exponent is consumed as part of its number. A letter that survives that branch is a real command and still faces the M/L/Z vocabulary check; anything else leaves the path unreadable, which stays a finding. Adds both polarities: a shipped coordinate rewritten as 4.4e2 must verify, and a falsified one written the same way must not. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .factory-plugin/plugin.json | 2 +- .github/workflows/ci.yml | 12 + .maintainer-policy.json | 2 + CONTRIBUTING.md | 5 + scripts/test-verify-ridgeline.py | 545 ++++++++++ scripts/verify-ridgeline.py | 959 ++++++++++++++++++ skills/diagram-design/SKILL.md | 2 +- .../assets/example-ridgeline-dark.html | 104 ++ .../assets/example-ridgeline-full.html | 134 +++ .../assets/example-ridgeline.html | 104 ++ skills/diagram-design/assets/index.html | 3 + skills/diagram-design/references/type-line.md | 68 ++ 14 files changed, 1940 insertions(+), 4 deletions(-) create mode 100644 scripts/test-verify-ridgeline.py create mode 100644 scripts/verify-ridgeline.py create mode 100644 skills/diagram-design/assets/example-ridgeline-dark.html create mode 100644 skills/diagram-design/assets/example-ridgeline-full.html create mode 100644 skills/diagram-design/assets/example-ridgeline.html diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f048e26..24c0458 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "diagram-design", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", - "version": "2.6.1", + "version": "2.6.2", "author": { "name": "Cathryn Lavery", "url": "https://github.com/cathrynlavery" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 678303d..6a152f6 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "diagram-design", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", - "version": "2.6.1", + "version": "2.6.2", "author": { "name": "Cathryn Lavery", "url": "https://github.com/cathrynlavery" diff --git a/.factory-plugin/plugin.json b/.factory-plugin/plugin.json index f048e26..24c0458 100644 --- a/.factory-plugin/plugin.json +++ b/.factory-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "diagram-design", "description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.", - "version": "2.6.1", + "version": "2.6.2", "author": { "name": "Cathryn Lavery", "url": "https://github.com/cathrynlavery" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a411f9..37866e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -249,6 +249,16 @@ jobs: id: sankey_tests run: python scripts/test-verify-sankey.py + - name: Verify ridgeline shared amplitude + if: always() + id: ridgeline + run: python scripts/verify-ridgeline.py --all + + - name: Verify ridgeline checker + if: always() + id: ridgeline_tests + run: python scripts/test-verify-ridgeline.py + # lint-render.py's oracle is pixels, so the browser is part of the contract: # pin Playwright exactly, and let that pin choose the Chromium build. Bump # both together, deliberately, by editing PLAYWRIGHT_VERSION. @@ -325,6 +335,8 @@ jobs: echo "| Slopegraph Checker | ${{ steps.slopegraph_tests.outcome == 'success' && '✅ Passed' || (steps.slopegraph_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Sankey Flow Conservation | ${{ steps.sankey.outcome == 'success' && '✅ Passed' || (steps.sankey.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Sankey Checker | ${{ steps.sankey_tests.outcome == 'success' && '✅ Passed' || (steps.sankey_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY + echo "| Ridgeline Shared Amplitude | ${{ steps.ridgeline.outcome == 'success' && '✅ Passed' || (steps.ridgeline.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY + echo "| Ridgeline Checker | ${{ steps.ridgeline_tests.outcome == 'success' && '✅ Passed' || (steps.ridgeline_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Primitive Icons & Documentation | ${{ steps.icons.outcome == 'success' && '✅ Passed' || (steps.icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Render Linter Self-Test | ${{ steps.render_selftest.outcome == 'success' && '✅ Passed' || (steps.render_selftest.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY echo "| Rendered Layout (paint oracle) | ${{ steps.render.outcome == 'success' && '✅ Passed' || (steps.render.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.maintainer-policy.json b/.maintainer-policy.json index e18dab6..fd23594 100644 --- a/.maintainer-policy.json +++ b/.maintainer-policy.json @@ -51,6 +51,8 @@ "python3 scripts/test-verify-dumbbell.py", "python3 scripts/verify-slopegraph.py --all", "python3 scripts/test-verify-slopegraph.py", + "python3 scripts/verify-ridgeline.py --all", + "python3 scripts/test-verify-ridgeline.py", "python3 scripts/build-icons.py && git diff --ignore-space-at-eol --exit-code -- skills/diagram-design/assets/icons.html skills/diagram-design/references/primitive-icons.md" ], "required_check_names": [], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d3b0b9..6792cdd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,8 @@ The helper refuses to run if the Claude and Codex versions already differ. If an | Dumbbell checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-dumbbell.py` | | Slopegraph axes share one scale and every endpoint matches its printed value | `python3 scripts/verify-slopegraph.py --all` | | Slopegraph checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-slopegraph.py` | +| Ridgeline ridges share one amplitude and every printed range matches its bins | `python3 scripts/verify-ridgeline.py --all` | +| Ridgeline checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-ridgeline.py` | | Sankey flow conservation and ribbon geometry | `python3 scripts/verify-sankey.py --all` | | Sankey checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-sankey.py` | | Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files | @@ -107,6 +109,8 @@ python3 scripts/test-plugin-package.py \ && python3 scripts/test-verify-dumbbell.py \ && python3 scripts/verify-slopegraph.py --all \ && python3 scripts/test-verify-slopegraph.py \ + && python3 scripts/verify-ridgeline.py --all \ + && python3 scripts/test-verify-ridgeline.py \ && python3 scripts/verify-sankey.py --all \ && python3 scripts/test-verify-sankey.py ``` @@ -118,6 +122,7 @@ python3 scripts/test-plugin-package.py \ - **`verify-*.py`:** the extractor's real behavior no longer matches its fixture or the documentation, or the reference/command/prompt wiring drifted. Fix the source of truth — do not widen a test to avoid a failure. - **`verify-screenshot-freshness.py`:** a canonical minimal-light example or its committed PNG changed without a synchronized catalog refresh. Before the first regeneration, install the renderer with `python3 -m pip install playwright && python3 -m playwright install chromium`. Then run `python3 scripts/render-canonical-screenshots.py`, inspect all 39 renders, and commit the updated PNGs plus `docs/screenshots/manifest.json`. - **`verify-slopegraph.py`:** the two axes disagree about scale or origin, or an endpoint is drawn somewhere other than where its own declared value belongs. Fix the coordinate, never the label — and never move a point to stop two endpoint labels colliding, because crowded labels mean the values really are close. +- **`verify-ridgeline.py`:** a ridge is drawn on its own amplitude, a baseline sits off the stack's pitch or away from its drawn rule, a ridge is sampled on its own x positions, or a printed range is wider than the bins it claims. Fix the geometry or the declaration so they state one thing — never renormalise a single ridge to make it readable, and never move a baseline to buy one ridge headroom. - **`verify-geometry.py`:** a label mask overlaps a node declared later in the document, so the node fill clips the label at render time. Move the label to a free segment of its connector — keep the 6–10px gap from the stroke required by SKILL.md §6, and do not shrink the mask to sneak under the check. - **Icon assets:** you changed `scripts/vendor/icons/` or `scripts/build-icons.py` and the generated files went stale. Rerun `python3 scripts/build-icons.py` and commit the regenerated files. diff --git a/scripts/test-verify-ridgeline.py b/scripts/test-verify-ridgeline.py new file mode 100644 index 0000000..b5179db --- /dev/null +++ b/scripts/test-verify-ridgeline.py @@ -0,0 +1,545 @@ +#!/usr/bin/env python3 +"""Adversarial tests for verify-ridgeline.py — both polarities. + +Per ADR 0005, a geometric contract is a checker plus fixtures proving it fires +when it should and stays quiet when it shouldn't. Each mutation below renders +perfectly and no other gate would catch it: one ridge drawn on its own +amplitude, a baseline nudged off the pitch to buy headroom, a spline through +binned counts, a printed range wider than the mass it claims, a name label +sitting on a neighbour's row. + +The synthetic figures at the end prove the checker reads the FIGURE's own +amplitude and pitch rather than memorising the shipped layout: a valid ridgeline +at a different top, pitch and scale must pass, and the budget rules must fire at +BOTH ends on counts the shipped example cannot be mutated into. + +The last case pins the scope treaty with the sibling Line-variant checkers. +`data-bins` on a `` is this contract's vocabulary; `data-series`, +`data-ranks` and `data-layer` belong to the slopegraph, bump and streamgraph +gates. Every sibling checker present in the tree is handed the ridgeline +examples directly and must skip them without a word — a sibling that claimed one +would reject it for lacking elements it never said it had. The reverse direction +is asserted too: this checker must skip the siblings' shipped examples. + +Usage: python3 scripts/test-verify-ridgeline.py +Exit: 0 all pass, 1 a case failed. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CHECKER = ROOT / "scripts/verify-ridgeline.py" +GOOD = ROOT / "skills/diagram-design/assets/example-ridgeline.html" + +# The sibling Line-variant gates. Any that exists in the tree must skip the +# ridgeline examples rather than claim them; the ones not yet landed are absent, +# and their absence is printed rather than passed over in silence. +SIBLINGS = ["verify-slopegraph.py", "verify-bump.py", "verify-streamgraph.py"] +RIDGELINES = sorted((ROOT / "skills/diagram-design/assets").glob("example-ridgeline*.html")) + +CHILD_ENV = {**os.environ, "PYTHONIOENCODING": "utf-8"} + +# Anchors, all literal excerpts of the shipped example. A fixture that fails to +# build means the example moved, which is itself worth knowing. +FOCAL_D = ('d="M320,320 L350,317.6 L380,305.6 L410,279.2 L440,269.6 L470,286.4 ' + 'L500,300.8 L530,305.6 L560,303.2 L590,298.4 L620,303.2 L650,310.4 L680,320 Z"') +FOCAL_PATH = ('') +# The same ridge redrawn at 3.6px per unit instead of the figure's 2.4. Every +# vertex still sits on a straight, closed outline over the same bins; only the +# scale is private. This is the defect the type exists to prevent. +PRIVATE_AMPLITUDE_D = ('d="M320,320 L350,316.4 L380,298.4 L410,258.8 L440,244.4 ' + 'L470,269.6 L500,291.2 L530,298.4 L560,294.8 L590,287.6 ' + 'L620,294.8 L650,305.6 L680,320 Z"') +REPORT_RULE = ('') + + +def invoke(argv): + result = subprocess.run( + argv, capture_output=True, text=True, + encoding="utf-8", errors="replace", env=CHILD_ENV, + ) + return result.returncode, (result.stdout or "") + (result.stderr or "") + + +def run(*paths): + return invoke([sys.executable, str(CHECKER), *(str(p) for p in paths)]) + + +def write(directory, name, source): + path = directory / name + path.write_text(source, encoding="utf-8", newline="\n") + return path + + +def case(failures, directory, name, source, original, expect, describe): + """One mutation: it must be a real edit, and it must be rejected.""" + if source == original: + failures.append("could not build the %s fixture (anchor moved)" % name) + return + code, output = run(write(directory, "%s.html" % name, source)) + if code == 0: + failures.append("%s was accepted" % describe) + elif expect not in output: + failures.append("%s was reported without the expected reason: %s" + % (describe, output.strip())) + else: + print("OK: %s is rejected" % describe) + + +def hump(n_bins, peak_at, height=12, step=3): + """A closed distribution: zero at both ends, one interior peak.""" + values = [] + for j in range(n_bins): + if j in (0, n_bins - 1): + values.append(0) + else: + values.append(max(0, height - step * abs(j - peak_at))) + return values + + +def synthetic(n_ridges, n_bins, top=100, pitch=40, amp=2.0, x0=200, dx=20, unit=10): + """A minimal valid ridgeline: n_ridges over n_bins, on its own grid.""" + xs = [x0 + i * dx for i in range(n_bins)] + parts = [ + 'synthetic ridgeline', + '', + 'synthetic ridgeline', + 'ridgeline fixture', + ] + for index, (x, value) in enumerate(((xs[0], 0), (xs[-1], (n_bins - 1) * unit))): + parts.append('%d' + % (index, value, x, value)) + for s in range(n_ridges): + baseline = top + s * pitch + values = hump(n_bins, 1 + (s % max(1, n_bins - 2))) + pts = ["%g,%g" % (x, baseline - amp * v) for x, v in zip(xs, values)] + d = "M" + pts[0] + " " + " ".join("L" + p for p in pts[1:]) + " Z" + focal = s == 0 + stroke = "#eb6c36" if focal else "rgba(45,49,66,0.70)" + width = "2.4" if focal else "1.2" + live = [i for i, v in enumerate(values) if v > 0] + parts.append('' + % (s, xs[0], baseline, xs[-1], baseline)) + parts.append('' + % (s, baseline, ",".join(str(v) for v in values), d, stroke, width)) + parts.append('r%d' % (s, xs[0] - 16, baseline + 3.5, s)) + parts.append('%d–%d ms' + % (s, xs[-1] + 16, baseline + 3.5, + live[0] * unit, live[-1] * unit)) + parts.append("") + return "\n".join(parts) + + +def main() -> int: + source = GOOD.read_text(encoding="utf-8") + failures = [] + + with tempfile.TemporaryDirectory() as raw: + directory = Path(raw) + + # 1. The shipped example must pass untouched. + code, output = run(GOOD) + if code != 0: + failures.append("clean example was rejected: %s" % output.strip()) + else: + print("OK: the shipped ridgeline passes") + + # 2. THE defect: one ridge redrawn on its own amplitude. Every other + # check stays green — the outline is straight, closed, on the shared + # bins, and its labels are correct. + case( + failures, directory, "private-amplitude", + source.replace(FOCAL_D, PRIVATE_AMPLITUDE_D, 1), + source, "one amplitude", + "a ridge drawn on its own amplitude", + ) + + # 3. A baseline nudged off the pitch to buy one ridge headroom, with its + # rule, its outline and its labels all moved to match. + case( + failures, directory, "off-pitch", + source.replace('data-baseline="320"', 'data-baseline="328"', 1) + .replace(FOCAL_D, FOCAL_D.replace(",320 ", ",328 ") + .replace(",320\"", ",328\""), 1) + .replace('checkout-api', + 'text-anchor="end">checkout', 1), + source, "binding must agree", + "a name label reading a different name than it binds", + ) + case( + failures, directory, "unlabelled", + source.replace(' 40–440 ms\n', + "", 1), + source, "prints no range label", + "a ridge with no range label", + ) + + # 12. A printed range wider than the mass it claims. Every geometric + # check still passes; only the sentence beside the ridge is false. + case( + failures, directory, "false-range", + source.replace(">40–440 ms<", ">40–480 ms<", 1), + source, "on the figure's own tick scale", + "a printed range wider than the ridge's nonzero bins", + ) + + # 13. Bin ticks: text edited without its binding, and a tick moved off + # the bins it labels. + case( + failures, directory, "tick-swap", + source.replace('text-anchor="middle">240', + 'text-anchor="middle">280', 1), + source, "must state one number", + "a tick whose visible text was edited without its binding", + ) + case( + failures, directory, "tick-off-bins", + source.replace('data-tick="2" data-bin="240" x="500"', + 'data-tick="2" data-bin="240" x="505"', 1), + source, "not a bin position", + "a tick drawn off the bins it labels", + ) + + # 14. Transforms: on the ridge, on an ancestor, and from CSS. Raw + # coordinates are the basis, so any of the three moves the rendered + # mark away from the bin it was checked against. + case( + failures, directory, "transformed", + source.replace('' + FOCAL_PATH + "", 1), + source, "ancestor", + "a ridge wrapped in a transformed group", + ) + case( + failures, directory, "css-transform", + source.replace("", re.IGNORECASE | re.DOTALL) +# `transform:` but not `text-transform:` - the editorial template uses the latter. +CSS_TRANSFORM_RE = re.compile(r"(?]+>") +# Both quote styles, for the same reason verify-slopegraph gives: a +# single-quoted attribute must be read, not silently dropped from the set. +ATTR_RE = re.compile( + r"""(?P[\w:-]+)\s*=\s*(?P["'])(?P.*?)(?P=quote)""", + re.DOTALL, +) +DECLARES_BINS_RE = re.compile(r"\bdata-bins\s*=", re.IGNORECASE) +NUM_RE = re.compile(r"[-+]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?") +# Path-data tokens, number branch FIRST so an exponent's `e` is consumed as part +# of its number instead of being read as a command. See parse_d. +D_TOKEN_RE = re.compile( + r"(?P[-+]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?)" + r"|(?P[A-Za-z])" + r"|(?P[\s,]+)" + r"|(?P[\s\S])" +) +# En dash or hyphen, because a range printed with either reads the same. +RANGE_LABEL_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*[–-]\s*(\d+(?:\.\d+)?)\s*(\S.*)?$") + +# The shipped coordinates carry one decimal, so a vertex can sit 0.05px from +# its true position honestly. Everything past that is an edit. +AMPLITUDE_TOLERANCE = 0.06 # px, a vertex against the figure's one amplitude +PITCH_TOLERANCE = 0.01 # px, a baseline against the derived pitch +BIN_TOLERANCE = 0.01 # px, a ridge's x samples against the shared bins +RULE_TOLERANCE = 0.5 # px, a baseline rule against its row and the bin run +TICK_TOLERANCE = 0.5 # px, a tick caption against a bin position +SCALE_TOLERANCE = 0.5 # in x-axis units, a printed range against its bin +LABEL_ROW_OFFSET = 3.5 # px, a label baseline below its row, per the reference +MIN_RIDGES, MAX_RIDGES = 3, 12 +MIN_BINS, MAX_BINS = 8, 40 +FOCAL_WIDTH, PLAIN_WIDTH = 2.4, 1.2 +ACCENTS = {"#eb6c36", "#f08a59"} # light and dark skin accent tokens + + +class Ridge: + __slots__ = ("name", "values", "points", "baseline", "stroke", "width", "offset") + + def __init__(self, name, values, points, baseline, stroke, width, offset): + self.name = name + self.values = values + self.points = points + self.baseline = baseline + self.stroke = stroke + self.width = width + self.offset = offset + + @property + def focal(self): + return self.stroke in ACCENTS + + +def attrs_of(raw: str) -> dict: + found = {} + for match in ATTR_RE.finditer(raw): + name = match.group("name").lower() + if name not in found: # a browser keeps the FIRST of a repeat + found[name] = html.unescape(match.group("value")) + return found + + +def number(value): + if value is None: + return None + match = NUM_RE.fullmatch(value.strip()) + if not match: + return None + try: + return float(match.group(0)) + except ValueError: + return None + + +def line_of(source: str, offset: int) -> int: + return source.count("\n", 0, offset) + 1 + + +def plain(body: str) -> str: + return TAG_RE.sub("", html.unescape(body)).strip() + + +def blank_comments(source: str) -> str: + """Comments replaced with spaces, offsets preserved.""" + return COMMENT_RE.sub(lambda m: " " * len(m.group(0)), source) + + +def named_text(source: str) -> str: + return " ".join( + plain(m.group("body")).lower() for m in NAMED_RE.finditer(source) + ) + + +def median(values): + ordered = sorted(values) + middle = len(ordered) // 2 + if len(ordered) % 2: + return ordered[middle] + return (ordered[middle - 1] + ordered[middle]) / 2.0 + + +def looks_like_ridgeline(path: Path, source: str) -> bool: + """Does this file present itself as a ridgeline? + + Generous on the vocabulary this contract binds: `data-bins` belongs to no + other chart in this repo, so any file declaring it is held to the contract + even if nothing else parses - that combination is the fail-closed case. + """ + if path.name.startswith("example-ridgeline"): + return True + if DECLARES_BINS_RE.search(source): + return True + described = named_text(source) + return any(word in described for word in ("ridgeline", "ridge line", "joyplot")) + + +def parse_d(d: str): + """(commands, points) for a closed polygon path, or (None, None). + + Tokenised left to right with numbers tried BEFORE letters, because `e` and + `E` are letters inside a valid coordinate: `3.5e2` is one number, not a + number followed by an `e` command. Scanning for `[A-Za-z]` separately would + invent that command and then reject a perfectly good path for having one + token too many. A letter that survives the number branch is a real command + (and is held to the M/L/Z vocabulary by the caller); anything else makes the + path unreadable, which is a finding rather than a skip. + """ + commands, numbers = [], [] + for match in D_TOKEN_RE.finditer(d): + kind = match.lastgroup + if kind == "num": + try: + numbers.append(float(match.group())) + except ValueError: + return None, None + elif kind == "cmd": + commands.append(match.group()) + elif kind == "junk": + return None, None + if len(numbers) % 2 != 0: + return None, None + points = list(zip(numbers[0::2], numbers[1::2])) + return commands, points + + +def parse_ridges(source: str, findings: list, name: str) -> list: + ridges = [] + seen = set() + for match in PATH_RE.finditer(source): + raw = match.group("attrs") + attrs = attrs_of(raw) + label = attrs.get("data-ridge") + if label is None: + # A with no data-ridge is scenery. One whose raw text DOES + # declare data-bins and still parsed to nothing is markup this + # checker could not read, which is a finding rather than scenery. + if DECLARES_BINS_RE.search(raw): + findings.append( + "%s:%d: a declares data-bins but no data-ridge — a " + "distribution outline must say whose distribution it draws, or " + "its labels cannot be bound to it" + % (name, line_of(source, match.start())) + ) + continue + if label in seen: + findings.append( + "%s:%d: a second path declares data-ridge=%r — one ridge, one " + "outline, or the reader has two shapes and no way to pick" + % (name, line_of(source, match.start()), label) + ) + continue + seen.add(label) + bins_raw = attrs.get("data-bins") + if bins_raw is None: + findings.append( + "%s:%d: ridge %r declares data-ridge but no data-bins — the declared " + "bins are the basis every drawn vertex is verified against, so an " + "outline without them cannot be checked" + % (name, line_of(source, match.start()), label) + ) + continue + values = [number(token) for token in bins_raw.split(",")] + if not values or any(v is None for v in values): + findings.append( + "%s:%d: ridge %r has data-bins=%r, which is not a comma list of " + "numbers" % (name, line_of(source, match.start()), label, bins_raw) + ) + continue + if any(v < 0 for v in values): + findings.append( + "%s:%d: ridge %r declares a negative bin — a share of requests below " + "zero is not a share, and it would draw below the baseline" + % (name, line_of(source, match.start()), label) + ) + continue + baseline = number(attrs.get("data-baseline")) + if baseline is None: + findings.append( + "%s:%d: ridge %r has no data-baseline — the baseline is the zero of " + "this ridge's amplitude, so an unstated one can be moved to give the " + "ridge headroom and nothing here would notice" + % (name, line_of(source, match.start()), label) + ) + continue + commands, points = parse_d(attrs.get("d", "")) + if commands is None or not points: + findings.append( + "%s:%d: ridge %r has a path this checker cannot read — cannot verify " + "its outline" % (name, line_of(source, match.start()), label) + ) + continue + # Absolute M + L* + Z is the entire permitted grammar. A relative + # command is refused rather than folded into its absolute twin: it + # renders against the previous point while the numbers parsed here read + # as absolute coordinates, so folding case would certify positions the + # figure never drew. `z` is the one exception and is NOT relative - + # closepath takes no coordinates, so it has no relative form and the two + # spellings are the same command. + if any(c.islower() and c != "z" for c in commands): + findings.append( + "%s:%d: ridge %r uses relative path commands (%s) — a relative " + "coordinate renders against the previous point, so the numbers this " + "checker reads are not where the outline lands. Write the path in " + "absolute M/L/Z commands" + % (name, line_of(source, match.start()), label, "/".join(commands)) + ) + continue + commands = ["Z" if c == "z" else c for c in commands] + # The vocabulary first, so a curve is reported as a curve. A C/S/Q/A + # command also carries control points, which would otherwise trip the + # count rule below and hide the real defect behind an arithmetic one. + if set(commands) - {"M", "L", "Z"}: + findings.append( + "%s:%d: ridge %r draws %s — a ridgeline joins adjacent bins with " + "straight segments and closes with Z. A spline through binned counts " + "invents extrema the sample never had, and the source line states the " + "drawing is unsmoothed" + % (name, line_of(source, match.start()), label, "/".join(commands)) + ) + continue + # One command letter per point. SVG lets `L` take repeated coordinate + # pairs, and that shorthand is legal - but this checker pairs commands + # with points positionally, so it is required to be written out rather + # than silently mis-paired. + if len(commands) != len(points) + 1: + findings.append( + "%s:%d: ridge %r writes %d path command(s) for %d point(s) — this " + "contract needs one explicit command per vertex (implicit repetition " + "after an L is legal SVG but leaves the checker pairing commands with " + "the wrong coordinates). Write M, then one L per bin, then Z" + % (name, line_of(source, match.start()), label, + len(commands), len(points)) + ) + continue + expected = ["M"] + ["L"] * (len(points) - 1) + ["Z"] + if commands != expected: + findings.append( + "%s:%d: ridge %r draws %s — the outline opens with one M, walks the " + "bins with L, and closes with a single Z. A second subpath or a " + "mid-path M lifts the pen inside one distribution" + % (name, line_of(source, match.start()), label, "/".join(commands)) + ) + continue + if len(points) != len(values): + findings.append( + "%s:%d: ridge %r declares %d bins but draws %d points — every declared " + "bin needs its vertex and every vertex its bin" + % (name, line_of(source, match.start()), label, len(values), len(points)) + ) + continue + width = number(attrs.get("stroke-width")) + ridges.append(Ridge(label, values, points, baseline, + (attrs.get("stroke") or "").strip().lower(), + width, match.start())) + return ridges + + +def transformed_spans(source: str) -> list: + events = [] + for match in GROUP_OPEN_RE.finditer(source): + if match.group("selfclose"): + continue + attrs = attrs_of(match.group("attrs")) + events.append((match.start(), "open", "transform" in attrs)) + for match in GROUP_CLOSE_RE.finditer(source): + events.append((match.start(), "close", False)) + events.sort() + spans, stack = [], [] + for position, kind, transformed in events: + if kind == "open": + stack.append((position, transformed)) + elif stack: + start, was_transformed = stack.pop() + if was_transformed: + spans.append((start, position)) + for start, was_transformed in stack: + if was_transformed: + spans.append((start, len(source))) + return spans + + +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 coordinates, so a " + "transform moves the rendered mark away from the bin it was checked " + "against. Bake the offset into the coordinates instead" + % (name, line_of(source, offset), what, how) + ) + + for pattern, key in ((PATH_RE, "data-ridge"), (LINE_RE, "data-ridge")): + for match in pattern.finditer(source): + attrs = attrs_of(match.group("attrs")) + if key not in attrs: + continue + what = "ridge %r" % attrs[key] + if "transform" in attrs: + report(match.start(), what, "transform=%r" % attrs["transform"]) + elif enclosed(match.start()): + report(match.start(), what, "an ancestor / transform") + + for match in TEXT_RE.finditer(source): + attrs = attrs_of(match.group("attrs")) + if "data-ridge" 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 / 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" + % (name, line_of(source, match.start("body") + found.start())) + ) + + +def check_bins(ridges: list, findings: list, source: str, name: str) -> bool: + """Every ridge samples the same x positions, in increasing order.""" + reference = [round(x, 3) for x, _ in ridges[0].points] + if sorted(set(reference)) != reference: + findings.append( + "%s:%d: ridge %r samples x=%s, which is not strictly increasing — bins " + "are ordered, and an x visited twice or out of order redraws the scale" + % (name, line_of(source, ridges[0].offset), ridges[0].name, + "/".join("%g" % x for x in reference[:6]) + "…") + ) + return False + shared = True + for r in ridges[1:]: + xs = [round(x, 3) for x, _ in r.points] + if len(xs) != len(reference) or any( + abs(a - b) > BIN_TOLERANCE for a, b in zip(xs, reference) + ): + findings.append( + "%s:%d: ridge %r samples its own x positions — every ridge must share " + "one x-scale or a peak at one x stops meaning one latency" + % (name, line_of(source, r.offset), r.name) + ) + shared = False + return shared + + +def check_baselines(ridges: list, source: str, findings: list, name: str): + """Baselines strictly downward at one pitch, each with its drawn rule. + + Returns the derived pitch, or None when the rows could not be read. + """ + ordered = sorted(ridges, key=lambda r: r.baseline) + rows = [r.baseline for r in ordered] + if len(set(rows)) != len(rows): + findings.append( + "%s: two ridges declare the same baseline — ridges are stacked, and two " + "sharing a row is one ridge drawn over another" % name + ) + return None + pitch = (rows[-1] - rows[0]) / (len(rows) - 1) + if pitch <= 0: + findings.append( + "%s: no downward baseline pitch could be derived — refusing to report OK " + "on a stack this checker could not read" % name + ) + return None + for index, r in enumerate(ordered): + expected = rows[0] + index * pitch + if abs(r.baseline - expected) > PITCH_TOLERANCE: + findings.append( + "%s:%d: ridge %r sits on a baseline at y=%g, but a fixed pitch of %g " + "puts row %d at y=%g — an unevenly pitched stack reads relative " + "amplitude wrong, and a row nudged for headroom is the same lie as a " + "private scale" + % (name, line_of(source, r.offset), r.name, r.baseline, pitch, + index + 1, expected) + ) + + # Each ridge's outline must actually start and end on its own baseline. + for r in ridges: + for end, (_, y) in (("first", r.points[0]), ("last", r.points[-1])): + if abs(y - r.baseline) > AMPLITUDE_TOLERANCE: + findings.append( + "%s:%d: ridge %r starts or ends off its baseline (%s vertex at " + "y=%g, baseline y=%g) — the outline closes along the baseline, so " + "a distribution cut off mid-mass would be drawn as a cliff and " + "read as data" + % (name, line_of(source, r.offset), r.name, end, y, r.baseline) + ) + for _, y in r.points: + if y - r.baseline > AMPLITUDE_TOLERANCE: + findings.append( + "%s:%d: ridge %r draws a vertex at y=%g, below its own baseline " + "y=%g — a ridge rises from its baseline and never dips through it" + % (name, line_of(source, r.offset), r.name, y, r.baseline) + ) + break + + # One drawn rule per ridge, on its row, spanning the bin run. + x_first = min(x for x, _ in ridges[0].points) + x_last = max(x for x, _ in ridges[0].points) + rules = {} + for match in LINE_RE.finditer(source): + attrs = attrs_of(match.group("attrs")) + if attrs.get("data-role") != "baseline": + continue + label = attrs.get("data-ridge") + if label is None: + findings.append( + "%s:%d: a baseline rule declares no data-ridge — an unbound rule can " + "be moved off the row it belongs to and nothing here would notice" + % (name, line_of(source, match.start())) + ) + continue + rules[label] = (attrs, match.start()) + + declared = {r.name: r for r in ridges} + for label, (attrs, offset) in sorted(rules.items(), key=lambda item: item[1][1]): + if label not in declared: + findings.append( + "%s:%d: a baseline rule names ridge %r, which no path declares — a " + "rule with no ridge reads as a row that is not there" + % (name, line_of(source, offset), label) + ) + continue + r = declared[label] + y1, y2 = number(attrs.get("y1")), number(attrs.get("y2")) + rx1, rx2 = number(attrs.get("x1")), number(attrs.get("x2")) + if y1 is None or y2 is None or abs(y1 - y2) > RULE_TOLERANCE: + findings.append( + "%s:%d: the baseline rule for ridge %r is not horizontal — a sloped " + "baseline makes every amplitude on that row a different number" + % (name, line_of(source, offset), label) + ) + continue + if abs(y1 - r.baseline) > RULE_TOLERANCE: + findings.append( + "%s:%d: the baseline rule for ridge %r is drawn at y=%g but the ridge " + "declares its baseline at y=%g — the drawn zero and the measured zero " + "must be one line" % (name, line_of(source, offset), label, y1, r.baseline) + ) + if rx1 is None or rx2 is None \ + or abs(min(rx1, rx2) - x_first) > RULE_TOLERANCE \ + or abs(max(rx1, rx2) - x_last) > RULE_TOLERANCE: + findings.append( + "%s:%d: the baseline rule for ridge %r does not span the bin run " + "(x=%g to x=%g) — a short rule reads as a narrower support than the " + "ridge was sampled over" + % (name, line_of(source, offset), label, x_first, x_last)) + + for r in ridges: + if r.name not in rules: + findings.append( + "%s:%d: ridge %r has no baseline rule (a with data-ridge and " + "data-role=\"baseline\") — the baseline is where a zero bin sits, and " + "an undrawn one leaves the reader estimating the zero of every " + "amplitude" % (name, line_of(source, r.offset), r.name) + ) + return pitch + + +def check_amplitude(ridges: list, findings: list, source: str, name: str) -> None: + """One figure-wide amplitude, derived robustly then enforced exactly.""" + # Median of PER-RIDGE medians, not of every vertex pooled. Pooling would let + # one ridge with many nonzero bins outvote several honest ridges with few, + # so the figure's scale would be derived from the very ridge under suspicion. + # One ridge per vote makes a single private amplitude a minority by + # construction whenever the figure has three or more ridges. + samples = [] + for r in ridges: + own = [(r.baseline - y) / value + for value, (_, y) in zip(r.values, r.points) if value > 0] + if own: + samples.append(median(own)) + if not samples: + findings.append( + "%s: every declared bin is zero — there is no amplitude to verify, and a " + "figure of five flat lines is not a ridgeline. Refusing to report OK" % name + ) + return + amplitude = median(samples) + if amplitude <= 0: + findings.append( + "%s: the figure's amplitude derives as %g — a ridge rises from its " + "baseline, so amplitude is positive by construction. Refusing to report " + "OK on a scale this checker could not read" % (name, amplitude) + ) + return + for r in ridges: + for value, (x, y) in zip(r.values, r.points): + expected = r.baseline - amplitude * value + if abs(y - expected) > AMPLITUDE_TOLERANCE: + findings.append( + "%s:%d: ridge %r draws its %g bin at x=%g, y=%g, but the figure's " + "one amplitude (%.4gpx per unit) puts it at y=%g — a ridge on its " + "own scale wears a silhouette it did not earn, which is the one " + "lie this type is built to tell" + % (name, line_of(source, r.offset), r.name, value, x, y, + amplitude, expected) + ) + break + + +def check_overlap(ridges: list, pitch, findings: list, source: str, name: str) -> None: + """Overlap is a feature; a peak hidden behind a peak is not.""" + if pitch is None: # the stack could not be read; check_baselines said so + return + for r in ridges: + intrusion = r.baseline - min(y for _, y in r.points) + if intrusion >= 2 * pitch: + findings.append( + "%s:%d: ridge %r rises %gpx above its baseline, past the row two above " + "it (pitch %gpx) — overlap is how a ridgeline reads as depth, but a " + "peak reaching that far is occluded by the peaks it passes. Increase " + "the pitch; never shrink one ridge's amplitude to fit" + % (name, line_of(source, r.offset), r.name, intrusion, pitch) + ) + + +def check_focus(ridges: list, findings: list, source: str, name: str) -> None: + """Exactly one accent ridge, with stroke weight agreeing with stroke colour.""" + focal = [r for r in ridges if r.focal] + if len(focal) != 1: + findings.append( + "%s: %d ridges carry the accent stroke — the accent marks exactly one " + "editorially focal distribution, and spending it twice spends it entirely" + % (name, len(focal)) + ) + for r in ridges: + want = FOCAL_WIDTH if r.focal else PLAIN_WIDTH + if r.width is None or abs(r.width - want) > 1e-9: + findings.append( + "%s:%d: ridge %r has stroke-width=%s but a %s ridge takes %g — weight " + "is the focus cue that survives greyscale, so a mismatch between " + "colour and weight sends the two cues to different ridges" + % (name, line_of(source, r.offset), r.name, + "%g" % r.width if r.width is not None else "?", + "focal" if r.focal else "non-focal", want) + ) + + +def check_ticks(ridges: list, source: str, findings: list, name: str): + """Bin ticks bound to their printed value, on bin positions, one linear scale. + + Returns a callable mapping x to an axis value, or None when the scale could + not be read - in which case the range labels are reported as unverifiable + rather than silently passed. + """ + bin_xs = [x for x, _ in ridges[0].points] + seen = {} + for match in TEXT_RE.finditer(source): + attrs = attrs_of(match.group("attrs")) + index = attrs.get("data-tick") + if index is None: + continue + body = plain(match.group("body")) + if not index.isdigit(): + findings.append( + "%s:%d: a bin tick has data-tick=%r, which is not a tick index" + % (name, line_of(source, match.start()), index) + ) + continue + position = int(index) + if position in seen: + findings.append( + "%s:%d: a second tick with data-tick=%r — one tick, one caption" + % (name, line_of(source, match.start()), index) + ) + continue + declared = number(attrs.get("data-bin")) + if declared is None: + findings.append( + "%s:%d: the tick reading %r has no data-bin — its visible text is " + "unbound, so it can be exchanged with another tick and nothing here " + "would notice" % (name, line_of(source, match.start()), body[:20]) + ) + continue + printed = number(body) + if printed is None or abs(printed - declared) > 1e-9: + findings.append( + "%s:%d: the tick at data-bin=%g prints %r — the visible caption and " + "its binding must state one number" + % (name, line_of(source, match.start()), declared, body[:20]) + ) + continue + x = number(attrs.get("x")) + if x is None or not any(abs(x - bx) <= TICK_TOLERANCE for bx in bin_xs): + findings.append( + "%s:%d: the tick reading %r is drawn at x=%s, which is not a bin " + "position — a tick off the bins labels a latency the figure never " + "sampled" % (name, line_of(source, match.start()), body[:20], + "%g" % x if x is not None else "?") + ) + continue + seen[position] = (x, declared, match.start()) + + if len(seen) < 2: + findings.append( + "%s: %d readable bin tick(s) — a shared x-scale is only shared if it is " + "printed, and two ticks are the minimum that fixes one" % (name, len(seen)) + ) + return None + + ordered = [seen[k] for k in sorted(seen)] + (x0, v0, _), (xn, vn, _) = ordered[0], ordered[-1] + if abs(xn - x0) < 1e-9: + findings.append( + "%s: two bin ticks share an x position — a scale needs two distinct " + "points" % name + ) + return None + slope = (vn - v0) / (xn - x0) + for x, value, offset in ordered: + if abs((v0 + slope * (x - x0)) - value) > SCALE_TOLERANCE: + findings.append( + "%s:%d: the tick at x=%g reads %g, off the straight line the other " + "ticks set — a non-linear x-axis makes every printed range a " + "different distance" % (name, line_of(source, offset), x, value) + ) + return None + return lambda x: v0 + slope * (x - x0) + + +def check_labels(ridges: list, source: str, findings: list, name: str, scale) -> None: + """Name and range printed per ridge, bound, agreeing, and on its own row.""" + bound = {} + for match in TEXT_RE.finditer(source): + attrs = attrs_of(match.group("attrs")) + label = attrs.get("data-ridge") + if label is None: + continue + role = attrs.get("data-role") + key = (label, role) + if key in bound: + findings.append( + "%s:%d: a second %s label for ridge %r — one label per binding, or a " + "reader has two statements and no way to pick" + % (name, line_of(source, match.start()), role, label) + ) + continue + bound[key] = (plain(match.group("body")), number(attrs.get("y")), match.start()) + + declared = {r.name: r for r in ridges} + for (label, role), (body, y, offset) in sorted(bound.items(), + key=lambda item: item[1][2]): + if label not in declared: + findings.append( + "%s:%d: a %s label names ridge %r, which no path declares — a label " + "with no mark is not verifiable and reads as data" + % (name, line_of(source, offset), role, label) + ) + continue + if role not in ("name", "range"): + findings.append( + "%s:%d: a label for ridge %r has data-role=%r, which is neither " + "'name' nor 'range'" % (name, line_of(source, offset), label, role) + ) + continue + r = declared[label] + # Row placement: nearer another ridge's baseline than its own means the + # label renames a distribution, with every number still correct. + if y is not None: + own = abs(y - (r.baseline + LABEL_ROW_OFFSET)) + for other in ridges: + if other.name == label: + continue + if abs(y - (other.baseline + LABEL_ROW_OFFSET)) < own: + findings.append( + "%s:%d: the %s label for ridge %r is drawn at y=%g, nearer " + "%r's baseline than its own — a label on the wrong row renames " + "the distribution" + % (name, line_of(source, offset), role, label, y, other.name) + ) + break + if role == "name": + if body != label: + findings.append( + "%s:%d: a name label bound to ridge %r reads %r — the visible name " + "and its binding must agree" + % (name, line_of(source, offset), label, body) + ) + continue + + match = RANGE_LABEL_RE.match(body) + if not match: + findings.append( + "%s:%d: ridge %r prints its range as %r — a range label is two numbers " + "on the x-axis scale, low to high, so the reader can place the mass " + "without a gridline" % (name, line_of(source, offset), label, body) + ) + continue + if scale is None: + findings.append( + "%s:%d: ridge %r prints the range %r, which this checker cannot verify " + "because the figure's bin ticks did not read as one scale" + % (name, line_of(source, offset), label, body) + ) + continue + live = [i for i, v in enumerate(r.values) if v > 0] + if not live: + findings.append( + "%s:%d: ridge %r prints a range but declares no nonzero bin — an " + "empty distribution has no range" + % (name, line_of(source, offset), label) + ) + continue + want_low = scale(r.points[live[0]][0]) + want_high = scale(r.points[live[-1]][0]) + low, high = float(match.group(1)), float(match.group(2)) + if low > high: + findings.append( + "%s:%d: ridge %r prints the range %r low end first — %g is above %g" + % (name, line_of(source, offset), label, body, low, high) + ) + continue + if abs(low - want_low) > SCALE_TOLERANCE or abs(high - want_high) > SCALE_TOLERANCE: + findings.append( + "%s:%d: ridge %r prints the range %r but its nonzero bins run %g to %g " + "on the figure's own tick scale — a stated range wider than the mass " + "claims support the bins do not show, and a narrower one hides the " + "tail this chart exists to make visible" + % (name, line_of(source, offset), label, body, want_low, want_high) + ) + + for r in ridges: + for role in ("name", "range"): + if (r.name, role) not in bound: + findings.append( + "%s:%d: ridge %r prints no %s label — a ridgeline names each ridge " + "beside its baseline and states the span of its mass, or the " + "reader has a silhouette and no numbers" + % (name, line_of(source, r.offset), r.name, role) + ) + + +def check_source(path: Path, raw: str) -> list: + source = blank_comments(raw) + findings: list = [] + ridges = parse_ridges(source, findings, path.name) + + if len(ridges) < 2: + findings.append( + "%s: presents as a ridgeline but declares %d verifiable ridge(s) — every " + "distribution needs data-ridge with data-bins and data-baseline. Refusing " + "to report OK on a file this checker could not read" + % (path.name, len(ridges)) + ) + return findings + + if len(ridges) > MAX_RIDGES: + findings.append( + "%s: %d ridges against a budget of %d — past that the stack is taller than " + "a reader can hold one silhouette in mind across" + % (path.name, len(ridges), MAX_RIDGES) + ) + elif len(ridges) < MIN_RIDGES: + findings.append( + "%s: %d ridges against a budget of %d-%d — below %d there is no family of " + "shapes to compare, and two distributions are a pair of small multiples" + % (path.name, len(ridges), MIN_RIDGES, MAX_RIDGES, MIN_RIDGES) + ) + bins = len(ridges[0].values) + if not MIN_BINS <= bins <= MAX_BINS: + findings.append( + "%s: %d bins against a budget of %d-%d — below %d the outline is a " + "histogram wearing a curve's clothes, and past %d the bins are narrower " + "than the noise in them" + % (path.name, bins, MIN_BINS, MAX_BINS, MIN_BINS, MAX_BINS) + ) + + check_transforms(source, findings, path.name) + shared = check_bins(ridges, findings, source, path.name) + pitch = check_baselines(ridges, source, findings, path.name) + check_amplitude(ridges, findings, source, path.name) + check_overlap(ridges, pitch, findings, source, path.name) + check_focus(ridges, findings, source, path.name) + scale = check_ticks(ridges, source, findings, path.name) if shared else None + check_labels(ridges, source, findings, path.name, scale) + return findings + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Verify ridgeline outlines against the bins 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 ridgeline", + ) + args = parser.parse_args() + if not args.all and not args.paths: + parser.print_help() + return 2 + + if args.all: + targets = sorted(ASSET_DIR.glob("example-*.html")) + else: + targets = [Path(p) for p in args.paths] + + findings: list = [] + checked = 0 + skipped = 0 + for path in targets: + 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 + if not looks_like_ridgeline(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 ridgeline finding(s) across %d file(s).%s" + % (len(findings), checked, tail)) + return 1 + if not checked: + print("OK ridgeline: no ridgeline found to check%s" % tail) + return 0 + print("OK ridgeline: %d file(s), every vertex on one shared amplitude, baselines " + "evenly pitched and drawn, straight segments only, and every printed name, " + "range and tick bound to what it describes%s" % (checked, tail)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/diagram-design/SKILL.md b/skills/diagram-design/SKILL.md index 8e1262a..9fcd27f 100644 --- a/skills/diagram-design/SKILL.md +++ b/skills/diagram-design/SKILL.md @@ -102,7 +102,7 @@ The pattern owns semantic primitives and its tighter budget; the type owns layou | Ranked hierarchy or conversion drop-off | **Pyramid / funnel** | [type-pyramid.md](references/type-pyramid.md) | | Quantitative comparison across categories | **Bar chart** | [type-bar.md](references/type-bar.md) | | Part-of-whole where the relative sizes are the story | **Treemap** | [type-treemap.md](references/type-treemap.md) | -| Continuous trends over time, or change between exactly two states (slopegraph) | **Line chart** | [type-line.md](references/type-line.md) | +| Continuous trends over time, change between exactly two states (slopegraph), or one distribution per series (ridgeline) | **Line chart** | [type-line.md](references/type-line.md) | | Tasks and phases on a timeline | **Gantt** | [type-gantt.md](references/type-gantt.md) | | Distribution and correlation between two variables | **Scatter plot** | [type-scatter.md](references/type-scatter.md) | | End-to-end data stack on a container cluster | **High-Level** | [type-high-level.md](references/type-high-level.md) | diff --git a/skills/diagram-design/assets/example-ridgeline-dark.html b/skills/diagram-design/assets/example-ridgeline-dark.html new file mode 100644 index 0000000..ae9dab6 --- /dev/null +++ b/skills/diagram-design/assets/example-ridgeline-dark.html @@ -0,0 +1,104 @@ + + + + + + Request latency by service · Ridgeline + + + + +
+

Ridgeline · Diagram Design

+

Checkout is slow in two different ways

+ + + Checkout is slow in two different ways + Ridgeline chart of request-latency distributions for five services over one week; each ridge is the share of that service's requests falling in 40-millisecond bins, drawn on one shared amplitude scale, and checkout-api carries a second peak past 300 milliseconds that none of the other four show. + + + + + + + + + + + SHARE OF REQUESTS · 1% = 2.4PX ON EVERY RIDGE + + + + + + + + + + + static-cdn + 40–280 ms + + auth-api + 40–320 ms + + search-api + 40–400 ms + + + + checkout-api + 40–440 ms + + report-export + 80–440 ms + + + 0 + 120 + 240 + 360 + 480 + REQUEST LATENCY (MS), BIN LOWER EDGE + + + + LEGEND + SHARE OF EACH SERVICE'S OWN REQUESTS PER 40 MS BIN · 1% = 2.4PX ON EVERY RIDGE · UNSMOOTHED · ILLUSTRATIVE FIGURES + + + + checkout-api · focal, second peak past 300 ms + + + Other services · strongest tone is the fastest median + +
+ + diff --git a/skills/diagram-design/assets/example-ridgeline-full.html b/skills/diagram-design/assets/example-ridgeline-full.html new file mode 100644 index 0000000..b40f841 --- /dev/null +++ b/skills/diagram-design/assets/example-ridgeline-full.html @@ -0,0 +1,134 @@ + + + + + + Request latency by service · Ridgeline + + + + +
+
+

Ridgeline · Diagram Design

+

Checkout is slow in two different ways

+

Five services, one week of request latency, one amplitude scale. Each ridge is the share of that service's own requests in each 40 ms bin — so the ridges compare shape, not traffic. Four of them have a single hump and a thinning tail; checkout-api has a second, smaller peak past 300 ms, which is a different failure from simply being slow.

+
+
+ + Checkout is slow in two different ways + Ridgeline chart of request-latency distributions for five services over one week; each ridge is the share of that service's requests falling in 40-millisecond bins, drawn on one shared amplitude scale, and checkout-api carries a second peak past 300 milliseconds that none of the other four show. + + + + + + + + + + + SHARE OF REQUESTS · 1% = 2.4PX ON EVERY RIDGE + + + + + + + + + + + static-cdn + 40–280 ms + + auth-api + 40–320 ms + + search-api + 40–400 ms + + + + checkout-api + 40–440 ms + + report-export + 80–440 ms + + + 0 + 120 + 240 + 360 + 480 + REQUEST LATENCY (MS), BIN LOWER EDGE + + + + LEGEND + SHARE OF EACH SERVICE'S OWN REQUESTS PER 40 MS BIN · 1% = 2.4PX ON EVERY RIDGE · UNSMOOTHED · ILLUSTRATIVE FIGURES + + + + checkout-api · focal, second peak past 300 ms + + + Other services · strongest tone is the fastest median + +
+
+
+

FOCAL · CHECKOUT-API

+

A second peak is not a long tail

+

About a third of checkout's requests land past 280 ms, and they arrive as a distinct bump peaking near 360 ms rather than as a thinning tail. A tail says "sometimes slower"; a second peak says there are two paths through the code and one of them is always slow. Its p50 is 160 ms and its p99 is 400 ms — both true, and neither says the requests between them arrive in two clusters instead of one spread.

+
+
+

Height is share, so the ridges compare shape

+

static-cdn is the tallest ridge because its requests are concentrated in three bins, not because it serves the most traffic. Each ridge is normalised to its own service's request count and then drawn on one shared amplitude — that is what makes the silhouettes comparable, and it is the one thing a ridgeline must never fudge.

+
+
+

Overlap is deliberate; occlusion would not be

+

Ridges sit 56 px apart and the tallest rises 86 px, so peaks cross the row above and the stack reads as depth. The rule is that no peak may reach the row two above it — past that a peak hides behind a peak, and the fix is more pitch, never a smaller amplitude on one ridge.

+
+
+ +
+ + diff --git a/skills/diagram-design/assets/example-ridgeline.html b/skills/diagram-design/assets/example-ridgeline.html new file mode 100644 index 0000000..5ec0db9 --- /dev/null +++ b/skills/diagram-design/assets/example-ridgeline.html @@ -0,0 +1,104 @@ + + + + + + Request latency by service · Ridgeline + + + + +
+

Ridgeline · Diagram Design

+

Checkout is slow in two different ways

+ + + Checkout is slow in two different ways + Ridgeline chart of request-latency distributions for five services over one week; each ridge is the share of that service's requests falling in 40-millisecond bins, drawn on one shared amplitude scale, and checkout-api carries a second peak past 300 milliseconds that none of the other four show. + + + + + + + + + + + SHARE OF REQUESTS · 1% = 2.4PX ON EVERY RIDGE + + + + + + + + + + + static-cdn + 40–280 ms + + auth-api + 40–320 ms + + search-api + 40–400 ms + + + + checkout-api + 40–440 ms + + report-export + 80–440 ms + + + 0 + 120 + 240 + 360 + 480 + REQUEST LATENCY (MS), BIN LOWER EDGE + + + + LEGEND + SHARE OF EACH SERVICE'S OWN REQUESTS PER 40 MS BIN · 1% = 2.4PX ON EVERY RIDGE · UNSMOOTHED · ILLUSTRATIVE FIGURES + + + + checkout-api · focal, second peak past 300 ms + + + Other services · strongest tone is the fastest median + +
+ + diff --git a/skills/diagram-design/assets/index.html b/skills/diagram-design/assets/index.html index d36182f..c37e565 100644 --- a/skills/diagram-design/assets/index.html +++ b/skills/diagram-design/assets/index.html @@ -298,6 +298,9 @@ + diff --git a/skills/diagram-design/references/type-line.md b/skills/diagram-design/references/type-line.md index a80dcdb..42f55d0 100644 --- a/skills/diagram-design/references/type-line.md +++ b/skills/diagram-design/references/type-line.md @@ -40,6 +40,7 @@ ## Variants - **Slopegraph:** exactly two states, several series, read as slope and rank change. Full spec below. +- **Ridgeline:** one distribution per series, stacked with deliberate overlap on one shared amplitude. Full spec below. ### Slopegraph @@ -138,6 +139,70 @@ What each binding buys, and what it costs to omit: - An unbound visible string: a name, a value or a state caption with no attribute stating the same thing. - Curving the connector. There is nothing between the two points to curve through. +### Ridgeline + +**Best for:** one **distribution** per series, stacked at a fixed pitch with deliberate overlap, when the shape of each distribution is the story and the series are directly comparable — request latency per service, build duration per pipeline, session length per cohort. The line chart above plots one value per x; a ridgeline plots a whole distribution per row, and the reading is the silhouette: where the mass sits, how tight it is, whether there is a second peak. A p50/p99 pair cannot show a bimodal service, and a box plot flattens it to a rectangle. + +Not for: a single distribution (that is a histogram — one ridge is a ridgeline with the comparison removed); series measured in different units or on different x-ranges, which cannot share the one x-scale the type requires; time series, where each row is one value per moment rather than one distribution; or distributions so similar that the ridges are five copies of one shape, which a table of percentiles says shorter. + +#### Layout conventions + +- **One baseline per ridge at a fixed pitch**, inside the `0 0 1000 500` viewBox the other Line variants use. The shipped example runs five baselines at `y` 152/208/264/320/376, a 56px pitch. +- **The x-run is the slopegraph's**, `x` 320 → 680, sampled at 13 bins 30px apart. The gutters are symmetric about it: names right-aligned ending at `x=304`, ranges starting at `x=696`, both 16px clear of the plot. +- **Name left of the baseline, range right of it**, each on its ridge's own row (`y = baseline + 3.5`). The range is the span of the ridge's nonzero mass in x-axis units, which is the number a silhouette cannot give you. +- **Bin ticks** in Geist Mono 9px at `y=400`, centred on bin positions and tracked `0.14em`, with the x-axis caption at `y=424`. The rotated amplitude caption sits at `x=24`, as in the parent chart. +- **Ridge count 3–12, bins 8–40.** Below three ridges there is no family of shapes to compare and two distributions are a pair of small multiples; past twelve the stack is taller than a reader can hold one silhouette in mind across. Below eight bins the outline is a histogram wearing a curve's clothes; past forty the bins are narrower than the noise in them. +- **Straight segments between bins, closed with `Z`.** Not splines: the source line states the drawing is unsmoothed, and a curve through binned counts puts extrema between bins that the sample never measured. The draft this variant came from permits Catmull-Rom with vertices on true values; the shipped grammar does not use it, because unsmoothed bins need no footnote about what the curve is allowed to invent. +- **No gridlines.** The baselines are the rules, and every ridge prints its own range. + +#### Colour + +The slopegraph's colour section holds here unchanged, with one addition for the fill. One accent on the editorially focal ridge (stroke `accent`, fill the accent at `0.16`), an `ink` opacity ramp for the rest (`0.80 → 0.62`, floor `0.53`) ordered top row to bottom, and focus carried by stroke weight — 2.4px against 1.2px — rather than tone. Labels stay `ink` (names) and `muted` (ranges) on every ridge including the focal one. + +- **Fill `ink` at `0.12` on every non-focal ridge**, low enough that two overlapping ridges read as depth rather than as a third tone. This is the one place the type needs a fill at all: the outline alone does not say which side of the curve is mass. +- **Legend wording stays skin-neutral** — "strongest tone", never "darkest". The ramp is ink-at-opacity, so its top is the darkest line on light paper and the lightest on dark, and a legend that says "darker" ships false in one of the two skins while rendering perfectly in both. +- **The accent marks the ridge the story is about, not the worst performer.** In the shipped example it marks the service whose *shape* is unusual, not the slowest one. + +#### Honest-data rule + +**One amplitude on every ridge, stated in the source line.** That is the entire claim of the type: the ridges are stacked so their silhouettes can be compared, and per-ridge normalisation destroys exactly that while rendering beautifully — a rare, flat distribution given its own scale wears the same shape as a tight one. `scripts/verify-ridgeline.py` derives the figure's single amplitude and holds every vertex of every ridge to it. + +- **The baseline never lies.** Each ridge declares the row it rises from, and that row must sit on the stack's fixed pitch with its rule drawn across the full bin run. A baseline nudged up to give one ridge headroom is the same falsification as a private amplitude, told with different arithmetic — and it is the harder one to see, because the silhouette above it is untouched. +- **Every ridge shares one x-scale.** A peak at one x means one latency on every row, or the column comparison the type exists for is fiction. +- **State the overlap, and keep it overlap.** Ridges are meant to intrude on the row above — that is how the stack reads as depth. A peak that reaches the row *two* above is occluded by the peaks it passes, and the fix is more pitch, never a smaller amplitude on one ridge. The checker enforces the ceiling; the source line states the amplitude so the reader can convert a height back into a number. +- **A ridge starts and ends on its own baseline.** The first and last bins are zero, so the outline closes along the baseline. A distribution clipped mid-mass draws a cliff, and a cliff reads as data. +- **No smoothing beyond what the footnote states.** The shipped grammar smooths nothing at all. If a figure ever does smooth, the footnote names the method and the vertices stay on true values — a spline that invents a second peak is indistinguishable, on the page, from a service that has one. +- **Height is share, not volume.** Each ridge is normalised to its own series' count before the shared amplitude is applied, so the tallest ridge is the most concentrated, not the busiest. Say so in the source line; a reader who assumes height is traffic reads the figure exactly backwards. + +#### Declaring the values + +The binding contract is the slopegraph's, applied to areas: the outline declares its bins and its baseline, and every visible string is bound to what it describes. + +```svg + + +checkout-api +40–440 ms +240 +``` + +`data-bins` is the basis of every geometric check, and it is this contract's own vocabulary: the slopegraph above binds `data-series` on a ``, this variant binds `data-bins` on a ``, and neither gate reads the other's attribute, so neither claims the other's file. Any further Line variant should take its own attribute for the same reason — a shared name means two checkers holding one figure to two contracts, and the one that loses rejects it for lacking elements it never said it had. `data-baseline` is what makes a moved row detectable; without it the checker would have to infer the zero from the drawing, which is the very thing being falsified. The printed range is cross-checked against the first and last nonzero bin through the figure's own tick scale, so a range widened by a word is a finding. `scripts/verify-ridgeline.py` covers the amplitude, the pitch, the baseline rules, the shared bins, the segment grammar, the overlap ceiling, the focus pairing and every label binding; `scripts/test-verify-ridgeline.py` proves each check in both polarities and pins the scope treaty with the sibling gates. + +**No `transform` on any of it**, for the reason the slopegraph section gives: the checker reads raw coordinates, so a transform on an outline, a baseline rule, a bound label, an ancestor `` or a CSS rule moves the rendered mark away from the bin that was verified. The rotated amplitude caption is fine — it is neither verified geometry nor a bound label. + +#### Anti-patterns + +- Per-ridge normalisation, or any second amplitude anywhere in the figure — the type's one unforgivable error. +- A baseline moved off the pitch to buy one ridge headroom, or a baseline rule drawn somewhere other than the row its ridge declares. +- Ridges sampled on different x-ranges, or an x-scale that is not printed at all. +- Smoothing that puts a peak between two bins, and any spline in a figure whose footnote says the drawing is unsmoothed. +- A ridge clipped mid-mass so the outline closes as a cliff. +- Pitch so tight that peaks hide behind peaks — increase the pitch, never shrink one ridge. +- One hue per ridge instead of the ink ramp plus a single accent. +- A legend that says "darker is faster", which is false on one of the two skins. +- Fewer than 3 ridges (that is a histogram or a pair of small multiples) or more than 12. +- Reading traffic off ridge height, or shipping a figure whose source line lets a reader do so. + ## Examples - `assets/example-line.html` — minimal light @@ -146,3 +211,6 @@ What each binding buys, and what it costs to omit: - `assets/example-slopegraph.html` — slopegraph, minimal light - `assets/example-slopegraph-dark.html` — slopegraph, minimal dark - `assets/example-slopegraph-full.html` — slopegraph, full editorial +- `assets/example-ridgeline.html` — ridgeline, minimal light +- `assets/example-ridgeline-dark.html` — ridgeline, minimal dark +- `assets/example-ridgeline-full.html` — ridgeline, full editorial