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:
co-authored by
0xDarkMatter
Claude Fable 5
parent
5538b35116
commit
ddaeae936e
@@ -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,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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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 ms bin — so the ridges compare shape, not traffic. Four of them have a single hump and a thinning tail; checkout-api has a second, smaller peak past 300 ms, which is a different failure from simply being slow.</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 ms, and they arrive as a distinct bump peaking near 360 ms rather than as a thinning tail. A tail says "sometimes slower"; a second peak says there are two paths through the code and one of them is always slow. Its p50 is 160 ms and its p99 is 400 ms — both true, and neither says the requests between them arrive in two clusters instead of one spread.</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 px apart and the tallest rises 86 px, so peaks cross the row above and the stack reads as depth. The rule is that no peak may reach the row two above it — past that a peak hides behind a peak, and the fix is more pitch, never a smaller amplitude on one ridge.</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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user