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 <path>, 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: 0xDarkMatter <0xDarkMatter@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
0xDarkMatter
2026-08-21 12:23:44 -05:00
committed by GitHub
co-authored by 0xDarkMatter Claude Fable 5
parent 5538b35116
commit ddaeae936e
14 changed files with 1940 additions and 4 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.6.1",
"version": "2.6.2",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.6.1",
"version": "2.6.2",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, polar chart (polar/radial lollipop), loop/flywheel, nested, tree, org chart, layer stack, Venn, pyramid/funnel, treemap, bar, line, Gantt and scatter charts, high-level, process, medallion, data flow, DP integration, DP security matrix, sankey, fishbone, Wardley map, kanban, user journey, deployment, dependency graph, UML class, story map, or database schema diagrams as standalone HTML/SVG/PNG. Redraw .drawio/.drawio.png/.drawio.svg or Mermaid .mmd sources at a chosen size/detail; onboard brand tokens from a website; add semantic patterns, callouts, accessible motion, or sketchy/hand-drawn styling.",
"version": "2.6.1",
"version": "2.6.2",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
+12
View File
@@ -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
+2
View File
@@ -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": [],
+5
View File
@@ -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.
+545
View File
@@ -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 `<path>` 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 = ('<path data-ridge="checkout-api" data-baseline="320" '
'data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0" '
+ FOCAL_D +
' fill="rgba(235,108,54,0.16)" stroke="#eb6c36" stroke-width="2.4" '
'stroke-linejoin="round"/>')
# 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 = ('<line data-ridge="report-export" data-role="baseline" x1="320" y1="376" '
'x2="680" y2="376" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>')
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 = [
'<html><head><title>synthetic ridgeline</title></head><body>',
'<svg viewBox="0 0 2000 2000" role="img" aria-labelledby="t d">',
'<title id="t">synthetic ridgeline</title>',
'<desc id="d">ridgeline fixture</desc>',
]
for index, (x, value) in enumerate(((xs[0], 0), (xs[-1], (n_bins - 1) * unit))):
parts.append('<text data-tick="%d" data-bin="%d" x="%d" y="900">%d</text>'
% (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('<line data-ridge="r%d" data-role="baseline" x1="%d" y1="%g" '
'x2="%d" y2="%g" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>'
% (s, xs[0], baseline, xs[-1], baseline))
parts.append('<path data-ridge="r%d" data-baseline="%g" data-bins="%s" d="%s" '
'fill="rgba(45,49,66,0.12)" stroke="%s" stroke-width="%s"/>'
% (s, baseline, ",".join(str(v) for v in values), d, stroke, width))
parts.append('<text data-ridge="r%d" data-role="name" x="%d" y="%g" '
'text-anchor="end">r%d</text>' % (s, xs[0] - 16, baseline + 3.5, s))
parts.append('<text data-ridge="r%d" data-role="range" x="%d" y="%g">%d–%d ms</text>'
% (s, xs[-1] + 16, baseline + 3.5,
live[0] * unit, live[-1] * unit))
parts.append("</svg></body></html>")
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('<line data-ridge="checkout-api" data-role="baseline" x1="320" '
'y1="320" x2="680" y2="320"',
'<line data-ridge="checkout-api" data-role="baseline" x1="320" '
'y1="328" x2="680" y2="328"', 1),
source, "fixed pitch",
"a baseline nudged off the stack's pitch",
)
# 4. The drawn zero and the measured zero pulled apart: only the rule moves.
case(
failures, directory, "rule-off-row",
source.replace(REPORT_RULE, REPORT_RULE.replace('y1="376"', 'y1="368"')
.replace('y2="376"', 'y2="368"'), 1),
source, "drawn zero and the measured zero",
"a baseline rule drawn off its ridge's row",
)
# 5. A ridge with no drawn baseline at all.
case(
failures, directory, "rule-missing",
source.replace(" " + REPORT_RULE + "\n", "", 1),
source, "no baseline rule",
"a ridge with no drawn baseline",
)
# 6. A spline through binned counts invents extrema the sample never had.
case(
failures, directory, "spline",
source.replace("M320,320 L350,317.6 L380,305.6",
"M320,320 C330,320 340,317.6 350,317.6 L380,305.6", 1),
source, "invents extrema",
"a spline between two bins",
)
# 7. Relative commands render against the previous point while the parsed
# numbers read as absolute, so folding case would certify a figure at
# positions it never drew. Refused outright.
case(
failures, directory, "relative-path",
source.replace(FOCAL_D,
'd="M320,320 l30,-2.4 l30,-12 l30,-26.4 l30,-9.6 l30,16.8 '
'l30,14.4 l30,4.8 l30,-2.4 l30,-4.8 l30,4.8 l30,7.2 l30,9.6 z"',
1),
source, "relative path commands",
"a ridge drawn with relative path commands",
)
# 7b. `z` is NOT a relative command — closepath takes no coordinates, so
# it has no relative form and the two spellings are one command.
# Rejecting it would be a false positive on valid SVG.
lowered = source.replace(FOCAL_D, FOCAL_D.replace(" Z\"", " z\""), 1)
if lowered == source:
failures.append("could not build the lowercase-z fixture (anchor moved)")
else:
code, output = run(write(directory, "lowercase-z.html", lowered))
if code != 0:
failures.append("a path closed with lowercase z was rejected: %s"
% output.strip())
else:
print("OK: lowercase z is accepted (it is not a relative command)")
# 7b-ii. `e` and `E` are letters INSIDE a valid coordinate. `4.4e2` is
# 440, one number and no command, so a checker that scans for
# [A-Za-z] separately invents a command and rejects a good path.
# Positive case, and a falsified twin proving the number was read as
# the value it spells rather than skipped.
exponent = source.replace("L440,269.6", "L4.4e2,269.6", 1)
if exponent == source:
failures.append("could not build the exponent fixture (anchor moved)")
else:
code, output = run(write(directory, "exponent.html", exponent))
if code != 0:
failures.append("a coordinate in scientific notation was rejected: %s"
% output.strip())
else:
print("OK: a coordinate in scientific notation is accepted")
case(
failures, directory, "exponent-lie",
source.replace("L440,269.6", "L4.4e2,2.7e2", 1),
source, "one amplitude",
"a falsified coordinate written in scientific notation",
)
# 7c. Implicit command repetition (`L a,b c,d`) is legal SVG, but this
# checker pairs commands with points positionally, so it must say so
# rather than mis-pair them behind a green tick.
case(
failures, directory, "implicit-repeat",
source.replace("L350,317.6 L380,305.6", "L350,317.6 380,305.6", 1),
source, "one explicit command per vertex",
"a path using implicit command repetition",
)
# 8. A distribution cut off mid-mass: the last bin stops above the
# baseline, so the outline closes as a cliff and reads as data.
case(
failures, directory, "clipped-end",
source.replace('data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0"',
'data-bins="0,1,6,17,21,14,8,6,7,9,7,4,3"', 1)
.replace("L650,310.4 L680,320 Z", "L650,310.4 L680,312.8 Z", 1),
source, "starts or ends off its baseline",
"a distribution clipped mid-mass",
)
# 9. A vertex drawn through the baseline. A share below zero is not a share.
case(
failures, directory, "below-baseline",
source.replace("L650,310.4 L680,320 Z", "L650,326 L680,320 Z", 1),
source, "never dips through it",
"a vertex drawn below its own baseline",
)
case(
failures, directory, "negative-bin",
source.replace('data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0"',
'data-bins="0,1,6,17,21,14,8,6,7,9,7,-4,0"', 1),
source, "negative bin",
"a ridge declaring a negative bin",
)
# 10. A ridge sampled on its own x positions cannot be compared down the
# column: a peak at one x stops meaning one latency.
case(
failures, directory, "own-bins",
source.replace("L590,298.4 L620,303.2", "L594,298.4 L620,303.2", 1),
source, "share one x-scale",
"a ridge sampled on its own x positions",
)
# 11. Labels: wrong row, wrong text, missing.
case(
failures, directory, "wrong-row",
source.replace('<text data-ridge="checkout-api" data-role="name" x="304" '
'y="323.5"',
'<text data-ridge="checkout-api" data-role="name" x="304" '
'y="379.5"', 1),
source, "renames the distribution",
"a name label drawn on a neighbour's row",
)
case(
failures, directory, "renamed",
source.replace('text-anchor="end">checkout-api</text>',
'text-anchor="end">checkout</text>', 1),
source, "binding must agree",
"a name label reading a different name than it binds",
)
case(
failures, directory, "unlabelled",
source.replace(' <text data-ridge="checkout-api" data-role="range" '
'x="696" y="323.5" fill="#4f5d75" font-size="9" '
'font-family="\'Geist Mono\', monospace">40–440 ms</text>\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>',
'text-anchor="middle">280</text>', 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('<path data-ridge="checkout-api"',
'<path transform="translate(0 8)" data-ridge="checkout-api"', 1),
source, "moves the rendered mark",
"a transform on a ridge outline",
)
case(
failures, directory, "wrapped",
source.replace(FOCAL_PATH, '<g transform="translate(0 8)">' + FOCAL_PATH + "</g>", 1),
source, "ancestor",
"a ridge wrapped in a transformed group",
)
case(
failures, directory, "css-transform",
source.replace("<style>", "<style>\n svg path { transform: scaleY(1.1); }", 1),
source, "CSS `transform`",
"a CSS transform reaching the figure",
)
# 15. A second accent spends the accent entirely; colour and weight
# disagreeing sends the two focus cues to different ridges.
case(
failures, directory, "two-focal",
source.replace('stroke="rgba(45,49,66,0.80)" stroke-width="1.2"',
'stroke="#eb6c36" stroke-width="2.4"', 1),
source, "exactly one editorially focal",
"two ridges carrying the accent stroke",
)
case(
failures, directory, "weight-mismatch",
source.replace('stroke="#eb6c36" stroke-width="2.4"',
'stroke="#eb6c36" stroke-width="1.2"', 1),
source, "focus cue",
"a focal stroke at non-focal weight",
)
# 16. Unreadable declarations are findings, never skips.
case(
failures, directory, "no-bins",
source.replace(' data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0"', "", 1),
source, "no data-bins",
"a ridge outline with no declared bins",
)
case(
failures, directory, "no-baseline",
source.replace('data-ridge="checkout-api" data-baseline="320" data-bins',
'data-ridge="checkout-api" data-bins', 1),
source, "no data-baseline",
"a ridge with no declared baseline",
)
case(
failures, directory, "junk-bins",
source.replace('data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0"',
'data-bins="0,1,six,17,21,14,8,6,7,9,7,4,0"', 1),
source, "not a comma list of numbers",
"a bin that is not a number",
)
case(
failures, directory, "junk-path",
source.replace(FOCAL_D, 'd="M banana"', 1),
source, "cannot verify its outline",
"a ridge outline this checker cannot read",
)
case(
failures, directory, "count-mismatch",
source.replace('data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0"',
'data-bins="0,1,6,17,21,14,8,6,7,9,7,4"', 1),
source, "every declared bin needs its vertex",
"a ridge with more vertices than bins",
)
case(
failures, directory, "duplicate-ridge",
source.replace(FOCAL_PATH, FOCAL_PATH + "\n " + FOCAL_PATH, 1),
source, "one ridge, one outline",
"two outlines declaring the same ridge",
)
# 17. Single-quoted attributes must be read, not silently dropped — the
# same fault the slopegraph checker fixed. Positive case: the figure
# verifies; the falsified twin proves it was read at all.
singled = source.replace(FOCAL_PATH, FOCAL_PATH.replace('"', "'"), 1)
code, output = run(write(directory, "single-quoted.html", singled))
if code != 0:
failures.append("single-quoted attributes were rejected: %s" % output.strip())
else:
print("OK: single-quoted attributes are read")
case(
failures, directory, "single-quoted-lie",
singled.replace("data-bins='0,1,6,17,21,14,8,6,7,9,7,4,0'",
"data-bins='0,1,6,17,21,14,8,6,9,9,7,4,0'", 1),
source, "one amplitude",
"a falsified bin in single-quoted attributes",
)
# 18. Fail closed on a file that claims the type and yields nothing.
empty = ("<html><head><title>weekly ridgeline</title></head>"
"<body><svg><title>ridgeline of nothing</title></svg></body></html>")
code, output = run(write(directory, "empty.html", empty))
if code == 0:
failures.append("a ridgeline with no verifiable ridges was accepted")
elif "Refusing to report OK" not in output:
failures.append("empty figure reported without the fail-closed reason: %s"
% output.strip())
else:
print("OK: a ridgeline this checker cannot read is refused")
# 19. Synthetics: the checker reads the figure's OWN amplitude and pitch,
# and the budget rules fire at both ends on counts a mutation of the
# shipped example cannot reach.
code, output = run(write(directory, "synthetic.html", synthetic(5, 13)))
if code != 0:
failures.append("a valid synthetic at a different top/pitch/amplitude was "
"rejected: %s" % output.strip())
else:
print("OK: a valid ridgeline on a different grid passes")
for name, fixture, expect, describe in (
("too-many-ridges", synthetic(13, 13), "budget",
"thirteen ridges against a budget of twelve"),
("too-few-ridges", synthetic(2, 13), "budget",
"two ridges against a budget floor of three"),
("too-many-bins", synthetic(5, 41), "budget",
"forty-one bins against a budget of forty"),
("too-few-bins", synthetic(5, 7), "budget",
"seven bins against a budget floor of eight"),
("occluded", synthetic(5, 13, amp=8.0), "occluded",
"a peak reaching past the row two above it"),
):
code, output = run(write(directory, "%s.html" % name, fixture))
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)
# 20. The scope treaty. Each sibling Line-variant gate binds its own
# vocabulary; none of them may claim a ridgeline, and this one must
# not claim theirs. Each sibling is handed the ridgeline examples
# directly and must neither fail nor mention them — the assertion is
# on THIS variant's files, not on the health of the asset directory,
# so an unrelated example cannot make this case red.
for script in SIBLINGS:
path = ROOT / "scripts" / script
if not path.is_file():
print("note: %s is not in this tree yet — treaty untested against it"
% script)
continue
code, output = invoke([sys.executable, str(path), *(str(p) for p in RIDGELINES)])
if code != 0:
failures.append("%s claims the ridgeline examples: %s"
% (script, output.strip()))
elif "example-ridgeline" in output:
failures.append("%s reported on a ridgeline example instead of "
"skipping it: %s" % (script, output.strip()))
else:
print("OK: %s skips the ridgeline examples (scope treaty holds)" % script)
# And the reverse direction: this checker must skip the siblings' files.
for sibling in ("example-slopegraph.html", "example-bump.html",
"example-streamgraph.html"):
other = ROOT / "skills/diagram-design/assets" / sibling
if not other.is_file():
continue
code, output = run(other)
if code != 0 or "no ridgeline found" not in output:
failures.append("verify-ridgeline claims %s instead of skipping it: %s"
% (sibling, output.strip()))
else:
print("OK: verify-ridgeline skips %s" % sibling)
for failure in failures:
print("FAIL: %s" % failure)
if failures:
print("\n%d case(s) failed." % len(failures))
return 1
print("\nAll ridgeline checker cases passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+959
View File
@@ -0,0 +1,959 @@
#!/usr/bin/env python3
"""Verify that a ridgeline chart's drawn ridges match the bins they declare.
A ridgeline makes one claim above all others: **every ridge is drawn at the same
amplitude**. The whole type exists so silhouettes can be compared down a column,
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, and no other gate in this repo would notice. `lint-skin.py` reads colours
and fonts, `verify-geometry.py` reads label masks; neither compares a drawn
outline against the values sitting in the markup.
Eight invariants, in the spirit of ADR 0005 and the slopegraph checker:
1. ONE AMPLITUDE - one figure-wide px-per-unit, derived from the figure itself
(median of (baseline - y) / value over nonzero bins, so a single dishonest
ridge cannot drag the scale it is measured against) and then enforced on
every vertex of every ridge. This is the invariant the type lives on.
2. EVEN PITCH - baselines run strictly downward at one fixed pitch, and each
ridge's declared data-baseline is the row it is actually drawn against. A
baseline nudged to give one ridge more headroom is the same lie as a private
amplitude, told with different arithmetic.
3. SHARED BINS - every ridge samples the same x positions in the same order, so
a peak at one x means the same latency on every row.
4. CLOSED BY ZERO - the outline is absolute M/L*/Z, one point per declared bin,
with the first and last bin at zero so the shape starts and ends on its own
baseline. No vertex may sit below the baseline: a negative share is not a
share, and a clipped end is a distribution cut off mid-mass.
5. NO SMOOTHING - straight segments only. A spline through binned counts invents
extrema the sample never had, and the footnote states the drawing is
unsmoothed. Relative commands are REFUSED rather than folded into their
absolute twins - `l30,-62` renders against the previous point while the
numbers this checker reads look absolute, so accepting them would certify a
figure at positions it never drew. Lowercase `z` is the one exception and is
accepted: closepath takes no coordinates, so it has no relative form. One
explicit command per vertex is required — implicit repetition after an L is
legal SVG but would leave commands paired with the wrong coordinates here.
6. OVERLAP WITHOUT OCCLUSION - overlap is a feature of the type; a peak hidden
behind a peak is not. No ridge may rise past the baseline two rows above it.
The fix for a collision is more pitch, never a smaller amplitude on one ridge.
7. LABELS BOUND TO MEANING - each ridge prints its name and its range, bound by
data-ridge / data-role, sitting on its own baseline row, with the name
agreeing with its binding and the printed range agreeing with the first and
last nonzero bin read through the figure's own tick scale.
8. FAIL CLOSED - zero parseable ridges, a path that declares data-bins but
cannot be read, a missing baseline rule, or a transform anywhere near
verified geometry is a finding, never a skip.
WHAT THIS DOES NOT CHECK, deliberately:
- **What the values mean.** Share, density or count, and how the bins were cut -
that is the source line's job, and prose is not parsed. Every check here is
internal consistency between the markup and the drawing.
- **The tone ramp.** Fill and stroke opacity are `lint-skin.py`'s territory;
this gate only pairs the accent with its stroke weight.
SCOPE: this contract binds `data-bins` on a `<path>`, which no other chart in
this repo declares. `data-series` (slopegraph), `data-ranks` (bump) and
`data-layer` (streamgraph) are their checkers' vocabulary and are not read here,
so the sibling gates and this one never claim the same file.
Usage:
python3 scripts/verify-ridgeline.py --all
python3 scripts/verify-ridgeline.py skills/diagram-design/assets/example-ridgeline.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"
PATH_RE = re.compile(r"<path\b(?P<attrs>[^>]*?)/?>", re.IGNORECASE)
LINE_RE = re.compile(r"<line\b(?P<attrs>[^>]*?)/?>", re.IGNORECASE)
TEXT_RE = re.compile(r"<text\b(?P<attrs>[^>]*)>(?P<body>.*?)</text>", re.IGNORECASE | re.DOTALL)
COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
NAMED_RE = re.compile(r"<(?P<tag>title|desc)\b[^>]*>(?P<body>.*?)</(?P=tag)>",
re.IGNORECASE | re.DOTALL)
GROUP_OPEN_RE = re.compile(r"<(?:g|svg)\b(?P<attrs>[^>]*?)(?P<selfclose>/?)>", re.IGNORECASE)
GROUP_CLOSE_RE = re.compile(r"</(?:g|svg)\s*>", re.IGNORECASE)
STYLE_RE = re.compile(r"<style\b[^>]*>(?P<body>.*?)</style>", re.IGNORECASE | re.DOTALL)
# `transform:` but not `text-transform:` - the editorial template uses the latter.
CSS_TRANSFORM_RE = re.compile(r"(?<![\w-])transform\s*:", re.IGNORECASE)
TAG_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<name>[\w:-]+)\s*=\s*(?P<quote>["'])(?P<value>.*?)(?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<num>[-+]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?)"
r"|(?P<cmd>[A-Za-z])"
r"|(?P<sep>[\s,]+)"
r"|(?P<junk>[\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 <path> 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 <path> 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 <g>/<svg> 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 <g>/<svg> transform")
for match in STYLE_RE.finditer(source):
found = CSS_TRANSFORM_RE.search(match.group("body"))
if found:
findings.append(
"%s:%d: a CSS `transform` declaration — this checker cannot tell "
"which marks it applies to, and a transform on verified geometry "
"invalidates every coordinate here"
% (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 <line> 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())
+1 -1
View File
@@ -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) |
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request latency by service · Ridgeline</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-paper: #2d3142;
--color-ink: #f5f5f5;
--color-muted: #bfc0c0;
--color-accent: #f08a59;
--font-sans: 'Geist', system-ui, sans-serif;
--font-serif: 'Instrument Serif', serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
}
body { font-family: var(--font-sans); background: var(--color-paper); color: var(--color-ink); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; }
.frame { max-width: 1200px; width: 100%; }
.eyebrow { font-family: var(--font-mono); font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.5rem; }
h1 { font-family: var(--font-serif); font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; }
svg { width: 100%; min-width: 760px; display: block; }
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Ridgeline · Diagram Design</p>
<h1>Checkout is slow in two different ways</h1>
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="ridgeline-dark-title ridgeline-dark-desc">
<title id="ridgeline-dark-title">Checkout is slow in two different ways</title>
<desc id="ridgeline-dark-desc">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.</desc>
<defs>
<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" fill="rgba(245,245,245,0.10)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="#2d3142"/>
<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/>
<!-- ONE amplitude on every ridge: y = baseline - 2.4 x (share in %). Per-ridge
normalisation would let a rare, flat distribution wear the same silhouette
as a tight one, which is the single lie this type is built to tell.
scripts/verify-ridgeline.py derives the amplitude from the figure and then
holds every vertex of every ridge to it. -->
<text transform="rotate(-90 24 264)" x="24" y="264" fill="#bfc0c0" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">SHARE OF REQUESTS · 1% = 2.4PX ON EVERY RIDGE</text>
<!-- One baseline per ridge, 56px pitch. The baseline is where a bin of zero
requests sits, so it can never be moved to give a ridge more room. -->
<line data-ridge="static-cdn" data-role="baseline" x1="320" y1="152" x2="680" y2="152" stroke="rgba(245,245,245,0.25)" stroke-width="1"/>
<line data-ridge="auth-api" data-role="baseline" x1="320" y1="208" x2="680" y2="208" stroke="rgba(245,245,245,0.25)" stroke-width="1"/>
<line data-ridge="search-api" data-role="baseline" x1="320" y1="264" x2="680" y2="264" stroke="rgba(245,245,245,0.25)" stroke-width="1"/>
<line data-ridge="checkout-api" data-role="baseline" x1="320" y1="320" x2="680" y2="320" stroke="rgba(245,245,245,0.25)" stroke-width="1"/>
<line data-ridge="report-export" data-role="baseline" x1="320" y1="376" x2="680" y2="376" stroke="rgba(245,245,245,0.25)" stroke-width="1"/>
<!-- Shared x-scale: every ridge is sampled at the same 13 bins, 40ms wide,
labelled at the lower edge. Straight segments between bins - no smoothing,
because a spline through 13 bins invents extrema the sample never had. -->
<path data-ridge="static-cdn" data-baseline="152" data-bins="0,26,34,21,11,5,2,1,0,0,0,0,0" d="M320,152 L350,89.6 L380,70.4 L410,101.6 L440,125.6 L470,140 L500,147.2 L530,149.6 L560,152 L590,152 L620,152 L650,152 L680,152 Z" fill="rgba(245,245,245,0.12)" stroke="rgba(245,245,245,0.80)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="static-cdn" data-role="name" x="304" y="155.5" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">static-cdn</text>
<text data-ridge="static-cdn" data-role="range" x="696" y="155.5" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">40–280 ms</text>
<path data-ridge="auth-api" data-baseline="208" data-bins="0,6,28,36,18,7,3,1,1,0,0,0,0" d="M320,208 L350,193.6 L380,140.8 L410,121.6 L440,164.8 L470,191.2 L500,200.8 L530,205.6 L560,205.6 L590,208 L620,208 L650,208 L680,208 Z" fill="rgba(245,245,245,0.12)" stroke="rgba(245,245,245,0.74)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="auth-api" data-role="name" x="304" y="211.5" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">auth-api</text>
<text data-ridge="auth-api" data-role="range" x="696" y="211.5" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">40–320 ms</text>
<path data-ridge="search-api" data-baseline="264" data-bins="0,1,9,22,27,19,11,6,3,1,1,0,0" d="M320,264 L350,261.6 L380,242.4 L410,211.2 L440,199.2 L470,218.4 L500,237.6 L530,249.6 L560,256.8 L590,261.6 L620,261.6 L650,264 L680,264 Z" fill="rgba(245,245,245,0.12)" stroke="rgba(245,245,245,0.68)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="search-api" data-role="name" x="304" y="267.5" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">search-api</text>
<text data-ridge="search-api" data-role="range" x="696" y="267.5" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">40–400 ms</text>
<!-- checkout-api FOCAL - and it is the ridge whose SHAPE is the story,
not the slowest service. Drawn last so its second peak reads over the
ridge below it rather than under. -->
<path data-ridge="checkout-api" data-baseline="320" data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0" 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" fill="rgba(240,138,89,0.16)" stroke="#f08a59" stroke-width="2.4" stroke-linejoin="round"/>
<text data-ridge="checkout-api" data-role="name" x="304" y="323.5" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">checkout-api</text>
<text data-ridge="checkout-api" data-role="range" x="696" y="323.5" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">40–440 ms</text>
<path data-ridge="report-export" data-baseline="376" data-bins="0,0,1,3,7,12,17,19,16,12,9,4,0" d="M320,376 L350,376 L380,373.6 L410,368.8 L440,359.2 L470,347.2 L500,335.2 L530,330.4 L560,337.6 L590,347.2 L620,354.4 L650,366.4 L680,376 Z" fill="rgba(245,245,245,0.12)" stroke="rgba(245,245,245,0.62)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="report-export" data-role="name" x="304" y="379.5" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">report-export</text>
<text data-ridge="report-export" data-role="range" x="696" y="379.5" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">80–440 ms</text>
<!-- Bin ticks on the shared latency scale. data-bin binds the printed number,
so a caption cannot be edited without its binding. -->
<text data-tick="0" data-bin="0" x="320" y="400" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">0</text>
<text data-tick="1" data-bin="120" x="410" y="400" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">120</text>
<text data-tick="2" data-bin="240" x="500" y="400" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">240</text>
<text data-tick="3" data-bin="360" x="590" y="400" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">360</text>
<text data-tick="4" data-bin="480" x="680" y="400" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">480</text>
<text x="500" y="424" fill="#bfc0c0" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.18em" text-anchor="middle">REQUEST LATENCY (MS), BIN LOWER EDGE</text>
<!-- Legend -->
<line x1="40" y1="462" x2="960" y2="462" stroke="rgba(245,245,245,0.10)" stroke-width="0.8"/>
<text x="40" y="478" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.18em">LEGEND</text>
<text x="960" y="478" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.06em" text-anchor="end">SHARE OF EACH SERVICE'S OWN REQUESTS PER 40 MS BIN · 1% = 2.4PX ON EVERY RIDGE · UNSMOOTHED · ILLUSTRATIVE FIGURES</text>
<rect x="40" y="486" width="24" height="10" fill="rgba(240,138,89,0.16)"/>
<line x1="40" y1="486" x2="64" y2="486" stroke="#f08a59" stroke-width="2.4"/>
<text x="72" y="496" fill="#bfc0c0" font-size="8.5" font-family="'Geist', sans-serif">checkout-api · focal, second peak past 300 ms</text>
<rect x="360" y="486" width="24" height="10" fill="rgba(245,245,245,0.12)"/>
<line x1="360" y1="486" x2="384" y2="486" stroke="rgba(245,245,245,0.80)" stroke-width="1.2"/>
<text x="392" y="496" fill="#bfc0c0" font-size="8.5" font-family="'Geist', sans-serif">Other services · strongest tone is the fastest median</text>
</svg>
</div>
</body>
</html>
@@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request latency by service · Ridgeline</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root { --color-paper:#f5f5f5; --color-paper-2:#ececec; --color-ink:#2d3142; --color-muted:#4f5d75; --color-soft:#7a8399; --color-rule:rgba(45,49,66,0.12); --color-accent:#eb6c36; --font-sans:'Geist',system-ui,sans-serif; --font-serif:'Instrument Serif',serif; --font-mono:'Geist Mono',ui-monospace,monospace; }
body { font-family: var(--font-sans); background: var(--color-paper); min-height: 100vh; padding: 3rem 2rem; color: var(--color-ink); }
.container { max-width: 1200px; margin: 0 auto; }
.header { margin-bottom: 2.5rem; }
.header-eyebrow { font-family: var(--font-mono); font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.75rem; }
h1 { font-family: var(--font-serif); font-size: clamp(1.75rem, 3vw + 1rem, 2.5rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 0.5rem; }
.subtitle { font-size: 1rem; line-height: 1.55; color: var(--color-muted); max-width: 58ch; }
.diagram-container { background: var(--color-paper-2); border-radius: 8px; border: 1px solid var(--color-rule); padding: 1.5rem; overflow-x: auto; }
svg { width: 100%; min-width: 760px; display: block; }
.cards { display: grid; grid-template-columns: 1.1fr 1fr 0.9fr; gap: 1rem; margin-top: 1.5rem; }
@media (max-width: 720px) { .cards { grid-template-columns: 1fr; } }
.card { background: #fff; border-radius: 6px; border: 1px solid var(--color-rule); padding: 1.25rem; }
.card .eyebrow { font-family: var(--font-mono); font-size: 0.5rem; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.5rem; }
.card-header { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.875rem; padding-bottom: 0.875rem; border-bottom: 1px solid rgba(45,49,66,0.08); }
.card-dot { width: 7px; height: 7px; border-radius: 50%; }
.card-dot.coral { background: var(--color-accent); }
.card-dot.ink-strong { background: rgba(45,49,66,0.80); }
.card-dot.ink-light { background: rgba(45,49,66,0.62); }
.card h3 { font-size: 0.875rem; font-weight: 600; }
.card p { color: var(--color-muted); font-size: 0.8125rem; line-height: 1.55; }
.footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid rgba(45,49,66,0.10); font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.06em; color: var(--color-muted); display: flex; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<p class="header-eyebrow">Ridgeline · Diagram Design</p>
<h1>Checkout is slow in two different ways</h1>
<p class="subtitle">Five services, one week of request latency, one amplitude scale. Each ridge is the share of that service's own requests in each 40&nbsp;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&nbsp;ms, which is a different failure from simply being slow.</p>
</div>
<div class="diagram-container">
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="ridgeline-full-title ridgeline-full-desc">
<title id="ridgeline-full-title">Checkout is slow in two different ways</title>
<desc id="ridgeline-full-desc">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.</desc>
<defs>
<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" fill="rgba(45,49,66,0.10)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="#f5f5f5"/>
<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/>
<!-- ONE amplitude on every ridge: y = baseline - 2.4 x (share in %). Per-ridge
normalisation would let a rare, flat distribution wear the same silhouette
as a tight one, which is the single lie this type is built to tell.
scripts/verify-ridgeline.py derives the amplitude from the figure and then
holds every vertex of every ridge to it. -->
<text transform="rotate(-90 24 264)" x="24" y="264" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">SHARE OF REQUESTS · 1% = 2.4PX ON EVERY RIDGE</text>
<!-- One baseline per ridge, 56px pitch. The baseline is where a bin of zero
requests sits, so it can never be moved to give a ridge more room. -->
<line data-ridge="static-cdn" data-role="baseline" x1="320" y1="152" x2="680" y2="152" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line data-ridge="auth-api" data-role="baseline" x1="320" y1="208" x2="680" y2="208" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line data-ridge="search-api" data-role="baseline" x1="320" y1="264" x2="680" y2="264" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line data-ridge="checkout-api" data-role="baseline" x1="320" y1="320" x2="680" y2="320" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line data-ridge="report-export" data-role="baseline" x1="320" y1="376" x2="680" y2="376" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<!-- Shared x-scale: every ridge is sampled at the same 13 bins, 40ms wide,
labelled at the lower edge. Straight segments between bins - no smoothing,
because a spline through 13 bins invents extrema the sample never had. -->
<path data-ridge="static-cdn" data-baseline="152" data-bins="0,26,34,21,11,5,2,1,0,0,0,0,0" d="M320,152 L350,89.6 L380,70.4 L410,101.6 L440,125.6 L470,140 L500,147.2 L530,149.6 L560,152 L590,152 L620,152 L650,152 L680,152 Z" fill="rgba(45,49,66,0.12)" stroke="rgba(45,49,66,0.80)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="static-cdn" data-role="name" x="304" y="155.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">static-cdn</text>
<text data-ridge="static-cdn" data-role="range" x="696" y="155.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">40–280 ms</text>
<path data-ridge="auth-api" data-baseline="208" data-bins="0,6,28,36,18,7,3,1,1,0,0,0,0" d="M320,208 L350,193.6 L380,140.8 L410,121.6 L440,164.8 L470,191.2 L500,200.8 L530,205.6 L560,205.6 L590,208 L620,208 L650,208 L680,208 Z" fill="rgba(45,49,66,0.12)" stroke="rgba(45,49,66,0.74)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="auth-api" data-role="name" x="304" y="211.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">auth-api</text>
<text data-ridge="auth-api" data-role="range" x="696" y="211.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">40–320 ms</text>
<path data-ridge="search-api" data-baseline="264" data-bins="0,1,9,22,27,19,11,6,3,1,1,0,0" d="M320,264 L350,261.6 L380,242.4 L410,211.2 L440,199.2 L470,218.4 L500,237.6 L530,249.6 L560,256.8 L590,261.6 L620,261.6 L650,264 L680,264 Z" fill="rgba(45,49,66,0.12)" stroke="rgba(45,49,66,0.68)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="search-api" data-role="name" x="304" y="267.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">search-api</text>
<text data-ridge="search-api" data-role="range" x="696" y="267.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">40–400 ms</text>
<!-- checkout-api FOCAL - and it is the ridge whose SHAPE is the story,
not the slowest service. Drawn last so its second peak reads over the
ridge below it rather than under. -->
<path data-ridge="checkout-api" data-baseline="320" data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0" 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" fill="rgba(235,108,54,0.16)" stroke="#eb6c36" stroke-width="2.4" stroke-linejoin="round"/>
<text data-ridge="checkout-api" data-role="name" x="304" y="323.5" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">checkout-api</text>
<text data-ridge="checkout-api" data-role="range" x="696" y="323.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">40–440 ms</text>
<path data-ridge="report-export" data-baseline="376" data-bins="0,0,1,3,7,12,17,19,16,12,9,4,0" d="M320,376 L350,376 L380,373.6 L410,368.8 L440,359.2 L470,347.2 L500,335.2 L530,330.4 L560,337.6 L590,347.2 L620,354.4 L650,366.4 L680,376 Z" fill="rgba(45,49,66,0.12)" stroke="rgba(45,49,66,0.62)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="report-export" data-role="name" x="304" y="379.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">report-export</text>
<text data-ridge="report-export" data-role="range" x="696" y="379.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">80–440 ms</text>
<!-- Bin ticks on the shared latency scale. data-bin binds the printed number,
so a caption cannot be edited without its binding. -->
<text data-tick="0" data-bin="0" x="320" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">0</text>
<text data-tick="1" data-bin="120" x="410" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">120</text>
<text data-tick="2" data-bin="240" x="500" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">240</text>
<text data-tick="3" data-bin="360" x="590" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">360</text>
<text data-tick="4" data-bin="480" x="680" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">480</text>
<text x="500" y="424" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.18em" text-anchor="middle">REQUEST LATENCY (MS), BIN LOWER EDGE</text>
<!-- Legend -->
<line x1="40" y1="462" x2="960" y2="462" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="40" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.18em">LEGEND</text>
<text x="960" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.06em" text-anchor="end">SHARE OF EACH SERVICE'S OWN REQUESTS PER 40 MS BIN · 1% = 2.4PX ON EVERY RIDGE · UNSMOOTHED · ILLUSTRATIVE FIGURES</text>
<rect x="40" y="486" width="24" height="10" fill="rgba(235,108,54,0.16)"/>
<line x1="40" y1="486" x2="64" y2="486" stroke="#eb6c36" stroke-width="2.4"/>
<text x="72" y="496" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">checkout-api · focal, second peak past 300 ms</text>
<rect x="360" y="486" width="24" height="10" fill="rgba(45,49,66,0.12)"/>
<line x1="360" y1="486" x2="384" y2="486" stroke="rgba(45,49,66,0.80)" stroke-width="1.2"/>
<text x="392" y="496" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Other services · strongest tone is the fastest median</text>
</svg>
</div>
<div class="cards">
<div class="card">
<p class="eyebrow">FOCAL · CHECKOUT-API</p>
<div class="card-header"><span class="card-dot coral"></span><h3>A second peak is not a long tail</h3></div>
<p>About a third of checkout's requests land past 280&nbsp;ms, and they arrive as a distinct bump peaking near 360&nbsp;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&nbsp;ms and its p99 is 400&nbsp;ms — both true, and neither says the requests between them arrive in two clusters instead of one spread.</p>
</div>
<div class="card">
<div class="card-header"><span class="card-dot ink-strong"></span><h3>Height is share, so the ridges compare shape</h3></div>
<p>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.</p>
</div>
<div class="card">
<div class="card-header"><span class="card-dot ink-light"></span><h3>Overlap is deliberate; occlusion would not be</h3></div>
<p>Ridges sit 56&nbsp;px apart and the tallest rises 86&nbsp;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.</p>
</div>
</div>
<div class="footer">
<span>share of requests per 40 ms bin · one week · illustrative</span>
<span>example · diagram design</span>
</div>
</div>
</body>
</html>
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Request latency by service · Ridgeline</title>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-paper: #f5f5f5;
--color-ink: #2d3142;
--color-muted: #4f5d75;
--color-accent: #eb6c36;
--font-sans: 'Geist', system-ui, sans-serif;
--font-serif: 'Instrument Serif', serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
}
body { font-family: var(--font-sans); background: var(--color-paper); color: var(--color-ink); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; }
.frame { max-width: 1200px; width: 100%; }
.eyebrow { font-family: var(--font-mono); font-size: 0.66rem; font-weight: 500; letter-spacing: 0.18em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.5rem; }
h1 { font-family: var(--font-serif); font-size: clamp(1.5rem, 2.4vw + 0.75rem, 2rem); font-weight: 400; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; }
svg { width: 100%; min-width: 760px; display: block; }
</style>
</head>
<body>
<div class="frame">
<p class="eyebrow">Ridgeline · Diagram Design</p>
<h1>Checkout is slow in two different ways</h1>
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="ridgeline-title ridgeline-desc">
<title id="ridgeline-title">Checkout is slow in two different ways</title>
<desc id="ridgeline-desc">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.</desc>
<defs>
<pattern id="dots" width="22" height="22" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.9" fill="rgba(45,49,66,0.10)"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="#f5f5f5"/>
<rect width="100%" height="100%" fill="url(#dots)" opacity="0.55"/>
<!-- ONE amplitude on every ridge: y = baseline - 2.4 x (share in %). Per-ridge
normalisation would let a rare, flat distribution wear the same silhouette
as a tight one, which is the single lie this type is built to tell.
scripts/verify-ridgeline.py derives the amplitude from the figure and then
holds every vertex of every ridge to it. -->
<text transform="rotate(-90 24 264)" x="24" y="264" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">SHARE OF REQUESTS · 1% = 2.4PX ON EVERY RIDGE</text>
<!-- One baseline per ridge, 56px pitch. The baseline is where a bin of zero
requests sits, so it can never be moved to give a ridge more room. -->
<line data-ridge="static-cdn" data-role="baseline" x1="320" y1="152" x2="680" y2="152" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line data-ridge="auth-api" data-role="baseline" x1="320" y1="208" x2="680" y2="208" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line data-ridge="search-api" data-role="baseline" x1="320" y1="264" x2="680" y2="264" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line data-ridge="checkout-api" data-role="baseline" x1="320" y1="320" x2="680" y2="320" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<line data-ridge="report-export" data-role="baseline" x1="320" y1="376" x2="680" y2="376" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<!-- Shared x-scale: every ridge is sampled at the same 13 bins, 40ms wide,
labelled at the lower edge. Straight segments between bins - no smoothing,
because a spline through 13 bins invents extrema the sample never had. -->
<path data-ridge="static-cdn" data-baseline="152" data-bins="0,26,34,21,11,5,2,1,0,0,0,0,0" d="M320,152 L350,89.6 L380,70.4 L410,101.6 L440,125.6 L470,140 L500,147.2 L530,149.6 L560,152 L590,152 L620,152 L650,152 L680,152 Z" fill="rgba(45,49,66,0.12)" stroke="rgba(45,49,66,0.80)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="static-cdn" data-role="name" x="304" y="155.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">static-cdn</text>
<text data-ridge="static-cdn" data-role="range" x="696" y="155.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">40–280 ms</text>
<path data-ridge="auth-api" data-baseline="208" data-bins="0,6,28,36,18,7,3,1,1,0,0,0,0" d="M320,208 L350,193.6 L380,140.8 L410,121.6 L440,164.8 L470,191.2 L500,200.8 L530,205.6 L560,205.6 L590,208 L620,208 L650,208 L680,208 Z" fill="rgba(45,49,66,0.12)" stroke="rgba(45,49,66,0.74)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="auth-api" data-role="name" x="304" y="211.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">auth-api</text>
<text data-ridge="auth-api" data-role="range" x="696" y="211.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">40–320 ms</text>
<path data-ridge="search-api" data-baseline="264" data-bins="0,1,9,22,27,19,11,6,3,1,1,0,0" d="M320,264 L350,261.6 L380,242.4 L410,211.2 L440,199.2 L470,218.4 L500,237.6 L530,249.6 L560,256.8 L590,261.6 L620,261.6 L650,264 L680,264 Z" fill="rgba(45,49,66,0.12)" stroke="rgba(45,49,66,0.68)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="search-api" data-role="name" x="304" y="267.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">search-api</text>
<text data-ridge="search-api" data-role="range" x="696" y="267.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">40–400 ms</text>
<!-- checkout-api FOCAL - and it is the ridge whose SHAPE is the story,
not the slowest service. Drawn last so its second peak reads over the
ridge below it rather than under. -->
<path data-ridge="checkout-api" data-baseline="320" data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0" 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" fill="rgba(235,108,54,0.16)" stroke="#eb6c36" stroke-width="2.4" stroke-linejoin="round"/>
<text data-ridge="checkout-api" data-role="name" x="304" y="323.5" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">checkout-api</text>
<text data-ridge="checkout-api" data-role="range" x="696" y="323.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">40–440 ms</text>
<path data-ridge="report-export" data-baseline="376" data-bins="0,0,1,3,7,12,17,19,16,12,9,4,0" d="M320,376 L350,376 L380,373.6 L410,368.8 L440,359.2 L470,347.2 L500,335.2 L530,330.4 L560,337.6 L590,347.2 L620,354.4 L650,366.4 L680,376 Z" fill="rgba(45,49,66,0.12)" stroke="rgba(45,49,66,0.62)" stroke-width="1.2" stroke-linejoin="round"/>
<text data-ridge="report-export" data-role="name" x="304" y="379.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">report-export</text>
<text data-ridge="report-export" data-role="range" x="696" y="379.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">80–440 ms</text>
<!-- Bin ticks on the shared latency scale. data-bin binds the printed number,
so a caption cannot be edited without its binding. -->
<text data-tick="0" data-bin="0" x="320" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">0</text>
<text data-tick="1" data-bin="120" x="410" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">120</text>
<text data-tick="2" data-bin="240" x="500" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">240</text>
<text data-tick="3" data-bin="360" x="590" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">360</text>
<text data-tick="4" data-bin="480" x="680" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">480</text>
<text x="500" y="424" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.18em" text-anchor="middle">REQUEST LATENCY (MS), BIN LOWER EDGE</text>
<!-- Legend -->
<line x1="40" y1="462" x2="960" y2="462" stroke="rgba(45,49,66,0.10)" stroke-width="0.8"/>
<text x="40" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.18em">LEGEND</text>
<text x="960" y="478" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" letter-spacing="0.06em" text-anchor="end">SHARE OF EACH SERVICE'S OWN REQUESTS PER 40 MS BIN · 1% = 2.4PX ON EVERY RIDGE · UNSMOOTHED · ILLUSTRATIVE FIGURES</text>
<rect x="40" y="486" width="24" height="10" fill="rgba(235,108,54,0.16)"/>
<line x1="40" y1="486" x2="64" y2="486" stroke="#eb6c36" stroke-width="2.4"/>
<text x="72" y="496" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">checkout-api · focal, second peak past 300 ms</text>
<rect x="360" y="486" width="24" height="10" fill="rgba(45,49,66,0.12)"/>
<line x1="360" y1="486" x2="384" y2="486" stroke="rgba(45,49,66,0.80)" stroke-width="1.2"/>
<text x="392" y="496" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Other services · strongest tone is the fastest median</text>
</svg>
</div>
</body>
</html>
+3
View File
@@ -298,6 +298,9 @@
<button class="tab new" data-type="slopegraph">
<span class="eyebrow">30</span>Line · slopegraph
</button>
<button class="tab new" data-type="ridgeline">
<span class="eyebrow">20</span>Line · ridgeline
</button>
<button class="tab new" data-type="dp-security-matrix">
<span class="eyebrow">33</span>DP security matrix
</button>
@@ -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
<line data-ridge="checkout-api" data-role="baseline" x1="320" y1="320" x2="680" y2="320" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<path data-ridge="checkout-api" data-baseline="320" data-bins="0,1,6,17,21,14,8,6,7,9,7,4,0" 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" fill="rgba(235,108,54,0.16)" stroke="#eb6c36" stroke-width="2.4" stroke-linejoin="round"/>
<text data-ridge="checkout-api" data-role="name" x="304" y="323.5" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">checkout-api</text>
<text data-ridge="checkout-api" data-role="range" x="696" y="323.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">40–440 ms</text>
<text data-tick="2" data-bin="240" x="500" y="400" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">240</text>
```
`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 `<line>`, this variant binds `data-bins` on a `<path>`, 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 `<g>` 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