feat(line): add slopegraph variant for change between two states (#106)

This commit is contained in:
0xDarkMatter
2026-08-18 22:31:48 -07:00
committed by GitHub
parent 29917724f8
commit 5f1b6ddc66
12 changed files with 1943 additions and 4 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, 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, or DP security matrix 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.5.5",
"version": "2.5.6",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "diagram-design",
"description": "Create branded architecture, IT current-state, flowchart, sequence, state machine, ER/data model, timeline, swimlane, quadrant, radar/spider, 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, or DP security matrix 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.5.5",
"version": "2.5.6",
"author": {
"name": "Cathryn Lavery",
"url": "https://github.com/cathrynlavery"
+11
View File
@@ -206,6 +206,15 @@ jobs:
if: always()
id: dumbbell_tests
run: python scripts/test-verify-dumbbell.py
- name: Verify slopegraph shared scale and endpoint values
if: always()
id: slopegraph
run: python scripts/verify-slopegraph.py --all
- name: Verify slopegraph checker
if: always()
id: slopegraph_tests
run: python scripts/test-verify-slopegraph.py
- name: Verify generated icon assets
if: always()
@@ -245,4 +254,6 @@ jobs:
echo "| Treemap Checker | ${{ steps.treemap_tests.outcome == 'success' && '✅ Passed' || (steps.treemap_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Dumbbell Domain & Contrast | ${{ steps.dumbbell.outcome == 'success' && '✅ Passed' || (steps.dumbbell.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Dumbbell Checker | ${{ steps.dumbbell_tests.outcome == 'success' && '✅ Passed' || (steps.dumbbell_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Slopegraph Shared Scale | ${{ steps.slopegraph.outcome == 'success' && '✅ Passed' || (steps.slopegraph.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Slopegraph Checker | ${{ steps.slopegraph_tests.outcome == 'success' && '✅ Passed' || (steps.slopegraph_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
+8 -1
View File
@@ -60,6 +60,8 @@ The helper refuses to run if the Claude and Codex versions already differ. If an
| Treemap checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-treemap.py` |
| Dumbbell domain resolves finitely and its marks clear 3:1 | `python3 scripts/verify-dumbbell.py` |
| 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` |
| 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 |
The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 bytes so the installed skill remains practical to load. If that gate fails, reduce duplication or move detail into a routed reference; do not remove routing vocabulary from frontmatter.
@@ -86,7 +88,11 @@ python3 scripts/test-plugin-package.py \
&& python3 scripts/verify-geometry.py --all \
&& python3 scripts/test-verify-geometry.py \
&& python3 scripts/verify-treemap.py --all \
&& python3 scripts/test-verify-treemap.py \n && python3 scripts/verify-dumbbell.py \n && python3 scripts/test-verify-dumbbell.py
&& python3 scripts/test-verify-treemap.py \
&& python3 scripts/verify-dumbbell.py \
&& python3 scripts/test-verify-dumbbell.py \
&& python3 scripts/verify-slopegraph.py --all \
&& python3 scripts/test-verify-slopegraph.py
```
### If a gate fails
@@ -94,6 +100,7 @@ python3 scripts/test-plugin-package.py \
- **`verify-plugin-package.py`:** run the bump helper if the versions did not increase. If packaging validation fails, keep both marketplaces pointed at the repository root and keep the shared skill at `skills/diagram-design/SKILL.md`.
- **`lint-skin.py`:** the failure message names the file, line, and category (`color`, `font-family`, `a11y`, `external-asset`, `pure-black`, `script`). Colors must come from the palette in `skills/diagram-design/references/style-guide.md`; fonts from the allowed list; diagrams must satisfy the accessible SVG contract (see below). The linter also requires the SHA-pinned controller from `template-motion.html` verbatim and rejects remote resources, CSS `@import`, non-fragment CSS `url()`, event handlers, `srcdoc`, executable URLs, and extra scripts.
- **`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-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-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.
+673
View File
@@ -0,0 +1,673 @@
#!/usr/bin/env python3
"""Adversarial cases for verify-slopegraph.py, both polarities.
Every case is named for exactly what it asserts. A case called "unlabelled
sliver does not disable the check" once proved only that the *labelled* elements
stayed checked, which made a real gap look covered for days - so a name here
that overclaims is itself a defect.
The negative half matters as much as the positive: a checker that fires on an
honest indexed slopegraph, or on the other shipped example types, gets widened
or switched off, and then it guards nothing.
Fixtures live in a per-process temporary directory. They used to be written to
fixed paths under the repository root and unconditionally unlinked, which would
overwrite and then delete a real file that happened to share a fixture name, and
raced when two runs overlapped. Two cases at the end hold that fix in place.
Exit: 0 all pass, 1 any failure.
"""
from __future__ import annotations
import re
import shutil
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "scripts"))
verify = __import__("verify-slopegraph")
SHIPPED = ROOT / "skills/diagram-design/assets/example-slopegraph.html"
HEAD = """<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><title>t</title></head><body>
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img"
aria-labelledby="slopegraph-title slopegraph-desc">
<title id="slopegraph-title">t</title>
<desc id="slopegraph-desc">Slopegraph fixture.</desc>
"""
TAIL = "</svg></body></html>\n"
# The bound state captions every slopegraph must carry.
CAPTIONS = (
' <text data-axis="from" data-state="BEFORE" x="320" y="440">BEFORE</text>\n'
' <text data-axis="to" data-state="AFTER" x="680" y="440">AFTER</text>\n'
)
# y = 420 - (v - 100) * 380/450, the shared scale the shipped example uses:
# p95 milliseconds over a 100-550 domain.
PX = 380.0 / 450.0
def y(value: float) -> float:
return round(420 - (value - 100) * PX, 1)
def series(name: str, frm: float, to: float, y1=None, y2=None,
x1: float = 320, x2: float = 680, omit: str = "") -> str:
parts = ['data-series="%s"' % name]
if omit != "data-from":
parts.append('data-from="%s"' % frm)
if omit != "data-to":
parts.append('data-to="%s"' % to)
parts.append('x1="%g"' % x1)
if omit != "y1":
parts.append('y1="%g"' % (y(frm) if y1 is None else y1))
parts.append('x2="%g"' % x2)
if omit != "y2":
parts.append('y2="%g"' % (y(to) if y2 is None else y2))
return " <line %s stroke=\"#2d3142\" stroke-width=\"1.2\"/>\n" % " ".join(parts)
def labels(name: str, frm: float, to: float, shown_from=None, shown_to=None,
skip: str = "", skip_name: str = "", y_from=None, y_to=None) -> str:
"""The value and name label pair for each end of one series.
y_from / y_to place the labels independently of the value scale, so a
fixture that moves the geometry can move its labels with it. A fixture
whose labels stay behind reports a real row defect that has nothing to do
with what the case is testing.
"""
out = ""
for end, value, shown, row in (("from", frm, shown_from, y_from),
("to", to, shown_to, y_to)):
if skip == end:
continue
text = value if shown is None else shown
baseline = (y(value) if row is None else row) + 3.5
out += (' <text data-series="%s" data-end="%s" x="%d" y="%g">%s</text>\n'
% (name, end, 304 if end == "from" else 696, baseline, text))
if skip_name != end:
out += (' <text data-series="%s" data-end="%s" data-role="name" '
'x="%d" y="%g">%s</text>\n'
% (name, end, 272 if end == "from" else 728, baseline, name))
return out
def document(*blocks: str) -> str:
return HEAD + CAPTIONS + "".join(blocks) + TAIL
def honest(rows) -> str:
return document(honest_rows_block(rows))
def honest_rows_block(rows=None) -> str:
"""The series+labels block for a correct figure, without the wrapper."""
body = ""
for name, frm, to in (ROWS if rows is None else rows):
body += series(name, frm, to) + labels(name, frm, to)
return body
ROWS = [("Search", 512, 208), ("Catalog", 376, 164), ("Checkout", 291, 143),
("Auth", 154, 121), ("Recommender", 238, 431)]
class Harness:
def __init__(self) -> None:
self.failures = 0
self.count = 0
# A private temp dir per instance: fixture names are meaningful to the
# checker (it keys detection off the filename) but their directory is
# not, so there is no reason to put them anywhere a real file lives.
self.dir = Path(tempfile.mkdtemp(prefix="slopegraph-fixtures-"))
def close(self) -> None:
shutil.rmtree(self.dir, ignore_errors=True)
def path_for(self, name: str) -> Path:
return self.dir / name
def run(self, source: str, name: str = "example-slopegraph-fixture.html") -> list:
path = self.path_for(name)
path.write_text(source, encoding="utf-8")
try:
return verify.check(path)
finally:
path.unlink()
def expect_clean(self, label: str, source: str, name: str | None = None) -> None:
self.count += 1
found = self.run(source, name) if name else self.run(source)
if found:
self.failures += 1
print("FAIL %s" % label)
for item in found:
print(" unexpected: %s" % item)
else:
print("ok %s" % label)
def expect_finding(self, label: str, source: str, pattern: str,
name: str | None = None) -> None:
self.count += 1
found = self.run(source, name) if name else self.run(source)
if not found:
self.failures += 1
print("FAIL %s\n expected a finding, got none" % label)
return
if not any(re.search(pattern, item) for item in found):
self.failures += 1
print("FAIL %s\n no finding matched %r" % (label, pattern))
for item in found:
print(" got: %s" % item)
return
print("ok %s" % label)
def expect_only_one(self, label: str, source: str, pattern: str) -> None:
"""A single defect must produce a single finding, not a cascade."""
self.count += 1
found = self.run(source)
if len(found) == 1 and re.search(pattern, found[0]):
print("ok %s" % label)
return
self.failures += 1
print("FAIL %s\n expected exactly one finding matching %r, got %d"
% (label, pattern, len(found)))
for item in found:
print(" got: %s" % item)
def expect_out_of_scope(self, label: str, source: str, name: str) -> None:
"""Not merely finding-free - genuinely outside this checker's scope.
expect_clean cannot tell the two apart, so four cases named "out of
scope" were only ever proving "no findings", which a detected-and-clean
file also satisfies.
"""
self.count += 1
path = self.path_for(name)
path.write_text(source, encoding="utf-8")
try:
detected = verify.looks_like_slopegraph(path, source)
found = verify.check(path)
finally:
path.unlink()
if detected or found:
self.failures += 1
print("FAIL %s\n detected=%s findings=%d"
% (label, detected, len(found)))
return
print("ok %s" % label)
def check(self, label: str, condition: bool, detail: str = "") -> None:
self.count += 1
if condition:
print("ok %s" % label)
return
self.failures += 1
print("FAIL %s%s" % (label, ("\n " + detail) if detail else ""))
def main() -> int:
h = Harness()
try:
return run_cases(h)
finally:
h.close()
def run_cases(h: Harness) -> int:
# ── Negative half: honest figures must stay silent ────────────────────
h.expect_clean("an honest slopegraph reports nothing", honest(ROWS))
h.expect_clean(
"the shipped light example reports nothing",
SHIPPED.read_text(encoding="utf-8"), name="example-slopegraph.html",
)
# Two series holding identical values must land on identical y. This is the
# case the draft got wrong, and the honest rendering of it must pass.
h.expect_clean(
"two series with identical values drawn at identical y is legal",
honest([("Search", 512, 208), ("Mirror", 512, 208), ("Auth", 154, 121)]),
)
h.expect_clean(
"a correct indexed slopegraph (every series starts at the index base) "
"is not rejected",
honest([("api", 100, 143), ("web", 100, 118), ("worker", 100, 196)]),
)
# Coordinates ship rounded to 0.1px, and an author may reasonably round to
# whole pixels instead. Neither may read as a defect.
for label, quantum in (("one decimal", 1), ("whole pixels", 0)):
rounded = ""
for name, frm, to in ROWS:
rounded += series(name, frm, to, y1=round(y(frm), quantum),
y2=round(y(to), quantum)) + labels(name, frm, to)
h.expect_clean("coordinates rounded to %s stay within tolerance" % label,
document(rounded))
# The rotated value-axis caption carries a transform and must not be caught:
# it is not verified geometry and not a bound label.
h.expect_clean(
"the rotated value-axis caption's transform is not reported",
document(' <text transform="rotate(-90 24 230)" x="24" y="230">'
"P95 RESPONSE TIME, MS</text>\n" + honest_rows_block()),
)
# `text-transform` is not `transform`.
h.expect_clean(
"a CSS text-transform declaration is not read as a transform",
HEAD + "<style>.eyebrow { text-transform: uppercase; }</style>\n"
+ CAPTIONS + honest_rows_block() + TAIL,
)
# The detector must not drag the other example types into scope.
for other in ("example-line.html", "example-bar.html", "example-treemap.html",
"example-scatter.html"):
path = ROOT / "skills/diagram-design/assets" / other
if path.exists():
h.expect_out_of_scope(
"%s is not detected as a slopegraph at all" % other,
path.read_text(encoding="utf-8"), other)
# ── Positive half: each lie must be named as itself ───────────────────
# 1. Jitter. One point moved to free up label space.
jittered = ""
for name, frm, to in ROWS:
shift = 3.0 if name == "Search" else 0.0
jittered += series(name, frm, to, y2=y(to) - shift) + labels(name, frm, to)
h.expect_only_one(
"a single endpoint nudged 3px reports as one point, not as a bad axis",
document(jittered), r"'Search' draws its to endpoint.*off by 3\.0 px",
)
# The specific shape of the draft's bug: two series at identical values
# pulled apart so their labels clear each other.
collided = ""
for name, frm, to in [("Search", 512, 208), ("Mirror", 512, 208),
("Catalog", 376, 164), ("Checkout", 291, 143),
("Auth", 154, 121)]:
if name == "Search":
collided += series(name, frm, to, y1=y(frm) - 3, y2=y(to) - 3)
else:
collided += series(name, frm, to)
collided += labels(name, frm, to)
h.expect_finding(
"an equal-valued series pulled 3px clear of its twin's labels is reported",
document(collided), r"'Search' draws its (from|to) endpoint",
)
# An indexed slopegraph with one endpoint wrong. A whole-set fit called this
# clean: with three points and one 3px error Theil-Sen has no outlier budget,
# and the contaminated slope passed within 1px of every point.
indexed_lie = (series("api", 100, 118) + labels("api", 100, 118)
+ series("web", 100, 143) + labels("web", 100, 143)
+ series("worker", 100, 196, y2=y(196) + 3)
+ labels("worker", 100, 196))
h.expect_finding(
"an indexed slopegraph with one endpoint 3px wrong is reported",
document(indexed_lie), r"'worker' draws its to endpoint",
)
# 2. Dual scale, and a shared scale with a shifted origin.
dual = ""
for name, frm, to in ROWS:
dual += series(name, frm, to, y2=round(420 - (420 - y(to)) * 0.8, 1)) \
+ labels(name, frm, to)
h.expect_only_one(
"a right axis compressed to 80% reports as a scale mismatch and stops there",
document(dual), r"the two axes are not on the same scale",
)
shifted = ""
for name, frm, to in ROWS:
shifted += (series(name, frm, to, y2=y(to) - 12)
+ labels(name, frm, to, y_to=y(to) - 12))
h.expect_only_one(
"a right axis shifted 12px at an identical slope reports as an origin offset",
document(shifted), r"share a scale but not an origin",
)
# 3. Transforms move the rendered mark away from the checked coordinates.
h.expect_finding(
"a transform on a series line is reported",
document(series("Search", 512, 208).replace(
" <line ", ' <line transform="translate(0 80)" ', 1)
+ labels("Search", 512, 208)
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
r"series 'Search' carries transform",
)
h.expect_finding(
"a transform on a bound label is reported",
document(honest_rows_block().replace(
' <text data-series="Search" data-end="from"',
' <text transform="translate(0 40)" data-series="Search" data-end="from"',
1)),
r"bound label.*carries transform",
)
h.expect_finding(
"an ancestor <g> transform over the series is reported",
document(' <g transform="translate(0 60)">\n' + honest_rows_block()
+ " </g>\n"),
r"carries an ancestor <g>/<svg> transform",
)
h.expect_finding(
"a CSS transform declaration is reported",
HEAD + "<style>line { transform: translateY(40px); }</style>\n"
+ CAPTIONS + honest_rows_block() + TAIL,
r"a CSS `transform` declaration",
)
# 4. The printed value must be one complete token that matches metadata.
h.expect_finding(
"a printed value that contradicts the declared one is reported",
document(series("Search", 512, 208)
+ labels("Search", 512, 208, shown_to="180")
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
r"'Search' prints '180' at its to endpoint but declares 208",
)
h.expect_finding(
"a thousands separator that changes the value is reported",
document(series("Search", 512, 208)
+ labels("Search", 512, 208, shown_from="512,000")
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
r"prints '512,000' at its from endpoint but declares 512",
)
h.expect_finding(
"a label carrying two numeric tokens is reported, not half-read",
document(series("Search", 512, 208)
+ labels("Search", 512, 208, shown_from="512 was 600")
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
r"prints more than one numeric token",
)
h.expect_clean(
"a unit suffix on a printed value is accepted",
document("".join(series(n, f, t)
+ labels(n, f, t, shown_from="%dms" % f,
shown_to="%dms" % t)
for n, f, t in ROWS)),
)
h.expect_finding(
"a series missing one endpoint value label is reported",
document(series("Search", 512, 208)
+ labels("Search", 512, 208, skip="to")
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
r"'Search' prints no to endpoint value",
)
# 5. Captions and names must be bound to what they describe.
swapped_text = document(honest_rows_block()).replace(
">BEFORE</text>", ">@@T@@</text>", 1).replace(
">AFTER</text>", ">BEFORE</text>", 1).replace(
">@@T@@</text>", ">AFTER</text>", 1)
h.expect_finding(
"swapping only the visible caption text is reported",
swapped_text, r"state caption reads 'AFTER' but declares data-state='BEFORE'",
)
swapped_x = document(honest_rows_block()).replace(
'data-state="BEFORE" x="320"', 'data-state="BEFORE" x="@@"', 1).replace(
'data-state="AFTER" x="680"', 'data-state="AFTER" x="320"', 1).replace(
'x="@@"', 'x="680"', 1)
h.expect_finding(
"swapping the caption x values is reported",
swapped_x, r"state caption \('BEFORE'\) is drawn at x=680",
)
h.expect_finding(
"a caption with no data-state is reported",
document(honest_rows_block()).replace(' data-state="BEFORE"', "", 1),
r"has no data-state",
)
h.expect_finding(
"a missing state caption is reported",
HEAD + ' <text data-axis="from" data-state="BEFORE" x="320" y="440">'
"BEFORE</text>\n" + honest_rows_block() + TAIL,
r"no 'to' state caption",
)
h.expect_finding(
"two captions reading the same thing is reported",
document(honest_rows_block()).replace(
'data-state="AFTER" x="680" y="440">AFTER<',
'data-state="BEFORE" x="680" y="440">BEFORE<', 1),
r"both state captions read 'BEFORE'",
)
h.expect_finding(
"two series names swapped between rows is reported",
document(honest_rows_block()).replace(
'data-role="name" x="272" y="%g">Search<' % (y(512) + 3.5),
'data-role="name" x="272" y="%g">Catalog<' % (y(512) + 3.5), 1),
r"name label bound to series 'Search' reads 'Catalog'",
)
h.expect_finding(
"a name label drawn on another series' row is reported",
document(honest_rows_block()).replace(
'data-series="Auth" data-end="from" data-role="name" x="272" y="%g"'
% (y(154) + 3.5),
'data-series="Auth" data-end="from" data-role="name" x="272" y="%g"'
% (y(512) + 3.5), 1),
r"name label for series 'Auth' is drawn at y=.*nearer 'Search'",
)
h.expect_finding(
"a series with no name label is reported",
document(series("Search", 512, 208)
+ labels("Search", 512, 208, skip_name="from")
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
r"'Search' has no from name label",
)
h.expect_finding(
"a second label for the same endpoint is reported",
document(honest_rows_block()
+ ' <text data-series="Search" data-end="from" x="304" y="80">999'
"</text>\n"),
r"a second from value label for series 'Search'",
)
h.expect_finding(
"an endpoint label for a series with no line is reported",
document(honest_rows_block()
+ ' <text data-series="Ghost" data-end="from" x="304" y="80">300'
"</text>\n"),
r"names series 'Ghost', which no line declares",
)
# 6. Fail closed, and read what is actually there.
h.expect_finding(
"a file named example-slopegraph with no series is reported, not passed",
document(' <line x1="320" y1="40" x2="320" y2="420" stroke="#2d3142"/>\n'),
r"presents as a slopegraph but declares 0 verifiable series",
name="example-slopegraph-empty.html",
)
h.expect_finding(
"a file whose desc says slopegraph but declares no series is reported",
document(' <line x1="320" y1="40" x2="320" y2="420" stroke="#2d3142"/>\n'),
r"presents as a slopegraph but declares 0 verifiable series",
name="figure.html",
)
h.expect_finding(
"a slopegraph declared only in a desc past 4000 chars is still detected",
"<!doctype html><style>/*" + ("x" * 4200) + "*/</style>"
+ '<svg role="img"><title>t</title>'
+ "<desc>Slopegraph of latency with no declared series.</desc>"
+ '<line x1="1" y1="1" x2="2" y2="2"/></svg>',
r"presents as a slopegraph but declares 0 verifiable series",
name="figure.html",
)
for omitted in ("data-from", "data-to", "y1", "y2"):
h.expect_finding(
"a series line missing %s is reported rather than dropped" % omitted,
document(series("Search", 512, 208, omit=omitted)
+ labels("Search", 512, 208)
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
r"missing %s" % omitted,
)
h.expect_finding(
"a declared value that is not a finite number is reported rather than dropped",
document(' <line data-series="Search" data-from="half a second" '
'data-to="208" x1="320" y1="72.1" x2="680" y2="328.8" '
'stroke="#2d3142"/>\n'
+ honest_rows_block([r for r in ROWS if r[0] != "Search"])),
r"not a finite number",
)
h.expect_finding(
"a NaN coordinate is reported, not silently accepted",
document(' <line data-series="a" data-from="nan" data-to="nan" x1="320" '
'y1="nan" x2="680" y2="nan" stroke="#2d3142"/>\n'
+ honest_rows_block()),
r"not a finite number",
)
h.expect_clean(
"a commented-out series line is ignored, not read as data",
document(honest_rows_block()
+ ' <!-- old draft: <line data-series="ghost" data-from="999" '
'data-to="1" x1="320" y1="10" x2="680" y2="410"/> -->\n'),
)
h.expect_clean(
"an honest slopegraph written with single-quoted attributes is parsed",
HEAD + CAPTIONS + "".join(
" <line data-series='%s' data-from='%s' data-to='%s' x1='320' "
"y1='%g' x2='680' y2='%g' stroke='#2d3142'/>\n"
% (n, f, t, y(f), y(t)) + labels(n, f, t) for n, f, t in ROWS) + TAIL,
)
h.expect_finding(
"a single-quoted series line with a nudged endpoint is reported",
HEAD + CAPTIONS + "".join(
" <line data-series='%s' data-from='%s' data-to='%s' x1='320' "
"y1='%g' x2='680' y2='%g' stroke='#2d3142'/>\n"
% (n, f, t, y(f), y(t) - (3.0 if n == "Search" else 0.0))
+ labels(n, f, t) for n, f, t in ROWS) + TAIL,
r"'Search' draws its to endpoint",
)
h.expect_finding(
"a <line> declaring data-series whose attributes cannot be parsed is reported",
document(honest_rows_block()
+ " <line data-series=unquoted data-from=1 data-to=2 "
"x1=320 y1=40 x2=680 y2=420 stroke='#2d3142'/>\n"),
r"declares data-series but its attributes could not be parsed",
)
h.expect_clean(
"axis rules and legend swatches without data-series are skipped silently",
document(honest_rows_block()
+ ' <line x1="320" y1="40" x2="320" y2="420" stroke="#2d3142"/>\n'
' <line x1="40" y1="492" x2="64" y2="492" stroke="#eb6c36"/>\n'),
)
for label in ("left", "right"):
rows = ""
for name, frm, to in ROWS:
stray = name == "Search"
rows += series(
name, frm, to,
x1=300 if (stray and label == "left") else 320,
x2=640 if (stray and label == "right") else 680,
) + labels(name, frm, to)
h.expect_finding(
"a series that does not share the %s axis position is reported" % label,
document(rows), r"series do not share one x[12]",
)
h.expect_finding(
"a figure where every series holds one value on each axis is reported "
"as unverifiable",
honest([("api", 100, 120), ("web", 100, 120)]),
r"neither axis has two distinct values",
)
sci = ""
for n, f, t in (("a", 512, 208), ("b", 376, 164), ("c", 291, 143),
("d", 154, 121)):
sci += (series(n, "%ge0" % f, "%ge0" % t, y1=y(f), y2=y(t))
+ labels(n, f, t, shown_from="%ge0" % f, shown_to="%ge0" % t))
h.expect_clean(
"scientific-notation labels are parsed, not truncated to the mantissa",
document(sci),
)
# Real geometry, values offset by a billion: the two axes share one
# transform, and only an intercept compared at value zero would call them
# shifted. Mapping the raw billions through the fixture scale instead put
# every point at y = -8.4e8, where the fit is degenerate and the case
# passed without testing anything.
base = 1000000000
billions = ""
for n, f, t in ROWS:
billions += (series(n, base + f, base + t, y1=y(f), y2=y(t))
+ labels(n, base + f, base + t, y_from=y(f), y_to=y(t)))
h.expect_clean(
"a shared scale on billion-magnitude values is not reported as shifted",
document(billions),
)
# ── The fixture-isolation fix, held in place ──────────────────────────
sentinel = ROOT / "example-slopegraph-fixture.html"
existed = sentinel.exists()
if not existed:
sentinel.write_text("KEEP ME\n", encoding="utf-8")
try:
h.run(honest(ROWS))
h.run(honest(ROWS), name="example-slopegraph-fixture.html")
h.check(
"a pre-existing file sharing a fixture name is never touched",
sentinel.exists() and sentinel.read_text(encoding="utf-8") == "KEEP ME\n",
"%s was overwritten or deleted by the harness" % sentinel,
)
finally:
if not existed and sentinel.exists():
sentinel.unlink()
other = Harness()
try:
h.check(
"two harnesses use different directories, so parallel runs cannot collide",
other.dir != h.dir and not str(other.dir).startswith(str(ROOT)),
"dirs %s and %s" % (h.dir, other.dir),
)
finally:
other.close()
h.check(
"fixtures are written outside the repository",
not str(h.dir).startswith(str(ROOT)),
"fixture dir %s is inside %s" % (h.dir, ROOT),
)
print()
if h.failures:
print("%d of %d case(s) failed." % (h.failures, h.count))
return 1
print("OK slopegraph checker: %d case(s), both polarities" % h.count)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+754
View File
@@ -0,0 +1,754 @@
#!/usr/bin/env python3
"""Verify that a slopegraph's drawn slopes match the values it prints.
A slopegraph makes exactly one claim: **both axes carry the same scale**, so the
angle of a line is a rate of change and two angles are comparable. Break that
claim and nothing errors - the figure renders, every label is present, and the
lie is in the geometry. `lint-skin.py` reads colors and fonts, `verify-geometry.py`
reads label masks against later-painted nodes, and neither compares a drawn
endpoint against the number sitting beside it.
Six invariants, each of which has shipped broken in a draft of this type:
1. SHARED SCALE - the left and right axes must map value to y through the same
linear transform. Both halves are checked, slope *and* origin: a second axis
that is rescaled makes every slope wrong by a factor, and one that is merely
shifted makes every slope wrong by a constant. Checking the slope alone would
pass the shifted case, which is the easier mistake to make.
2. NO JITTER - each declared value must sit where the shared scale puts it. The
draft this checker was written against nudged two series apart by 3px because
their labels collided at identical values; both then read as different
numbers. Crowded endpoint labels are data, not a coordinate problem.
3. UNTRANSFORMED GEOMETRY - the coordinates read here are raw attributes, so any
`transform` on verified geometry, on its labels, or on an ancestor moves the
rendered mark away from the number this checker validated. A single
`transform="translate(0 80)"` on one series line slid its endpoint 80px and
every check still passed. Transforms are rejected rather than resolved: a
partial implementation of the SVG transform stack is worse than an honest
refusal, because it looks like coverage.
4. COMPLETE PRINTED VALUES - the whole visible numeric token must match the
declared one. Matching only the first fragment read the label "512,000" as
512 and agreed with metadata that said 512.
5. LABELS BOUND TO MEANING - state captions belong to a named axis, and each
series name belongs to a series and an end. Unbound, the two captions can be
swapped - reversing the reading of the entire figure - or two names exchanged
between rows, with every geometric check still green.
6. FAIL CLOSED - a file that presents as a slopegraph but yields nothing
parseable is a finding, never a pass. A checker that reports OK because it
found nothing to compare is the bug, not the gate. `verify-treemap.py`
returned early on `len(cells) < 3` and that is precisely how an undersized
cell went unverified.
The basis for geometry is the `data-from` / `data-to` pair each series line
declares, never the rendered text. Deriving the basis from text is what let a
treemap cell with no label drop silently out of the checked set; here a series
whose label is missing or restyled stays in the set and its missing label is
itself reported.
WHAT THIS DOES NOT CHECK, deliberately:
- **Direction.** An axis whose y grows with value is accepted. That is correct
for a rank slopegraph, where rank 1 belongs at the top, and this checker has no
way to tell an intended inversion from a mistaken one.
- **Absolute truth.** Every check here is internal consistency. If every label
and every declared value is wrong by the same constant, the figure is
self-consistent and passes; nothing in the file could reveal otherwise. The
source line is where the domain is stated to a reader, and prose is not parsed.
- **Curvature below tolerance.** A value-to-y map that bends by less than
RESIDUAL_TOLERANCE at every point is indistinguishable from a straight one.
Usage:
python3 scripts/verify-slopegraph.py --all
python3 scripts/verify-slopegraph.py skills/diagram-design/assets/example-slopegraph.html
Exit: 0 clean, 1 findings, 2 usage.
"""
from __future__ import annotations
import argparse
import html
import math
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills/diagram-design/assets"
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 full-editorial template uses the
# latter, so an unguarded pattern would report every editorial variant.
CSS_TRANSFORM_RE = re.compile(r"(?<![\w-])transform\s*:", re.IGNORECASE)
TAG_RE = re.compile(r"<[^>]+>")
# The COMPLETE numeric token an author may print: sign, comma-grouped thousands,
# decimals, leading-dot decimals, exponents. Matching only `-?\d+(\.\d+)?` read
# "1e3" as 1 and "512,000" as 512, so a mangled label agreed with its metadata.
NUMBER_RE = re.compile(
r"[-+]?(?:\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?|\.\d+)(?:[eE][-+]?\d+)?"
)
DIGIT_RE = re.compile(r"\d")
# Both quote styles. Matching only double quotes made a single-quoted series
# line invisible: the detector below sees `data-series` in the raw source either
# way, but the attribute parser returned nothing, so the line was dropped from
# the verified set without a word - a file carrying a 400px lie reported clean.
ATTR_RE = re.compile(
r"""(?P<name>[\w:-]+)\s*=\s*(?P<quote>["'])(?P<value>.*?)(?P=quote)""",
re.DOTALL,
)
DECLARES_SERIES_RE = re.compile(r"\bdata-series\s*=", re.IGNORECASE)
# Coordinates ship rounded to one decimal, so a point can sit 0.05px from its
# true position honestly; an author rounding to whole pixels can sit 0.5px off.
# 1.0px clears both and still catches the smallest dishonest nudge worth making
# (the draft's was 3px). In data units at the shipped 0.844 px-per-ms scale that
# is 1.2ms of slack on values running 121-512 - about a quarter of one percent,
# below the precision the labels themselves claim.
RESIDUAL_TOLERANCE = 1.0 # px, per endpoint, against the shared scale
ORIGIN_TOLERANCE = 0.5 # px, between the two axes at mid-domain
SCALE_TOLERANCE = 1.0 # px, worst-case slope disagreement across the range
VALUE_TOLERANCE = 0.001 # printed label vs declared attribute
CAPTION_TOLERANCE = 0.5 # px, state caption x vs the axis it names
ROW_TOLERANCE = 0.5 # px, slack before a label counts as another row
class Series:
__slots__ = ("name", "frm", "to", "x1", "y1", "x2", "y2", "offset")
def __init__(self, name, frm, to, x1, y1, x2, y2, offset):
self.name = name
self.frm, self.to = frm, to
self.x1, self.y1 = x1, y1
self.x2, self.y2 = x2, y2
self.offset = offset
def value(self, end):
return self.frm if end == "from" else self.to
def endpoint_y(self, end):
return self.y1 if end == "from" else self.y2
def axis_x(self, end):
return self.x1 if end == "from" else self.x2
def line_of(source: str, offset: int) -> int:
return source.count("\n", 0, offset) + 1
def blank_comments(source: str) -> str:
"""Comments out, length and line numbers preserved.
Markup inside a comment is not rendered, so treating it as data reports a
commented-out old draft as a live defect. Replacing each comment with spaces
of the same length keeps every later offset and line number honest.
"""
return COMMENT_RE.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), source)
def attrs_of(raw: str) -> dict:
return {m.group("name"): m.group("value") for m in ATTR_RE.finditer(raw)}
def plain(body: str) -> str:
return html.unescape(TAG_RE.sub("", body)).strip()
def number(value):
"""A finite float from a complete numeric token, or None.
`float("nan")` succeeds and then every `abs(x) > tolerance` comparison is
False, so a NaN coordinate silently satisfied every check in the file. Any
non-finite value is treated as unreadable instead.
"""
if value is None:
return None
try:
parsed = float(str(value).replace(",", ""))
except (TypeError, ValueError):
return None
return parsed if math.isfinite(parsed) else None
def printed_number(body: str):
"""(value, reason) for a label's visible text.
Returns a value only when the text carries exactly one complete numeric
token. Trailing units are fine ("512ms"); a second number, or digits the
token did not consume, is ambiguous and reported rather than guessed at.
"""
match = NUMBER_RE.search(body)
if match is None:
return None, "prints no number"
outside = body[:match.start()] + body[match.end():]
if DIGIT_RE.search(outside):
return None, "prints more than one numeric token"
value = number(match.group())
if value is None:
return None, "prints a number this checker cannot read"
return value, None
def median(values: list) -> float:
ordered = sorted(values)
middle = len(ordered) // 2
if len(ordered) % 2:
return ordered[middle]
return (ordered[middle - 1] + ordered[middle]) / 2.0
def fit(points: list) -> tuple:
"""Robust (slope, intercept) for y = slope * value + intercept.
Theil-Sen - the median of all pairwise slopes - rather than least squares, so
that one dishonest point does not drag the line it is being measured against.
Returns (None, None) when every value is the same, which is legitimate on an
indexed axis and handled by the caller.
"""
slopes = [
(yb - ya) / (vb - va)
for index, (va, ya) in enumerate(points)
for vb, yb in points[index + 1:]
if vb != va
]
if not slopes:
return None, None
slope = median(slopes)
return slope, median([y - slope * v for v, y in points])
def outliers(points: list, tolerance: float) -> list:
"""Points that disagree with the line their PEERS describe.
Leave-one-out, because measuring a point against a fit that includes it lets
it hide inside its own influence. Theil-Sen's breakdown point is around 29%,
which is zero outliers on a three-point axis: an indexed three-series figure
with one endpoint 3px wrong produced a whole-set fit that called all three
points clean, because the contaminated slope passed within 1px of every one
of them. Excluding the point under test removes that escape.
Returns [(index, drawn, expected)]. Needs four points, so each fit is made
from at least three.
"""
if len(points) < 4:
return []
found = []
for index, (value, drawn) in enumerate(points):
peers = points[:index] + points[index + 1:]
slope, intercept = fit(peers)
if slope is None:
continue
expected = slope * value + intercept
if abs(drawn - expected) > tolerance:
found.append((index, drawn, expected))
return found
def named_text(source: str) -> str:
"""The accessible name and description, where a figure says what it is."""
return " ".join(plain(m.group("body")) for m in NAMED_RE.finditer(source)).casefold()
def looks_like_slopegraph(path: Path, source: str) -> bool:
"""Does this file present itself as a slopegraph?
Deliberately generous. Anything that claims the type in its name, its
accessible description, or its markup is held to the contract even if it
declares no parseable series - that combination is the fail-closed case, not
a pass. The whole document is searched: a 4000-character window missed a file
whose only declaration sat behind a long stylesheet.
"""
if path.name.startswith("example-slopegraph"):
return True
if DECLARES_SERIES_RE.search(source):
return True
described = named_text(source)
return "slopegraph" in described or "slope graph" in described
def transformed_spans(source: str) -> list:
"""Offset ranges enclosed by a <g>/<svg> that carries a transform.
Element-level transforms are easy to see; an ancestor's is not, and shifts
everything inside it identically - which is exactly the change that leaves
all internal consistency intact while moving every mark on the page.
"""
events = []
for match in GROUP_OPEN_RE.finditer(source):
if match.group("selfclose"):
continue
events.append((match.start(), 0, "transform" in attrs_of(match.group("attrs"))))
for match in GROUP_CLOSE_RE.finditer(source):
events.append((match.start(), 1, False))
events.sort()
stack, spans = [], []
for position, kind, transformed in events:
if kind == 0:
stack.append((position, transformed))
elif stack:
start, was_transformed = stack.pop()
if was_transformed:
spans.append((start, position))
# An unclosed transformed group covers everything after it.
for start, was_transformed in stack:
if was_transformed:
spans.append((start, len(source)))
return spans
def parse_series(source: str, findings: list, name: str) -> list:
"""Series lines, with anything unparseable reported rather than dropped."""
series = []
for match in LINE_RE.finditer(source):
raw = match.group("attrs")
attrs = attrs_of(raw)
label = attrs.get("data-series")
if label is None:
# A <line> with no data-series is scenery - an axis rule, a legend
# swatch - and skipping it is correct. But one whose raw text DOES
# declare data-series and still parsed to nothing is markup this
# checker cannot read, and dropping it silently is how a lie ships.
if DECLARES_SERIES_RE.search(raw):
findings.append(
"%s:%d: a <line> declares data-series but its attributes could "
"not be parsed — the checker will not silently skip markup it "
"cannot read. Use plain double-quoted attributes"
% (name, line_of(source, match.start()))
)
continue
missing = [key for key in ("data-from", "data-to", "x1", "y1", "x2", "y2")
if key not in attrs]
if missing:
findings.append(
"%s:%d: series %r declares data-series but is missing %s — a series "
"line must state both values and both endpoints or it cannot be verified"
% (name, line_of(source, match.start()), label, ", ".join(missing))
)
continue
parsed = [number(attrs[key])
for key in ("data-from", "data-to", "x1", "y1", "x2", "y2")]
if any(value is None for value in parsed):
findings.append(
"%s:%d: series %r has a value or coordinate that is not a finite "
"number — cannot verify its slope"
% (name, line_of(source, match.start()), label)
)
continue
series.append(Series(label, parsed[0], parsed[1], parsed[2], parsed[3],
parsed[4], parsed[5], match.start()))
return series
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 x/y attributes, so "
"a transform moves the rendered mark away from the number it was "
"checked against. Bake the offset into the coordinates instead"
% (name, line_of(source, offset), what, how)
)
for match in LINE_RE.finditer(source):
attrs = attrs_of(match.group("attrs"))
if "data-series" not in attrs:
continue
what = "series %r" % attrs["data-series"]
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-series" not in attrs and "data-axis" 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. Remove it, or bake the offset "
"into the coordinates"
% (name, line_of(source, match.start("body") + found.start()))
)
def check_axes(series: list, findings: list, source: str, name: str) -> None:
"""Every series must span the same two axis positions."""
for attr, end in (("x1", "from"), ("x2", "to")):
positions = sorted({round(s.axis_x(end), 3) for s in series})
if len(positions) > 1:
offenders = ", ".join("%s at %g" % (s.name, s.axis_x(end)) for s in series)
findings.append(
"%s:%d: series do not share one %s — found %s (%s). Every line must "
"run between the same two axes or the slopes are not comparable"
% (name, line_of(source, series[0].offset), attr,
"/".join("%g" % p for p in positions), offenders)
)
def check_scale(series: list, findings: list, source: str, name: str) -> None:
"""The two axes must share one linear value-to-y map, and no point may drift."""
left = [(s.frm, s.y1) for s in series]
right = [(s.to, s.y2) for s in series]
combined = left + right
values = [v for v, _ in combined]
span = max(values) - min(values)
mid = (max(values) + min(values)) / 2.0
line = line_of(source, series[0].offset)
left_fit = fit(left)
right_fit = fit(right)
if left_fit[0] is not None and right_fit[0] is not None:
slope_drift = abs(left_fit[0] - right_fit[0]) * span
if slope_drift > SCALE_TOLERANCE:
findings.append(
"%s:%d: the two axes are not on the same scale — %.4g px/unit on the "
"left against %.4g on the right, which puts the same value up to "
"%.1f px apart across the plotted range. Both axes must use one scale "
"or every slope is a lie"
% (name, line, left_fit[0], right_fit[0], slope_drift)
)
return
# Compared at mid-domain, not at value zero. Raw intercepts are the
# predicted y AT zero, so on values in the billions two axes sharing one
# transform disagreed by hundreds of pixels there through nothing but
# float cancellation, and an honest figure was reported as shifted.
origin_drift = abs((left_fit[0] * mid + left_fit[1])
- (right_fit[0] * mid + right_fit[1]))
if origin_drift > ORIGIN_TOLERANCE:
findings.append(
"%s:%d: the two axes share a scale but not an origin — offset by "
"%.1f px at mid-domain. A shifted axis tilts every slope by the same "
"amount, so the comparison between series survives and every rate is "
"wrong" % (name, line, origin_drift)
)
return
# Both returns above are deliberate. Per-point residuals are measured
# against "the" shared scale, so once the two axes disagree about what
# that scale is there is nothing meaningful to measure against - every
# point reports an error and the one finding that names the cause is
# buried in its own consequences.
elif left_fit[0] is None and right_fit[0] is None:
findings.append(
"%s:%d: neither axis has two distinct values, so the scale cannot be "
"derived and no slope here is verifiable" % (name, line)
)
return
ends = ["from"] * len(left) + ["to"] * len(right)
for index, drawn, expected in outliers(combined, RESIDUAL_TOLERANCE):
s = series[index % len(series)]
value = combined[index][0]
findings.append(
"%s:%d: series %r draws its %s endpoint (%g) at y=%g where the shared "
"scale its peers describe puts %g at y=%.1f — off by %.1f px. Do not move "
"a point to make room for its label"
% (name, line_of(source, s.offset), s.name, ends[index], value, drawn,
value, expected, abs(drawn - expected))
)
def misplaced_row(series: list, owner: str, end: str, y: float):
"""A series whose endpoint is strictly nearer this label than its own owner's.
Nearer, not nearest. Two series holding the same value at an end sit at the
same y by necessity - that is the honest rendering of equal values, and it is
also what an indexed slopegraph does at every from-endpoint - so a tie must
pass. Only a label that has drifted onto a row genuinely closer to someone
else has been mislabelled.
"""
own = min(abs(y - s.endpoint_y(end)) for s in series if s.name == owner)
for other in series:
if other.name != owner and abs(y - other.endpoint_y(end)) < own - ROW_TOLERANCE:
return other.name
return None
def collect_bound_labels(source: str, findings: list, name: str) -> dict:
"""Bound labels keyed by (series, end, role), with duplicates reported."""
found = {}
for match in TEXT_RE.finditer(source):
attrs = attrs_of(match.group("attrs"))
label, end = attrs.get("data-series"), attrs.get("data-end")
if label is None or end is None:
continue
role = attrs.get("data-role", "value")
key = (label, end, role)
if key in found:
# A second label for one endpoint used to overwrite the first, so a
# figure could print two contradictory numbers for one point and be
# judged on whichever came last.
findings.append(
"%s:%d: a second %s %s label for series %r — one endpoint, one "
"label, or the figure states two things about one point"
% (name, line_of(source, match.start()), end, role, label)
)
continue
found[key] = (plain(match.group("body")), number(attrs.get("y")),
match.start())
return found
def check_labels(series: list, source: str, findings: list, name: str) -> None:
"""Printed values and names must exist, be unique, match, and sit on their row."""
bound = collect_bound_labels(source, findings, name)
declared = {s.name for s in series}
for (label, end, 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 series %r, which no line declares — a label "
"with no mark is not verifiable and reads as data"
% (name, line_of(source, offset), role, label)
)
continue
if end not in ("from", "to"):
findings.append(
"%s:%d: label for series %r has data-end=%r, which is neither "
"'from' nor 'to'" % (name, line_of(source, offset), label, end)
)
continue
# Row placement. A label bound to one series but drawn beside another's
# endpoint mislabels the row, and no value check would notice: two names
# exchanged between rows leaves every number correct in isolation.
if y is not None:
sits_by = misplaced_row(series, label, end, y)
if sits_by is not None:
findings.append(
"%s:%d: the %s %s label for series %r is drawn at y=%g, nearer "
"%r's endpoint than its own — a label on the wrong row renames "
"the line"
% (name, line_of(source, offset), end, role, label, y, sits_by)
)
for s in series:
for end in ("from", "to"):
value_entry = bound.get((s.name, end, "value"))
if value_entry is None:
findings.append(
"%s:%d: series %r prints no %s endpoint value — a slopegraph must "
"label both ends, or the reader has a slope and no magnitude"
% (name, line_of(source, s.offset), s.name, end)
)
else:
body, _y, offset = value_entry
shown, reason = printed_number(body)
if shown is None:
findings.append(
"%s:%d: the %s endpoint label for %r %s (%r) — label both "
"endpoints with one complete value each"
% (name, line_of(source, offset), end, s.name, reason,
body[:28])
)
elif abs(shown - s.value(end)) > VALUE_TOLERANCE:
findings.append(
"%s:%d: series %r prints %r at its %s endpoint but declares "
"%g — the label and the geometry must state one number"
% (name, line_of(source, offset), s.name, body, end,
s.value(end))
)
name_entry = bound.get((s.name, end, "name"))
if name_entry is None:
findings.append(
"%s:%d: series %r has no %s name label (data-role=\"name\") — a "
"slopegraph names every series at both ends, and an unbound name "
"can be swapped with another row undetected"
% (name, line_of(source, s.offset), s.name, end)
)
continue
body, _y, offset = name_entry
if body != s.name:
findings.append(
"%s:%d: a name label bound to series %r reads %r — the visible "
"name and the binding must agree"
% (name, line_of(source, offset), s.name, body[:28])
)
def check_captions(series: list, source: str, findings: list, name: str) -> None:
"""Each state caption must name the axis it is drawn against."""
seen = {}
for match in TEXT_RE.finditer(source):
attrs = attrs_of(match.group("attrs"))
end = attrs.get("data-axis")
if end is None:
continue
if end not in ("from", "to"):
findings.append(
"%s:%d: a state caption has data-axis=%r, which is neither 'from' "
"nor 'to'" % (name, line_of(source, match.start()), end)
)
continue
if end in seen:
findings.append(
"%s:%d: a second %r state caption — one axis, one caption"
% (name, line_of(source, match.start()), end)
)
continue
seen[end] = (number(attrs.get("x")), plain(match.group("body")),
attrs.get("data-state"), match.start())
for end in ("from", "to"):
if end not in seen:
findings.append(
"%s: no %r state caption (data-axis=%r) — an unbound caption pair can "
"be swapped, which reverses the reading of the whole figure"
% (name, end, end)
)
continue
x, body, declared, offset = seen[end]
expected = series[0].axis_x(end)
if x is None or abs(x - expected) > CAPTION_TOLERANCE:
findings.append(
"%s:%d: the %r state caption (%r) is drawn at x=%s but its axis is at "
"x=%g — the captions are swapped or misplaced, which reverses the "
"direction every slope is read in"
% (name, line_of(source, offset), end, body[:20],
"%g" % x if x is not None else "?", expected)
)
# Position alone is not enough: swapping just the two visible strings
# leaves both captions where they were and reverses the figure anyway.
# data-state binds the text, exactly as data-series binds a series name.
if declared is None:
findings.append(
"%s:%d: the %r state caption (%r) has no data-state — its visible "
"text is unbound, so it can be exchanged with the other caption and "
"nothing here would notice"
% (name, line_of(source, offset), end, body[:20])
)
elif body != declared:
findings.append(
"%s:%d: the %r state caption reads %r but declares data-state=%r — the "
"visible caption and its binding must agree"
% (name, line_of(source, offset), end, body[:20], declared)
)
if len(seen) == 2:
first, second = seen["from"], seen["to"]
if first[1] and first[1] == second[1]:
findings.append(
"%s:%d: both state captions read %r — two states the reader cannot "
"tell apart is not a comparison"
% (name, line_of(source, second[3]), first[1])
)
def check_source(path: Path, raw: str) -> list:
"""Findings for one already-read document."""
source = blank_comments(raw)
findings: list = []
series = parse_series(source, findings, path.name)
if len(series) < 2:
findings.append(
"%s: presents as a slopegraph but declares %d verifiable series — every "
"line needs data-series with data-from and data-to. Refusing to report "
"OK on a file this checker could not read" % (path.name, len(series))
)
return findings
check_transforms(source, findings, path.name)
check_axes(series, findings, source, path.name)
check_scale(series, findings, source, path.name)
check_labels(series, source, findings, path.name)
check_captions(series, source, findings, path.name)
return findings
def check(path: Path) -> list:
"""Findings for one file on disk, or [] if it is not a slopegraph."""
raw = path.read_text(encoding="utf-8")
if not looks_like_slopegraph(path, raw):
return []
return check_source(path, raw)
def targets(args: argparse.Namespace) -> list:
if args.all:
return sorted(ASSET_DIR.glob("example-*.html"))
return [Path(p) for p in args.paths]
def main() -> int:
parser = argparse.ArgumentParser(
description="Verify slopegraph slopes against the values they are labelled with."
)
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 slopegraph",
)
args = parser.parse_args()
if not args.all and not args.paths:
parser.print_help()
return 2
findings: list = []
checked = 0
skipped = 0
for path in targets(args):
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
# Read once, then decide. Scope is reported separately from verification
# in both modes: printing "1 file(s) verified" for a bar chart that was
# skipped is a claim the run never made good on.
if not looks_like_slopegraph(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 slopegraph finding(s) across %d file(s).%s"
% (len(findings), checked, tail))
return 1
if not checked:
print("OK slopegraph: no slopegraph found to check%s" % tail)
return 0
print("OK slopegraph: %d file(s), one shared scale on both axes, no transforms on "
"verified geometry, and every printed value, name and caption bound to what "
"it describes%s" % (checked, tail))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -101,7 +101,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 | **Line chart** | [type-line.md](references/type-line.md) |
| Continuous trends over time, or change between exactly two states (slopegraph) | **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,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>p95 by service, before and after · Slopegraph · dark</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">Slopegraph · Diagram Design</p>
<h1>One service went the other way</h1>
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="slopegraph-dark-title slopegraph-dark-desc">
<title id="slopegraph-dark-title">One service went the other way</title>
<desc id="slopegraph-dark-desc">Slopegraph of p95 response time by service before and after a caching layer, on one shared 100-to-550-millisecond scale; four services got faster and the recommender got slower, crossing three of them.</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"/>
<!-- Both axes carry the SAME 100-550 ms scale. That is the whole claim of a
slopegraph and the only thing that makes one slope comparable with
another; scripts/verify-slopegraph.py enforces it against the values
each line declares. -->
<text transform="rotate(-90 24 230)" x="24" y="230" fill="#bfc0c0" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">P95 RESPONSE TIME, MS</text>
<!-- No gridlines: every endpoint prints its own value, so a gridline would
be ink carrying nothing. The two axis rules are the scale. -->
<line x1="320" y1="40" x2="320" y2="420" stroke="rgba(245,245,245,0.25)" stroke-width="1"/>
<text data-axis="from" data-state="BEFORE" x="320" y="440" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">BEFORE</text>
<line x1="680" y1="40" x2="680" y2="420" stroke="rgba(245,245,245,0.25)" stroke-width="1"/>
<text data-axis="to" data-state="AFTER" x="680" y="440" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">AFTER</text>
<line data-series="Search" data-from="512" data-to="208" x1="320" y1="72.1" x2="680" y2="328.8" stroke="rgba(245,245,245,0.80)" stroke-width="1.2"/>
<circle cx="320" cy="72.1" r="3" fill="rgba(245,245,245,0.80)"/>
<circle cx="680" cy="328.8" r="3" fill="rgba(245,245,245,0.80)"/>
<text data-series="Search" data-end="from" data-role="name" x="272" y="75.6" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Search</text>
<text data-series="Search" data-end="from" x="304" y="75.6" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">512</text>
<text data-series="Search" data-end="to" x="696" y="332.3" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">208</text>
<text data-series="Search" data-end="to" data-role="name" x="728" y="332.3" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Search</text>
<line data-series="Catalog" data-from="376" data-to="164" x1="320" y1="186.9" x2="680" y2="366" stroke="rgba(245,245,245,0.74)" stroke-width="1.2"/>
<circle cx="320" cy="186.9" r="3" fill="rgba(245,245,245,0.74)"/>
<circle cx="680" cy="366" r="3" fill="rgba(245,245,245,0.74)"/>
<text data-series="Catalog" data-end="from" data-role="name" x="272" y="190.4" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Catalog</text>
<text data-series="Catalog" data-end="from" x="304" y="190.4" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">376</text>
<text data-series="Catalog" data-end="to" x="696" y="369.5" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">164</text>
<text data-series="Catalog" data-end="to" data-role="name" x="728" y="369.5" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Catalog</text>
<line data-series="Checkout" data-from="291" data-to="143" x1="320" y1="258.7" x2="680" y2="383.7" stroke="rgba(245,245,245,0.68)" stroke-width="1.2"/>
<circle cx="320" cy="258.7" r="3" fill="rgba(245,245,245,0.68)"/>
<circle cx="680" cy="383.7" r="3" fill="rgba(245,245,245,0.68)"/>
<text data-series="Checkout" data-end="from" data-role="name" x="272" y="262.2" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Checkout</text>
<text data-series="Checkout" data-end="from" x="304" y="262.2" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">291</text>
<text data-series="Checkout" data-end="to" x="696" y="387.2" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">143</text>
<text data-series="Checkout" data-end="to" data-role="name" x="728" y="387.2" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Checkout</text>
<line data-series="Auth" data-from="154" data-to="121" x1="320" y1="374.4" x2="680" y2="402.3" stroke="rgba(245,245,245,0.62)" stroke-width="1.2"/>
<circle cx="320" cy="374.4" r="3" fill="rgba(245,245,245,0.62)"/>
<circle cx="680" cy="402.3" r="3" fill="rgba(245,245,245,0.62)"/>
<text data-series="Auth" data-end="from" data-role="name" x="272" y="377.9" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Auth</text>
<text data-series="Auth" data-end="from" x="304" y="377.9" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">154</text>
<text data-series="Auth" data-end="to" x="696" y="405.8" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">121</text>
<text data-series="Auth" data-end="to" data-role="name" x="728" y="405.8" fill="#f5f5f5" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Auth</text>
<!-- Recommender FOCAL -- and it is the series that got WORSE. The accent marks
the editorially focal line, never the winner. Focus is carried by STROKE
WEIGHT, not tone: the accent measures 2.86:1 on light paper, so on tone
alone the focal line would read fainter than the ink ramp it is meant to
dominate. Its labels stay ink/muted for the same reason -- accent text
misses AA at this size. Being the only ascending line it crosses three of
the other four -- not Auth, which starts and ends below it, because moving
the opposite way does not guarantee meeting. The crossings are left
unannotated: two endpoints say nothing about WHEN the regression started. -->
<line data-series="Recommender" data-from="238" data-to="431" x1="320" y1="303.5" x2="680" y2="140.5" stroke="#f08a59" stroke-width="2.4"/>
<circle cx="320" cy="303.5" r="4" fill="#f08a59"/>
<circle cx="680" cy="140.5" r="4" fill="#f08a59"/>
<text data-series="Recommender" data-end="from" data-role="name" x="272" y="307" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">Recommender</text>
<text data-series="Recommender" data-end="from" x="304" y="307" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">238</text>
<text data-series="Recommender" data-end="to" x="696" y="144" fill="#bfc0c0" font-size="9" font-family="'Geist Mono', monospace">431</text>
<text data-series="Recommender" data-end="to" data-role="name" x="728" y="144" fill="#f5f5f5" font-size="11" font-weight="600" font-family="'Geist', sans-serif">Recommender</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">P95 RESPONSE TIME IN MS, LOWER IS BETTER · SAME 100-550 SCALE ON BOTH AXES · ILLUSTRATIVE FIGURES, NOT A MEASURED RELEASE</text>
<line x1="40" y1="492" x2="64" y2="492" stroke="#f08a59" stroke-width="2.4"/>
<circle cx="52" cy="492" r="4" fill="#f08a59"/>
<text x="72" y="496" fill="#bfc0c0" font-size="8.5" font-family="'Geist', sans-serif">Recommender · focal, the only regression</text>
<line x1="320" y1="492" x2="344" y2="492" stroke="rgba(245,245,245,0.80)" stroke-width="1.2"/>
<circle cx="332" cy="492" r="3" fill="rgba(245,245,245,0.80)"/>
<text x="352" y="496" fill="#bfc0c0" font-size="8.5" font-family="'Geist', sans-serif">Other services · strongest tone was slowest before</text>
</svg>
</div>
</body>
</html>
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>p95 by service, before and after · Slopegraph</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">Slopegraph · Diagram Design</p>
<h1>One service went the other way</h1>
<p class="subtitle">p95 response time per service before and after the caching layer, both axes on one 100-to-550ms scale. Four services got faster. The recommender got 81% slower, and being the only line going up it crosses three of them.</p>
</div>
<div class="diagram-container">
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="slopegraph-full-title slopegraph-full-desc">
<title id="slopegraph-full-title">One service went the other way</title>
<desc id="slopegraph-full-desc">Slopegraph of p95 response time by service before and after a caching layer, on one shared 100-to-550-millisecond scale; four services got faster and the recommender got slower, crossing three of them.</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"/>
<!-- Both axes carry the SAME 100-550 ms scale. That is the whole claim of a
slopegraph and the only thing that makes one slope comparable with
another; scripts/verify-slopegraph.py enforces it against the values
each line declares. -->
<text transform="rotate(-90 24 230)" x="24" y="230" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">P95 RESPONSE TIME, MS</text>
<!-- No gridlines: every endpoint prints its own value, so a gridline would
be ink carrying nothing. The two axis rules are the scale. -->
<line x1="320" y1="40" x2="320" y2="420" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<text data-axis="from" data-state="BEFORE" x="320" y="440" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">BEFORE</text>
<line x1="680" y1="40" x2="680" y2="420" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<text data-axis="to" data-state="AFTER" x="680" y="440" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">AFTER</text>
<line data-series="Search" data-from="512" data-to="208" x1="320" y1="72.1" x2="680" y2="328.8" stroke="rgba(45,49,66,0.80)" stroke-width="1.2"/>
<circle cx="320" cy="72.1" r="3" fill="rgba(45,49,66,0.80)"/>
<circle cx="680" cy="328.8" r="3" fill="rgba(45,49,66,0.80)"/>
<text data-series="Search" data-end="from" data-role="name" x="272" y="75.6" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Search</text>
<text data-series="Search" data-end="from" x="304" y="75.6" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">512</text>
<text data-series="Search" data-end="to" x="696" y="332.3" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">208</text>
<text data-series="Search" data-end="to" data-role="name" x="728" y="332.3" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Search</text>
<line data-series="Catalog" data-from="376" data-to="164" x1="320" y1="186.9" x2="680" y2="366" stroke="rgba(45,49,66,0.74)" stroke-width="1.2"/>
<circle cx="320" cy="186.9" r="3" fill="rgba(45,49,66,0.74)"/>
<circle cx="680" cy="366" r="3" fill="rgba(45,49,66,0.74)"/>
<text data-series="Catalog" data-end="from" data-role="name" x="272" y="190.4" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Catalog</text>
<text data-series="Catalog" data-end="from" x="304" y="190.4" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">376</text>
<text data-series="Catalog" data-end="to" x="696" y="369.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">164</text>
<text data-series="Catalog" data-end="to" data-role="name" x="728" y="369.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Catalog</text>
<line data-series="Checkout" data-from="291" data-to="143" x1="320" y1="258.7" x2="680" y2="383.7" stroke="rgba(45,49,66,0.68)" stroke-width="1.2"/>
<circle cx="320" cy="258.7" r="3" fill="rgba(45,49,66,0.68)"/>
<circle cx="680" cy="383.7" r="3" fill="rgba(45,49,66,0.68)"/>
<text data-series="Checkout" data-end="from" data-role="name" x="272" y="262.2" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Checkout</text>
<text data-series="Checkout" data-end="from" x="304" y="262.2" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">291</text>
<text data-series="Checkout" data-end="to" x="696" y="387.2" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">143</text>
<text data-series="Checkout" data-end="to" data-role="name" x="728" y="387.2" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Checkout</text>
<line data-series="Auth" data-from="154" data-to="121" x1="320" y1="374.4" x2="680" y2="402.3" stroke="rgba(45,49,66,0.62)" stroke-width="1.2"/>
<circle cx="320" cy="374.4" r="3" fill="rgba(45,49,66,0.62)"/>
<circle cx="680" cy="402.3" r="3" fill="rgba(45,49,66,0.62)"/>
<text data-series="Auth" data-end="from" data-role="name" x="272" y="377.9" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Auth</text>
<text data-series="Auth" data-end="from" x="304" y="377.9" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">154</text>
<text data-series="Auth" data-end="to" x="696" y="405.8" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">121</text>
<text data-series="Auth" data-end="to" data-role="name" x="728" y="405.8" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Auth</text>
<!-- Recommender FOCAL -- and it is the series that got WORSE. The accent marks
the editorially focal line, never the winner. Focus is carried by STROKE
WEIGHT, not tone: the accent measures 2.86:1 on light paper, so on tone
alone the focal line would read fainter than the ink ramp it is meant to
dominate. Its labels stay ink/muted for the same reason -- accent text
misses AA at this size. Being the only ascending line it crosses three of
the other four -- not Auth, which starts and ends below it, because moving
the opposite way does not guarantee meeting. The crossings are left
unannotated: two endpoints say nothing about WHEN the regression started. -->
<line data-series="Recommender" data-from="238" data-to="431" x1="320" y1="303.5" x2="680" y2="140.5" stroke="#eb6c36" stroke-width="2.4"/>
<circle cx="320" cy="303.5" r="4" fill="#eb6c36"/>
<circle cx="680" cy="140.5" r="4" fill="#eb6c36"/>
<text data-series="Recommender" data-end="from" data-role="name" x="272" y="307" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">Recommender</text>
<text data-series="Recommender" data-end="from" x="304" y="307" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">238</text>
<text data-series="Recommender" data-end="to" x="696" y="144" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">431</text>
<text data-series="Recommender" data-end="to" data-role="name" x="728" y="144" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif">Recommender</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">P95 RESPONSE TIME IN MS, LOWER IS BETTER · SAME 100-550 SCALE ON BOTH AXES · ILLUSTRATIVE FIGURES, NOT A MEASURED RELEASE</text>
<line x1="40" y1="492" x2="64" y2="492" stroke="#eb6c36" stroke-width="2.4"/>
<circle cx="52" cy="492" r="4" fill="#eb6c36"/>
<text x="72" y="496" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Recommender · focal, the only regression</text>
<line x1="320" y1="492" x2="344" y2="492" stroke="rgba(45,49,66,0.80)" stroke-width="1.2"/>
<circle cx="332" cy="492" r="3" fill="rgba(45,49,66,0.80)"/>
<text x="352" y="496" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Other services · strongest tone was slowest before</text>
</svg>
</div>
<div class="cards">
<div class="card">
<p class="eyebrow">FOCAL · RECOMMENDER</p>
<div class="card-header"><span class="card-dot coral"></span><h3>The recommender went from fourth-slowest to slowest</h3></div>
<p>238ms to 431ms. It is the only line that rises, so it crosses three of the other four — all but Auth, which stays below it throughout. Where it crosses on the page is an artifact of joining two endpoints with a straight line: this chart cannot say when the regression started, only that it is there.</p>
</div>
<div class="card">
<div class="card-header"><span class="card-dot ink-strong"></span><h3>Search won the most and is still the second slowest</h3></div>
<p>512ms to 208ms, down 304 — the steepest slope here. It started so far above everything else that the largest absolute win still leaves it near the top. Level and change share one scale, which is what lets you read both at once.</p>
</div>
<div class="card">
<div class="card-header"><span class="card-dot ink-light"></span><h3>Auth barely moved because it had nothing to win</h3></div>
<p>154ms to 121ms, down 33. Nearly flat, and low on the axis — a shallow slope down here means a service that was already fast, while the same shallow slope near the top would mean a cache that did not work.</p>
</div>
</div>
<div class="footer">
<span>p95 response time · before and after the caching layer · illustrative</span>
<span>example · diagram design</span>
</div>
</div>
</body>
</html>
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>p95 by service, before and after · Slopegraph</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">Slopegraph · Diagram Design</p>
<h1>One service went the other way</h1>
<svg viewBox="0 0 1000 500" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="slopegraph-title slopegraph-desc">
<title id="slopegraph-title">One service went the other way</title>
<desc id="slopegraph-desc">Slopegraph of p95 response time by service before and after a caching layer, on one shared 100-to-550-millisecond scale; four services got faster and the recommender got slower, crossing three of them.</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"/>
<!-- Both axes carry the SAME 100-550 ms scale. That is the whole claim of a
slopegraph and the only thing that makes one slope comparable with
another; scripts/verify-slopegraph.py enforces it against the values
each line declares. -->
<text transform="rotate(-90 24 230)" x="24" y="230" fill="#4f5d75" font-size="7" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">P95 RESPONSE TIME, MS</text>
<!-- No gridlines: every endpoint prints its own value, so a gridline would
be ink carrying nothing. The two axis rules are the scale. -->
<line x1="320" y1="40" x2="320" y2="420" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<text data-axis="from" data-state="BEFORE" x="320" y="440" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">BEFORE</text>
<line x1="680" y1="40" x2="680" y2="420" stroke="rgba(45,49,66,0.25)" stroke-width="1"/>
<text data-axis="to" data-state="AFTER" x="680" y="440" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">AFTER</text>
<line data-series="Search" data-from="512" data-to="208" x1="320" y1="72.1" x2="680" y2="328.8" stroke="rgba(45,49,66,0.80)" stroke-width="1.2"/>
<circle cx="320" cy="72.1" r="3" fill="rgba(45,49,66,0.80)"/>
<circle cx="680" cy="328.8" r="3" fill="rgba(45,49,66,0.80)"/>
<text data-series="Search" data-end="from" data-role="name" x="272" y="75.6" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Search</text>
<text data-series="Search" data-end="from" x="304" y="75.6" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">512</text>
<text data-series="Search" data-end="to" x="696" y="332.3" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">208</text>
<text data-series="Search" data-end="to" data-role="name" x="728" y="332.3" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Search</text>
<line data-series="Catalog" data-from="376" data-to="164" x1="320" y1="186.9" x2="680" y2="366" stroke="rgba(45,49,66,0.74)" stroke-width="1.2"/>
<circle cx="320" cy="186.9" r="3" fill="rgba(45,49,66,0.74)"/>
<circle cx="680" cy="366" r="3" fill="rgba(45,49,66,0.74)"/>
<text data-series="Catalog" data-end="from" data-role="name" x="272" y="190.4" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Catalog</text>
<text data-series="Catalog" data-end="from" x="304" y="190.4" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">376</text>
<text data-series="Catalog" data-end="to" x="696" y="369.5" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">164</text>
<text data-series="Catalog" data-end="to" data-role="name" x="728" y="369.5" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Catalog</text>
<line data-series="Checkout" data-from="291" data-to="143" x1="320" y1="258.7" x2="680" y2="383.7" stroke="rgba(45,49,66,0.68)" stroke-width="1.2"/>
<circle cx="320" cy="258.7" r="3" fill="rgba(45,49,66,0.68)"/>
<circle cx="680" cy="383.7" r="3" fill="rgba(45,49,66,0.68)"/>
<text data-series="Checkout" data-end="from" data-role="name" x="272" y="262.2" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Checkout</text>
<text data-series="Checkout" data-end="from" x="304" y="262.2" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">291</text>
<text data-series="Checkout" data-end="to" x="696" y="387.2" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">143</text>
<text data-series="Checkout" data-end="to" data-role="name" x="728" y="387.2" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Checkout</text>
<line data-series="Auth" data-from="154" data-to="121" x1="320" y1="374.4" x2="680" y2="402.3" stroke="rgba(45,49,66,0.62)" stroke-width="1.2"/>
<circle cx="320" cy="374.4" r="3" fill="rgba(45,49,66,0.62)"/>
<circle cx="680" cy="402.3" r="3" fill="rgba(45,49,66,0.62)"/>
<text data-series="Auth" data-end="from" data-role="name" x="272" y="377.9" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif" text-anchor="end">Auth</text>
<text data-series="Auth" data-end="from" x="304" y="377.9" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">154</text>
<text data-series="Auth" data-end="to" x="696" y="405.8" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">121</text>
<text data-series="Auth" data-end="to" data-role="name" x="728" y="405.8" fill="#2d3142" font-size="11" font-weight="500" font-family="'Geist', sans-serif">Auth</text>
<!-- Recommender FOCAL -- and it is the series that got WORSE. The accent marks
the editorially focal line, never the winner. Focus is carried by STROKE
WEIGHT, not tone: the accent measures 2.86:1 on light paper, so on tone
alone the focal line would read fainter than the ink ramp it is meant to
dominate. Its labels stay ink/muted for the same reason -- accent text
misses AA at this size. Being the only ascending line it crosses three of
the other four -- not Auth, which starts and ends below it, because moving
the opposite way does not guarantee meeting. The crossings are left
unannotated: two endpoints say nothing about WHEN the regression started. -->
<line data-series="Recommender" data-from="238" data-to="431" x1="320" y1="303.5" x2="680" y2="140.5" stroke="#eb6c36" stroke-width="2.4"/>
<circle cx="320" cy="303.5" r="4" fill="#eb6c36"/>
<circle cx="680" cy="140.5" r="4" fill="#eb6c36"/>
<text data-series="Recommender" data-end="from" data-role="name" x="272" y="307" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">Recommender</text>
<text data-series="Recommender" data-end="from" x="304" y="307" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">238</text>
<text data-series="Recommender" data-end="to" x="696" y="144" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">431</text>
<text data-series="Recommender" data-end="to" data-role="name" x="728" y="144" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif">Recommender</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">P95 RESPONSE TIME IN MS, LOWER IS BETTER · SAME 100-550 SCALE ON BOTH AXES · ILLUSTRATIVE FIGURES, NOT A MEASURED RELEASE</text>
<line x1="40" y1="492" x2="64" y2="492" stroke="#eb6c36" stroke-width="2.4"/>
<circle cx="52" cy="492" r="4" fill="#eb6c36"/>
<text x="72" y="496" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Recommender · focal, the only regression</text>
<line x1="320" y1="492" x2="344" y2="492" stroke="rgba(45,49,66,0.80)" stroke-width="1.2"/>
<circle cx="332" cy="492" r="3" fill="rgba(45,49,66,0.80)"/>
<text x="352" y="496" fill="#4f5d75" font-size="8.5" font-family="'Geist', sans-serif">Other services · strongest tone was slowest before</text>
</svg>
</div>
</body>
</html>
+3
View File
@@ -279,6 +279,9 @@
<button class="tab new" data-type="treemap">
<span class="eyebrow">29</span>Treemap
</button>
<button class="tab new" data-type="slopegraph">
<span class="eyebrow">30</span>Line · slopegraph
</button>
<button class="tab new" data-type="dp-security-matrix">
<span class="eyebrow">32</span>DP security matrix
</button>
@@ -37,8 +37,112 @@
- Y-axis that doesn't include zero when the absolute magnitude matters.
- Connecting discontinuous data segments without a visual gap.
## Variants
- **Slopegraph:** exactly two states, several series, read as slope and rank change. Full spec below.
### Slopegraph
**Best for:** change between exactly **two** comparable states across several series — two years, before and after, two cohorts, two scenarios. The reading is threefold and no other type gives all three at once: direction (up or down), steepness (how fast), and **crossings** (who overtook whom). Tufte's original (*The Visual Display of Quantitative Information*, 1983) is a table whose rows have been given an angle — every number is still printed, which is why both endpoints keep their labels here.
Not for: three or more states (that is the **line chart** above, or a bump chart); a single series (write the sentence instead); ranking at one moment with no change (use a **bar chart**); or two variables measured in different units (use a **scatter plot** — a slope between unlike units means nothing).
#### Layout conventions
- **Two vertical axis rules**, `y` 40 → 420 inside a `0 0 1000 500` viewBox, at `x` 320 and `x` 680. The plot sits inside `x` 40 → 956 with the rotated value-axis caption at `x=24`, as in the parent chart, and the legend keeps the house rhythm (rule at `y=462`, `LEGEND` at `478`, key swatches at `492` with their text at `496`), so a reader of bar, line or treemap finds it where they expect.
- **Keep the run narrower than the plot is tall.** The 360px run against a 380px height puts the steepest shipped slope at 35° and the flattest at 4° — the spread you need before "halved" and "barely moved" look like different claims. Widening the run flattens every slope toward horizontal and throws away the comparison the type exists to make; the leftover width belongs to the label gutters, which need it.
- **Label gutters:** on the left, names right-aligned ending at `x=272` and values right-aligned ending at `x=304`; mirrored on the right from `x=696` (values) and `x=728` (names). Size the gutter to the longest name — a name that collides with the axis is the one thing here you cannot fix by moving a coordinate.
- **State captions** in Geist Mono 9px, centred under each axis at `y=440`, tracked `0.14em`.
- **Series count:** 4–10 — deliberately more than the parent chart's cap of 5. That cap exists because eight-vertex polylines tangle in mid-plot; a slopegraph has no mid-plot, so what sets the ceiling here is endpoint labels colliding, not the lines. Below 4 a sentence or a pair of bars says it better.
- **No gridlines.** Every endpoint prints its own value, so a gridline carries nothing the figure has not already said; the two axis rules *are* the scale. This is a real departure from the parent line chart, where the gridlines do the reading.
- **Domain:** pick round bounds that contain the data and state them in the source line. The shipped example runs 100–550ms over `y` 420 → 40, i.e. 0.84px per millisecond. The parent chart's include-zero rule does not bind here: a slope is unchanged by moving the origin, provided *both* axes move together. What a tight domain does do is magnify every slope equally, so state the bounds and let the reader calibrate.
- **Legend keys are a 24px line plus its dot**, not the 16×8 rect the parent section specifies — which is also what `example-line.html` actually ships, the parent prose being stale on this point. A slopegraph key has to show stroke weight, because weight is what marks the focal series.
- **Dots at both endpoints** — `r=3` non-focal, `r=4` focal. Also a departure: the line chart puts dots on the focal series only, because it has eight vertices per series and dots everywhere would be mush. A slopegraph has exactly two per series and both are where a value is read.
- **4px grid** applies to the designed constants — axis positions, gutter edges, state-caption baselines. Endpoint `y` values are data-scaled and exempt; snapping them would move the data. Two inherited constants are also off-grid and stay that way: the legend rule at `y=462` and the `LEGEND`/source baseline at `478`, which every chart type in `assets/` shares. Matching the house rhythm beats matching the grid here — moving them 4px would misalign this variant against bar, line, scatter and treemap to satisfy a rule none of them satisfies either.
#### Colour
- **One accent, and an `ink` opacity ramp for everything else** — not the series palette. In a line chart hue is the only way to follow a series across eight x-positions, so `series-1`…`series-4` earn their place. Here every series is named at both ends, so hue would be a second encoding of something the labels already carry, and it would spend the one-accent rule for nothing.
- **The ramp runs 0.80 → 0.62**, ordered by the left-hand value. The hard floor is **0.53** — that is where an `ink` stroke crosses 3:1 against light paper (0.53 measures 3.03:1, 0.52 measures 2.95:1), and every line in this figure is data, not decoration. The shipped ramp bottoms out at 0.62 (3.84:1) rather than hugging the floor, because a ramp whose lightest member is only just legible has no room left to add a series.
- **Note what the ramp does not buy you.** It separates the ends of the range, not adjacent members — 0.80 and 0.74 are not distinguishable at 1.2px, as the shipped example shows. It is there to help trace one line through a crossing. It must never be the only way to tell two series apart; that is the labels' job.
- **The accent marks the editorially focal series, not the best or the biggest.** In the shipped example it marks the one service that got *worse*.
- **Focus is carried by stroke weight, not tone** — 2.4px focal against 1.2px. Check the token you actually ship: `accent` measures **2.86:1 on light paper** and 5.21:1 on dark, so on light paper the focal line has *less* contrast than the ink ramp it is meant to dominate, and weight is the only cue that survives both skins and greyscale.
- **Be honest about what that leaves.** 2.86:1 is below WCAG 1.4.11's 3:1 floor for a graphical object, and a heavier stroke does not raise a contrast ratio — it only makes the mark easier to find. The focal line clears the bar on redundancy rather than on contrast: its position and its two endpoint labels (`ink` at 11.8:1, `muted` at 6.1:1) carry the data, and its accent adds only *which series is focal*, which the legend states in words and the stroke weight repeats. Nothing here rests on the accent alone. This is a property of the skin's accent token on light paper, not of this variant — the focal bar, the focal line and the focal treemap cell inherit it too, so fixing it properly means changing `accent` in style-guide.md.
- **Labels stay `ink` (names) and `muted` (values) on every series, including the focal one.** Accent text at 9–11px misses AA on light paper at that same 2.86:1. A focal value label in accent is the most common way to make a slopegraph fail contrast while looking deliberate.
- **Legend wording must be skin-neutral: "strongest tone", never "darkest".** The ramp is ink-at-opacity, so the top of it is the darkest line on light paper and the *lightest* on dark. A legend that says "darker is higher" ships false in one of the two variants — and it renders perfectly in both, so only reading the dark file catches it.
#### Honest-data rule
**Both axes must carry the same scale and the same units.** That is the entire claim of the type: if the scales differ, the angle of every line is fiction. `scripts/verify-slopegraph.py` gates it.
- **The two axes must never differ from each other** — not in scale, not in origin, not in transform. A shared *origin* matters as much as a shared scale: shifting one axis tilts every slope by the same amount, so the series still rank correctly against each other while every rate is wrong. That is the harder version to spot by eye, which is why the checker tests slope and origin separately.
- **A domain tighter than zero is fine; an undisclosed one is not.** Both axes sharing a 100–550 window is legitimate, because moving the origin leaves every slope unchanged when both axes move together — this is exactly where a slopegraph differs from a bar chart, whose truncated baseline distorts the ratio between bars. What a tight window does do is magnify all slopes equally, so the bounds go in the source line and the reader calibrates. Log-scaling is a different matter and stays out: it makes the angle mean nothing.
- **Label both endpoints with actual values.** A slope with no magnitudes is a mood.
- **Round once, then draw from the rounded number**, so the printed label, the declared metadata and the drawn `y` are three statements of one number rather than three chances to disagree.
- **If a series is missing an endpoint, drop it and say so.** Never interpolate to complete a line.
- **Crowded endpoint labels are data.** When two series sit a few tenths apart their labels crowd *because the values are close*. Moving a point to open up space converts a legibility problem into a false statement — and it is invisible afterwards, because the label you moved it for now sits comfortably beside the wrong position. This is the specific defect the gate exists for.
- **Two series that coincide at both endpoints cannot be separated at all.** Merge them into one labelled line, or drop one and name the omission in the source line. What you must not do is nudge them apart, which is the same defect as above wearing a better excuse.
- **The straight line is a connector, not a trajectory.** Two endpoints say nothing about the path between them: the underlying series may have dipped, spiked, or crossed three times. So never read an intermediate value off a slope, and never annotate a crossing with a date. In the shipped example one service regresses while four improve, so its line crosses three others; none of the three crossings is annotated, and the card beside the figure says why.
#### Declaring the values
**Every visible string that carries meaning is bound to an attribute stating the same thing.** That is the whole contract, and it exists because each unbound string is a place the figure can be made to lie while every geometric check stays green.
```svg
<!-- State captions: data-axis names the axis, data-state binds the text -->
<text data-axis="from" data-state="BEFORE" x="320" y="440" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">BEFORE</text>
<text data-axis="to" data-state="AFTER" x="680" y="440" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" letter-spacing="0.14em" text-anchor="middle">AFTER</text>
<!-- A series: the line declares its two values, and each of its four labels
declares which series and which end it belongs to -->
<line data-series="Recommender" data-from="238" data-to="431"
x1="320" y1="303.5" x2="680" y2="140.5" stroke="#eb6c36" stroke-width="2.4"/>
<circle cx="320" cy="303.5" r="4" fill="#eb6c36"/>
<circle cx="680" cy="140.5" r="4" fill="#eb6c36"/>
<text data-series="Recommender" data-end="from" data-role="name" x="272" y="307" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif" text-anchor="end">Recommender</text>
<text data-series="Recommender" data-end="from" x="304" y="307" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace" text-anchor="end">238</text>
<text data-series="Recommender" data-end="to" x="696" y="144" fill="#4f5d75" font-size="9" font-family="'Geist Mono', monospace">431</text>
<text data-series="Recommender" data-end="to" data-role="name" x="728" y="144" fill="#2d3142" font-size="11" font-weight="600" font-family="'Geist', sans-serif">Recommender</text>
```
Non-focal series: `stroke="rgba(45,49,66,0.68)"` at `stroke-width="1.2"`, dots `r=3`, names at `font-weight="500"`.
What each binding buys, and what it costs to omit:
| Binding | Without it |
|---|---|
| `data-from` / `data-to` on the line | The checker would have to read the labels, and a series whose label went missing would drop silently out of the verified set — the exact hole that let a treemap cell ship 50% oversized. |
| `data-end` on a value label | A printed number could not be cross-checked against the value it claims to state. |
| `data-role="name"` on a name label | Two series names could be exchanged between rows, renaming both lines, with every number still correct in isolation. |
| `data-axis` on a state caption | The captions could be swapped, reversing the direction every slope is read in. |
| `data-state` on a state caption | Swapping just the two visible strings leaves both captions in place and reverses the figure anyway. |
`scripts/verify-slopegraph.py` requires all of them, cross-checks each visible string against its binding, and reports any label drawn nearer another series' endpoint than its own — a label on the wrong row renames the line.
**No `transform` on any of it.** The checker reads raw `x`/`y` attributes, so a `transform` on a series line, on a bound label, on an ancestor `<g>`, or in a CSS rule moves the rendered mark away from the number that was verified — `transform="translate(0 80)"` on one line slid its endpoint 80px past every green check. Transforms are rejected rather than resolved: a partial implementation of the SVG transform stack looks like coverage without being it. Bake the offset into the coordinates. The rotated value-axis caption is fine — it is neither verified geometry nor a bound label.
**Print one complete number per value label.** A unit suffix is fine (`208ms`); a thousands separator that changes the value is not, and neither is a second number in the same label. Reading only the first numeric fragment let the label `512,000` agree with metadata that said `512`.
#### Anti-patterns
- Different scales, different units, or different origins on the two axes — the type's one unforgivable error.
- A value axis whose two halves disagree, or a tight domain the source line never states.
- Moving an endpoint to make room for its label.
- More than 10 series (labels collide) or fewer than 4 (a sentence is shorter).
- Three or more state columns — that is the parent line chart, or a bump chart, not this.
- One hue per series instead of the ink ramp plus a single accent.
- A value label in `accent`, or any text in `soft` (3.48:1 on paper).
- Gridlines, or a value axis that repeats numbers the endpoints already print.
- Annotating the crossing with a date, or reading any intermediate value off a slope.
- A `transform` on a series line, a bound label, an ancestor group, or in CSS — it moves the mark away from the checked coordinate.
- 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.
## Examples
- `assets/example-line.html` — minimal light
- `assets/example-line-dark.html` — minimal dark
- `assets/example-line-full.html` — full editorial
- `assets/example-slopegraph.html` — slopegraph, minimal light
- `assets/example-slopegraph-dark.html` — slopegraph, minimal dark
- `assets/example-slopegraph-full.html` — slopegraph, full editorial