Skip to content

[Feature]: Route strip — sites along a linear asset on an honest metric axis (pattern on Timeline) #252

Description

@djok

What are you trying to show?

A chain of sites along one linear asset — a cable route, a duct run, a pipeline, a
rail line — where the distance between neighbours is load-bearing: the reader must see
that one span is 80 m and the next is 1.8 km, and where along a span a tap or a drop
sits.

Timeline already states the rule that matters (skills/diagram-design/references/type-timeline.md):
the scale must be honest, unequal intervals are spaced unequally, and a dense region is
broken visibly. But the reference is written for time only and is one of the two
shortest in the set:

  • no unit other than time, and no rule for printing the unit — only the
    anti-pattern "what unit is this?";
  • no span length — events are points; a stretch between two of them has no mark
    of its own;
  • no rounding contract — the 4px grid and the honest-scale rule cannot both hold
    exactly for arbitrary values, and nothing says which one yields and by how much;
  • no instruction for how to break the axis, only that it may be broken;
  • no budget row — Timeline has none in SKILL.md §7.

Proposed solution

Proposal

A semantic pattern Route strip, routed to Timeline, in the next free section of
skills/diagram-design/references/semantic-patterns.md (§ 10 at dc1ace4), with the
six fields scripts/verify-semantic-motion.py requires, one routing row there and one
in SKILL.md §3, one new entry in PATTERN_NAMES, and the ADR 0002 amendment for the
pattern count in the same PR; plus five additions to type-timeline.md that are as
true for time as for distance. No new visual type.

Routing row for semantic-patterns.md:

The reader must understand… Semantic pattern Nearest visual type
Where things sit along a linear asset, and how long each stretch is Route strip Timeline

SKILL.md §3 row (108 bytes):

| Sites along a linear asset to scale; span lengths; a declared axis break | **Route strip** → Timeline |

The pattern's primitives: one metric axis with bare-number ticks and the unit printed
exactly once; a solid 16px band per segment whose width is Δdistance × scale; end
markers from a closed set of at most six kinds, one shape each; drops as r=4 dots on
the band with a hairline leader to a mono label; an estimated position or length drawn
hollow; a visible axis break — two slashes across band and axis, a paper-coloured gap,
the omitted amount as a coral number — declared in markup as data-axis-break. All of
it under one root data-grammar="route-strip".

Additions to type-timeline.md:

  1. Units. The axis unit is printed exactly once, at the axis end; tick labels are
    bare numbers; data-unit on the axis group.
  2. Span length. When a span between two events carries meaning, draw it as a 16px
    band whose width is Δvalue × scale, not as a hairline.
  3. Rounding contract. Positions are proportional first and snapped to the 4px grid
    second; the distance between two snapped points may differ from the exact
    Δvalue × scale by at most one grid step (4px), declared as
    data-grid-tolerance="4".
  4. How to break the axis. Two slashes, a paper-coloured gap, the omitted amount as
    a coral number, data-axis-break with the declared range. A break never hides an
    event.
  5. A budget row (see Budget below).

In the form of ADR 0007 — the row we evaluated, and why it is a pattern and not a type:

Type The grammar that did not exist Nearest existing type, and why it fails
Route strip Sites along a linear asset to scale, spans as bands whose width is the distance, a closed vocabulary of end kinds, a declared axis break Timeline is the same grammar — one axis, marks on it, an honest scale, a visible break — and fails only on missing primitives (unit, span band, rounding contract, break drawing) and a missing budget. Layout exists, so under ADR 0002 this is a pattern plus an amendment to the type reference

Visual proof

The generic example rendered in the light, dark and full variants (PNG @2, below) —
seven nodes and six segments between 80 m and 1820 m (2989 m in total), three drops of
which one is at an estimated position (hollow), and one declared break that omits
1587 m of the longest segment. The PR will carry the usual .github/pr-visual-proof/
light, dark and full PNGs at @3.

route-strip light
route-strip dark
route-strip full

Verification

scripts/verify-route-strip.py — stdlib only, modeled on scripts/verify-geometry.py
(ADR 0005), check(path) -> list[str], exit 0/1 (2 when a file cannot be read). It
reads only the first data-grammar="route-strip" root; a file without it gets no
findings and a SKIP <path>: not a route-strip figure line.

data-axis-break defines the coordinate transform. A break is
<g data-axis-break="…" data-from-m="…" data-to-m="…" data-cx="…">: data-from-m and
data-to-m are authoritative, and the printed label is to − from rounded to whole
units. A break has zero axis width, x(from) = x(to) = data-cx; the 8px paper gap and
the slashes are painted over that coordinate. Positions are judged in visible distance,
visible(m) = m − Σ(to − from) over breaks with to ≤ m, against drawn neighbours:
Δx = (visible(m₂) − visible(m₁)) · s. The positioned elements are every data-pos-m
in the root (sites, drops, ticks), sorted by metres; elements at the same metre are one
point. An adjacent pair is interrupted when a break lies strictly between them in
metres (m₁ < from, to < m₂), and s is the median Δx/Δm over uninterrupted pairs
only.

Sixteen rules over the data-* contract, one finding category each:

  1. strip.missing — the root carries data-route-strip and numeric data-total-m and
    data-scale-px-per-m.
  2. strip.contract — data-unit appears exactly once, on the root, and the root
    declares data-grid-tolerance="4", the contract's tolerance.
  3. strip.vocab — data-install and data-basis are in their closed vocabularies.
  4. axis.position — every element with data-pos-m has a numeric data-cx.
  5. axis.monotonic — elements at one metre are drawn at one x, and x never runs
    backwards.
  6. axis.scale — the scale can be derived; the declared data-scale-px-per-m stands
    unless the median puts more of the uninterrupted pairs within 4px, and then s is
    the median. Every adjacent pair of non-hidden elements must satisfy
    |Δx − (visible(m₂) − visible(m₁)) · s| ≤ 4px; an uninterrupted pair that misses
    it by being too long is axis.scale (too short is rule 7, an interrupted pair is
    rule 8). First→last is checked with the same two-sided condition only if neither
    the scale check nor any pair raised a finding, and catches drift under 4px per pair.
  7. axis.break-undeclared — an uninterrupted pair is drawn more than 4px too short.
  8. break.amount — an interrupted pair misses its visible distance by more than 4px.
  9. break.range — data-from-m and data-to-m are numbers, from < to; data-axis-break
    equals to − from (±0.05 m); several breaks are allowed, in one span or several, but
    they must not overlap or touch, and must lie between the first and last site.
  10. break.position — with p the last non-hidden positioned element before from,
    x(from) = x(p) + (visible(from) − visible(p)) · s; a finding if data-cx is
    missing or |data-cx − x(from)| > 4px.
  11. break.hides — a break never hides an event: any positioned element with
    from ≤ m ≤ to (closed, so an edge counts), or drawn near data-cx — a site < 16px
    (a 16px marker's half-width plus the slashes' 8px reach), a drop < 8px (radius 4
    plus half the gap), a tick < 4px (under the gap). A hidden element skips the axis
    checks, so it yields one finding. If a stop inside the range matters, split the
    break around it.
  12. section.sum — Σ data-length-m equals data-total-m and the span of the points.
  13. section.width — a segment band is one rect from the data-cx of its data-a site
    to that of its data-z site (±0.01px), and data-length-m = pos(z) − pos(a)
    (±0.05 m). Across a break it stays one rect: [a, from] and [to, z] at scale s,
    joined at data-cx under the gap; the pair rules judge its scale.
  14. marker.kind — data-kind is one of the closed set of end kinds, or drop.
  15. marker.centre — data-cx is the centre of the drawn shape.
  16. grid.off — every coordinate inside the root is divisible by 4.

Adversarial tests exercise each rule with one targeted mutation and prove zero
findings on the light, dark and full examples, on a print-size render, on a render
without a break and on a benign edit. Every fixture of the contract is one check with
its complete findings, on the example re-rendered in memory, on a render with two breaks
in two spans, or on hand-made markup the renderer refuses. The three examples pass
self_check.py, verify-geometry.py and lint-skin.py with zero findings, and
lint-render.py --fonts and verify-skin-polarity.py with zero findings.

Budget

Proposed row for SKILL.md §7 — Timeline has none today, and the pattern uses the
same numbers:

Limit Rule
Max events / spans / sub-events / breaks / coral (timeline) 9 / 8 / 6 / 2 / 1

A legend is required above two marker kinds. Beyond the budget, split an overview
strip from per-segment detail.

Rejected alternatives

  • A new visual type. One axis with marks on it is Timeline's grammar; ADR 0002
    grows the type count only for a new layout grammar.
  • Gantt. Task × time; the bars are tasks, not spans of one continuous asset.
  • User journey. Stages plus a sentiment curve; no proportional axis.
  • Sankey. Band width encodes a quantity that splits and merges; a route neither
    splits nor merges.
  • Equal spacing for readability. It is the anti-pattern Timeline already names.
  • Squeezing a long span without a break. It lies about the scale; the break is
    declared and prints what it omits.

Scope

New or changed primitive

A semantic pattern and its primitives: one semantic-patterns.md section, two routing
rows, one PATTERN_NAMES entry and the ADR 0002 amendment, five additions to
type-timeline.md with its budget row, plus one stdlib verifier with its adversarial
test — the ADR 0010 shape. No new type, no gallery tab. SKILL.md is at 39,506 of the
40,000 bytes ADR 0004 allows; this routing row adds 108 and the budget row 84. With the
port cross-connect and link topology drafts, the four rows add 448 bytes.

Design-system fit

  • I've checked the anti-patterns and 'When not to use' — this still seems worth drawing
  • I'm willing to contribute the implementation or pair with a maintainer

Activity

  1. cathrynlavery commented on Sep 26, 2026

    @cathrynlavery
    Owner

    Thanks for identifying the proportional-scale/grid tension explicitly; the rounding contract is a useful addition to Timeline.

    The axis-break verification needs one more precise rule before implementation. The proposal says the checker derives scale from median Δx / Δdistance, while the example deliberately compresses 1,587 m inside one segment. If a pair crossing that break participates in the median, it can distort the inferred scale; if it is excluded, the markup and algorithm need to say exactly how. data-axis-break should define a piecewise coordinate transform, not just annotate a visual gap.

    Please specify that scale is inferred only from uninterrupted intervals, then validate positions from cumulative visible distance plus declared break gaps. Also define how a span crossing a break is split, whether a site/drop may fall inside the omitted range, and how its position is represented if allowed.

    Focused fixtures should cover a break inside a span, at a site, multiple breaks, and an event inside the omitted interval. That would make “a break never hides an event” mechanically enforceable rather than only documented. The pattern/type amendment remains a maintainer product decision.

  2. djok commented on Sep 27, 2026

    @djok
    ContributorAuthor

    Thanks, agreed: data-axis-break should define the coordinate transform. Proposed contract:

    Transform

    • A break is <g data-axis-break="…" data-from-m="…" data-to-m="…" data-cx="…">. data-from-m/data-to-m are authoritative; data-axis-break must equal to − from (±0.05 m), else break.range. The printed label is that value rounded to whole units.
    • visible(m) = m − Σ(to − from) over breaks with to ≤ m. Positions are judged against drawn neighbours, Δx = (visible(m₂) − visible(m₁)) · s, so no global origin is needed.
    • A break has zero axis width: x(from) = x(to) = data-cx. The 8 px paper gap and the slashes are painted over that coordinate, so the declared-gap term is 0.

    Scale

    • Positioned elements: every data-pos-m in the root (sites, drops, ticks), sorted by metres; elements at the same metre are one point (drawn at one x, else axis.monotonic). An adjacent pair is interrupted when a break lies strictly between them in metres (m₁ < from, to < m₂).
    • s is the median Δx/Δm over uninterrupted pairs only. The declared data-scale-px-per-m stands unless the median puts more of those pairs within 4 px; then axis.scale, and s is the median. My implementation of the filed rules (not pushed yet) already excludes them, but by the break's drawn x; by metres, a misplaced break cannot change the set.

    Positions

    • Every adjacent pair of non-hidden elements: |Δx − (visible(m₂) − visible(m₁)) · s| ≤ 4 px. A miss is break.amount on an interrupted pair, else axis.break-undeclared (short) or axis.scale (long).
    • First→last, checked the same way only if neither the scale check nor any pair raised a finding, catches drift under 4 px per pair (axis.scale).
    • New break.position: with p the last non-hidden positioned element before from, x(from) = x(p) + (visible(from) − visible(p)) · s; a finding if |data-cx − x(from)| > 4 px or data-cx is missing.

    A span crossing a break

    • New section.width: a segment band is one rect from the data-cx of its data-a site to that of its data-z site (±0.01 px), and data-length-m = pos(z) − pos(a) (±0.05 m). Across a break it stays one rect: [a, from] and [to, z] at scale s, joined at data-cx under the gap; the pair rule judges its scale.
    • Several breaks are allowed, in one span or several. They must not overlap or touch, and must lie between the first and last site (break.range). My renderer stays stricter (one break per segment).

    Events inside an omitted range

    Not allowed: the range collapses to one coordinate, so an event there has no honest x. New break.hides makes "a break never hides an event" mechanical: any positioned element with from ≤ m ≤ to (closed, so an edge counts), or drawn near data-cx: a site < 16 px (a 16 px marker's half-width plus the slashes' 8 px reach), a drop < 8 px (radius 4 plus half the gap), a tick < 4 px (under the gap). A hidden element skips the axis checks, so it yields one finding. If a stop inside the range matters, split the break around it.

    Fixtures (one mutation each → expected verdict)

    • light, dark, full (one break inside a span), print-size, no-break render, benign label/colour edit → zero findings
    • two breaks in two spans → zero findings
    • two breaks in one span with a drop between them → zero findings
    • two non-touching breaks between one pair of marks → zero findings
    • declared scale 0.604 → 0.5 → one axis.scale finding; median 0.600 from 14 of 14 uninterrupted pairs, the interrupted one not counted
    • break group moved 40 px, metres unchanged → break.position
    • data-from-m/data-to-m shifted +21.5 m, drawing unchanged → break.position
    • data-cx removed from the break → break.position only
    • declared amount 1587 → 1500 → break.range only
    • to and amount changed together (1400 m omitted), drawing unchanged → break.amount
    • data-axis-break attribute removed, compression kept → axis.break-undeclared
    • band of the broken span narrowed 40 px → section.width
    • break drawn consistently but ending exactly at a site → break.hides for that site only
    • site strictly inside the omitted range → break.hides for that site only
    • drop exactly at from → break.hides
    • drop 8.5 m before from, drawn 4 px from the break → break.hides
    • drop at 1500 m inside 878.5–2465.5 m, drawn at the break x → break.hides only
    • same drop drawn 12 px past the break → break.hides only
    • tick inside the omitted range → break.hides
    • tick 0.5 m before from, drawn at the break x → break.hides
    • two touching breaks in one span → break.range

    Next

    I'll implement this contract and these fixtures in scripts/verify-route-strip.py (three new rules, sixteen in total), update the issue body with the amended rule list, and post the fixture results here. The pattern/type decision stays yours.

  3. djok commented on Sep 28, 2026

    @djok
    ContributorAuthor

    The contract is in scripts/verify-route-strip.py: three new rules (break.position,
    break.hides, section.width), sixteen rules in total, and break.range now also rejects
    breaks that overlap, touch or lie outside the first and last site. The renderer stays
    stricter (one break per segment, no drop inside it), so the examples are byte-for-byte the
    same as before. The issue body now lists the sixteen rules.

    Each fixture is one check in our test suite with its complete list of findings. The mutations
    start from the light example re-rendered in memory; "two breaks in two spans" is a render of
    the pattern, and "two breaks in one span with a drop between them" is hand-made markup, since
    the renderer refuses it. All 21 checks pass.

    Fixture (from the reply) Expected Got
    light, dark, full (one break inside a span), print-size, no-break render, benign label/colour edit zero findings 0 on each of 6 renders
    two breaks in two spans zero findings 0
    two breaks in one span with a drop between them zero findings 0
    two non-touching breaks between one pair of marks zero findings 0
    declared scale 0.604 → 0.5 one axis.scale finding; median 0.600 from 14 of 14 uninterrupted pairs, the interrupted one not counted 1: axis.scale: declared 0.5 px/m; the median of the 14 uninterrupted pairs is 0.600 px/m and fits 14 of them, the declared 4
    break group moved 40 px, metres unchanged break.position 1: break.position: axis-break=1587 is drawn at x=548; tick@800 (800 m, x=540) puts 878.5 m at x=587.4
    data-from-m/data-to-m shifted +21.5 m, drawing unchanged break.position 1: break.position: axis-break=1587 is drawn at x=588; tick@800 (800 m, x=540) puts 900 m at x=600.4
    data-cx removed from the break break.position only 1: break.position: axis-break=1587 has no numeric data-cx
    declared amount 1587 → 1500 break.range only 1: break.range: axis-break=1500: data-to-m − data-from-m is 1587 m
    to and amount changed together (1400 m omitted), drawing unchanged break.amount 1: break.amount: tick@800 → node=CM-3 is 116 px; 1778.5 m less 1400 m omitted (axis-break=1400) is 228.6 px
    data-axis-break attribute removed, compression kept axis.break-undeclared 1: axis.break-undeclared: tick@800 → node=CM-3 is 116 px for 1778.5 m (expected 1074.2 px) and no data-axis-break lies between them
    band of the broken span narrowed 40 px section.width 1: section.width: segment=S4 is drawn x=516…616; its sites node=PL-9 and node=CM-3 are at x=516 and x=656
    break drawn consistently but ending exactly at a site break.hides for that site only 1: break.hides: node=CM-3 (2578.5 m) lies inside axis-break=1587 (991.5…2578.5 m)
    site strictly inside the omitted range break.hides for that site only 1: break.hides: node=PL-X (1500 m) lies inside axis-break=1587 (878.5…2465.5 m)
    drop exactly at from break.hides 1: break.hides: drop=HID-1 (878.5 m) lies inside axis-break=1587 (878.5…2465.5 m)
    drop 8.5 m before from, drawn 4 px from the break break.hides 1: break.hides: drop=HID-1 (870 m) is drawn at x=584, 4 px from axis-break=1587 at x=588; a drop needs 8 px
    drop at 1500 m inside 878.5–2465.5 m, drawn at the break x break.hides only 1: break.hides: drop=HID-1 (1500 m) lies inside axis-break=1587 (878.5…2465.5 m)
    same drop drawn 12 px past the break break.hides only 1: break.hides: drop=HID-1 (1500 m) lies inside axis-break=1587 (878.5…2465.5 m)
    tick inside the omitted range break.hides 1: break.hides: tick@2000 (2000 m) lies inside axis-break=1587 (878.5…2465.5 m)
    tick 0.5 m before from, drawn at the break x break.hides 1: break.hides: tick@878 (878 m) is drawn at x=588, 0 px from axis-break=1587 at x=588; a tick needs 4 px
    two touching breaks in one span break.range 1: break.range: axis-break=821.5 (878.5…1700 m) and axis-break=765.5 (1700…2465.5 m) touch

    The pattern/type decision stays yours; the PR waits for it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions