Repository navigation
[Feature]: Route strip — sites along a linear asset on an honest metric axis (pattern on Timeline) #252
Description
Activity
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-breakshould 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.
Thanks, agreed:
data-axis-breakshould 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-mare authoritative;data-axis-breakmust equalto − from(±0.05 m), elsebreak.range. The printed label is that value rounded to whole units. visible(m) = m − Σ(to − from)over breaks withto ≤ 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-min the root (sites, drops, ticks), sorted by metres; elements at the same metre are one point (drawn at one x, elseaxis.monotonic). An adjacent pair is interrupted when a break lies strictly between them in metres (m₁ < from,to < m₂). sis the median Δx/Δm over uninterrupted pairs only. The declareddata-scale-px-per-mstands unless the median puts more of those pairs within 4 px; thenaxis.scale, andsis 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 isbreak.amounton an interrupted pair, elseaxis.break-undeclared(short) oraxis.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 beforefrom,x(from) = x(p) + (visible(from) − visible(p)) · s; a finding if|data-cx − x(from)| > 4 pxordata-cxis missing.
A span crossing a break
- New
section.width: a segment band is one rect from thedata-cxof itsdata-asite to that of itsdata-zsite (±0.01 px), anddata-length-m = pos(z) − pos(a)(±0.05 m). Across a break it stays one rect:[a, from]and[to, z]at scales, joined atdata-cxunder 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.hidesmakes "a break never hides an event" mechanical: any positioned element withfrom ≤ m ≤ to(closed, so an edge counts), or drawn neardata-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.scalefinding; 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-mshifted +21.5 m, drawing unchanged →break.positiondata-cxremoved from the break →break.positiononly- declared amount 1587 → 1500 →
break.rangeonly toand amount changed together (1400 m omitted), drawing unchanged →break.amountdata-axis-breakattribute 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.hidesfor that site only - site strictly inside the omitted range →
break.hidesfor 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.hidesonly - same drop drawn 12 px past the break →
break.hidesonly - 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.- A break is
The contract is in
scripts/verify-route-strip.py: three new rules (break.position,
break.hides,section.width), sixteen rules in total, andbreak.rangenow 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.scalefinding; median 0.600 from 14 of 14 uninterrupted pairs, the interrupted one not counted1: 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 4break group moved 40 px, metres unchanged break.position1: break.position: axis-break=1587 is drawn at x=548; tick@800 (800 m, x=540) puts 878.5 m at x=587.4data-from-m/data-to-mshifted +21.5 m, drawing unchangedbreak.position1: break.position: axis-break=1587 is drawn at x=588; tick@800 (800 m, x=540) puts 900 m at x=600.4data-cxremoved from the breakbreak.positiononly1: break.position: axis-break=1587 has no numeric data-cxdeclared amount 1587 → 1500 break.rangeonly1: break.range: axis-break=1500: data-to-m − data-from-m is 1587 mtoand amount changed together (1400 m omitted), drawing unchangedbreak.amount1: break.amount: tick@800 → node=CM-3 is 116 px; 1778.5 m less 1400 m omitted (axis-break=1400) is 228.6 pxdata-axis-breakattribute removed, compression keptaxis.break-undeclared1: 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 themband of the broken span narrowed 40 px section.width1: section.width: segment=S4 is drawn x=516…616; its sites node=PL-9 and node=CM-3 are at x=516 and x=656break drawn consistently but ending exactly at a site break.hidesfor that site only1: 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.hidesfor that site only1: break.hides: node=PL-X (1500 m) lies inside axis-break=1587 (878.5…2465.5 m)drop exactly at frombreak.hides1: 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 breakbreak.hides1: 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 pxdrop at 1500 m inside 878.5–2465.5 m, drawn at the break x break.hidesonly1: 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.hidesonly1: break.hides: drop=HID-1 (1500 m) lies inside axis-break=1587 (878.5…2465.5 m)tick inside the omitted range break.hides1: 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 xbreak.hides1: break.hides: tick@878 (878 m) is drawn at x=588, 0 px from axis-break=1587 at x=588; a tick needs 4 pxtwo touching breaks in one span break.range1: break.range: axis-break=821.5 (878.5…1700 m) and axis-break=765.5 (1700…2465.5 m) touchThe pattern/type decision stays yours; the PR waits for it.
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:
anti-pattern "what unit is this?";
of its own;
exactly for arbitrary values, and nothing says which one yields and by how much;
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 atdc1ace4), with thesix fields
scripts/verify-semantic-motion.pyrequires, one routing row there and onein
SKILL.md§3, one new entry inPATTERN_NAMES, and the ADR 0002 amendment for thepattern count in the same PR; plus five additions to
type-timeline.mdthat are astrue for time as for distance. No new visual type.
Routing row for
semantic-patterns.md:SKILL.md§3 row (108 bytes):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; endmarkers from a closed set of at most six kinds, one shape each; drops as
r=4dots onthe 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 ofit under one root
data-grammar="route-strip".Additions to
type-timeline.md:bare numbers;
data-uniton the axis group.band whose width is
Δvalue × scale, not as a hairline.second; the distance between two snapped points may differ from the exact
Δvalue × scaleby at most one grid step (4px), declared asdata-grid-tolerance="4".a coral number,
data-axis-breakwith the declared range. A break never hides anevent.
In the form of ADR 0007 — the row we evaluated, and why it is a pattern and not a type:
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.
Verification
scripts/verify-route-strip.py— stdlib only, modeled onscripts/verify-geometry.py(ADR 0005),
check(path) -> list[str], exit 0/1 (2 when a file cannot be read). Itreads only the first
data-grammar="route-strip"root; a file without it gets nofindings and a
SKIP <path>: not a route-strip figureline.data-axis-breakdefines the coordinate transform. A break is<g data-axis-break="…" data-from-m="…" data-to-m="…" data-cx="…">:data-from-manddata-to-mare authoritative, and the printed label isto − fromrounded to wholeunits. A break has zero axis width,
x(from) = x(to) = data-cx; the 8px paper gap andthe slashes are painted over that coordinate. Positions are judged in visible distance,
visible(m) = m − Σ(to − from)over breaks withto ≤ m, against drawn neighbours:Δx = (visible(m₂) − visible(m₁)) · s. The positioned elements are everydata-pos-min 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₂), andsis the median Δx/Δm over uninterrupted pairsonly.
Sixteen rules over the
data-*contract, one finding category each:strip.missing— the root carriesdata-route-stripand numericdata-total-manddata-scale-px-per-m.strip.contract—data-unitappears exactly once, on the root, and the rootdeclares
data-grid-tolerance="4", the contract's tolerance.strip.vocab—data-installanddata-basisare in their closed vocabularies.axis.position— every element withdata-pos-mhas a numericdata-cx.axis.monotonic— elements at one metre are drawn at one x, and x never runsbackwards.
axis.scale— the scale can be derived; the declareddata-scale-px-per-mstandsunless the median puts more of the uninterrupted pairs within 4px, and then
sisthe median. Every adjacent pair of non-hidden elements must satisfy
|Δx − (visible(m₂) − visible(m₁)) · s| ≤ 4px; an uninterrupted pair that missesit by being too long is
axis.scale(too short is rule 7, an interrupted pair isrule 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.
axis.break-undeclared— an uninterrupted pair is drawn more than 4px too short.break.amount— an interrupted pair misses its visible distance by more than 4px.break.range—data-from-manddata-to-mare numbers, from < to;data-axis-breakequals
to − from(±0.05 m); several breaks are allowed, in one span or several, butthey must not overlap or touch, and must lie between the first and last site.
break.position— with p the last non-hidden positioned element beforefrom,x(from) = x(p) + (visible(from) − visible(p)) · s; a finding ifdata-cxismissing or
|data-cx − x(from)| > 4px.break.hides— a break never hides an event: any positioned element withfrom ≤ m ≤ to(closed, so an edge counts), or drawn neardata-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.
section.sum— Σdata-length-mequalsdata-total-mand the span of the points.section.width— a segment band is one rect from thedata-cxof itsdata-asiteto that of its
data-zsite (±0.01px), anddata-length-m = pos(z) − pos(a)(±0.05 m). Across a break it stays one rect:
[a, from]and[to, z]at scales,joined at
data-cxunder the gap; the pair rules judge its scale.marker.kind—data-kindis one of the closed set of end kinds, ordrop.marker.centre—data-cxis the centre of the drawn shape.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.pyandlint-skin.pywith zero findings, andlint-render.py --fontsandverify-skin-polarity.pywith zero findings.Budget
Proposed row for
SKILL.md§7 — Timeline has none today, and the pattern uses thesame numbers:
A legend is required above two marker kinds. Beyond the budget, split an overview
strip from per-segment detail.
Rejected alternatives
grows the type count only for a new layout grammar.
splits nor merges.
declared and prints what it omits.
Scope
New or changed primitive
A semantic pattern and its primitives: one
semantic-patterns.mdsection, two routingrows, one
PATTERN_NAMESentry and the ADR 0002 amendment, five additions totype-timeline.mdwith its budget row, plus one stdlib verifier with its adversarialtest — the ADR 0010 shape. No new type, no gallery tab.
SKILL.mdis at 39,506 of the40,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