figdown 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/figdown.js CHANGED
@@ -1,4 +1,4 @@
1
- // figdown.js — FigDown embeddable library (0.4.0)
1
+ // figdown.js — FigDown embeddable library (0.4.1)
2
2
  // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
3
  // Regenerate with: node tools/make-lib.js
4
4
  (function (root, factory) {
@@ -10,7 +10,7 @@
10
10
  }
11
11
  }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
12
12
  'use strict';
13
- var VERSION = "0.4.0";
13
+ var VERSION = "0.4.1";
14
14
 
15
15
  // ---- engine (extracted verbatim from editor/figdown.html) ----
16
16
  var __engine = (function () {
@@ -24,7 +24,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
24
24
  // input to that promise, and under core §13 a 0.x renderer may differ from
25
25
  // the next — which makes the recorded version the only thing that can
26
26
  // explain a diff between two renderings of one source.
27
- const FIGDOWN_VERSION = '0.4.0';
27
+ const FIGDOWN_VERSION = '0.4.1';
28
28
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
29
29
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
30
30
  // engine and only ever increases), so 0.1 is followed by
@@ -39,7 +39,7 @@ const FIGDOWN_VERSION = '0.4.0';
39
39
  // fixes only. No new features. The language does not move." Shipping `note=`
40
40
  // under `v0.2.z` would make `figdown 0.2` name two different languages: the one
41
41
  // `v0.2.0` published and the one with `note=`. So the language number moves.
42
- // `SEQUENCE-SOURCE-STANDARD`-R182: `figdown 0.4` joins the set, and it joins it for the
42
+ // `SEQUENCE-GENRE-VOCABULARY`: `figdown 0.4` joins the set, and it joins it for the
43
43
  // same reason `0.2` did — a GENRE token is language surface, and core §13.0
44
44
  // makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
45
45
  // no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
@@ -52,7 +52,7 @@ const GENRES_BY_VERSION = {
52
52
  '0.1': ['block','topology','flowchart','bitfield','table','timing'],
53
53
  '0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
54
54
  '0.3': ['block','topology','flowchart','bitfield','table','timing','statechart'],
55
- // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` is dispatchable from here on. Like
55
+ // `SEQUENCE-GENRE-VOCABULARY`: `sequence` is dispatchable from here on. Like
56
56
  // `statechart` at `STATECHART-GENRE-SCOPE` it arrived with NO vocabulary of its own, and the
57
57
  // consequence was stated here rather than left to be discovered: there was no
58
58
  // `GENRE_KW.sequence` row, and the allowlist guard is written
@@ -60,7 +60,7 @@ const GENRES_BY_VERSION = {
60
60
  // row is NOT narrowed — a `figdown 0.4 sequence` document could write any
61
61
  // registered keyword and it parsed. The document that increment meant to
62
62
  // land was the header ALONE.
63
- // `SEQUENCE-SOURCE-STANDARD`-R182: CLOSED. `GENRE_KW.sequence` exists below, so the
63
+ // `SEQUENCE-GENRE-VOCABULARY`: CLOSED. `GENRE_KW.sequence` exists below, so the
64
64
  // genre now constrains what it names — five keywords of its own, `class`,
65
65
  // and the genre-free core — and `flow`/`rank`/`group` are line errors under
66
66
  // it. The genre still has NO RENDERER: a valid `sequence` document parses to
@@ -374,7 +374,7 @@ const DIRECTIVE_OPTS={
374
374
  // keyed by the surface word an author actually wrote.
375
375
  flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
376
376
  transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
377
- // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own rows. `message` is
377
+ // `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's four own rows. `message` is
378
378
  // the fourth connector spelling and takes the connector set — `fill=` and
379
379
  // the three retired label keys stay listed for the same reason they are
380
380
  // listed on the other three, so their dedicated diagnostics fire instead of
@@ -440,7 +440,7 @@ const DIRECTIVE_OPTS={
440
440
  cell:['fill','stroke','class'], width:[],
441
441
  signal:['data','fill','stroke'], gap:[]
442
442
  };
443
- // `SEQUENCE-SOURCE-STANDARD`-R182: the FIRST genre-conditional option row, and it
443
+ // `SEQUENCE-GENRE-VOCABULARY`: the FIRST genre-conditional option row, and it
444
444
  // exists because `DIRECTIVE_OPTS` is keyed by the SURFACE WORD an author
445
445
  // wrote, while `GENRE-VOCABULARY-OBLIGATION` makes a surface word a per-genre declaration. Every other
446
446
  // shared spelling in the language names the same construct in every genre
@@ -1023,7 +1023,7 @@ const FLOWCHART_SUBJECT_KW=['external'];
1023
1023
  // none of the six; `external` is additionally UML 2.5.1 §14's own
1024
1024
  // `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
1025
1025
  const STATECHART_SUBJECT_KW=[];
1026
- // `sequence` (EXPERIMENTAL, 0.4, `SEQUENCE-SOURCE-STANDARD`-R182): THREE, and the array is
1026
+ // `sequence` (EXPERIMENTAL, 0.4, `SEQUENCE-GENRE-VOCABULARY`): THREE, and the array is
1027
1027
  // this genre's whole declaration of what a sequence figure is OF. `state` and
1028
1028
  // `fragment` and `operand` describe referents UML clause 17 defines —
1029
1029
  // `StateInvariant` (§17.12.25), `CombinedFragment` (§17.12.3) and
@@ -1055,6 +1055,23 @@ const FLOWCHART_ROLE_KW=['process','decision','terminator'];
1055
1055
  // only its default drawing, and `shape=` on the same line overrides the
1056
1056
  // drawing without touching the role (§12.7).
1057
1057
  const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
1058
+ // Geometry DERIVED from the NODE DIRECTIVE ITSELF, for a genre whose node has
1059
+ // exactly one kind and therefore no role to map (backlog 52).
1060
+ // `statechart`'s own §Defaults rules "do not encode state-ness in a shape (`SHAPE-ENUM-VOCABULARY`)
1061
+ // — the genre already says it", and until now the engine contradicted it: a
1062
+ // `state` fell through to `box`, so every author who obeyed the rule got
1063
+ // right-angle rectangles that read as ISO 5807 process boxes, and four of the
1064
+ // six shipped statechart figures wrote `shape=rounded` on EVERY state line to
1065
+ // get out of it — twenty-one hand-keyed presentation keys restating one genre
1066
+ // fact. The derivation is the same move `flowchart` already makes through
1067
+ // ROLE_SHAPE, one level up: there the ROLE picks the drawing, here the
1068
+ // DIRECTIVE does, because `state` is the whole model (`GENRE-NODE-SPELLING` — no role is
1069
+ // recorded and none is missing). `SHAPE-ENUM-VOCABULARY` is satisfied for the same reason it is
1070
+ // satisfied for `decision`: the SOURCE says what the thing is, and the shape
1071
+ // is only how that thing happens to be drawn. `shape=` on the same line stays
1072
+ // legal and still overrides the DRAWING ONLY (§12.7), exactly as on a
1073
+ // `decision` — a `state x "S" shape=box` is still a state in the model.
1074
+ const KW_SHAPE={state:'rounded'};
1058
1075
 
1059
1076
  // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: PER-GENRE NODE AND CONNECTOR SPELLINGS.
1060
1077
  // Each scene genre takes the word its own domain uses for the line between
@@ -1082,7 +1099,7 @@ const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'s
1082
1099
  const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition',sequence:'message'};
1083
1100
  const NODE_SPELLINGS=new Set(['node','state','lifeline']);
1084
1101
  const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition','message']);
1085
- // `SEQUENCE-SOURCE-STANDARD`-R182: WHERE EACH GENRE'S NODE ROWS LIVE IN THE PARSED DOC.
1102
+ // `SEQUENCE-GENRE-VOCABULARY`: WHERE EACH GENRE'S NODE ROWS LIVE IN THE PARSED DOC.
1086
1103
  // `GENRE_NODE_KW` above says what the WORD is; this says which `doc`
1087
1104
  // collection the parser puts that word's rows in. The two are separate facts
1088
1105
  // and only one of them is `nodes`: `statechart` renames the word and keeps the
@@ -1091,11 +1108,11 @@ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition','message']);
1091
1108
  // the form "is this id a thing this genre declares" has to ask through here.
1092
1109
  // Hand-writing `doc.nodes` is the defect it closes: the Fill/Delete/Raise/
1093
1110
  // Lower enablement asked `lastDoc.nodes` and so was permanently false under
1094
- // `sequence`, greying out four buttons whose edits (`SEQUENCE-SOURCE-STANDARD`-R182) already worked.
1111
+ // `sequence`, greying out four buttons whose edits (`SEQUENCE-GENRE-VOCABULARY`) already worked.
1095
1112
  const GENRE_NODE_COLL={block:'nodes',topology:'nodes',flowchart:'nodes',
1096
1113
  statechart:'nodes',sequence:'lifelines'};
1097
1114
  const docNodes=(doc)=>(doc&&doc[GENRE_NODE_COLL[doc.genre]||'nodes'])||[];
1098
- // `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that have their own
1115
+ // `SEQUENCE-GENRE-VOCABULARY`: the four `sequence` directives that have their own
1099
1116
  // parser (`message` rides the connector scanner). The set is what dispatches
1100
1117
  // to `parseSeqDirective`, and it is scoped by `doc.genre` at the call site so
1101
1118
  // `state` still reaches `statechart`'s node parser under `statechart`.
@@ -1163,7 +1180,7 @@ const WORD_WHY={
1163
1180
  transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
1164
1181
  node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
1165
1182
  state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)',
1166
- // `SEQUENCE-SOURCE-STANDARD`-R182: both are WHOLE borrows from the genre's source
1183
+ // `SEQUENCE-GENRE-VOCABULARY`: both are WHOLE borrows from the genre's source
1167
1184
  // standard, verified against the clause text rather than against a
1168
1185
  // secondary description (spec/standards-claims.tsv).
1169
1186
  lifeline:'a sequence figure has exactly ONE kind of participant column and it is a LIFELINE — the term UML 2.5.1 §17.3.3.1 defines and §17.3.4.1 draws',
@@ -1312,7 +1329,7 @@ const GENRE_KW={
1312
1329
  // chart is experimental and attaches to a table id in the same document
1313
1330
  table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
1314
1331
  timing:new Set(GENRE_FREE_KW.concat(['class','timing'])),
1315
- // `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` gets its row, and the row is what
1332
+ // `SEQUENCE-GENRE-VOCABULARY`: `sequence` gets its row, and the row is what
1316
1333
  // closes the Batch-1 finding that the genre "states a reading and constrains
1317
1334
  // nothing". It is NOT built on `SCENE_HOST_KW`, and the three absences are
1318
1335
  // each a decision rather than an oversight:
@@ -1356,7 +1373,7 @@ function splitFigdownSections(text){
1356
1373
  return secs;
1357
1374
  }
1358
1375
 
1359
- // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's DERIVED reading, in one
1376
+ // `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's DERIVED reading, in one
1360
1377
  // function so the parser's checks and (from a later increment) the renderer
1361
1378
  // cannot disagree about what a document says.
1362
1379
  //
@@ -1455,7 +1472,7 @@ function parseOne(text){
1455
1472
  const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
1456
1473
  flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
1457
1474
  classes:[],boundaries:[],
1458
- // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` model. FIVE collections,
1475
+ // `SEQUENCE-GENRE-VOCABULARY`: the `sequence` model. FIVE collections,
1459
1476
  // one per keyword, and they are separate arrays rather than
1460
1477
  // aliases of `nodes`/`edges` because a message is not an edge: an
1461
1478
  // edge is a relation between two nodes and has no position, while
@@ -1609,7 +1626,7 @@ function parseOne(text){
1609
1626
  if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
1610
1627
  else err(n,'unknown option "'+u2[0]+'="');
1611
1628
  return; }
1612
- // `SEQUENCE-SOURCE-STANDARD`-R182: `message` adds ONE trailing positional — the
1629
+ // `SEQUENCE-GENRE-VOCABULARY`: `message` adds ONE trailing positional — the
1613
1630
  // quoted message label — to the connector grammar, because that is where
1614
1631
  // every sequence source in the corpus writes it and the `[mid]` form
1615
1632
  // reads as an annotation rather than as the message itself. Every other
@@ -1668,7 +1685,7 @@ function parseOne(text){
1668
1685
  if(!pc.ok){ err(n,pc.err); return; }
1669
1686
  ecls=pc.ids;
1670
1687
  }
1671
- // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` fork. It is the SAME scanner with
1688
+ // `SEQUENCE-GENRE-VOCABULARY`: the `sequence` fork. It is the SAME scanner with
1672
1689
  // a different reading, and the model it writes is a different collection:
1673
1690
  // a message is an OCCURRENCE with a place in the figure's total order
1674
1691
  // (draft §31), an edge is a relation with no position at all.
@@ -1708,7 +1725,7 @@ function parseOne(text){
1708
1725
  plane:o2.plane||'base',line:n});
1709
1726
  }
1710
1727
 
1711
- // `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that are not the
1728
+ // `SEQUENCE-GENRE-VOCABULARY`: the four `sequence` directives that are not the
1712
1729
  // connector. Reached only from the `doc.genre==='sequence'` dispatch, so
1713
1730
  // `state` under `statechart` never arrives here. Every option-VALUE check
1714
1731
  // (colours, `style=` enum, `class=` list, `in=` id spelling, `note=` and
@@ -1870,7 +1887,7 @@ function parseOne(text){
1870
1887
  // the dispatch cannot be narrowed to the genre's own word — a `flowline`
1871
1888
  // under `block` would then fall through to `unrecognized line`, which is
1872
1889
  // exactly the answer these rulings owe an author better than.
1873
- // `SEQUENCE-SOURCE-STANDARD`-R182: `message` is the FOURTH spelling scanned here,
1890
+ // `SEQUENCE-GENRE-VOCABULARY`: `message` is the FOURTH spelling scanned here,
1874
1891
  // and it is derived from `CONNECTOR_SPELLINGS` rather than spelled again,
1875
1892
  // so a fifth genre's connector reaches the named diagnostic by joining
1876
1893
  // that set and not by remembering to edit a regex.
@@ -1916,7 +1933,7 @@ function parseOne(text){
1916
1933
  // Directives not in DIRECTIVE_OPTS (title's single quoted string, unknown
1917
1934
  // keywords) are handled by their own paths.
1918
1935
  const badOpts=(k)=>{
1919
- // `SEQUENCE-SOURCE-STANDARD`-R182: the row is read GENRE-FIRST. `state` is one
1936
+ // `SEQUENCE-GENRE-VOCABULARY`: the row is read GENRE-FIRST. `state` is one
1920
1937
  // spelling with two declarations and two option sets (`GENRE-VOCABULARY-OBLIGATION`), and
1921
1938
  // `GENRE_DIRECTIVE_OPTS` is the only place that fact is recorded.
1922
1939
  const allowed=directiveOpts(k,doc.genre);
@@ -2476,7 +2493,7 @@ function parseOne(text){
2476
2493
  continue;
2477
2494
  }
2478
2495
 
2479
- // `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own directives.
2496
+ // `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's four own directives.
2480
2497
  // Dispatched BEFORE the switch and scoped by `doc.genre`, which is what
2481
2498
  // keeps `state` reaching `statechart`'s node parser under `statechart` —
2482
2499
  // `GENRE-VOCABULARY-OBLIGATION` in the dispatcher, not only in the allowlist.
@@ -2557,8 +2574,10 @@ function parseOne(text){
2557
2574
  // `GENRE-NODE-SPELLING`: `state` joins the same case. It is `node` renamed
2558
2575
  // for one genre, not a role — a statechart node has exactly one kind,
2559
2576
  // so there is no role to record and `role` stays null, exactly as a
2560
- // bare `node` does. The DRAWING is unchanged too (`shape=box` default),
2561
- // which is what let the five corpus figures migrate byte-identically.
2577
+ // bare `node` does. The DRAWING was unchanged too (`shape=box` default),
2578
+ // which is what let the five corpus figures migrate byte-identically;
2579
+ // 0.4 gave `state` its own derived geometry (KW_SHAPE, rounded)
2580
+ // because the box default was making the genre's `SHAPE-ENUM-VOCABULARY` rule unfollowable.
2562
2581
  case 'process': case 'decision': case 'terminator':
2563
2582
  case 'state':
2564
2583
  case 'node': {
@@ -2571,7 +2590,7 @@ function parseOne(text){
2571
2590
  // DRAWING only. §12.7: presentation is not meaning on its own, so
2572
2591
  // `decision q "…" shape=box` is still a decision in the model — the
2573
2592
  // role is the model, the shape is how it happens to be drawn.
2574
- const shape=opts.shape||(role?ROLE_SHAPE[role]:'box');
2593
+ const shape=opts.shape||(role?ROLE_SHAPE[role]:KW_SHAPE[kw]||'box');
2575
2594
  if(RETIRED_SHAPES[shape]){ err(n,RETIRED_SHAPES[shape]); break; }
2576
2595
  if(!SHAPES.includes(shape)){ err(n,'unknown shape "'+shape+'" ('+SHAPES.join('|')+')'); break; }
2577
2596
  if(pos[2]!==undefined&&!posq[2]){ err(n,kw+' label must be quoted: '+kw+' '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
@@ -2984,7 +3003,7 @@ function parseOne(text){
2984
3003
  else if(groupIds.has(e.b)) errs.push('Line '+e.line+': edge endpoint "'+e.b+'" is a group — connect to a member node (group edges are not in v0.1)');
2985
3004
  if(e.plane && !planeIds.has(e.plane)) errs.push('Line '+e.line+': unknown plane "'+e.plane+'"');
2986
3005
  }
2987
- // ── `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's semantic checks ────────
3006
+ // ── `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's semantic checks ────────
2988
3007
  // Everything here needs the WHOLE document, so none of it can live in
2989
3008
  // `parseSeqDirective`: forward references are legal (fixture 021 pins that
2990
3009
  // for the scene genres and the rule is language-wide), so an id can only be
@@ -3033,12 +3052,51 @@ function parseOne(text){
3033
3052
  // is never restated, so a genuine duplicate is always a mistake — and
3034
3053
  // because a transition is DERIVED from an adjacent pair, a reader
3035
3054
  // "tidying duplicates" could otherwise silently delete a fact.
3055
+ //
3056
+ // THE CHECK IS OPERAND-SCOPED (item 35, ruled 2026-08-17).
3057
+ // It used to compare each occurrence with the previous one IN DECLARATION
3058
+ // ORDER and never asked whether the two are reachable together, so an
3059
+ // `alt` whose `granted` operand ends `state c "INIT"` and whose `refused`
3060
+ // operand ends `state c "INIT"` was refused — while AT MOST ONE OF THOSE
3061
+ // TWO EVER HAPPENS, so neither can restate the other. The diagnostic was
3062
+ // right about the sequence and wrong about the figure, and
3063
+ // `examples/sequence/dhcp-lease.fd` carried the fact it could not draw in
3064
+ // a `description=` because of it.
3065
+ //
3066
+ // The scope is the CONTAINER (UML 2.5.1 §17.12.13: a StateInvariant is an
3067
+ // InteractionFragment, and an InteractionOperand contains the fragments
3068
+ // that occur when its branch is taken). So an occurrence is compared with
3069
+ // the most recent occurrence in a scope that ENCLOSES OR IS its own —
3070
+ // itself, its fragment, the document — and never with one in a SIBLING
3071
+ // operand, which is on a path it is exclusive with. Two consequences are
3072
+ // deliberate and are the answer to the "what is the lifeline in after the
3073
+ // fragment" question the item asked: an occurrence in an operand still
3074
+ // restates one written outside it (the outer state is on every path into
3075
+ // the branch), and an occurrence AFTER a fragment is compared with the
3076
+ // last one outside it, because which branch ran is exactly what the
3077
+ // figure does not say. Outside operands nothing changes: the linear
3078
+ // sequence is compared as before.
3079
+ const scopeOwner={};
3080
+ for(const o of doc.operands) scopeOwner[o.id]=o['in']||'';
3081
+ for(const f of doc.fragments) scopeOwner[f.id]=f['in']||'';
3082
+ const chainOf=id=>{
3083
+ const out=[], seen=new Set();
3084
+ let cur=id||'';
3085
+ while(cur&&!seen.has(cur)){ seen.add(cur); out.push(cur); cur=scopeOwner[cur]||''; }
3086
+ out.push('');
3087
+ return out;
3088
+ };
3036
3089
  const lastState={};
3037
3090
  for(const st of doc.states.slice().sort((a,b)=>a.line-b.line)){
3038
- if(lastState[st.ref]===st.name)
3091
+ const chain=chainOf(st['in']);
3092
+ const seen=lastState[st.ref]||(lastState[st.ref]={});
3093
+ let prev=null;
3094
+ for(const sc of chain){ const r=seen[sc]; if(r&&(!prev||r.line>prev.line)) prev=r; }
3095
+ if(prev&&prev.name===st.name)
3039
3096
  errs.push('Line '+st.line+': lifeline "'+st.ref+'" is already in state "'+st.name+
3040
- '" — a state that has not changed is never restated, and a transition is derived from the adjacent pair, so a restatement would assert a transition that did not happen (draft §23.2)');
3041
- lastState[st.ref]=st.name;
3097
+ '" — a state that has not changed is never restated, and a transition is derived from the adjacent pair, so a restatement would assert a transition that did not happen (draft §23.2)'+
3098
+ (doc.operands.length?'. Occurrences in DIFFERENT operands of one fragment are exclusive and do not restate each other; this pair is not that':''));
3099
+ seen[chain[0]]={name:st.name,line:st.line};
3042
3100
  }
3043
3101
  const SM=seqModel(doc);
3044
3102
  // A containment CYCLE is checked before anything that walks the chain, or
@@ -3586,7 +3644,33 @@ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
3586
3644
  // glyph with it is what lets a reader recognise the box as an aside without a
3587
3645
  // legend entry). It is deliberately unlike a `node`: no rounded corners, a
3588
3646
  // paler wash, smaller type, and a corner no node shape has.
3589
- const NOTE_FS=10, NOTE_PAD=6, NOTE_FOLD=9, NOTE_MAXCH=30;
3647
+ // NOTE_MAXW is the note's CORRIDOR: the widest line of note text the box will
3648
+ // hold, in pixels, measured in the type the note is actually SET in.
3649
+ //
3650
+ // It replaces `NOTE_MAXCH=30`, a CHARACTER count, and the character count was
3651
+ // wrong twice over (item 56a). It was width-blind — thirty `i`s and thirty
3652
+ // `W`s are not one measure — and, worse, the box built around it was sized by
3653
+ // `tw()`, which measures at the NODE font (13 px) and adds the NODE padding.
3654
+ // A note is set at 10 px. So a note wrapped at thirty characters was fitted
3655
+ // into a box a third wider than the text it wrapped for: `annotated-datapath`
3656
+ // broke "Only valid while the port is up." across two lines and then drew it
3657
+ // in a 250 px box with 70 px of empty paper on the right. A line that fits
3658
+ // its corridor now stays on one line, and the box is the text's own width.
3659
+ //
3660
+ // 230 px is chosen to KEEP the box the old rule produced (230 + padding +
3661
+ // fold = 251 px, the width every wide note in the corpus already draws at)
3662
+ // and make the wrap actually mean it. Measured over the whole corpus: the two
3663
+ // short notes lose 60 px of empty box each, "Only valid while the port is up."
3664
+ // goes from two lines to one, and the figure-level note from three to two.
3665
+ const NOTE_FS=10, NOTE_PAD=6, NOTE_FOLD=9, NOTE_MAXW=230;
3666
+ // The note's own per-unit advance: `CH` is calibrated for FONT (13 px), and a
3667
+ // note is set at NOTE_FS. One constant, so the wrap and the box cannot
3668
+ // disagree about how wide a line is.
3669
+ const NOTE_CH=CH*NOTE_FS/FONT;
3670
+ // A bold run is wider than the same glyphs regular. 6% is the usual figure for
3671
+ // a synthesised/duplexed weight and is charged so the target-label prefix
3672
+ // (item 56b) cannot overflow the corridor it was measured against.
3673
+ const NOTE_BOLD=1.06;
3590
3674
  const NOTE_FILL='#fdfaf0', NOTE_STROKE='#c9c4b2', NOTE_INK='#5c584c';
3591
3675
  // The leader is deliberately DARKER than the box outline. Drawn in the box's
3592
3676
  // own stroke it was legible in the SVG and invisible on the page at 1x — a
@@ -3598,24 +3682,46 @@ const NOTE_LEADER='#9c968a';
3598
3682
  // Deterministic greedy word wrap. Author newlines are honoured and never
3599
3683
  // merged; a run longer than the wrap width is broken only between words, so a
3600
3684
  // long identifier keeps its shape and simply widens the box.
3601
- function noteWrap(text){
3602
- const out=[];
3603
- for(const para of String(text).split('\n')){
3604
- const words=para.split(/ +/).filter(w=>w.length);
3605
- if(!words.length){ out.push(''); continue; }
3606
- let cur=words[0];
3607
- for(let i=1;i<words.length;i++){
3608
- if((cur+' '+words[i]).length<=NOTE_MAXCH) cur+=' '+words[i];
3609
- else { out.push(cur); cur=words[i]; }
3610
- }
3611
- out.push(cur);
3685
+ // A wrapped line is a list of RUNS — {t: text, b: bold} — because a note may
3686
+ // open with its target's label in bold (item 56b) and the prefix can wrap into
3687
+ // the body like any other words. A note with no prefix is one regular run per
3688
+ // line, which is what every note was before.
3689
+ function noteRunW(r){ return cw(r.t)*NOTE_CH*(r.b?NOTE_BOLD:1); }
3690
+ function noteLineW(runs){ return runs.reduce((a,r)=>a+noteRunW(r),0); }
3691
+ // Deterministic greedy word wrap, measured in PIXELS against NOTE_MAXW.
3692
+ // Author newlines are honoured and never merged; a single word wider than the
3693
+ // corridor is never broken, so a long identifier keeps its shape and simply
3694
+ // widens the box.
3695
+ function noteWrap(text,prefix){
3696
+ const words=[];
3697
+ if(prefix) for(const w of String(prefix).split(/ +/).filter(w=>w.length)) words.push({t:w,b:true,br:false});
3698
+ const paras=String(text).split('\n');
3699
+ paras.forEach((para,pi)=>{
3700
+ const ws=para.split(/ +/).filter(w=>w.length);
3701
+ if(!ws.length){ words.push({t:'',b:false,br:pi>0}); return; }
3702
+ ws.forEach((w,wi)=>words.push({t:w,b:false,br:pi>0&&wi===0}));
3703
+ });
3704
+ const SPW=cw(' ')*NOTE_CH;
3705
+ const out=[]; let cur=[], curW=0;
3706
+ const push=()=>{ out.push(cur.length?cur:[{t:'',b:false}]); cur=[]; curW=0; };
3707
+ for(const w of words){
3708
+ const ww=cw(w.t)*NOTE_CH*(w.b?NOTE_BOLD:1);
3709
+ if(w.br&&cur.length) push();
3710
+ const sp=cur.length?SPW:0;
3711
+ if(cur.length&&curW+sp+ww>NOTE_MAXW){ push(); }
3712
+ const gap=cur.length?' ':'';
3713
+ const last=cur[cur.length-1];
3714
+ if(last&&last.b===w.b) last.t+=gap+w.t;
3715
+ else cur.push({t:gap+w.t,b:w.b});
3716
+ curW+=(gap?SPW:0)+ww;
3612
3717
  }
3718
+ push();
3613
3719
  return out;
3614
3720
  }
3615
- function noteBox(text){
3616
- const lines=noteWrap(text);
3721
+ function noteBox(text,prefix){
3722
+ const lines=noteWrap(text,prefix);
3617
3723
  const lh=NOTE_FS*1.35;
3618
- const w=Math.max(28, Math.max.apply(null,lines.map(tw))+NOTE_PAD*2+NOTE_FOLD);
3724
+ const w=Math.max(28, Math.max.apply(null,lines.map(noteLineW))+NOTE_PAD*2+NOTE_FOLD);
3619
3725
  const h=lines.length*lh+NOTE_PAD*2-lh*0.15;
3620
3726
  return {w:Math.round(w*100)/100, h:Math.round(h*100)/100, lines, lh};
3621
3727
  }
@@ -3626,13 +3732,51 @@ function noteSvg(x,y,box,carrier){
3626
3732
  const F=NOTE_FOLD, w=box.w, h=box.h;
3627
3733
  const d='M'+x+' '+y+' H'+(x+w-F)+' L'+(x+w)+' '+(y+F)+' V'+(y+h)+' H'+x+' Z';
3628
3734
  const fold='M'+(x+w-F)+' '+y+' V'+(y+F)+' H'+(x+w)+' Z';
3629
- const out=['<g class="fd-note"'+(carrier&&carrier.kind?' data-note-on="'+carrier.kind+'"':'')+'>',
3735
+ // `data-note-on` names the KIND; `data-note-for` names the TARGET (item 40b).
3736
+ // Without the second attribute a machine reader holding only the drawing
3737
+ // cannot recover WHICH element a note is about — it can see that a note is
3738
+ // about "a group" and no more — so the association the source states was
3739
+ // lost on the way to the page even when the placement was perfect. The pair
3740
+ // (kind, ref) is unambiguous: `ref` is the element's identity in this very
3741
+ // drawing — a `data-node` id, a `data-group` id, or the `data-edge` line
3742
+ // number a connector is keyed by, since a connector has no id to carry.
3743
+ // `title` is the one kind with no ref and needs none: it names the FIGURE,
3744
+ // and there is exactly one. The attribute changes no pixel.
3745
+ const ref=carrier&&carrier.ref!==undefined&&carrier.ref!==null?' data-note-for="'+esc(String(carrier.ref))+'"':'';
3746
+ const out=['<g class="fd-note"'+(carrier&&carrier.kind?' data-note-on="'+carrier.kind+'"':'')+ref+'>',
3630
3747
  '<path d="'+d+'" fill="'+NOTE_FILL+'" stroke="'+NOTE_STROKE+'" stroke-width="1"/>',
3631
3748
  '<path d="'+fold+'" fill="'+NOTE_STROKE+'" fill-opacity="0.35" stroke="'+NOTE_STROKE+'" stroke-width="1"/>'];
3632
3749
  const first=y+NOTE_PAD+NOTE_FS*0.85;
3750
+ // A line is a list of RUNS (item 56b): the target-label prefix is bold, the
3751
+ // note's own words are regular. One `<text>` per line either way, so a note
3752
+ // with no prefix emits exactly what it always did but for the tspan wrapper
3753
+ // — and a single regular run is written bare, so those notes are unchanged
3754
+ // byte for byte.
3633
3755
  box.lines.forEach((ln,i)=>{
3634
- out.push('<text x="'+(x+NOTE_PAD)+'" y="'+Math.round((first+i*box.lh)*100)/100+
3635
- '" font-size="'+NOTE_FS+'" text-anchor="start" fill="'+NOTE_INK+'">'+esc(ln)+'</text>');
3756
+ const yy=Math.round((first+i*box.lh)*100)/100;
3757
+ const runs=Array.isArray(ln)?ln:[{t:ln,b:false}];
3758
+ let body;
3759
+ if(runs.length===1&&!runs[0].b) body=esc(runs[0].t);
3760
+ else {
3761
+ // EVERY RUN CARRIES ITS OWN `x`. A bare `<tspan>` is supposed to
3762
+ // continue where the last one stopped, and browsers do that — but a
3763
+ // note is an ARTIFACT that outlives this engine and gets rasterised by
3764
+ // whatever the reader has. Two library rasterisers restart each tspan
3765
+ // at the parent's `x`, which draws the prefix and the sentence on top
3766
+ // of each other. The advance is already measured (the wrap measured it
3767
+ // to decide this line), so it is written down rather than inferred.
3768
+ let off=0; const parts=[];
3769
+ for(const r of runs){
3770
+ const lead=/^ /.test(r.t)?1:0, t=lead?r.t.slice(1):r.t;
3771
+ off+=lead*cw(' ')*NOTE_CH;
3772
+ parts.push('<tspan x="'+Math.round((x+NOTE_PAD+off)*100)/100+'" y="'+yy+'"'+
3773
+ (r.b?' font-weight="600"':'')+'>'+esc(t)+'</tspan>');
3774
+ off+=cw(t)*NOTE_CH*(r.b?NOTE_BOLD:1);
3775
+ }
3776
+ body=parts.join('');
3777
+ }
3778
+ out.push('<text x="'+(x+NOTE_PAD)+'" y="'+yy+
3779
+ '" font-size="'+NOTE_FS+'" text-anchor="start" fill="'+NOTE_INK+'">'+body+'</text>');
3636
3780
  });
3637
3781
  out.push('</g>');
3638
3782
  return out.join('');
@@ -3640,7 +3784,7 @@ function noteSvg(x,y,box,carrier){
3640
3784
  // The leader is drawn ONLY when adjacency failed, and it is drawn AFTER the
3641
3785
  // box is placed, so it is correct by construction: it runs from the box edge
3642
3786
  // facing the carrier to the carrier's nearest point, and cannot be stale.
3643
- function noteLeader(best,box,rect){
3787
+ function noteLeaderPts(best,box,rect){
3644
3788
  const bx=best.x, by=best.y, bw=box.w, bh=box.h;
3645
3789
  const cx=rect.x+rect.w/2, cy=rect.y+rect.h/2;
3646
3790
  let x1,y1;
@@ -3652,7 +3796,15 @@ function noteLeader(best,box,rect){
3652
3796
  // thing it points at
3653
3797
  const x2=Math.max(rect.x, Math.min(rect.x+rect.w, x1));
3654
3798
  const y2=Math.max(rect.y, Math.min(rect.y+rect.h, y1));
3655
- return '<line x1="'+x1+'" y1="'+y1+'" x2="'+(rect.w||rect.h?x2:cx)+'" y2="'+(rect.w||rect.h?y2:cy)+
3799
+ return [[x1,y1],[rect.w||rect.h?x2:cx, rect.w||rect.h?y2:cy]];
3800
+ }
3801
+ // A LEADER IS INK AND A LATER NOTE MUST YIELD TO IT (item 40). The pass hands
3802
+ // the drawn segment back so the notes placed after this one are charged for
3803
+ // crossing it — a leader covered by the next note's box is a leader that is
3804
+ // not there, which is the same defect the LEADER's own colour was fixed for.
3805
+ function noteLeader(best,box,rect){
3806
+ const [p,q]=noteLeaderPts(best,box,rect);
3807
+ return '<line x1="'+p[0]+'" y1="'+p[1]+'" x2="'+q[0]+'" y2="'+q[1]+
3656
3808
  '" stroke="'+NOTE_LEADER+'" stroke-width="1" stroke-dasharray="4 3"/>';
3657
3809
  }
3658
3810
  // A band's optional edge stroke. `renderScene` has had this as a local since
@@ -4179,6 +4331,22 @@ function renderScene(doc,y0){
4179
4331
  // decided after, and a figure that suppresses and then declines draws
4180
4332
  // straight through its own boxes (bfd-session: score 30 -> 35 with a new
4181
4333
  // `thru`). That belongs with item 27's ordering change, priced.
4334
+ // THE ADMISSIBILITY KEY, WRITTEN OUT (item 60). Members share a trunk only
4335
+ // if they share the target, the label, the stroke, the dash AND THE CLASS
4336
+ // SET. The first four were always tested; the class was implied by them,
4337
+ // because the class cascade has already resolved `stroke=` and `style=` onto
4338
+ // the edge by the time this runs — but "implied" is not "stated", and two
4339
+ // classes that happen to paint alike are still two meanings. So the key says
4340
+ // it. Same class (or all unclassed with identical labels) → admissible;
4341
+ // MIXED CLASSES → refused, which is item 46's refusal and the reason
4342
+ // `bfd-session`'s three returns into DOWN (advance/fail/admin, three
4343
+ // different sentences) stay three lanes while its three `admin disable`
4344
+ // transitions become one trunk. Narrowing only: no group admitted before is
4345
+ // admitted less.
4346
+ const clsKey=x=>{
4347
+ const ids=(x.cls===undefined||x.cls===null)?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
4348
+ return ids.slice().sort().join(',');
4349
+ };
4182
4350
  const busGroups=[];
4183
4351
  {
4184
4352
  const g=new Map();
@@ -4187,11 +4355,52 @@ function renderScene(doc,y0){
4187
4355
  if(!A||!B||e.a===e.b||isBack.has(e)) continue;
4188
4356
  if(e.op!=='->'||e.tail||e.head) continue;
4189
4357
  if(pinned(e.a)||pinned(e.b)||B.rank<=A.rank) continue;
4190
- const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'');
4358
+ const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'')+' '+clsKey(e);
4191
4359
  if(!g.has(k)) g.set(k,[]); g.get(k).push(e);
4192
4360
  }
4193
4361
  for(const [,m] of g) if(m.length>=3) busGroups.push(m);
4194
4362
  }
4363
+ // ── RETURN MERGE BUS (item 46, and item 26's >=3 rule reaching the RETURNS) ──
4364
+ // The forward bus (above) collapses three or more arrows arriving at one
4365
+ // target into one trunk. The same statement is made just as often by RETURN
4366
+ // edges — N states falling back to one — and the forward construct cannot
4367
+ // reach them, because a return is drawn by the side-channel plan and not by
4368
+ // the straight router (item 26 recorded `patterns/state-b`'s four `reset`
4369
+ // edges as exactly this decline). `state-b` draws four CONCENTRIC RINGS, each
4370
+ // labelled "reset", 818 px wide for a 500 px figure: four copies of one
4371
+ // sentence.
4372
+ //
4373
+ // A return bus is the ring stack COLLAPSED ONTO ITS INNERMOST RING — not a
4374
+ // new geometry. Every member drops from its own source to the shared rail,
4375
+ // the rail and the trunk and the entry point are the innermost member's, one
4376
+ // arrowhead lands on the target and one label rides the shared run. The
4377
+ // members' own drops stay distinct and carry the junction dots, so the reader
4378
+ // still counts the lines. Because the collapsed route is a route the
4379
+ // innermost member already drew, no member can gain a crossing the figure did
4380
+ // not already have — which is why this construct needs no adoption test where
4381
+ // the forward rail (a NEW line across the figure) does.
4382
+ //
4383
+ // The admission rule is the forward bus's, unchanged, because the reasons are
4384
+ // the same: ONE label (a trunk draws one, so members that say different things
4385
+ // may not share it), ONE stroke and ONE dash (the trunk is stroked once per
4386
+ // member; three colours on one line is three claims about one stroke, and in a
4387
+ // classed figure those colours carry the legend's meaning), no endpoint
4388
+ // labels, distinct sources. Measured over the corpus, exactly one group
4389
+ // qualifies and two are refused for stated reasons — see the DONE note on 46.
4390
+ const retBusGroups=[];
4391
+ {
4392
+ const g=new Map();
4393
+ for(const e of doc.edges){
4394
+ const A=byId[e.a], B=byId[e.b];
4395
+ if(!A||!B||e.a===e.b||!isBack.has(e)) continue;
4396
+ if(e.op!=='->'||e.tail||e.head) continue;
4397
+ if(pinned(e.a)||pinned(e.b)) continue; // the channel plan skips those
4398
+ const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'')+' '+clsKey(e);
4399
+ if(!g.has(k)) g.set(k,[]); g.get(k).push(e);
4400
+ }
4401
+ for(const [,m] of g)
4402
+ if(m.length>=3&&new Set(m.map(e=>e.a)).size===m.length) retBusGroups.push(m);
4403
+ }
4195
4404
  for(const e of doc.edges){
4196
4405
  const A=byId[e.a], B=byId[e.b];
4197
4406
  if(!A||!B||isBack.has(e)) continue;
@@ -4500,6 +4709,165 @@ function renderScene(doc,y0){
4500
4709
  if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
4501
4710
  else { n.x=p.fx; n.y=y0+20+p.fy; }
4502
4711
  }
4712
+ // ── CORRIDOR BUDGETING (item 44) ─────────────────────────────────────────
4713
+ // A SEAM IS ASKED WHAT LIVES IN IT. Until this pass, every gap in a scene —
4714
+ // between two ranks, between two neighbours in one rank — was derived from
4715
+ // NODE GEOMETRY ALONE: `GAPX`/`GAPY` plus, for horizontal flow only, the
4716
+ // width of a mid label that rides on the edge. Nothing else in the drawing
4717
+ // was ever asked for its area. But a gap is where a figure keeps most of its
4718
+ // non-node ink: endpoint labels, bundle lassos and their captions, and the
4719
+ // padded band a group draws around its members (whose TOP strip, `BAND.t`,
4720
+ // exists precisely to hold the group's name). The reference topology figure
4721
+ // is what that blindness costs — measured on the 0.4 artifact, the
4722
+ // spine→leaf corridor was 4 px of clearance asked to house three lassos
4723
+ // (2 x ry 26 each), eight endpoint labels, two bundle captions and the
4724
+ // `Leaf` band's whole label strip; the l1—l2 gap was 56 px asked to house a
4725
+ // 92 px-wide lasso, which therefore ate both node boxes.
4726
+ //
4727
+ // THE MODEL. For each seam the demand is derived FIRST, from ink alone —
4728
+ // no coordinate is read to compute it:
4729
+ // need = max( bandGap , max(lasso, endpointLabels) + nameStrip )
4730
+ // `bandGap` is what the two sides' group BANDS need of each other: the
4731
+ // padding each projects into the seam plus `SEP_BAND` of white, so two bands
4732
+ // read as two (nothing when one group spans the seam — its band covers the
4733
+ // seam anyway). `lasso` is the breadth a bundle ring needs to cross the seam
4734
+ // without reaching a node box, from the ring's own MINIMUM radii.
4735
+ // `endpointLabels` is the tail/head labels an edge through the seam must
4736
+ // stack. `nameStrip` is the ONE part of a band that is ink rather than
4737
+ // backdrop — the strip carrying the group's NAME.
4738
+ //
4739
+ // WHY MAX AND NOT SUM. A band is a BACKDROP: every figure in this corpus
4740
+ // draws edges across one, and a lasso crossing a band's fill is ordinary
4741
+ // layering, not a collision. So the band requirement and the free-ink
4742
+ // requirement are two claims on the same span and the binding one is the
4743
+ // larger — only the band's NAME is ink the free ink must clear, and that is
4744
+ // the term which adds. Summing them was built and measured first: it put the
4745
+ // reference figure's corridor at 108 px, which then DETACHED the endpoint
4746
+ // labels from their ports (they are anchored at a FRACTION of edge length —
4747
+ // item 42 — so they drift as the seam deepens) and cost four new F5
4748
+ // label-association defects on a figure that had none. A seam wide enough to
4749
+ // break the ink it was widened for is the wrong answer; item 42's fixed-
4750
+ // distance anchoring is what would lift that ceiling.
4751
+ //
4752
+ // IT IS APPLIED TO THE REALISED SEAM, NOT TO `mainGap`. A pre-coordinate
4753
+ // widening cannot see a PIN: pins are absolute canvas coordinates, applied
4754
+ // above, and the reference figure's spine pin spends 40 px of its own rank's
4755
+ // gap — a `mainGap` bumped to 108 would have produced a 68 px corridor and
4756
+ // reported success. So the demand is measured against the geometry that
4757
+ // exists, here, after the pins have had their say.
4758
+ //
4759
+ // THREE THINGS IT DOES NOT DO, ON PURPOSE. It never moves a PINNED node
4760
+ // (the group-band precedent: the coordinate is the author's word). It never
4761
+ // NARROWS a seam — an adequate seam is left bit-for-bit alone, which is why
4762
+ // 31 of the corpus's 34 scene figures are byte-identical across this change.
4763
+ // And it changes no ROUTE and no SHAPE: edge paths, lasso radii and label
4764
+ // anchors are all computed downstream from these coordinates and are
4765
+ // untouched — they simply get room. The lasso that is still drawn as a
4766
+ // near-circle regardless of its members' direction is item 43; the endpoint
4767
+ // label still anchored by fraction rather than distance is item 42; the note
4768
+ // and element-label association frontiers are items 40 and 45. This pass
4769
+ // buys those fixes the space they need and deliberately leaves them open.
4770
+ //
4771
+ // WHERE IT DECLINES. If two ranks OVERLAP on the main axis — the author
4772
+ // pinned them into each other, or into a different order than the ranking —
4773
+ // there is no seam to budget and the pass says nothing. 39 of the corpus's
4774
+ // 204 seams are that shape, all of them in substantially pinned scenes.
4775
+ {
4776
+ const SEP_BAND=6, // white left between two distinct group bands
4777
+ LASSO_CLEAR=6, // a bundle ring's clearance from a node box
4778
+ RY_MIN=26, RX_MIN=46, // the ring's own floor radii (see doc.trunks)
4779
+ EPL_H=13, EPL_PAD=4; // an endpoint label's line box and its gutter
4780
+ const mLo=n=>horiz?n.x:n.y, mSz=n=>horiz?n.w:n.h;
4781
+ const cLo=n=>horiz?n.y:n.x, cSz=n=>horiz?n.h:n.w;
4782
+ const mvM=(n,d)=>{ if(horiz) n.x+=d; else n.y+=d; };
4783
+ const mvC=(n,d)=>{ if(horiz) n.y+=d; else n.x+=d; };
4784
+ const ranked=nodes.filter(n=>!n.boundary&&n.rank!==undefined);
4785
+ const byRank=new Map();
4786
+ for(const n of ranked){ if(!byRank.has(n.rank)) byRank.set(n.rank,[]); byRank.get(n.rank).push(n); }
4787
+ // Ranks are ordered by where they ACTUALLY sit, not by their index: `flow
4788
+ // up`/`flow left` mirror the main axis after placement, so rank order and
4789
+ // coordinate order are opposite there, and a seam is a fact about the
4790
+ // drawing.
4791
+ const rMid=r=>{ const a=byRank.get(r); return a.reduce((s,n)=>s+mLo(n)+mSz(n)/2,0)/a.length; };
4792
+ const rks=[...byRank.keys()].sort((a,b)=>rMid(a)-rMid(b));
4793
+ // A group's rank span, so a band that runs THROUGH a seam asks for nothing.
4794
+ const gSpan={};
4795
+ for(const g of doc.groups){
4796
+ const m=ranked.filter(n=>n.group===g.id); if(!m.length) continue;
4797
+ gSpan[g.id]={lo:Math.min(...m.map(n=>n.rank)), hi:Math.max(...m.map(n=>n.rank))};
4798
+ }
4799
+ const lblW=s=>(s===null||s===undefined||s==='')?0:lblPx(s);
4800
+ // ── main axis: rank against rank ──────────────────────────────────────
4801
+ for(let k=0;k+1<rks.length;k++){
4802
+ const i=rks[k], j=rks[k+1];
4803
+ const hiI=Math.max(...byRank.get(i).map(n=>mLo(n)+mSz(n)));
4804
+ const loJ=Math.min(...byRank.get(j).map(n=>mLo(n)));
4805
+ const actual=loJ-hiI;
4806
+ if(actual<0) continue; // ranks interleave: not a seam
4807
+ let bA=0,bB=0;
4808
+ for(const id in gSpan){ const s=gSpan[id];
4809
+ if(s.lo<=i&&s.hi>=j) continue; // the band spans the seam itself
4810
+ if(s.hi===i) bA=Math.max(bA,horiz?BAND.r:BAND.b);
4811
+ if(s.lo===j) bB=Math.max(bB,horiz?BAND.l:BAND.t);
4812
+ }
4813
+ const bandGap=bA+bB+((bA&&bB)?SEP_BAND:0);
4814
+ // The one piece of band padding that is INK rather than backdrop: under
4815
+ // vertical flow the lower group's `BAND.t` strip is where its NAME is
4816
+ // written, and a lasso or a label drawn through it lands on that name.
4817
+ // Under horizontal flow the name strip is on the cross axis, so a
4818
+ // main-axis seam does not contain it and the guard is nothing.
4819
+ const guard=horiz?0:bB;
4820
+ const spans=(a,b)=>{ const A=byId[a],B=byId[b];
4821
+ return A&&B&&A.rank!==undefined&&B.rank!==undefined
4822
+ &&Math.min(A.rank,B.rank)<=i&&Math.max(A.rank,B.rank)>=j; };
4823
+ // A ONE-MEMBER bundle draws no ring (item 53), so it asks the seam for
4824
+ // no ring breadth. Its caption is placed by the collision-aware label
4825
+ // pass, which can use any white the figure already has.
4826
+ let lasso=0;
4827
+ for(const t of doc.trunks||[]) if(t.pairs.length>1) for(const [a,b] of t.pairs) if(spans(a,b))
4828
+ lasso=Math.max(lasso, 2*(horiz?RX_MIN:RY_MIN)+2*LASSO_CLEAR,
4829
+ horiz?lblW(t.label)+2*LASSO_CLEAR:0);
4830
+ let epl=0;
4831
+ for(const e of doc.edges){ if(!spans(e.a,e.b)) continue;
4832
+ const c=(e.tail?1:0)+(e.head?1:0); if(!c) continue;
4833
+ epl=Math.max(epl, c*EPL_H+(c+1)*EPL_PAD); }
4834
+ const d=Math.max(bandGap, Math.max(lasso,epl)+guard)-actual;
4835
+ if(d<=0) continue;
4836
+ // Everything at or beyond the far lip travels; a pinned node holds its
4837
+ // ground. The movers are selected by POSITION, not by rank membership:
4838
+ // boundary anchors and long-edge waypoints have ranks of their own that
4839
+ // may hold no drawn node at all, and selecting by rank left the
4840
+ // reference figure's `WAN` anchor behind while the leaf rank it hangs
4841
+ // off moved 86 px away — the external's label then landed on the leaf
4842
+ // group's name. `actual >= 0` guarantees no near-side node is past the
4843
+ // lip, so this cannot pick up the rank the seam is measured from.
4844
+ for(const n of lay) if(mLo(n)>=loJ-0.001&&!(n.id&&pinned(n.id))) mvM(n,d);
4845
+ }
4846
+ // ── cross axis: neighbour against neighbour inside one rank ───────────
4847
+ for(const r of rks){
4848
+ const lane=byRank.get(r).slice().sort((a,b)=>cLo(a)-cLo(b));
4849
+ for(let q=0;q+1<lane.length;q++){
4850
+ const A=lane[q], B=lane[q+1];
4851
+ const actual=cLo(B)-(cLo(A)+cSz(A));
4852
+ if(actual<0) continue;
4853
+ let bandGap=0, guard=0;
4854
+ if(A.group!==B.group){
4855
+ const pa=A.group?(horiz?BAND.b:BAND.r):0, pb=B.group?(horiz?BAND.t:BAND.l):0;
4856
+ bandGap=pa+pb+((pa&&pb)?SEP_BAND:0);
4857
+ guard=horiz?pb:0; // the name strip, on the cross axis this time
4858
+ }
4859
+ let lasso=0;
4860
+ for(const t of doc.trunks||[]) if(t.pairs.length>1) for(const [a,b] of t.pairs)
4861
+ if((a===A.id&&b===B.id)||(a===B.id&&b===A.id))
4862
+ lasso=Math.max(lasso, 2*(horiz?RY_MIN:RX_MIN)+2*LASSO_CLEAR,
4863
+ horiz?0:lblW(t.label)+2*LASSO_CLEAR);
4864
+ const d=Math.max(bandGap, lasso?lasso+guard:0)-actual;
4865
+ if(d<=0) continue;
4866
+ const from=cLo(B);
4867
+ for(const n of lay) if(n.rank===r&&cLo(n)>=from&&!(n.id&&pinned(n.id))) mvC(n,d);
4868
+ }
4869
+ }
4870
+ }
4503
4871
  // ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
4504
4872
  // A group's band is the BOUNDING BOX of its members (see gBox below), and
4505
4873
  // until this pass nothing checked that the box contained only members. A
@@ -4651,6 +5019,55 @@ function renderScene(doc,y0){
4651
5019
  if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
4652
5020
  y:Math.min(...mem.map(n=>n.y))};
4653
5021
  }
5022
+ // ── A NODE THAT COVERS ANOTHER COMPLETELY IS A FALSE DRAWING (item 47b) ──
5023
+ // The group-band precedent, one construct over. There the rule is: whoever
5024
+ // chose the position bears responsibility, and an author pin that forces a
5025
+ // drawing to state something the source does not is a GEOMETRY-TIME error,
5026
+ // not a lint number. `examples/rpf-check.fd` is the case that asked the
5027
+ // question: `pin drop at=(-30,0)` aged against a moving layout until
5028
+ // `Drop (RPF fail)` sat entirely ON `Accept and forward` — PIN ROT — and the
5029
+ // covered node was not merely crowded but INVISIBLE, which is the line
5030
+ // between "ugly is not an error" and "wrong is". PARTIAL overlap stays on
5031
+ // the `novlp` axis and is never an error: two boxes that clip each other are
5032
+ // ugly and both readable, and drawing tools let them.
5033
+ //
5034
+ // THE MEASUREMENT NARROWED THE RULE, AND THE NARROWING IS THE FINDING.
5035
+ // Over the corpus (59 figures, 231 nodes, 1640 ordered node pairs) full
5036
+ // containment occurs 28 times and every one of them is `examples/rainbow.fd`
5037
+ // — seven concentric pinned ellipses around a pinned core, the figure whose
5038
+ // entire subject is that DOCUMENT ORDER IS PAINT ORDER (`PAINT-ORDER-CONSTRUCT`). So the
5039
+ // premise a blanket rule would rest on ("if the cover is intentional the
5040
+ // figure cannot state it — there is no z-order construct") is FALSE in this
5041
+ // language: line order is that construct, and this corpus ships its
5042
+ // reference figure. What distinguishes rainbow from rpf-check is not the
5043
+ // geometry, which is identical, but WHO CHOSE IT: rainbow's covered node is
5044
+ // pinned by the author on its own line, while rpf-check's was placed by the
5045
+ // engine and drifted underneath. So the error fires when the cover is not
5046
+ // wholly the author's: at least one of the two boxes is at a coordinate the
5047
+ // author never wrote. Both pinned = an author statement in the one paint
5048
+ // order the language has, and it is drawn.
5049
+ {
5050
+ const real=nodes.filter(n=>!n.boundary&&n.w>0&&n.h>0);
5051
+ const inside=(a,b)=>a.x>=b.x-0.01&&a.y>=b.y-0.01
5052
+ &&a.x+a.w<=b.x+b.w+0.01&&a.y+a.h<=b.y+b.h+0.01;
5053
+ const covered=new Set();
5054
+ for(const a of real) for(const b of real){
5055
+ if(a===b||covered.has(a.id+' '+b.id)) continue;
5056
+ if(!inside(a,b)) continue;
5057
+ if(pinned(a.id)&&pinned(b.id)) continue; // both coordinates are the author's
5058
+ covered.add(a.id+' '+b.id);
5059
+ const who=pinned(b.id)?b.id:(pinned(a.id)?a.id:null);
5060
+ const ln=who!==null?srcLine(doc.pins[who].line):srcLine(a.line);
5061
+ gErrs.push('Line '+ln+': '+(who!==null?'pin puts':'the layout puts')+' "'+b.id+'" completely over "'
5062
+ +a.id+'" — a node drawn entirely inside another is invisible, so the figure states that "'
5063
+ +a.id+'" is not there. '+(who!==null
5064
+ ? 'Move the pin off "'+a.id+'", or pin "'+a.id+'" too if the cover is deliberate — '
5065
+ +'document order is paint order (`PAINT-ORDER-CONSTRUCT`), so two author-placed boxes may overlap however '
5066
+ +'the author says.'
5067
+ : 'Pin one of them clear of the other.')
5068
+ +' Partial overlap is not this error; only a complete cover is.');
5069
+ }
5070
+ }
4654
5071
  // Boundary adjacency in pinned scenes (presentation-only): auto-layout ranks
4655
5072
  // a degree-1 boundary relative to the free lanes, so in a scene where the
4656
5073
  // real content is pinned to a compact box the boundary can drift to a far
@@ -4723,9 +5140,83 @@ function renderScene(doc,y0){
4723
5140
  // drawn as a full concentric ring — drop row, channel, return row and hub
4724
5141
  // entry nested in the same order, entering the hub's top edge on the
4725
5142
  // channel side — so fan-in hubs (N states -> IDLE reset) have no crossings.
5143
+ // ── A RECIPROCAL PAIR RUNS ANTIPARALLEL-ADJACENT (item 58) ───────────────
5144
+ // `A -> B` and `B -> A` between one node pair are ONE EXCHANGE: request
5145
+ // beside reply, and reading it means reading them together. The engine
5146
+ // already had the drawing — `apOff` below fans a same-pair straight group
5147
+ // onto opposite sides of its own centre line, and the label pass puts each
5148
+ // label on the OUTER flank of its own stroke — but it could not reach the
5149
+ // common case, because the second direction of any pair CLOSES A CYCLE and
5150
+ // is therefore a back edge, which the channel plan takes and routes the
5151
+ // long way round the figure. `showcase/arp-resolution` is the case: the ARP
5152
+ // reply left Host B, ran down under the whole figure and climbed back into
5153
+ // Host A, 236 px of detour to say the thing the request says in 100.
5154
+ //
5155
+ // So a reciprocal partner is EXEMPTED FROM THE CHANNEL PLAN and drawn
5156
+ // straight, which puts both members into `apOff` and gives the pair its
5157
+ // two adjacent lines. Note what is NOT touched: `isBack` itself. That set
5158
+ // ranks the figure — it is what breaks the cycle so the layers can be
5159
+ // computed at all — and a rank DAG with the cycle put back has no layering.
5160
+ // This is a ROUTING exemption and nothing else, so it is a second set that
5161
+ // the three routing decisions consult and the ranking never sees.
5162
+ //
5163
+ // THE PAIR MUST BE EXACTLY A PAIR: one edge each way, neither a self-loop,
5164
+ // and the forward member drawn straight itself (a multi-rank forward edge
5165
+ // rides a waypoint chain, and a chain has no single centre line to fan a
5166
+ // partner about). Anything else is a different figure and keeps the routing
5167
+ // it had.
5168
+ const recip=new Set();
5169
+ {
5170
+ const dir=new Map();
5171
+ for(const e of doc.edges){
5172
+ if(!byId[e.a]||!byId[e.b]||e.a===e.b) continue;
5173
+ const k=e.a+'\t'+e.b;
5174
+ if(!dir.has(k)) dir.set(k,[]); dir.get(k).push(e);
5175
+ }
5176
+ // A MERGE BUS OUTRANKS A PAIR. `state_a -> idle` is BOTH the return
5177
+ // half of a reciprocal pair AND one of four returns that item 46 merges
5178
+ // into one trunk, and only one of the two drawings can be on the page.
5179
+ // The bus wins, on its own argument: it says something about FOUR edges
5180
+ // ("all of these fall back to one place") that the pairwise reading
5181
+ // cannot say, and `patterns/state-b` measured 397 ink/edge as four
5182
+ // concentric rings against 101 as four straight lines — but the four
5183
+ // straight lines are four separate statements where the figure makes
5184
+ // one. Taking the pair here would also have silently deleted item 54's
5185
+ // acceptance figure.
5186
+ const inBus=new Set();
5187
+ for(const m of busGroups) for(const e of m) inBus.add(e);
5188
+ for(const m of retBusGroups) for(const e of m) inBus.add(e);
5189
+ // EACH LABEL MUST FIT BESIDE ITS OWN LINE. This is not a nicety bolted
5190
+ // on: it is the second half of the direction ("labels on outer flanks,
5191
+ // each label unambiguously beside its own line"), and a label longer
5192
+ // than the line it names cannot satisfy it — it overhangs both ends and
5193
+ // lands on whatever is past them. Measured: `statechart/bfd-session`'s
5194
+ // `INIT -> DOWN` carries a 200 px two-line label on an 80 px run, and
5195
+ // forcing the pair there cost the figure 3 new label collisions, 3
5196
+ // coincident lines and a label emitted off the canvas. 1.25 is the
5197
+ // allowance — a quarter of the run may overhang, which is what a label
5198
+ // centred on a short link needs and what a label that has nothing to do
5199
+ // with the link exceeds.
5200
+ const RECIP_FIT=1.25;
5201
+ const fits=(e,len)=>!e.mid||lblPx(e.mid)<=len*RECIP_FIT;
5202
+ for(const [k,fwd] of dir){
5203
+ const [a,b]=k.split('\t');
5204
+ const rev=dir.get(b+'\t'+a);
5205
+ if(!rev||fwd.length!==1||rev.length!==1) continue;
5206
+ if(chains.get(fwd[0])||chains.get(rev[0])) continue;
5207
+ if(inBus.has(fwd[0])||inBus.has(rev[0])) continue;
5208
+ const A=byId[a], B=byId[b];
5209
+ const p=borderPoint(A,B.x+B.w/2,B.y+B.h/2), q=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
5210
+ const len=Math.hypot(q[0]-p[0],q[1]-p[1]);
5211
+ if(!fits(fwd[0],len)||!fits(rev[0],len)) continue;
5212
+ recip.add(fwd[0]); recip.add(rev[0]);
5213
+ }
5214
+ }
5215
+ // A back edge the routing treats as straight, for item 58's reason.
5216
+ const chBack=e=>isBack.has(e)&&!recip.has(e);
4726
5217
  const chPlan=new Map(); let chTop=0, chShift=0;
4727
5218
  {
4728
- const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&isBack.has(e)&&!pinned(e.a)&&!pinned(e.b));
5219
+ const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&chBack(e)&&!pinned(e.a)&&!pinned(e.b));
4729
5220
  const near=e=>{ const A=byId[e.a]; return horiz?A.y+A.h/2:A.x+A.w/2; };
4730
5221
  const order=chList.map((e,i)=>({e,i}));
4731
5222
  order.sort((p,q)=>near(q.e)-near(p.e)||p.i-q.i);
@@ -4773,6 +5264,45 @@ function renderScene(doc,y0){
4773
5264
  const m=g.length;
4774
5265
  g.forEach((e,k)=>{ chPlan.get(e).ef=(m-k)/(m+1); });
4775
5266
  }
5267
+ // RETURN BUS COLLAPSE (item 46). Every member of an admitted group takes
5268
+ // the INNERMOST member's ring, slot, entry point and entry fraction, so the
5269
+ // rail, the trunk and the arrival are one line instead of N nested ones.
5270
+ // Deterministic: the innermost member is the one the sort above already put
5271
+ // closest to the channel (lowest ring; ties by document order). The LAST
5272
+ // member in document order draws the arrowhead, the junction dots and the
5273
+ // single label; every other member draws its path only. The label rides the
5274
+ // SHARED run rather than one member's source stub — a stub label would say
5275
+ // the word names that one member, and what the bus asserts is that all of
5276
+ // them say it.
5277
+ //
5278
+ // A group whose members disagree about `ringOK` is not collapsed: the two
5279
+ // route forms enter the target on different edges of the box, so a part-ring
5280
+ // part-channel bus would be two trunks, not one.
5281
+ for(const m of retBusGroups){
5282
+ if(!m.every(e=>chPlan.has(e))) continue;
5283
+ const P0=chPlan.get(m.reduce((a,b)=>chPlan.get(a).ring<=chPlan.get(b).ring?a:b));
5284
+ if(!m.every(e=>chPlan.get(e).ringOK===P0.ringOK)) continue;
5285
+ // Two roles, and they are not the same member. The LABEL goes to the
5286
+ // member whose own leg into the trunk is LONGEST — the forward bus's rule
5287
+ // verbatim, and the reason is the same: the longest leg is the one whose
5288
+ // longest segment is the shared rail, so the word lands on ink every
5289
+ // member draws instead of on the return row above the figure (where the
5290
+ // canvas, which grows right and down only, clips it). The ARROWHEAD and
5291
+ // the junction dots go to the LAST member in document order, because the
5292
+ // dots can only be drawn once every member's own join point exists.
5293
+ const T=byId[m[0].b], tc=horiz?T.y+T.h/2:T.x+T.w/2;
5294
+ let lead=m[0], bestD=-1;
5295
+ for(const e of m){
5296
+ const A2=byId[e.a], d=Math.abs((horiz?A2.y+A2.h/2:A2.x+A2.w/2)-tc);
5297
+ if(d>bestD+0.5){ bestD=d; lead=e; }
5298
+ }
5299
+ const last=m[m.length-1];
5300
+ for(const e of m){
5301
+ const P=chPlan.get(e);
5302
+ P.ring=P0.ring; P.slot=P0.slot; P.ex=P0.ex; P.ef=P0.ef;
5303
+ P.bus=e.b; P.busLead=(e===lead); P.busTail=(e===last);
5304
+ }
5305
+ }
4776
5306
  // ring return rows run above the top rank; shift the whole scene down
4777
5307
  // when they would spill into the title band. The shift is uniform
4778
5308
  // (relative geometry, incl. pins, is preserved) and meta.top reports
@@ -4794,7 +5324,7 @@ function renderScene(doc,y0){
4794
5324
  // opposite sides; endpoints and labels shift together.
4795
5325
  const apOff=new Map();
4796
5326
  {
4797
- const straight=e=>byId[e.a]&&byId[e.b]&&!(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b))&&!chains.get(e);
5327
+ const straight=e=>byId[e.a]&&byId[e.b]&&!(chBack(e)&&!pinned(e.a)&&!pinned(e.b))&&!chains.get(e);
4798
5328
  const pk=e=>e.a<e.b?e.a+'\t'+e.b:e.b+'\t'+e.a;
4799
5329
  const pairN={}, seen={};
4800
5330
  for(const e of doc.edges) if(straight(e)){ const k=pk(e); pairN[k]=(pairN[k]||0)+1; }
@@ -4809,6 +5339,391 @@ function renderScene(doc,y0){
4809
5339
  apOff.set(e,[-dy/L*off, dx/L*off]);
4810
5340
  }
4811
5341
  }
5342
+ // ── LONG-EDGE CORRIDOR ROUTER — SIDE BY OCCUPANCY, THEN LANES ────────────
5343
+ // Items 41 and 46, and the approach-column half of item 26, for the long
5344
+ // edges the side-channel plan above does not reach: multi-rank forward edges,
5345
+ // and returns whose endpoints are PINNED (the channel plan takes only the
5346
+ // unpinned ones, so a hand-placed statechart's returns were routed by the
5347
+ // straight router and had no corridor at all).
5348
+ //
5349
+ // TWO DEFECTS, ONE MECHANISM.
5350
+ // 41 — `patterns/flowchart-b` routes BOTH its long edges down the right of a
5351
+ // single-column spine and they cross, while the left margin is empty
5352
+ // for the figure's whole height. The router never asked which side was
5353
+ // free: the diagonal took the geometric straight line and the channel
5354
+ // defaults to one side, so two long edges on one side of one spine are
5355
+ // guaranteed to meet.
5356
+ // 46 — `statechart/dhcp-client` sends `REQUESTING -> INIT` and
5357
+ // `BOUND -> INIT` over the top of the figure at near-identical y. They
5358
+ // read as ONE line with two label stacks.
5359
+ // Both are the same missing question — WHICH corridor, at WHAT height — so
5360
+ // they are answered by one pass: a long edge is offered an orthogonal
5361
+ // corridor route on each margin, at a LANE within that corridor, and the
5362
+ // figure keeps whichever costs least.
5363
+ //
5364
+ // THE COST, and it is item 26's, unchanged:
5365
+ // 10000*(crossings + pierces) + 1000*corridor conflicts + 40*bends + ink
5366
+ // Crossings and pierces dominate because a line through a box or through
5367
+ // another line is a misreading; the CONFLICT term is what this pass adds —
5368
+ // two routes running parallel within a lane width over more than 40 px are
5369
+ // one line to the reader, which is exactly dhcp-client's defect and is
5370
+ // invisible to a crossing count. Ink is charged 1/px so a corridor that buys
5371
+ // nothing loses to the straight line. **The incumbent stays in the candidate
5372
+ // set and is kept unless STRICTLY beaten**, which is what makes this pass
5373
+ // monotone rather than a gamble: a figure whose long edges are already clean
5374
+ // is byte-identical after it.
5375
+ //
5376
+ // THE LANE ORDER IS NOT A CHOICE — it is forced, and the rule is the one the
5377
+ // ring plan above already states. Sources are ordered by DISTANCE FROM THE
5378
+ // TARGET along the flow axis (ties by document order, so one document has one
5379
+ // assignment); the NEAREST source takes lane 0, hard against the figure, and
5380
+ // the FARTHEST takes the outermost lane. It has to be that way round: a far
5381
+ // source on an inner lane has its long run cut by every nearer source's climb
5382
+ // out to an outer lane. For the same reason the ARRIVAL fans across the
5383
+ // target's border — an outer lane must land BEYOND where every inner lane
5384
+ // stops, or it crosses them all on the way in.
5385
+ //
5386
+ // A GROUP IS (target, side of approach). Sources on opposite sides of one
5387
+ // target are two corridors, not one, and laning them together would send a
5388
+ // route the long way round the figure to reach a lane it has no reason to be
5389
+ // in.
5390
+ //
5391
+ // THE LEFT MARGIN EXISTS NOW. The canvas grows right and down only, so a
5392
+ // corridor on the low side needs the scene shifted — the uniform-shift
5393
+ // pattern `bShift`/`chShift` already use, and the thing the self-loop note
5394
+ // below records as missing. Relative geometry (pins included) is preserved
5395
+ // and `meta.left`/`meta.top` report the shifted origin, so the editor's
5396
+ // drag->pin round-trip stays stable.
5397
+ const longPlan=new Map(); // edge -> {side, lane, frac}
5398
+ let lShift=0, tShift=0;
5399
+ if(nodes.length){
5400
+ const CORR_GAP=26, LANE_PITCH=18, PAR_MIN=40;
5401
+ // group bands, computed exactly as gBox draws them below
5402
+ const gbb=[];
5403
+ for(const g of doc.groups){
5404
+ const mem=nodes.filter(n=>n.group===g.id); if(!mem.length) continue;
5405
+ const x0=Math.min(...mem.map(n=>n.x))-BAND.l, x1=Math.max(...mem.map(n=>n.x+n.w))+BAND.r;
5406
+ const yA=Math.min(...mem.map(n=>n.y))-BAND.t, yB=Math.max(...mem.map(n=>n.y+n.h))+BAND.b;
5407
+ gbb.push({x:x0,y:yA,w:x1-x0,h:yB-yA});
5408
+ }
5409
+ const cLo=n=>horiz?n.y:n.x, cHi=n=>horiz?n.y+n.h:n.x+n.w;
5410
+ const fC =n=>horiz?n.x+n.w/2:n.y+n.h/2;
5411
+ const bLo=Math.min(...nodes.map(cLo), ...gbb.map(b=>horiz?b.y:b.x));
5412
+ const bHi=Math.max(...nodes.map(cHi), ...gbb.map(b=>horiz?b.y+b.h:b.x+b.w));
5413
+ const corrAt=(side,off)=>side<0?bLo-CORR_GAP-off:bHi+CORR_GAP+off;
5414
+ // A LANE IS AS WIDE AS WHAT IT CARRIES. A fixed 18 px pitch put
5415
+ // dhcp-client's second return straight through the first one's two-line
5416
+ // label, which is the ink-blind spacing item 44 removed from ranks arriving
5417
+ // again one axis over. The step between two lanes is therefore the inner
5418
+ // lane's own LABEL extent — height when the corridor runs along the flow
5419
+ // axis, width when it runs across it — floored at the bare pitch.
5420
+ const lblExt=e=>Math.max(LANE_PITCH,
5421
+ !e.mid?0:(horiz?String(e.mid).split('\n').length*13+10:lblPx(e.mid)+10));
5422
+ // Lanes are allocated from a per-side pool, not per convergence: two
5423
+ // components that were decided separately (their INCUMBENTS did not share a
5424
+ // corridor) still share the corridor once both are adopted, and dhcp-client
5425
+ // drew exactly that — `RENEWING -> INIT` and `REBINDING -> INIT` laid on one
5426
+ // line along the bottom, `coinc 2`. The pool remembers every adopted run's
5427
+ // span, so a later component takes the next free height over the stretch it
5428
+ // actually occupies and nothing else.
5429
+ const usedLanes=new Map();
5430
+ const allocOff=(side,lo,hi,ext,taken)=>{
5431
+ const list=(usedLanes.get(side)||[]).concat(taken||[]);
5432
+ let off=0;
5433
+ for(let guard=0;guard<64;guard++){
5434
+ let clash=null;
5435
+ for(const u of list)
5436
+ if(Math.min(u.hi,hi)-Math.max(u.lo,lo)>PAR_MIN&&Math.abs(u.off-off)<Math.max(u.ext,ext)
5437
+ &&(!clash||u.off>clash.off)) clash=u;
5438
+ if(!clash) break;
5439
+ off=clash.off+Math.max(clash.ext,ext);
5440
+ }
5441
+ return off;
5442
+ };
5443
+ // A CORRIDOR ROUTE IS BUILT ORTHOGONALLY OR NOT OFFERED AT ALL.
5444
+ // The first cut handed the four-point L to `routeAround` when it hit
5445
+ // something, which is the wrong tool here: Dijkstra over a visibility graph
5446
+ // returns the SHORTEST clear polyline, not an orthogonal one, and on
5447
+ // `patterns/block-b` it returned a five-bend zigzag that doubled back
5448
+ // across the figure (0 -> 9 on the lint, 3 crossings and a `thru`). A
5449
+ // corridor's whole claim is "this margin is free"; when it is not free for
5450
+ // this edge, the honest answer is to withdraw the candidate, not to bend it
5451
+ // until it fits.
5452
+ //
5453
+ // So a leg out of a node is one of exactly two shapes: straight across to
5454
+ // the corridor, or — when something stands in that column — out of the
5455
+ // node's FLOW side, along to the nearest clear column, and across. That is
5456
+ // the escape the side-channel plan above already uses (`blockedV`/
5457
+ // `blockedH`), in the same two forms, and it keeps every corridor route
5458
+ // axis-aligned end to end. If neither shape is clear, `build` returns null
5459
+ // and the figure keeps its incumbent.
5460
+ //
5461
+ // Because the planned route is clean of every node and every group band by
5462
+ // construction, the draw pass runs NO detour on it: plan and drawing are the
5463
+ // same polyline, which is what makes the cost above a prediction rather
5464
+ // than a guess.
5465
+ const P=(f,c)=>horiz?[f,c]:[c,f];
5466
+ const fLoN=n=>horiz?n.x:n.y, fHiN=n=>horiz?n.x+n.w:n.y+n.h;
5467
+ const cLoN=n=>horiz?n.y:n.x, cHiN=n=>horiz?n.y+n.h:n.x+n.w;
5468
+ const oF=o=>horiz?[o.x,o.x+o.w]:[o.y,o.y+o.h];
5469
+ const oC=o=>horiz?[o.y,o.y+o.h]:[o.x,o.x+o.w];
5470
+ const blockCol=(f,c0,c1,obs)=>obs.some(o=>{
5471
+ const [fa,fb]=oF(o), [ca,cb]=oC(o);
5472
+ return f>fa-2&&f<fb+2&&Math.min(c1,cb)>Math.max(c0,ca)+0.5;
5473
+ });
5474
+ const blockRow=(c,f0,f1,obs)=>obs.some(o=>{
5475
+ const [fa,fb]=oF(o), [ca,cb]=oC(o);
5476
+ return c>ca-2&&c<cb+2&&Math.min(f1,fb)>Math.max(f0,fa)+0.5;
5477
+ });
5478
+ // the leg from node N (at flow position f) out to the corridor at cross c,
5479
+ // ordered node-first; null when neither shape is clear
5480
+ const legTo=(N,f,c,obs)=>{
5481
+ const cb=c>cHiN(N)?cHiN(N):cLoN(N);
5482
+ if(!blockCol(f,Math.min(cb,c),Math.max(cb,c),obs)) return [P(f,cb),P(f,c)];
5483
+ const cc=(cLoN(N)+cHiN(N))/2, gs=[];
5484
+ for(const o of obs){ const [fa,fb]=oF(o); gs.push(fa-10,fb+10); }
5485
+ gs.sort((a,b)=>Math.abs(a-f)-Math.abs(b-f)||b-a);
5486
+ for(const g of gs){
5487
+ if(blockCol(g,Math.min(cc,c),Math.max(cc,c),obs)) continue;
5488
+ if(blockRow(cc,Math.min(f,g),Math.max(f,g),obs)) continue;
5489
+ return [P(g>f?fHiN(N):fLoN(N),cc),P(g,cc),P(g,c)];
5490
+ }
5491
+ return null;
5492
+ };
5493
+ const build=(e,q)=>{
5494
+ const A=byId[e.a], B=byId[e.b], c=corrAt(q.side,q.off);
5495
+ const obs=obsFor(A,B);
5496
+ const la=legTo(A,fC(A),c,obs);
5497
+ const lb=legTo(B,horiz?B.x+B.w*q.frac:B.y+B.h*q.frac,c,obs);
5498
+ if(!la||!lb) return null;
5499
+ const fa=la[la.length-1][horiz?0:1], fb=lb[lb.length-1][horiz?0:1];
5500
+ if(blockRow(c,Math.min(fa,fb),Math.max(fa,fb),obs)) return null;
5501
+ const pts=la.concat(lb.slice().reverse());
5502
+ // drop a zero-length hop where the two legs meet on one column
5503
+ for(let i=1;i<pts.length;i++)
5504
+ if(Math.hypot(pts[i][0]-pts[i-1][0],pts[i][1]-pts[i-1][1])<0.5){ pts.splice(i,1); i--; }
5505
+ return pts;
5506
+ };
5507
+ // TWO OBSTACLE SETS, AND THE DIFFERENCE IS DELIBERATE.
5508
+ // `obsFor` — nodes AND foreign group bands — is what a corridor candidate
5509
+ // must be CLEAR OF to be offered at all: a corridor drawn through a band it
5510
+ // neither starts nor ends inside asserts a membership the document does not.
5511
+ // `nodeObs` — nodes only — is what the pierce TERM charges, because that is
5512
+ // what the drawing is measured on (`thru` counts node rectangles). Charging
5513
+ // the incumbent for a band crossing instead made `patterns/block-b`'s `ptr`
5514
+ // edge look two pierces worse than it is measured to be, and the corridor
5515
+ // won a comparison the figure then paid for in crossings it did not have
5516
+ // (0 -> 9). An edge crossing a foreign band is a real defect and it is
5517
+ // backlog items 6 and 19; it is not this pass's to trade measured axes for.
5518
+ const obsFor=(A,B)=>{
5519
+ const o=nodeObs(A,B);
5520
+ const inb=(b,q)=>q[0]>b.x&&q[0]<b.x+b.w&&q[1]>b.y&&q[1]<b.y+b.h;
5521
+ const pa=[A.x+A.w/2,A.y+A.h/2], pb=[B.x+B.w/2,B.y+B.h/2];
5522
+ for(const b of gbb) if(!inb(b,pa)&&!inb(b,pb)) o.push(b);
5523
+ return o;
5524
+ };
5525
+ const nodeObs=(A,B)=>nodes.filter(n=>n!==A&&n!==B&&!n.boundary)
5526
+ .map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
5527
+ // occupancy the channel plan will use, recomputed from the same inputs so
5528
+ // the two cannot drift; the shift below moves both together.
5529
+ let occRg=0, occBg=0;
5530
+ for(const n of nodes){ occRg=Math.max(occRg,n.x+n.w); occBg=Math.max(occBg,n.y+n.h); }
5531
+ for(const b of gbb){ occRg=Math.max(occRg,b.x+b.w); occBg=Math.max(occBg,b.y+b.h); }
5532
+ if(!horiz) chains.forEach((chain,e)=>{ if(!e.mid) return;
5533
+ const v=chain[1+Math.floor((chain.length-3)/2)];
5534
+ occRg=Math.max(occRg, v.x+v.w/2+9+lblPx(e.mid)); });
5535
+ // A channel route reconstructed as the draw pass will build it — the plain
5536
+ // form of each of the three shapes. The blocked-detour variants are not
5537
+ // reconstructed: they differ only in a short escape leg near an endpoint,
5538
+ // and this is a comparison of corridors, not of stubs.
5539
+ const chGuess=e=>{
5540
+ const A=byId[e.a], B=byId[e.b], P=chPlan.get(e);
5541
+ if(A===B) return null;
5542
+ if(horiz){
5543
+ const chY=occBg+28+P.slot;
5544
+ return [[A.x+A.w/2,outSide(A,'b')],[A.x+A.w/2,chY],[B.x+B.w*P.ef,chY],[B.x+B.w*P.ef,outSide(B,'b')]];
5545
+ }
5546
+ if(P.ringOK){
5547
+ const sx=A.x+A.w/2, gy=occBg+14+P.ring*12, chX=occRg+28+P.slot, topY=chTop-14-P.ring*12;
5548
+ return [[sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]];
5549
+ }
5550
+ const chX=occRg+28+P.slot;
5551
+ return [[outSide(A,'r'),A.y+A.h/2],[chX,A.y+A.h/2],[chX,B.y+B.h*P.ef],[outSide(B,'r'),B.y+B.h*P.ef]];
5552
+ };
5553
+ const guess=e=>{
5554
+ const A=byId[e.a], B=byId[e.b];
5555
+ if(!A||!B||A===B) return null;
5556
+ if(longPlan.has(e)) return longPlan.get(e).pts;
5557
+ if(chPlan.has(e)) return chGuess(e);
5558
+ const ch=chains.get(e), pp=[];
5559
+ if(ch) for(const v of ch.slice(1,-1)) pp.push([v.x+v.w/2,v.y+v.h/2]);
5560
+ const f=pp.length?pp[0]:[B.x+B.w/2,B.y+B.h/2];
5561
+ const l=pp.length?pp[pp.length-1]:[A.x+A.w/2,A.y+A.h/2];
5562
+ const st=[borderPoint(A,f[0],f[1]),...pp,borderPoint(B,l[0],l[1])];
5563
+ // THE INCUMBENT MUST BE GUESSED AS IT WILL BE DRAWN, DETOURS INCLUDED.
5564
+ // Guessing the undetoured line charged the incumbent for pierces the
5565
+ // drawing never contains, and the corridor then won a comparison it had
5566
+ // not earned: `patterns/block-b`'s `ptr` edge scored the incumbent at two
5567
+ // pierces (a chain run measured straight through `Unit B`, which the
5568
+ // chain branch detours around) and took a top corridor that cost the
5569
+ // figure 3 crossings and a `thru` — 0 -> 9. Every guess now runs the same
5570
+ // routeAround pass the draw branches run.
5571
+ const obs=obsFor(A,B);
5572
+ if(st.length>2){
5573
+ for(let i=0;i+1<st.length;i++){
5574
+ const d=routeAround(st[i],st[i+1],obs);
5575
+ if(!d) continue;
5576
+ const ins=d.slice(1,-1); st.splice(i+1,0,...ins); i+=ins.length;
5577
+ }
5578
+ return st;
5579
+ }
5580
+ const rr=routeAround(st[0],st[1],obs);
5581
+ if(!rr) return st;
5582
+ rr[0]=borderPoint(A,rr[1][0],rr[1][1]);
5583
+ rr[rr.length-1]=borderPoint(B,rr[rr.length-2][0],rr[rr.length-2][1]);
5584
+ return rr;
5585
+ };
5586
+ const xseg=(a,b,c,d)=>{
5587
+ const rx=b[0]-a[0], ry=b[1]-a[1], sx=d[0]-c[0], sy=d[1]-c[1];
5588
+ const den=rx*sy-ry*sx; if(Math.abs(den)<1e-9) return false;
5589
+ const t=((c[0]-a[0])*sy-(c[1]-a[1])*sx)/den, u=((c[0]-a[0])*ry-(c[1]-a[1])*rx)/den;
5590
+ return t>1e-6&&t<1-1e-6&&u>1e-6&&u<1-1e-6;
5591
+ };
5592
+ const crossN=(r,o)=>{ let n=0;
5593
+ for(let i=0;i+1<r.length;i++) for(let j=0;j+1<o.length;j++)
5594
+ if(xseg(r[i],r[i+1],o[j],o[j+1])) n++;
5595
+ return n; };
5596
+ const pierceN=(r,A,B)=>{ const obs=nodeObs(A,B); let n=0;
5597
+ for(let i=0;i+1<r.length;i++) if(segHitsObs(r[i],r[i+1],obs)) n++;
5598
+ return n; };
5599
+ const runsOf=r=>{ const out=[];
5600
+ for(let i=0;i+1<r.length;i++){
5601
+ const p=r[i], q=r[i+1], dx=q[0]-p[0], dy=q[1]-p[1];
5602
+ if(Math.abs(dx)<0.5&&Math.abs(dy)>=PAR_MIN) out.push({v:1,c:p[0],lo:Math.min(p[1],q[1]),hi:Math.max(p[1],q[1])});
5603
+ else if(Math.abs(dy)<0.5&&Math.abs(dx)>=PAR_MIN) out.push({v:0,c:p[1],lo:Math.min(p[0],q[0]),hi:Math.max(p[0],q[0])});
5604
+ }
5605
+ return out; };
5606
+ const conflictN=(r,o)=>{ let n=0;
5607
+ for(const a of runsOf(r)) for(const b of runsOf(o))
5608
+ if(a.v===b.v&&Math.abs(a.c-b.c)<LANE_PITCH-1&&Math.min(a.hi,b.hi)-Math.max(a.lo,b.lo)>PAR_MIN) n++;
5609
+ return n; };
5610
+ const inkOf=r=>{ let s=0;
5611
+ for(let i=0;i+1<r.length;i++){
5612
+ const dx=r[i+1][0]-r[i][0], dy=r[i+1][1]-r[i][1], L=Math.hypot(dx,dy);
5613
+ s+=(Math.abs(dx)<0.5||Math.abs(dy)<0.5)?L:L*1.7;
5614
+ }
5615
+ return s; };
5616
+ const costOf=(r,e,peers)=>{
5617
+ if(!r) return Infinity;
5618
+ const A=byId[e.a], B=byId[e.b];
5619
+ let cr=0, cf=0;
5620
+ for(const q of peers){ if(q.e===e||!q.r) continue; cr+=crossN(r,q.r); cf+=conflictN(r,q.r); }
5621
+ return 10000*(cr+pierceN(r,A,B))+1000*cf+40*Math.max(0,r.length-2)+inkOf(r);
5622
+ };
5623
+ const inBus=new Set();
5624
+ for(const m of busGroups) for(const e of m) inBus.add(e);
5625
+ for(const m of retBusGroups) for(const e of m) inBus.add(e);
5626
+ const longE=doc.edges.filter(e=>{
5627
+ const A=byId[e.a], B=byId[e.b];
5628
+ return A&&B&&e.a!==e.b&&!inBus.has(e)&&!chPlan.has(e)
5629
+ &&(isBack.has(e)||Math.abs(B.rank-A.rank)>1);
5630
+ });
5631
+ const grp=new Map();
5632
+ for(const e of longE){
5633
+ const A=byId[e.a], B=byId[e.b];
5634
+ const k=e.b+'|'+(fC(A)>=fC(B)?'+':'-');
5635
+ if(!grp.has(k)) grp.set(k,[]); grp.get(k).push(e);
5636
+ }
5637
+ // THE UNIT OF DECISION IS THE CONFLICT COMPONENT, not the whole convergence.
5638
+ // Measured on dhcp-client: laning ALL FIVE returns to INIT costs 8 crossings
5639
+ // and 9 conflicts against the incumbent's 3 and 2 — the five sources sit in
5640
+ // three different rows, so four of them have to climb PAST the figure to
5641
+ // reach one corridor and cross everything on the way. What is actually
5642
+ // defective there is the PAIR whose runs already share a corridor
5643
+ // (`REQUESTING -> INIT` and `BOUND -> INIT`, one line to the reader). So a
5644
+ // group is split into connected components under "your incumbent route runs
5645
+ // parallel to mine inside one lane width", and each component is decided on
5646
+ // its own. A component of one is still decided — that is item 41's
5647
+ // single-edge side choice, and flowchart-b is a component of one.
5648
+ const comps=[];
5649
+ for(const [,mem] of grp){
5650
+ const inc0=new Map(); for(const e of mem) inc0.set(e,guess(e));
5651
+ const par=mem.map((_,i)=>i);
5652
+ const find=i=>par[i]===i?i:(par[i]=find(par[i]));
5653
+ for(let i=0;i<mem.length;i++) for(let j=i+1;j<mem.length;j++)
5654
+ if(conflictN(inc0.get(mem[i]),inc0.get(mem[j]))) par[find(j)]=find(i);
5655
+ const byRoot=new Map();
5656
+ mem.forEach((e,i)=>{ const r=find(i); if(!byRoot.has(r)) byRoot.set(r,[]); byRoot.get(r).push(e); });
5657
+ for(const [,c] of byRoot) comps.push(c);
5658
+ }
5659
+ for(const mem of comps){
5660
+ const T=byId[mem[0].b], tf=fC(T);
5661
+ const ord=mem.map((e,i)=>({e,i}))
5662
+ .sort((p,q)=>Math.abs(fC(byId[p.e.a])-tf)-Math.abs(fC(byId[q.e.a])-tf)||p.i-q.i)
5663
+ .map(o=>o.e);
5664
+ const nM=ord.length, up=fC(byId[ord[0].a])>=tf;
5665
+ // arrival fractions: lane 0 lands nearest the sources, the outermost
5666
+ // lane beyond every inner lane's turn-in
5667
+ const fracs=ord.map((e,k)=>nM===1?0.5:(up?(nM-k)/(nM+1):(k+1)/(nM+1)));
5668
+ const peerE=doc.edges.filter(x=>byId[x.a]&&byId[x.b]&&x.a!==x.b&&mem.indexOf(x)<0);
5669
+ const peers=peerE.map(x=>({e:x,r:guess(x)}));
5670
+ const inc=ord.map(e=>({e,r:guess(e)}));
5671
+ const base=inc.reduce((s,q)=>s+costOf(q.r,q.e,peers.concat(inc)),0);
5672
+ let best=null, bestC=base, bestSide=0;
5673
+ for(const side of [-1,1]){
5674
+ const taken=[], cand=[];
5675
+ for(let k=0;k<ord.length;k++){
5676
+ const e=ord[k], A=byId[e.a], B=byId[e.b], frac=fracs[k];
5677
+ const a=fC(A), b=horiz?B.x+B.w*frac:B.y+B.h*frac;
5678
+ const lo=Math.min(a,b), hi=Math.max(a,b), ext=lblExt(e);
5679
+ const off=allocOff(side,lo,hi,ext,taken);
5680
+ taken.push({off,lo,hi,ext});
5681
+ const q={side,off,frac};
5682
+ cand.push({e,q,r:build(e,q),u:{off,lo,hi,ext}});
5683
+ }
5684
+ if(cand.some(z=>!z.r)) continue; // a corridor that is not free here
5685
+ const c=cand.reduce((s,z)=>s+costOf(z.r,z.e,peers.concat(cand)),0);
5686
+ if(c<bestC-1e-6){ bestC=c; best=cand; bestSide=side; }
5687
+ }
5688
+ if(best){
5689
+ if(!usedLanes.has(bestSide)) usedLanes.set(bestSide,[]);
5690
+ for(const z of best){ z.q.pts=z.r; longPlan.set(z.e,z.q); usedLanes.get(bestSide).push(z.u); }
5691
+ }
5692
+ }
5693
+ // the low-side margin the adopted corridors need, taken as a uniform shift
5694
+ let minC=Infinity;
5695
+ for(const [,q] of longPlan) if(q.side<0) minC=Math.min(minC,corrAt(q.side,q.off));
5696
+ if(minC!==Infinity){
5697
+ if(horiz) tShift=Math.max(0,(y0+26)-minC);
5698
+ else lShift=Math.max(0,6-minC);
5699
+ }
5700
+ if(lShift||tShift){
5701
+ for(const n of lay){ n.x+=lShift; n.y+=tShift; }
5702
+ for(const k in gOrigin){ gOrigin[k].x+=lShift; gOrigin[k].y+=tShift; }
5703
+ for(const [,q] of longPlan) q.pts=q.pts.map(z=>[z[0]+lShift,z[1]+tShift]);
5704
+ chTop+=tShift;
5705
+ }
5706
+ }
5707
+ // THE SOURCE STUB a long edge's label rides: the first stretch of the route as
5708
+ // it LEAVES the source, capped so the box lands beside the box the line comes
5709
+ // out of rather than halfway down the corridor. The cap scales with the LABEL
5710
+ // (a stub shorter than the label puts the box back on top of the source
5711
+ // whatever parameter is chosen), and it is shared by the channel routes and
5712
+ // the corridor routes because both have the same problem: a long run whose
5713
+ // far end is nowhere near either endpoint.
5714
+ const srcStubOf=(pp,wpx)=>{
5715
+ const a=pp[0], b=pp[1], L=Math.hypot(b[0]-a[0],b[1]-a[1])||1;
5716
+ const k=Math.min(1,Math.max(64,wpx+24)/L);
5717
+ return [a,[a[0]+(b[0]-a[0])*k, a[1]+(b[1]-a[1])*k]];
5718
+ };
5719
+ // THE DRAWN ROUTE IS THE PLANNED ROUTE, to the pixel. An earlier cut rebuilt
5720
+ // the polyline at draw time from the final coordinates, and the rebuild was a
5721
+ // SIMPLER builder than the planner's — it had no escape leg — so a corridor
5722
+ // whose arrival the planner had routed around a node was redrawn straight
5723
+ // through it (`dhcp-client` gained a `thru` that way). The plan carries its
5724
+ // own points, translated once with the scene shift, and nothing recomputes
5725
+ // them: whatever the cost function scored is exactly what the reader sees.
5726
+ const longRoute=e=>{ const q=longPlan.get(e); return q?q.pts.map(z=>z.slice()):null; };
4812
5727
  let W=0,Hh=0;
4813
5728
  for(const n of lay){ W=Math.max(W,n.x+n.w); Hh=Math.max(Hh,n.y+n.h-y0-20); }
4814
5729
  if(W===0){W=480;} if(Hh===0){Hh=280;}
@@ -4842,6 +5757,230 @@ function renderScene(doc,y0){
4842
5757
  const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
4843
5758
  gsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.9"'+bandEdge(f)+'/>');
4844
5759
  }
5760
+ // ── ELEMENT LABELS ARE INK, AND A ROUTE MUST GO ROUND THEM (item 45a) ─────
5761
+ // §14.4's frontier, on the drawing side. `showcase/arp-resolution` ran the
5762
+ // GREEN unicast shaft straight through `rest of the LAN (hosts C, D, ...)`,
5763
+ // and the misreading it invites — that the unicast reaches the rest of the
5764
+ // LAN — is the exact claim the figure exists to deny. The router's obstacle
5765
+ // set was node boxes and group rects: geometry that is DRAWN AS A SHAPE. An
5766
+ // external is never drawn as a shape (`EXTERNAL-EDGE-ENDPOINTS`) and a band NAME is not a shape at
5767
+ // all, so both were invisible to routing while being fully visible on the
5768
+ // page.
5769
+ //
5770
+ // The mechanism is the SMALLEST one consistent with the router that exists:
5771
+ // these two label boxes join the obstacle list `routeAround` already takes,
5772
+ // so a shaft detours around them exactly as it detours around a node. The
5773
+ // alternative — making the LABEL yield — was rejected: an external's label
5774
+ // has one meaningful place (beyond the open end, away from the figure, `EXTERNAL-EDGE-ENDPOINTS`)
5775
+ // and a band's name has one place (the strip it names), so a yielding label
5776
+ // has nowhere to go, and moving it would break the very adjacency that makes
5777
+ // it readable. A node's own label needs nothing here: it lives inside the
5778
+ // node box, which is already an obstacle.
5779
+ //
5780
+ // An external's label is NOT an obstacle to its OWN edge — that edge must
5781
+ // reach the anchor the label names.
5782
+ const extLbl=n=>{
5783
+ const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
5784
+ const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
5785
+ let ox,oy;
5786
+ if(Math.abs(bdx)>=Math.abs(bdy)){ ox=bdx>=0?cx+10:cx-10-bw; oy=cy+3.5-13*bl/2-1.5; }
5787
+ else { ox=cx-bw/2; oy=(bdy>=0?cy+17:cy-10)-13*bl/2-1.5; }
5788
+ return {x:ox,y:oy,w:bw,h:bh};
5789
+ };
5790
+ const extObs=[];
5791
+ for(const n of nodes) if(n.boundary&&n.label) extObs.push(Object.assign(extLbl(n),{id:n.id}));
5792
+ const nameObs=[];
5793
+ for(const k in gBox){ const g=doc.groups.find(z=>z.id===k);
5794
+ if(!g||!g.label) continue;
5795
+ const B=gBox[k];
5796
+ nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1});
5797
+ }
5798
+ // The obstacle list a given edge must respect: every band name, plus every
5799
+ // external label except the ones this edge itself terminates at.
5800
+ // A label's ink is its glyphs plus the clearance that keeps a line from
5801
+ // READING as struck through it. 4 px on every side — the same number the
5802
+ // legibility floor uses for a label's association margin — so a shaft that
5803
+ // grazes a name at 3 px is detoured rather than tolerated.
5804
+ const LBL_PAD=4;
5805
+ const padded=o=>({x:o.x-LBL_PAD,y:o.y-LBL_PAD,w:o.w+2*LBL_PAD,h:o.h+2*LBL_PAD});
5806
+ const lblObs=e=>extObs.filter(o=>o.id!==e.a&&o.id!==e.b).map(padded)
5807
+ .concat(nameObs.map(padded));
5808
+ // ── A BUNDLE RING IS ORIENTED BY ITS MEMBERS (item 43) ───────────────────
5809
+ // Until now the ring was derived from the member links' MIDPOINTS and
5810
+ // nothing else: `rx = max(46, x-spread + 38)`, `ry = max(26, y-spread + 22)`.
5811
+ // Two facts about that formula are the defect. It is DIRECTION-BLIND — the
5812
+ // axes are the canvas's, never the links' — and its floors are large enough
5813
+ // that a bundle of two short legs draws a near-circle whatever the legs do.
5814
+ // Measured on `patterns/topology-b`: rx 46 / ry 42.7, aspect
5815
+ // 1.07, sitting on two legs that fan VERTICALLY, with the caption lying
5816
+ // across the waist and on top of the `p3` endpoint label.
5817
+ //
5818
+ // The drawing convention for a link bundle is a loop THROUGH which the
5819
+ // links run: narrow along the links, long across them. So the ring is now
5820
+ // derived from the members' own frame.
5821
+ //
5822
+ // 1. DIRECTION. The mean UNDIRECTED direction of the members, by the
5823
+ // doubled-angle circular mean — doubling is what makes `a--b` and
5824
+ // `b--a` the same direction, so a bundle does not change shape when an
5825
+ // author writes a member the other way round (conformance 394).
5826
+ // 2. THE BAND. The ring sits at the MIDPOINT OF THE MEMBERS' SHARED RUN
5827
+ // along that direction — the stretch of the corridor every member is
5828
+ // actually in. That is the placement rule because it is the only band
5829
+ // at which "the spread of the members" is a fact about all of them: a
5830
+ // fanning set (the reference topology's ECMP pair, which leaves two
5831
+ // different spines and arrives at one leaf) has a different spread at
5832
+ // every station, and the shared run is the interval over which the
5833
+ // question is even well posed. Members with no shared run at all fall
5834
+ // back to the mean of their midpoints, which is the old centre.
5835
+ // 3. THE AXES. MINOR along the mean direction, sized to clear the strokes;
5836
+ // MAJOR across it, the members' spread at the band plus padding. A ring
5837
+ // needs a long axis to read as a ring, and it takes it from the spread
5838
+ // when the members fan (`rA >= rL`) and from its own RUN when they do
5839
+ // not — a single-member bundle (`reference/topology`'s multi-home link)
5840
+ // has zero spread, and the shape that hugs one link is an ellipse lying
5841
+ // ALONG it, never a circle straddling it.
5842
+ // 4. NO NODE CONTACT. The ring is shrunk along its minor axis until it
5843
+ // clears every node box, and if it cannot it reverts to the pre-0.4
5844
+ // geometry rather than inventing a shape (no corpus figure does).
5845
+ //
5846
+ // The caption is NOT placed here — it is registered with the label pass
5847
+ // below, so it can see the endpoint labels and the edges it has to avoid.
5848
+ const rnd3=v=>Math.round(v*1000)/1000; // ring coordinates, printed short
5849
+ const RING_ALONG=15, // semi-minor: the ring's body along the links
5850
+ RING_ACROSS=20, // clearance beyond the outermost member
5851
+ RING_LONG=34, // the long axis a ring needs to read as a ring
5852
+ RING_FAN=2, // below this spread the members are one line
5853
+ RING_FLOOR=7, // how thin the minor axis may be squeezed
5854
+ RING_SOLO=4; // a ONE-member bundle: the caption's stand-off
5855
+ // ── A ONE-MEMBER BUNDLE IS ITS CAPTION (item 53) ─────────────────────────
5856
+ // `bundle mh "multi-home" l1--l2` is legal and means something real (a
5857
+ // one-link LAG, an Ethernet Segment with a single member), so the
5858
+ // declaration is not an error and is not dropped. But a ring exists to
5859
+ // UNITE lines, and around ONE line it unites nothing: it is ink that adds
5860
+ // no fact, and on the reference topology it also bought the seam a 104 px
5861
+ // corridor demand (2*RX_MIN + clearance) for a shape nobody had to see.
5862
+ //
5863
+ // So a single-member bundle DRAWS NO ELLIPSE. What it draws is its
5864
+ // CAPTION, bundle-styled (the trunk's own stroke, the ring caption's type)
5865
+ // and placed BY THE LINK — which is exactly the statement the construct
5866
+ // makes: this link is the bundle, and here is its name. The frame below is
5867
+ // therefore kept as a PLACEMENT frame and not as a drawing: the caption
5868
+ // pass already sites a caption around a ring's rim, so a ring squeezed to
5869
+ // `RING_SOLO` across gives the same pass a candidate family hugging the
5870
+ // link. Nothing else in the pass changes.
5871
+ //
5872
+ // AN EMPTY CAPTION ON A ONE-MEMBER BUNDLE THEREFORE DRAWS NOTHING AT ALL.
5873
+ // That is a real consequence and it is not hidden: the reference figure
5874
+ // keeps `bundle unlabelled "" s2--l2` — it is the form demonstrator for
5875
+ // the empty label — and states in a comment that this form is now
5876
+ // MODEL-ONLY. The model still carries the trunk, `read` still reports it,
5877
+ // and the drawing says nothing because there is nothing a ring around one
5878
+ // unnamed line could say.
5879
+ // Does the ring's disc meet an axis-aligned rect? Both are mapped into the
5880
+ // frame where the ring is the unit circle; the rect becomes a convex quad,
5881
+ // and the test is "is the quad within 1 of the origin".
5882
+ const ringHitsRect=(R,RL,RA,b)=>{
5883
+ const q=[[b.x,b.y],[b.x+b.w,b.y],[b.x+b.w,b.y+b.h],[b.x,b.y+b.h]].map(p=>{
5884
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
5885
+ return [(R.ux*dx+R.uy*dy)/RL, (R.vx*dx+R.vy*dy)/RA];
5886
+ });
5887
+ let inside=false;
5888
+ for(let i=0,j=3;i<4;j=i++)
5889
+ if((q[i][1]>0)!==(q[j][1]>0) &&
5890
+ 0 < (q[j][0]-q[i][0])*(0-q[i][1])/(q[j][1]-q[i][1])+q[i][0]) inside=!inside;
5891
+ if(inside) return true;
5892
+ for(let i=0,j=3;i<4;j=i++){
5893
+ const vx=q[j][0]-q[i][0], vy=q[j][1]-q[i][1], L2=vx*vx+vy*vy;
5894
+ const t=L2?Math.max(0,Math.min(1,-(q[i][0]*vx+q[i][1]*vy)/L2)):0;
5895
+ if(Math.hypot(q[i][0]+t*vx, q[i][1]+t*vy)<1) return true;
5896
+ }
5897
+ return false;
5898
+ };
5899
+ const ringOf=t=>{
5900
+ const segs=[];
5901
+ for(const [a,b] of t.pairs){
5902
+ const A=byId[a], B=byId[b]; if(!A||!B) continue;
5903
+ const p=borderPoint(A,B.x+B.w/2,B.y+B.h/2), q=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
5904
+ if(Math.hypot(q[0]-p[0],q[1]-p[1])>1e-9) segs.push([p,q]);
5905
+ }
5906
+ if(!segs.length) return null;
5907
+ // (1) mean undirected direction
5908
+ let c2=0,s2=0;
5909
+ for(const [p,q] of segs){
5910
+ const L=Math.hypot(q[0]-p[0],q[1]-p[1]);
5911
+ const c=(q[0]-p[0])/L, s=(q[1]-p[1])/L;
5912
+ c2+=c*c-s*s; s2+=2*c*s;
5913
+ }
5914
+ const th=Math.hypot(c2,s2)<1e-9
5915
+ ? Math.atan2(segs[0][1][1]-segs[0][0][1], segs[0][1][0]-segs[0][0][0])
5916
+ : 0.5*Math.atan2(s2,c2);
5917
+ const ux=Math.cos(th), uy=Math.sin(th), vx=-uy, vy=ux;
5918
+ // (2) the band: the midpoint of the shared run
5919
+ const iv=segs.map(([p,q])=>{
5920
+ const a=ux*p[0]+uy*p[1], b=ux*q[0]+uy*q[1];
5921
+ return a<=b?[a,b,p,q]:[b,a,q,p];
5922
+ });
5923
+ const lo=Math.max(...iv.map(z=>z[0])), hi=Math.min(...iv.map(z=>z[1]));
5924
+ const s=lo<hi ? (lo+hi)/2 : iv.reduce((x,z)=>x+(z[0]+z[1])/2,0)/iv.length;
5925
+ const P=iv.map(z=>{
5926
+ const f=Math.min(1,Math.max(0,(s-z[0])/((z[1]-z[0])||1)));
5927
+ return [z[2][0]+(z[3][0]-z[2][0])*f, z[2][1]+(z[3][1]-z[2][1])*f];
5928
+ });
5929
+ const cx=P.reduce((a,p)=>a+p[0],0)/P.length, cy=P.reduce((a,p)=>a+p[1],0)/P.length;
5930
+ const spread=Math.max(...P.map(p=>Math.abs(vx*(p[0]-cx)+vy*(p[1]-cy))));
5931
+ const runHalf=Math.min(...iv.map(z=>Math.min(s-z[0],z[1]-s)));
5932
+ // (3) axes. Two shapes, and which one is drawn is decided by whether the
5933
+ // members FAN at all. A set that fans is CROSSED by the ring: thin along
5934
+ // the links, long enough across them to take the whole spread with room
5935
+ // to spare, so each member passes through a part of the rim where the
5936
+ // ring is still visibly open. A set that does not fan — one link, or
5937
+ // members lying on top of each other — has no spread to take, and the
5938
+ // shape that says "this link is the bundle" is an ellipse lying ALONG it.
5939
+ let rA, rL;
5940
+ if(spread<RING_FAN){ rL=Math.max(RING_ALONG,Math.min(RING_LONG,Math.max(0,runHalf))); rA=RING_ACROSS; }
5941
+ else { rL=RING_ALONG; rA=Math.max(spread+RING_ACROSS,RING_LONG); }
5942
+ const R={cx,cy,ux,uy,vx,vy,th};
5943
+ // (0) ONE MEMBER (item 53): no ellipse is drawn, so this is a caption
5944
+ // frame and not a shape. It is squeezed across to `RING_SOLO` so the
5945
+ // caption pass's rim stations sit beside the link rather than a ring's
5946
+ // radius away from it, and the node-clearance step below is skipped —
5947
+ // there is no ink to keep out of a box.
5948
+ if(segs.length===1){
5949
+ R.solo=true; R.rL=rL; R.rA=RING_SOLO;
5950
+ R.hw=Math.hypot(rL*ux, RING_SOLO*vx); R.hh=Math.hypot(rL*uy, RING_SOLO*vy);
5951
+ return R;
5952
+ }
5953
+ // (4) out of every node box
5954
+ const boxes=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
5955
+ if(boxes.some(b=>ringHitsRect(R,rL,rA,b))){
5956
+ let ok=false;
5957
+ for(let k=1;k<=24&&!ok;k++){
5958
+ const RL=rL-(rL-RING_FLOOR)*k/24;
5959
+ if(!boxes.some(b=>ringHitsRect(R,RL,rA,b))){ rL=RL; ok=true; }
5960
+ }
5961
+ // No orientation of this ring clears the drawing. Rather than publish a
5962
+ // ring lying over a node, revert to the pre-0.4 derivation, which is at
5963
+ // least the shape every earlier artifact recorded. No corpus figure
5964
+ // takes this branch; it exists so that a hostile geometry degrades to
5965
+ // the old defect instead of a new one.
5966
+ if(!ok){
5967
+ const M=segs.map(([p,q])=>[(p[0]+q[0])/2,(p[1]+q[1])/2]);
5968
+ const mx=M.reduce((a,m)=>a+m[0],0)/M.length, my=M.reduce((a,m)=>a+m[1],0)/M.length;
5969
+ const lx=Math.max(46,Math.max(...M.map(m=>Math.abs(m[0]-mx)))+38);
5970
+ const ly=Math.max(26,Math.max(...M.map(m=>Math.abs(m[1]-my)))+22);
5971
+ return {cx:mx, cy:my, ux:1, uy:0, vx:0, vy:1, th:0, legacy:true,
5972
+ rL:lx, rA:ly, hw:lx, hh:ly};
5973
+ }
5974
+ }
5975
+ R.rL=rL; R.rA=rA;
5976
+ // the axis-aligned box the caption is placed beside and the canvas grows to
5977
+ R.hw=Math.hypot(rL*ux, rA*vx); R.hh=Math.hypot(rL*uy, rA*vy);
5978
+ return R;
5979
+ };
5980
+ const rings=new Map();
5981
+ for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
5982
+ // filled by the label pass below, consumed by the ring drawing further down
5983
+ const ringLbl=new Map(), ringLeads=[];
4845
5984
  // edges (sorted by plane z, then doc order)
4846
5985
  const edges=zsort(doc.edges);
4847
5986
  const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
@@ -4862,6 +6001,12 @@ function renderScene(doc,y0){
4862
6001
  // (direction: from→tip). Geometry matches #arr marker (viewBox 0 0 10 10,
4863
6002
  // refX=9, refY=5, markerWidth=7, markerHeight=7, markerUnits=strokeWidth=1.6):
4864
6003
  // arm = 9*(7/10)*1.6 ≈ 10.08 px, half-width = 5*(7/10)*1.6 ≈ 5.6 px.
6004
+ // Which edge is currently drawing, and which `lblsvg` slots its arrowheads
6005
+ // took. Item 55 recolours an edge AFTER the label pass has run, and an
6006
+ // arrowhead in a different colour from its own shaft is not a discrimination
6007
+ // aid, it is a bug. Recorded rather than inferred: the arrowhead carries no
6008
+ // id of its own.
6009
+ let arrowOwner=null; const arrowIdx=new Map();
4865
6010
  const arrowTri=(tip,from,col)=>{
4866
6011
  const dx=tip[0]-from[0], dy=tip[1]-from[1], L=Math.hypot(dx,dy)||1;
4867
6012
  const ux=dx/L, uy=dy/L; // unit vector from→tip
@@ -4873,6 +6018,9 @@ function renderScene(doc,y0){
4873
6018
  arrowBox.push({x:Math.min(tip[0],lx,rx), y:Math.min(tip[1],ly,ry),
4874
6019
  w:Math.max(tip[0],lx,rx)-Math.min(tip[0],lx,rx),
4875
6020
  h:Math.max(tip[1],ly,ry)-Math.min(tip[1],ly,ry)});
6021
+ if(arrowOwner){ let a=arrowIdx.get(arrowOwner);
6022
+ if(!a){ a=[]; arrowIdx.set(arrowOwner,a); }
6023
+ a.push(lblsvg.length-1); }
4876
6024
  };
4877
6025
  // back-edge side channel: beyond the occupied lanes (nodes AND group boxes)
4878
6026
  let occR=0, occB=0;
@@ -4924,6 +6072,9 @@ function renderScene(doc,y0){
4924
6072
  // figure where every convergence is a trunk, or none is, is one drawing
4925
6073
  // either way. There is deliberately no per-edge escape.
4926
6074
  const busRoute=new Map();
6075
+ // Where each RETURN-BUS member's own leg meets the shared trunk, collected as
6076
+ // the members draw so the last one can dot the joins (item 46).
6077
+ const busJoins=new Map();
4927
6078
  {
4928
6079
  const RAIL_GAP=22, RAIL_CLEAR=12, RAIL_ROOM=30;
4929
6080
  const fLo=n=>horiz?n.x:n.y, fHi=n=>horiz?n.x+n.w:n.y+n.h;
@@ -4961,7 +6112,7 @@ function renderScene(doc,y0){
4961
6112
  for(const e of edges){
4962
6113
  if(!byId[e.a]||!byId[e.b]||e.a===e.b) continue;
4963
6114
  if(busMem.has(e)) continue;
4964
- if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)) continue; // channel routes: not reconstructible here
6115
+ if(chBack(e)&&!pinned(e.a)&&!pinned(e.b)) continue; // channel routes: not reconstructible here
4965
6116
  others.push(incumbent(e));
4966
6117
  }
4967
6118
  const xseg=(a,b,c,d)=>{
@@ -4995,6 +6146,51 @@ function renderScene(doc,y0){
4995
6146
  };
4996
6147
  const built=[];
4997
6148
  let figureOK=busGroups.length>0;
6149
+ // ── TWO TRUNK FORMS, AND WHY THERE HAD TO BE A SECOND (item 60) ────────
6150
+ // FORM 1, THE CROSS RAIL, is the construct as first built: the sources
6151
+ // spread across the cross axis, each drops onto a shared rail just short
6152
+ // of the target, and the rail carries them in. It assumes the sources are
6153
+ // BESIDE one another. When they are not — when the figure is a ladder and
6154
+ // the sources sit one above the other in a single column — the "drop"
6155
+ // from the far source runs straight down the column and through every
6156
+ // source between it and the target, so the form fails its own pierce test
6157
+ // and the figure declined. `statechart/bfd-session` is exactly that shape:
6158
+ // three purple dashed `admin disable` transitions, DOWN/INIT/UP -> ADMINDOWN,
6159
+ // one class and one meaning, drawn as three separate lines in a few pixels
6160
+ // of shared column with the one sentence written three times.
6161
+ //
6162
+ // FORM 2, THE COLUMN TRUNK, is the same statement rotated: each source
6163
+ // leaves on its cross-facing border, runs the short way out to a trunk
6164
+ // standing clear of the column, and the trunk runs ALONG the flow axis
6165
+ // into the target's side. One trunk, one stroke, one arrowhead, junction
6166
+ // dots at the interior joins, and item 54's per-leg labels at each origin
6167
+ // block edge — the same drawing `patterns/state-b` makes with its four
6168
+ // `reset` returns, which is the pattern the review named.
6169
+ //
6170
+ // Both forms face the same three tests, in the same order, and a form
6171
+ // that fails any of them is not offered: every leg clears every node and
6172
+ // band it does not touch; the form crosses no more of the figure than the
6173
+ // routes it replaces; and — clause 3 of the figure-level style rule above
6174
+ // — if NO form passes for ANY eligible group, the whole figure declines.
6175
+ // The rail is offered first and ties go to it, so no figure that draws a
6176
+ // rail today starts drawing a column instead.
6177
+ const TRUNK_GAP=24;
6178
+ // WHERE ON THE BORDER A BUS LEG LEAVES, AND WHY IT IS THREE EIGHTHS.
6179
+ // Not the centre: every other route in and out of a box is anchored there,
6180
+ // so a leg on the centre-line lies along whatever that border already
6181
+ // sends the same way — measured on `bfd-session`, the three legs ran
6182
+ // exactly down the three red teardown routes, coinc 0 -> 6 and F5 1 -> 8.
6183
+ // Not a quarter either, and that is the same lesson one step further in:
6184
+ // the side-channel plan spreads its own arrivals at k/(m+1) of a box's
6185
+ // extent, so a box with three of them anchors at 1/4, 1/2 and 3/4 and a
6186
+ // leg at 1/4 lands ON one — coinc 0 -> 2 (bfd's DOWN, 107 px of shared
6187
+ // ink), also measured. 3/8 sits BETWEEN the plan's own stations, and it
6188
+ // measures best of everything tried: leg fractions 1/8, 1/5, 1/4, 3/10,
6189
+ // 3/8 and 2/5 give score 38, 20, 22, 16, 16, 16 on bfd, with 3/8 the
6190
+ // middle of the three that tie. Toward the target, because that is the
6191
+ // way the bus is going.
6192
+ const BUS_OFF=3/8;
6193
+ const crossLo=n=>horiz?n.y:n.x, crossHi=n=>horiz?n.y+n.h:n.x+n.w;
4998
6194
  for(const mem of busGroups){
4999
6195
  if(!figureOK) break;
5000
6196
  const T=byId[mem[0].b], src=mem.map(e=>byId[e.a]);
@@ -5005,37 +6201,120 @@ function renderScene(doc,y0){
5005
6201
  const railF=dir>0
5006
6202
  ? Math.min(fLo(T)-RAIL_CLEAR, Math.max(fLo(T)-RAIL_GAP, Math.max(...src.map(fHi))+RAIL_CLEAR))
5007
6203
  : Math.max(fHi(T)+RAIL_CLEAR, Math.min(fHi(T)+RAIL_GAP, Math.min(...src.map(fLo))-RAIL_CLEAR));
5008
- const tc=cC(T);
5009
- const cand=[]; let ok=true;
5010
- for(const e of mem){
5011
- const s=byId[e.a], cs=cC(s);
5012
- const j=P(railF,cs), h=P(railF,tc);
5013
- const pts=Math.abs(cs-tc)<0.5
5014
- ? [borderPoint(s,h[0],h[1]), h, borderPoint(T,h[0],h[1])]
5015
- : [borderPoint(s,j[0],j[1]), j, h, borderPoint(T,h[0],h[1])];
5016
- const obs=obsFor(s,T);
5017
- for(let i=0;i+1<pts.length;i++) if(segHitsObs(pts[i],pts[i+1],obs)) ok=false;
5018
- if(!ok) break;
5019
- cand.push({e,cs,pts});
6204
+ // The trunk's ENTRY is off the target's centre-line for the same reason
6205
+ // its legs are off their sources' (see the leg comment below): on
6206
+ // `bfd-session` the entry ran back along the `admin enable` return
6207
+ // leaving ADMINDOWN's own border centre, 92 px of shared ink and coinc 4.
6208
+ // It enters a quarter of the box's flow extent on the side the bus comes
6209
+ // FROM, which is the side a reader traces it back along.
6210
+ const tc=cC(T), ftc0=(fLo(T)+fHi(T))/2;
6211
+ const forms=[];
6212
+ // FORM 1 — the cross rail. `u` is a member's position ALONG the trunk,
6213
+ // which is the cross axis here and the flow axis in form 2; everything
6214
+ // downstream (dots, lead) is written in `u` so it serves both.
6215
+ {
6216
+ const cand=[]; let ok=true;
6217
+ for(const e of mem){
6218
+ const s=byId[e.a], cs=cC(s);
6219
+ const j=P(railF,cs), h=P(railF,tc);
6220
+ const pts=Math.abs(cs-tc)<0.5
6221
+ ? [borderPoint(s,h[0],h[1]), h, borderPoint(T,h[0],h[1])]
6222
+ : [borderPoint(s,j[0],j[1]), j, h, borderPoint(T,h[0],h[1])];
6223
+ const obs=obsFor(s,T);
6224
+ for(let i=0;i+1<pts.length;i++) if(segHitsObs(pts[i],pts[i+1],obs)) ok=false;
6225
+ if(!ok) break;
6226
+ cand.push({e,u:cs,jp:j,pts});
6227
+ }
6228
+ if(ok) forms.push({cand,uT:tc,tp:P(railF,tc)});
6229
+ }
6230
+ // FORM 2 — the column trunk. The trunk stands one TRUNK_GAP clear of
6231
+ // every box the run passes, on whichever side is cleaner; a band it
6232
+ // cannot clear is caught by the same pierce test as a node.
6233
+ {
6234
+ const ftc=ftc0-dir*(fHi(T)-fLo(T))*BUS_OFF;
6235
+ const runLo=Math.min(...src.map(fLo).concat([fLo(T)])),
6236
+ runHi=Math.max(...src.map(fHi).concat([fHi(T)]));
6237
+ const thru=nodes.filter(n=>!n.boundary&&fHi(n)>runLo+0.5&&fLo(n)<runHi-0.5);
6238
+ // WHERE THE TRUNK STANDS, AND WHY "CLEAR OF THE BOXES" IS NOT ENOUGH.
6239
+ // The first build put the trunk one gap outside the widest BOX in the
6240
+ // run, and on `bfd-session` that is x=149 — straight through the
6241
+ // waypoint detour the green DOWN -> UP advance edge takes at x=193.
6242
+ // The bus crossed 3 where the three separate lines crossed 1, the
6243
+ // never-worse test refused it, and the refusal was right: a trunk
6244
+ // drawn across the figure's existing routes is not a tidier drawing.
6245
+ // So each side offers TWO stations — one just outside the boxes, one
6246
+ // outside every route the figure already draws across the same span —
6247
+ // and the tight one is offered first so a figure only pays the extra
6248
+ // width when the crossings say it must.
6249
+ const oX=[];
6250
+ for(const o of others) for(let i=0;i+1<o.length;i++){
6251
+ const a=o[i], b=o[i+1], af=horiz?a[0]:a[1], bf=horiz?b[0]:b[1];
6252
+ if(Math.max(af,bf)<runLo-0.5||Math.min(af,bf)>runHi+0.5) continue;
6253
+ oX.push(horiz?a[1]:a[0], horiz?b[1]:b[0]);
6254
+ }
6255
+ const stations=[];
6256
+ if(thru.length){
6257
+ const nHi=Math.max(...thru.map(crossHi)), nLo=Math.min(...thru.map(crossLo));
6258
+ stations.push(nHi+TRUNK_GAP, nLo-TRUNK_GAP);
6259
+ if(oX.length){
6260
+ const clearHi=Math.max(nHi,...oX)+TRUNK_GAP, clearLo=Math.min(nLo,...oX)-TRUNK_GAP;
6261
+ if(clearHi>nHi+TRUNK_GAP+0.5) stations.splice(1,0,clearHi);
6262
+ if(clearLo<nLo-TRUNK_GAP-0.5) stations.push(clearLo);
6263
+ }
6264
+ }
6265
+ for(const trunkC of stations){
6266
+ if(trunkC<TRUNK_GAP) continue; // off the left/top margin
6267
+ const cand=[]; let ok=true;
6268
+ for(const e of mem){
6269
+ // THE LEG DOES NOT LEAVE ON THE CENTRE-LINE. Every other route in
6270
+ // and out of a box is anchored on its border centre, so a bus leg
6271
+ // that leaves there lies along whatever that box already sends the
6272
+ // same way: on `bfd-session` the three legs ran EXACTLY down the
6273
+ // three red teardown routes leaving the same borders (coinc 0 -> 6,
6274
+ // F5 1 -> 8, both measured). The leg therefore leaves a quarter of
6275
+ // the box's flow extent TOWARD the target — the direction it is
6276
+ // going anyway — which frees the centre-line for the traffic that
6277
+ // has nowhere else to be.
6278
+ const s=byId[e.a], fsc=(fLo(s)+fHi(s))/2+dir*(fHi(s)-fLo(s))*BUS_OFF;
6279
+ const j=P(fsc,trunkC), h=P(ftc,trunkC);
6280
+ // The leg leaves the border AT its own flow position, not at the
6281
+ // point a ray from the box centre happens to cross: a bus is
6282
+ // axis-aligned by construction (see the style decision above), and
6283
+ // `borderPoint` would start the leg on the centre ray and slant it.
6284
+ const ex0=P(fsc, trunkC>cC(s)?crossHi(s):crossLo(s));
6285
+ const en0=P(ftc, trunkC>cC(T)?crossHi(T):crossLo(T));
6286
+ const pts=[ex0, j, h, en0];
6287
+ const obs=obsFor(s,T);
6288
+ for(let i=0;i+1<pts.length;i++) if(segHitsObs(pts[i],pts[i+1],obs)) ok=false;
6289
+ if(!ok) break;
6290
+ cand.push({e,u:fsc,jp:j,pts});
6291
+ }
6292
+ if(ok) forms.push({cand,uT:ftc,tp:P(ftc,trunkC)});
6293
+ }
5020
6294
  }
5021
- if(!ok){ figureOK=false; break; } // a leg pierces something
5022
- const bpts=cand.map(c=>c.pts), ipts=mem.map(incumbent);
5023
- const bc=crossCount(bpts)+pierceCount(bpts,mem);
6295
+ const ipts=mem.map(incumbent);
5024
6296
  const ic=crossCount(ipts)+pierceCount(ipts,mem);
5025
- if(bc>ic){
5026
- figureOK=false; break; // not beaten: keep the incumbents
6297
+ let pick=null, pickC=Infinity;
6298
+ for(const F of forms){
6299
+ const bpts=F.cand.map(c=>c.pts);
6300
+ const bc=crossCount(bpts)+pierceCount(bpts,mem);
6301
+ if(bc>ic) continue; // not beaten: keep the incumbents
6302
+ if(bc<pickC-0.5){ pickC=bc; pick=F; }
5027
6303
  }
5028
- // junction dots mark the interior joins only: the two ends of the rail
6304
+ if(!pick){ figureOK=false; break; }
6305
+ const cand=pick.cand, uT=pick.uT;
6306
+ // junction dots mark the interior joins only: the two ends of the trunk
5029
6307
  // are corners, not junctions, and a dot on a corner is wrong.
5030
- const xs=cand.map(c=>c.cs).concat([tc]);
5031
- const cLo=Math.min(...xs), cHi=Math.max(...xs);
6308
+ const us=cand.map(c=>c.u).concat([uT]);
6309
+ const uLo=Math.min(...us), uHi=Math.max(...us);
5032
6310
  const dots=[];
5033
- for(const c of cand) if(c.cs>cLo+0.5&&c.cs<cHi-0.5) dots.push(P(railF,c.cs));
5034
- if(tc>cLo+0.5&&tc<cHi-0.5&&!dots.some(d=>Math.abs(d[horiz?1:0]-tc)<0.5)) dots.push(P(railF,tc));
5035
- // one label and one arrowhead for the whole bus: the member whose rail
5036
- // run is longest carries the label, document order breaks the tie.
6311
+ for(const c of cand) if(c.u>uLo+0.5&&c.u<uHi-0.5) dots.push(c.jp);
6312
+ if(uT>uLo+0.5&&uT<uHi-0.5&&
6313
+ !dots.some(d=>Math.hypot(d[0]-pick.tp[0],d[1]-pick.tp[1])<0.5)) dots.push(pick.tp);
6314
+ // one arrowhead for the whole bus, and the member whose trunk run is
6315
+ // longest leads it; document order breaks the tie.
5037
6316
  let lead=cand[0], best=-1;
5038
- for(const c of cand){ const d=Math.abs(c.cs-tc); if(d>best+0.5){ best=d; lead=c; } }
6317
+ for(const c of cand){ const d=Math.abs(c.u-uT); if(d>best+0.5){ best=d; lead=c; } }
5039
6318
  built.push({T,cand,dots,lead});
5040
6319
  }
5041
6320
  // Nothing to undo when the figure declines: the bus is a routing pass and
@@ -5045,6 +6324,7 @@ function renderScene(doc,y0){
5045
6324
  dots:i===g.cand.length-1?g.dots:null, arrow:i===g.cand.length-1}));
5046
6325
  }
5047
6326
  for(const e of edges){
6327
+ arrowOwner=e;
5048
6328
  const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
5049
6329
  // an edge is pure stroke: `stroke=` and `fill=` name the same channel
5050
6330
  // (stroke= wins), `text=` colours the [tail]/[mid]/[head] labels.
@@ -5062,31 +6342,99 @@ function renderScene(doc,y0){
5062
6342
  const wantsStart=e.op==='<->'||e.op==='<-', wantsEnd=e.op==='<->'||e.op==='->';
5063
6343
  const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
5064
6344
  const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
5065
- const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
6345
+ // ENDPOINT LABELS ANCHOR BY DISTANCE, NOT BY FRACTION (R? 0.4,
6346
+ // engine-backlog item 42). A `[tail]`/`[head]` label exists to say WHICH
6347
+ // END is called that; a placement at a FRACTION of the run says it only
6348
+ // when the run is long. On `patterns/topology-a`'s 70 px edge both `p1`s
6349
+ // landed mid-span, 23 px apart at the same y — two identical strings that
6350
+ // named neither end. So the request no longer carries a preferred
6351
+ // PARAMETER: it carries its OWN endpoint (`p`), the inward direction
6352
+ // (`q`), the OTHER end of the whole edge (`other`) and the edge's total
6353
+ // run (`tot`). The placement pass turns those into a fixed distance,
6354
+ // clamped to half the edge, and refuses any candidate whose box centre is
6355
+ // not strictly nearer its own endpoint than the other one.
6356
+ const runLen=pp=>{let s=0;for(let i=0;i+1<pp.length;i++)s+=Math.hypot(pp[i+1][0]-pp[i][0],pp[i+1][1]-pp[i][1]);return s;};
6357
+ // ── PORT MARKERS ARE THE TOPOLOGY CONVENTION (item 57) ────────────────
6358
+ // On a network diagram the interface name is written INSIDE the device
6359
+ // box, at the point the link crosses its border. That is not decoration:
6360
+ // it is what makes the name unambiguous, because a port belongs to a
6361
+ // DEVICE and the box is the device. `patterns/topology-a` is the case —
6362
+ // six links, twelve `p*` labels, every one of them floating in the seam
6363
+ // between two tiers where the reader has to decide by eye which box each
6364
+ // belongs to, and Access Node 1's two are the pair that cannot be
6365
+ // decided at all.
6366
+ //
6367
+ // So in the `topology` genre an endpoint label carries its OWN NODE, and
6368
+ // the placement pass below anchors it at the BORDER CROSSING, just inside
6369
+ // the box, instead of 16 px out along the shaft. Everything else about
6370
+ // the request is unchanged — same kind, same font, same collision
6371
+ // scoring, same `other`-end affinity filter — so the batch-2 machinery
6372
+ // does the work and only the candidate family is new.
6373
+ //
6374
+ // The owner is read from the GEOMETRY rather than passed in: `p` is the
6375
+ // point on a border and the owner is the box that border belongs to.
6376
+ // Every `seg()` call passes the tail end with A's border point and the
6377
+ // head end with B's, so this is the same answer with no call site to
6378
+ // keep in step.
6379
+ const portOwner=p=>{
6380
+ if(doc.genre!=='topology'||!A||!B||A.boundary||B.boundary) return null;
6381
+ const d=(N)=>Math.hypot(Math.max(N.x-p[0],0,p[0]-(N.x+N.w)),
6382
+ Math.max(N.y-p[1],0,p[1]-(N.y+N.h)));
6383
+ return A===B ? A : (d(A)<=d(B)?A:B);
6384
+ };
6385
+ const seg=(p,q,lbl,fs,other,tot)=>reqLabel({p,q,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end',
6386
+ other,tot,port:portOwner(p)});
5066
6387
  const bus=busRoute.get(e);
5067
6388
  if(bus){
5068
6389
  const pts=bus.pts;
5069
6390
  // data-bus is written LAST so every reader that keys on the
5070
6391
  // `d=… fill=none stroke=… stroke-width=1.6` prefix is unaffected.
5071
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+' data-bus="'+esc(bus.bus)+'"/>');
6392
+ // ONE TRUNK, ONE DASH (item 60). The shared trunk is stroked once per
6393
+ // member — that is the convention `data-bus` records — and each member's
6394
+ // dash pattern starts at ITS OWN path start, so three members whose legs
6395
+ // are different lengths lay three differently-phased dash patterns over
6396
+ // the same trunk and the gaps fill in: `bfd-session`'s admin trunk drew
6397
+ // SOLID purple under a legend that says the class is dashed, which is
6398
+ // the drawing contradicting the key. Every member's path ENDS at the
6399
+ // same point (the target's border), so aligning the phase there aligns
6400
+ // it along the whole shared run: the offset is whatever brings the
6401
+ // pattern to a period boundary at the path's end.
6402
+ const per=e.style==='dashed'?10:(e.style==='dotted'?6:0);
6403
+ const doff=per?' stroke-dashoffset="'+(((per-(runLen(pts)%per))%per).toFixed(2))+'"':'';
6404
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+doff+' data-bus="'+esc(bus.bus)+'"/>');
5072
6405
  noteSegs(e,pts);
5073
6406
  for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
5074
6407
  if(bus.dots) for(const d of bus.dots)
5075
6408
  lblsvg.push('<circle cx="'+d[0]+'" cy="'+d[1]+'" r="3" fill="'+col+'" stroke="none"/>');
5076
- // the trunk is drawn once by every member; the label and the arrowhead
5077
- // are drawn ONCE for the bus, which is the whole point of merging it.
5078
- if(bus.lead&&e.mid){ // longest rail run carries the one label
5079
- let bi=0,bl=-1;
5080
- for(let i=0;i+1<pts.length;i++){
5081
- const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
5082
- if(l>bl){ bl=l; bi=i; }
5083
- }
5084
- reqLabel({p:pts[bi],q:pts[bi+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:bi===0});
5085
- }
6409
+ // ── A MERGE-BUS LABELS AT EACH MEMBER'S OWN BLOCK EDGE (item 54) ───
6410
+ // A merged bus used to carry ONE label, on the shared trunk, because
6411
+ // that is what "they all say the same thing" seemed to mean. It reads
6412
+ // wrong. The trunk is the part of the drawing that belongs to no
6413
+ // member, and a word out there names the RAIL; what a reader wants to
6414
+ // know at each exit is what LEAVING THIS BLOCK means, and the place
6415
+ // they look for it is the connection point.
6416
+ //
6417
+ // So the text moves to the LEG and the trunk carries none: every
6418
+ // member with a label registers it as an ENDPOINT-kind request on its
6419
+ // own first segment, which anchors it 16 px from the source border —
6420
+ // the same distance and the same machinery a port label uses. On
6421
+ // `patterns/state-b` that is four small `reset` texts at the four
6422
+ // exits and one unlabelled trunk.
6423
+ //
6424
+ // THE REPETITION IS THE POINT, NOT A DEFECT. The admission rule for a
6425
+ // bus is that every member says the SAME thing (one label, one stroke,
6426
+ // one dash), so per-leg labelling necessarily repeats that one word
6427
+ // once per exit. That is the direction: the merge is about the INK of
6428
+ // the route, not about suppressing the statement at each origin.
6429
+ // the trunk is drawn once by every member; the arrowhead is drawn ONCE
6430
+ // for the bus, which is the whole point of merging its ink.
6431
+ if(e.mid)
6432
+ reqLabel({p:pts[0],q:pts[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,
6433
+ kind:'end',other:pts[pts.length-1],tot:runLen(pts)});
5086
6434
  if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
5087
6435
  continue;
5088
6436
  }
5089
- if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
6437
+ if(chBack(e)&&!pinned(e.a)&&!pinned(e.b)){
5090
6438
  // ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
5091
6439
  // Edge labels are DEFERRED: every label is registered against its
5092
6440
  // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
@@ -5154,8 +6502,8 @@ function renderScene(doc,y0){
5154
6502
  // is a placement a reader still reads as belonging to the loop.
5155
6503
  if(e.mid) reqLabel({p:sp[1],q:sp[2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false,
5156
6504
  ts:[0.5,0.2,0.8,-0.7,1.7,-1.4,2.4],tw:10});
5157
- if(e.tail) seg(sp[0],sp[1],0.5,e.tail,10);
5158
- if(e.head) seg(sp[3],sp[2],0.5,e.head,10);
6505
+ if(e.tail) seg(sp[0],sp[1],e.tail,10,sp[3],runLen(sp));
6506
+ if(e.head) seg(sp[3],sp[2],e.head,10,sp[0],runLen(sp));
5159
6507
  if(wantsStart) arrowTri(sp[0],sp[1],col);
5160
6508
  if(wantsEnd) arrowTri(sp[sp.length-1],sp[sp.length-2],col);
5161
6509
  continue;
@@ -5185,11 +6533,7 @@ function renderScene(doc,y0){
5185
6533
  // shorter than the label puts the box back on top of the source box
5186
6534
  // whatever parameter is chosen (bfd-session's "Detect expired, Echo
5187
6535
  // failed" is 169 px wide and a fixed 64 px stub buried it in INIT).
5188
- const srcStub=(pp,wpx)=>{
5189
- const a=pp[0], b=pp[1], L=Math.hypot(b[0]-a[0],b[1]-a[1])||1;
5190
- const k=Math.min(1,Math.max(64,wpx+24)/L);
5191
- return [a,[a[0]+(b[0]-a[0])*k, a[1]+(b[1]-a[1])*k]];
5192
- };
6536
+ const srcStub=srcStubOf;
5193
6537
  if(horiz){ // channel runs below the lanes
5194
6538
  const chY=occB+28+P.slot; // labels ride ON the channel
5195
6539
  const colR=r=>Math.max(...lane(r).map(n=>n.x+n.w));
@@ -5203,7 +6547,7 @@ function renderScene(doc,y0){
5203
6547
  const gx=colR(B.rank)+10+ring*7;
5204
6548
  pts.push([gx,chY],[gx,B.y+B.h*P.ef],[outSide(B,'r'),B.y+B.h*P.ef]);
5205
6549
  } else pts.push([tx,chY],[tx,outSide(B,'b')]);
5206
- if(e.mid){
6550
+ if(e.mid&&!P.bus){
5207
6551
  const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[1]===chY);
5208
6552
  reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
5209
6553
  }
@@ -5211,7 +6555,7 @@ function renderScene(doc,y0){
5211
6555
  const sx=A.x+A.w/2;
5212
6556
  const gy=occB+14+ring*12, chX=occR+28+P.slot, topY=chTop-14-ring*12;
5213
6557
  pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
5214
- if(e.mid){
6558
+ if(e.mid&&!P.bus){
5215
6559
  const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
5216
6560
  reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
5217
6561
  }
@@ -5228,18 +6572,45 @@ function renderScene(doc,y0){
5228
6572
  const gy=laneB(B.rank)+10+ring*7;
5229
6573
  pts.push([chX,gy],[B.x+B.w*P.ef,gy],[B.x+B.w*P.ef,outSide(B,'b')]);
5230
6574
  } else pts.push([chX,ty],[outSide(B,'r'),ty]);
5231
- if(e.mid){
6575
+ if(e.mid&&!P.bus){
5232
6576
  const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
5233
6577
  reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
5234
6578
  }
5235
6579
  }
6580
+ // A return-bus member's own leg is pts[0]->pts[1] on every form the plan
6581
+ // builds above; pts[1] is therefore where it joins the shared trunk. It is
6582
+ // recorded HERE, before the detour pass can splice points in front of it.
6583
+ if(P.bus&&pts.length>1){
6584
+ if(!busJoins.has(P.bus)) busJoins.set(P.bus,[]);
6585
+ busJoins.get(P.bus).push(pts[1]);
6586
+ }
5236
6587
  // non-incident nodes are obstacles for the channel runs too: a run
5237
6588
  // that would cut through a sibling (e.g. a pinned node parked on the
5238
6589
  // escape lane) detours around it instead of drawing across it. When
5239
6590
  // spanning the following corner point gives a shorter total run than
5240
6591
  // detour + remaining leg (a detour "spike"), the corner is dropped.
5241
- const obsN=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
6592
+ const obsN=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}))
6593
+ .concat(lblObs(e));
5242
6594
  const plen=pp=>{let s=0;for(let k=0;k+1<pp.length;k++)s+=Math.hypot(pp[k+1][0]-pp[k][0],pp[k+1][1]-pp[k][1]);return s;};
6595
+ // A SHORTCUT THAT BUYS A CROSSING IS NOT A SHORTCUT (item 45a). The
6596
+ // spike-prune below trades a planned channel corner for a straight span
6597
+ // whenever the span is shorter. It measures LENGTH and nothing else, so
6598
+ // on `showcase/arp-resolution` — where the element-label obstacle above
6599
+ // is what first pushed the shaft off the label — it happily replaced the
6600
+ // orthogonal channel corner with a long diagonal straight across the
6601
+ // broadcast arrow. Ink went down, the reader's job got harder. The prune
6602
+ // therefore also asks whether the span crosses a line already on the
6603
+ // canvas; `edgeSegs` holds exactly those, in draw order.
6604
+ const xseg=(a1,a2,b1,b2)=>{
6605
+ const d=(p1,p2,p3)=>(p2[0]-p1[0])*(p3[1]-p1[1])-(p2[1]-p1[1])*(p3[0]-p1[0]);
6606
+ const d1=d(a1,a2,b1), d2=d(a1,a2,b2), d3=d(b1,b2,a1), d4=d(b1,b2,a2);
6607
+ return ((d1>0)!==(d2>0))&&((d3>0)!==(d4>0));
6608
+ };
6609
+ const spanCuts=pp=>{
6610
+ for(let k=0;k+1<pp.length;k++)
6611
+ for(const g of edgeSegs) if(g.e!==e&&xseg(pp[k],pp[k+1],g.p,g.q)) return true;
6612
+ return false;
6613
+ };
5243
6614
  for(let i=0;i+1<pts.length;i++){
5244
6615
  const d=routeAround(pts[i],pts[i+1],obsN);
5245
6616
  if(!d) continue;
@@ -5247,7 +6618,7 @@ function renderScene(doc,y0){
5247
6618
  if(i+2<pts.length){
5248
6619
  const span=segHitsObs(pts[i],pts[i+2],obsN)
5249
6620
  ?routeAround(pts[i],pts[i+2],obsN):[pts[i],pts[i+2]];
5250
- if(span&&plen(span)<plen(d)+Math.hypot(pts[i+2][0]-pts[i+1][0],pts[i+2][1]-pts[i+1][1])-1e-6){
6621
+ if(span&&!spanCuts(span)&&plen(span)<plen(d)+Math.hypot(pts[i+2][0]-pts[i+1][0],pts[i+2][1]-pts[i+1][1])-1e-6){
5251
6622
  ins=span.slice(1,-1); drop=1;
5252
6623
  }
5253
6624
  }
@@ -5255,12 +6626,89 @@ function renderScene(doc,y0){
5255
6626
  i+=ins.length;
5256
6627
  }
5257
6628
  for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
5258
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6629
+ // RETURN BUS (item 46): the collapsed members draw the same trunk, so the
6630
+ // path carries `data-bus` for exactly the reason the forward bus does —
6631
+ // it is what lets a reader (and `layout-lint`'s coincident term) tell a
6632
+ // deliberate shared trunk from two lines hidden under each other. Written
6633
+ // LAST, after stroke-width, so no existing reader breaks.
6634
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash
6635
+ +(P.bus?' data-bus="'+esc(P.bus)+'"':'')+'/>');
5259
6636
  noteSegs(e,pts);
5260
- if(e.tail) seg(pts[0],pts[1],0.5,e.tail,10);
5261
- if(e.head) seg(pts[pts.length-1],pts[pts.length-2],0.5,e.head,10);
5262
- if(wantsStart) arrowTri(pts[0],pts[1],col);
5263
- if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
6637
+ if(P.busTail){
6638
+ // JUNCTION DOTS. Every member's OWN leg ends where the shared run
6639
+ // begins — pts[1] on every route form the channel plan builds (the drop
6640
+ // to the rail, the reach out to the trunk) — so the joins are collected
6641
+ // as the members draw and the last one dots them. The join FARTHEST
6642
+ // from the arrival is the rail's own end: a corner, not a junction, and
6643
+ // the forward bus does not dot its corners either.
6644
+ const jx=(busJoins.get(P.bus)||[]).slice(), tip=pts[pts.length-1];
6645
+ if(jx.length>1){
6646
+ let far=0;
6647
+ for(let i2=1;i2<jx.length;i2++)
6648
+ if(Math.hypot(jx[i2][0]-tip[0],jx[i2][1]-tip[1])>Math.hypot(jx[far][0]-tip[0],jx[far][1]-tip[1])) far=i2;
6649
+ jx.splice(far,1);
6650
+ }
6651
+ for(const d of jx)
6652
+ lblsvg.push('<circle cx="'+d[0]+'" cy="'+d[1]+'" r="3" fill="'+col+'" stroke="none"/>');
6653
+ }
6654
+ // ITEM 54: the label rides the member's OWN leg, at its origin block's
6655
+ // edge, and the shared trunk carries none. See the forward bus above
6656
+ // for why — the trunk belongs to no member, and the connection point
6657
+ // is where a branch's meaning is read.
6658
+ if(e.mid&&P.bus)
6659
+ reqLabel({p:pts[0],q:pts[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,
6660
+ kind:'end',other:pts[pts.length-1],tot:runLen(pts)});
6661
+ if(e.tail) seg(pts[0],pts[1],e.tail,10,pts[pts.length-1],runLen(pts));
6662
+ if(e.head) seg(pts[pts.length-1],pts[pts.length-2],e.head,10,pts[0],runLen(pts));
6663
+ if(wantsStart&&(!P.bus||P.busTail)) arrowTri(pts[0],pts[1],col);
6664
+ if(wantsEnd&&(!P.bus||P.busTail)) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
6665
+ continue;
6666
+ }
6667
+ // ── corridor route (items 41/46): side chosen by occupancy, lane by
6668
+ // distance from the shared target. Same detour and label discipline as
6669
+ // the channel routes — a corridor is a channel the figure chose.
6670
+ const lrPts=longRoute(e);
6671
+ if(lrPts){
6672
+ for(const p of lrPts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
6673
+ esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(lrPts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6674
+ noteSegs(e,lrPts);
6675
+ if(e.mid){
6676
+ // WHICH LEG CARRIES THE LABEL, and it is the OPPOSITE of the channel
6677
+ // router's answer. A channel run is the figure's outermost lane, so a
6678
+ // label out there says nothing about which line it names and the stub
6679
+ // wins. A CORRIDOR was chosen precisely because that margin is free —
6680
+ // that is the whole selection rule — so its long run is the clear ink
6681
+ // and the stub is the squeezed end, wedged between a source and the
6682
+ // figure's edge. Measured on dhcp-client: stub-first put
6683
+ // "server refuses / NAK -> drop IP" into the RENEWING/REBINDING corner
6684
+ // and took `lblcol` 2 -> 5; run-first leaves it on the open margin. The
6685
+ // stub stays as the ALTERNATE, so a corridor whose run is busy can
6686
+ // still fall back to it.
6687
+ const ss=srcStubOf(lrPts,lblPx(e.mid));
6688
+ let bi=0,bl=-1;
6689
+ for(let i=0;i+1<lrPts.length;i++){
6690
+ const dxx=lrPts[i+1][0]-lrPts[i][0], dyy=lrPts[i+1][1]-lrPts[i][1];
6691
+ if(Math.abs(dxx)>0.5&&Math.abs(dyy)>0.5) continue; // axis-aligned runs only
6692
+ const l=Math.hypot(dxx,dyy);
6693
+ if(l>bl){ bl=l; bi=i; }
6694
+ }
6695
+ // ...WITH ONE EXCEPTION, AND IT IS THE FLOWCHART CONVENTION (`FLOWCHART-ROLE-KEYWORDS`).
6696
+ // A short branch marker leaving a decision — `yes`, `no` — names that
6697
+ // BRANCH, and it reads as the branch's name only next to the decision.
6698
+ // The placement pass already knows this (its `branch` test), and it is
6699
+ // reachable only from the FIRST segment, so a corridor whose label is a
6700
+ // branch marker hands the stub back the primary carrier: flowchart-b's
6701
+ // left-margin `no` otherwise sat halfway down the figure, level with the
6702
+ // OTHER `no`, naming neither.
6703
+ const branch=A.role==='decision'&&String(e.mid).length<=3&&!String(e.mid).includes('\n');
6704
+ if(bl<0||branch) reqLabel({p:ss[0],q:ss[1],alt:bl<0?null:[lrPts[bi],lrPts[bi+1]],
6705
+ text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
6706
+ else reqLabel({p:lrPts[bi],q:lrPts[bi+1],alt:[ss[0],ss[1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
6707
+ }
6708
+ if(e.tail) seg(lrPts[0],lrPts[1],e.tail,10,lrPts[lrPts.length-1],runLen(lrPts));
6709
+ if(e.head) seg(lrPts[lrPts.length-1],lrPts[lrPts.length-2],e.head,10,lrPts[0],runLen(lrPts));
6710
+ if(wantsStart) arrowTri(lrPts[0],lrPts[1],col);
6711
+ if(wantsEnd) arrowTri(lrPts[lrPts.length-1],lrPts[lrPts.length-2],col);
5264
6712
  continue;
5265
6713
  }
5266
6714
  const chain=chains.get(e);
@@ -5354,8 +6802,8 @@ function renderScene(doc,y0){
5354
6802
  esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
5355
6803
  noteSegs(e,pts);
5356
6804
  if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
5357
- if(e.tail) seg(p0,pts[1],0.4,e.tail,10);
5358
- if(e.head) seg(p1,pts[pts.length-2],0.4,e.head,10);
6805
+ if(e.tail) seg(p0,pts[1],e.tail,10,p1,runLen(pts));
6806
+ if(e.head) seg(p1,pts[pts.length-2],e.head,10,p0,runLen(pts));
5359
6807
  if(wantsStart) arrowTri(pts[0],pts[1],col);
5360
6808
  if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
5361
6809
  continue;
@@ -5370,7 +6818,8 @@ function renderScene(doc,y0){
5370
6818
  // (routeAround). Obstacle-free edges keep the plain line unchanged.
5371
6819
  let route=null;
5372
6820
  if(A!==B){
5373
- const obs=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
6821
+ const obs=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}))
6822
+ .concat(lblObs(e));
5374
6823
  for(const k in gBox){
5375
6824
  const b=gBox[k];
5376
6825
  const inG=(px,py)=>px>b.x0&&px<b.x1&&py>b.yA&&py<b.yB;
@@ -5394,8 +6843,8 @@ function renderScene(doc,y0){
5394
6843
  }
5395
6844
  reqLabel({p:route[bi],q:route[bi+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:bi===0});
5396
6845
  }
5397
- if(e.tail) seg(route[0],route[1],0.25,e.tail,10);
5398
- if(e.head) seg(route[route.length-1],route[route.length-2],0.25,e.head,10);
6846
+ if(e.tail) seg(route[0],route[1],e.tail,10,route[route.length-1],runLen(route));
6847
+ if(e.head) seg(route[route.length-1],route[route.length-2],e.head,10,route[0],runLen(route));
5399
6848
  if(wantsStart) arrowTri(route[0],route[1],col);
5400
6849
  if(wantsEnd) arrowTri(route[route.length-1],route[route.length-2],col);
5401
6850
  for(const pP of route){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+4-y0-20); }
@@ -5406,8 +6855,8 @@ function renderScene(doc,y0){
5406
6855
  if(e.mid)
5407
6856
  reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
5408
6857
  // endpoint labels at the tail/head positions (three-position model, `EDGE-LABEL-PLACEMENT`)
5409
- if(e.tail) seg([x1,yy1],[x2,yy2],0.18,e.tail,10);
5410
- if(e.head) seg([x1,yy1],[x2,yy2],0.82,e.head,10);
6858
+ if(e.tail) seg([x1,yy1],[x2,yy2],e.tail,10,[x2,yy2],runLen([[x1,yy1],[x2,yy2]]));
6859
+ if(e.head) seg([x2,yy2],[x1,yy1],e.head,10,[x1,yy1],runLen([[x1,yy1],[x2,yy2]]));
5411
6860
  if(wantsStart) arrowTri([x1,yy1],[x2,yy2],col);
5412
6861
  if(wantsEnd) arrowTri([x2,yy2],[x1,yy1],col);
5413
6862
  }
@@ -5442,6 +6891,9 @@ function renderScene(doc,y0){
5442
6891
  else { ox=cx-bw/2; oy=(bdy>=0?cy+17:cy-10)-13*bl/2-1.5; }
5443
6892
  obst.push({x:ox,y:oy,w:bw,h:bh,n:null});
5444
6893
  }
6894
+ // gap between two boxes, 0 when they touch or overlap (item 43's tie-break)
6895
+ const boxGap=(a,b)=>Math.hypot(Math.max(0,Math.max(a.x-(b.x+b.w), b.x-(a.x+a.w))),
6896
+ Math.max(0,Math.max(a.y-(b.y+b.h), b.y-(a.y+a.h))));
5445
6897
  const ovl=(a,b)=>{
5446
6898
  const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
5447
6899
  const iy=Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y);
@@ -5461,7 +6913,56 @@ function renderScene(doc,y0){
5461
6913
  }
5462
6914
  return t1>t0;
5463
6915
  };
6916
+ // distance from a segment to a label box, measured to the box CENTRE —
6917
+ // the same point F5 measures from, so the scorer and the gate agree.
6918
+ const segBoxD=(p,q,b)=>{
6919
+ const cx=b.x+b.w/2, cy=b.y+b.h/2;
6920
+ const vx=q[0]-p[0], vy=q[1]-p[1], L2=vx*vx+vy*vy;
6921
+ const t=L2?Math.max(0,Math.min(1,((cx-p[0])*vx+(cy-p[1])*vy)/L2)):0;
6922
+ return Math.hypot(cx-(p[0]+t*vx), cy-(p[1]+t*vy));
6923
+ };
6924
+ // STANDOFF (item 59): the shortest distance from a carrying segment to the
6925
+ // label's own box — 0 when the line enters it. This is the distance a
6926
+ // reader crosses to get from the word to the line, and it is the quantity
6927
+ // the cap below is written in. It is NOT `segBoxD`, which measures to the
6928
+ // box CENTRE: a 117 px label beside a near-vertical shaft has its centre
6929
+ // 57 px away and its nearest edge 6 px away, and only the second number
6930
+ // says whether the two touch on the page.
6931
+ const segBoxGap=(p,q,b)=>{
6932
+ if(segHit(p,q,b)) return 0;
6933
+ const ptRect=(x,y)=>Math.hypot(Math.max(b.x-x,0,x-(b.x+b.w)),
6934
+ Math.max(b.y-y,0,y-(b.y+b.h)));
6935
+ const segPt=(x,y)=>{
6936
+ const vx=q[0]-p[0], vy=q[1]-p[1], L2=vx*vx+vy*vy;
6937
+ const t=L2?Math.max(0,Math.min(1,((x-p[0])*vx+(y-p[1])*vy)/L2)):0;
6938
+ return Math.hypot(x-(p[0]+t*vx), y-(p[1]+t*vy));
6939
+ };
6940
+ let d=Math.min(ptRect(p[0],p[1]), ptRect(q[0],q[1]));
6941
+ for(const c of [[b.x,b.y],[b.x+b.w,b.y],[b.x,b.y+b.h],[b.x+b.w,b.y+b.h]])
6942
+ d=Math.min(d,segPt(c[0],c[1]));
6943
+ return d;
6944
+ };
6945
+ // THE CAP, AND WHERE THE NUMBER COMES FROM (item 59). Measured over the
6946
+ // 137 mid-labels the corpus places: min 0.3 px, median
6947
+ // 3.0, p75 5.3, p90 6.0, p95 6.0 — the base side offset is 6 px and nine
6948
+ // labels in ten sit at it or inside it. Above that the DRAWN corpus uses
6949
+ // 7.9, 11.0, 17.4 and 17.4 px (all four on `statechart/bfd-session`), and
6950
+ // one figure reaches 32.6 — `layout-compare/srl-evpn-irb-auto`, which is
6951
+ // refused at geometry time and never published.
6952
+ //
6953
+ // 18 px, and the corpus chose it. Caps of 8 and of 12 were both built and
6954
+ // measured: each one pulls bfd's two 17.4 px labels ("rx Down" and
6955
+ // "rx Init, rx Up") in to 6.0 px, where their own self-loop then draws
6956
+ // through them — lblcol 2 -> 4 on that figure, in both runs. That relief
6957
+ // is EARNED, so the cap has to sit above it; 18 is three times the base
6958
+ // offset, just clear of the largest standoff the corpus can justify, and
6959
+ // it still refuses a float half again as far. Past the cap a mid-label is
6960
+ // not simply placed further out — it takes a LEADER (below), so the
6961
+ // association is drawn rather than left to proximity.
6962
+ const STANDOFF_CAP=18;
5464
6963
  const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
6964
+ // Endpoint-label anchor distance (item 42). See the `kind==='end'` branch.
6965
+ const END_D=16;
5465
6966
  // SLOPE CLEARANCE (`cl`): "3 px above the line" clears the line only where
5466
6967
  // the box touches it. The offsets are axis-aligned while the segment is
5467
6968
  // not, so on a diagonal the line keeps climbing across the box's WIDTH and
@@ -5473,27 +6974,111 @@ function renderScene(doc,y0){
5473
6974
  // below: a label 7 px further out to stop being struck is worth it, a
5474
6975
  // 90 px shove for a long label on a 45 degree line is not, and the scorer
5475
6976
  // decides which case it is holding.
5476
- const cand=(r,t,side,cl)=>{
6977
+ const cand=(r,t,side,cl,ep)=>{
5477
6978
  const lines=String(r.text).split('\n'), n=lines.length;
5478
6979
  const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
5479
6980
  const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
5480
6981
  const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
5481
6982
  const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
5482
6983
  const sdx=Math.abs(r.q[0]-r.p[0]), sdy=Math.abs(r.q[1]-r.p[1]);
5483
- let ex=0;
6984
+ let ex=ep||0; // extra perpendicular push (endpoint labels' escape)
5484
6985
  if(cl){
5485
- if(side==='above'||side==='below') ex=sdx>1e-9?Math.min(1,sdy/sdx)*w/2:0;
5486
- else if(side==='right'||side==='left') ex=sdy>1e-9?Math.min(1,sdx/sdy)*h/2:0;
6986
+ if(side==='above'||side==='below') ex+=sdx>1e-9?Math.min(1,sdy/sdx)*w/2:0;
6987
+ else if(side==='right'||side==='left') ex+=sdy>1e-9?Math.min(1,sdx/sdy)*h/2:0;
5487
6988
  }
6989
+ // FLUSH THE INK AGAINST THE SHAFT (item 59). `w` is an ESTIMATE — 6.5 px
6990
+ // per character at font-size 11 — and it runs about a sixth wide of what
6991
+ // the browser actually sets. That slack has to go somewhere, and until
6992
+ // this release it went on whichever side the anchor left it: a `left`
6993
+ // label was anchored `start` at the box's LEFT edge, so every pixel of
6994
+ // over-estimate opened up between the last glyph and the line the label
6995
+ // names. Measured on `showcase/tcp-state-machine`: both
6996
+ // "CLOSE / delete TCB" boxes hug their purple shaft at the placement's
6997
+ // own 6 px, and the TEXT stops 23 px short of it — which is the distance
6998
+ // the maintainer's review calls a drift the tone pairing cannot rescue.
6999
+ // A side label is therefore anchored on the edge that FACES its shaft:
7000
+ // `end` on the left, `start` on the right, so the ragged edge falls away
7001
+ // from the line and the estimate's slack can never be read as standoff.
7002
+ // The BOX is unchanged, so nothing in the collision scoring moves.
5488
7003
  let bx,by,x,anchor=n>1?'middle':'start';
5489
7004
  if(side==='on') { bx=mx-w/2; by=my-4-up; anchor='middle'; }
5490
7005
  else if(side==='above') { bx=mx-w/2; by=my-3-h-ex; anchor='middle'; }
5491
7006
  else if(side==='below') { bx=mx-w/2; by=my+3+ex; anchor='middle'; }
5492
- else if(side==='right') { bx=mx+6+ex; by=my-h/2; }
5493
- else { bx=mx-6-w-ex; by=my-h/2; }
5494
- x=anchor==='middle'?bx+w/2:bx;
7007
+ else if(side==='right') { bx=mx+6+ex; by=my-h/2; anchor='start'; }
7008
+ else { bx=mx-6-w-ex; by=my-h/2; anchor='end'; }
7009
+ x=anchor==='middle'?bx+w/2:anchor==='end'?bx+w:bx;
5495
7010
  return {x,y:by+up,anchor,t,side,ex,box:{x:bx,y:by,w,h}};
5496
7011
  };
7012
+ // ── PORT-MARKER CANDIDATES (item 57) ──────────────────────────────────
7013
+ // The 16 px own-port anchor of item 42 becomes a BORDER-CROSSING anchor:
7014
+ // the box sits just inside the node, on the side the link crosses, with
7015
+ // its along-border position free so two ports on one border spread
7016
+ // instead of stacking. `PORT_IN` is the gap from the border line to the
7017
+ // marker (enough that the box does not sit ON the stroke); `PORT_END` is
7018
+ // how near a corner a marker may be written; the offsets are one marker
7019
+ // width apart, which is exactly the pitch two ports need to be two.
7020
+ const PORT_IN=2, PORT_END=4, PORT_HUG=25, PORT_OUT=60;
7021
+ // The node's own drawn label, computed by the SAME formulas the node pass
7022
+ // emits it with (shrink-to-fit included), so the obstacle and the drawing
7023
+ // cannot disagree about where a name is.
7024
+ const nodeInk=n=>{
7025
+ if(!n||!n.label) return null;
7026
+ const nl=String(n.label).split('\n');
7027
+ const need=Math.max(...nl.map(cw))*CH;
7028
+ const avail=2*inscribedHalfW(shapeAxes(n),nl.length*8)-16;
7029
+ const fs=(n.rigid&&need>avail)?Math.max(8,FONT*avail/need):FONT;
7030
+ const lw=need*fs/FONT, lh=nl.length*fs*1.15;
7031
+ return {x:n.x+n.w/2-lw/2, y:n.y+n.h/2-lh/2, w:lw, h:lh};
7032
+ };
7033
+ // A label's box is its LINE box: full leading above and below. Its INK is
7034
+ // the glyph band inside that, and the two questions are different (see the
7035
+ // own-name term in the scorer).
7036
+ const inkBox=(b,fs)=>({x:b.x, y:b.y+fs*0.14, w:b.w, h:Math.max(1,b.h-fs*0.28)});
7037
+ const portCands=r=>{
7038
+ const N=r.port, P=r.p, out=[];
7039
+ if(!N) return out;
7040
+ const lines=String(r.text).split('\n'), nL=lines.length;
7041
+ const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
7042
+ const lh=r.fs*1.3, h=(nL-1)*lh+r.fs*1.1, up=(nL-1)*lh/2+r.fs*0.85;
7043
+ // Which border did the link cross? The nearest side of the box.
7044
+ const dl=Math.abs(P[0]-N.x), dr=Math.abs(P[0]-(N.x+N.w)),
7045
+ dt=Math.abs(P[1]-N.y), db=Math.abs(P[1]-(N.y+N.h));
7046
+ const m=Math.min(dl,dr,dt,db);
7047
+ const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
7048
+ const vert=(side==='top'||side==='bottom');
7049
+ // ROOM. A marker that cannot be written between the two corners of the
7050
+ // side it crosses has no inside form at all; the ordinary outside
7051
+ // family is then the only one offered, which is the stated fallback.
7052
+ const span=vert?N.w:N.h, need=(vert?w:h)+2*PORT_END;
7053
+ if(span<need) return out;
7054
+ const lo=(vert?N.x:N.y)+PORT_END, hi=(vert?N.x+N.w:N.y+N.h)-PORT_END;
7055
+ const pitch=(vert?w:h)+4;
7056
+ const u0=vert?P[0]:P[1];
7057
+ // TWO RINGS OF CANDIDATES, AND BOTH ARE AT THE CROSSING.
7058
+ // in = inside the border, the convention proper;
7059
+ // out = the mirror image, just OUTSIDE the same border.
7060
+ // The second exists because a small box genuinely runs out of room —
7061
+ // `reference/topology`'s L1 is 42 x 36 and holds one port name, not two
7062
+ // — and the alternative fallback (the ordinary 16 px-along-the-shaft
7063
+ // family) puts the second name out in the seam BETWEEN the two shafts
7064
+ // arriving at that box, which measured F5 margin 0.47 px: a label that
7065
+ // has stopped saying which of two lines it names. Hugging the border it
7066
+ // could not get inside keeps it on its own port, which is the fact it
7067
+ // exists to state. It is surcharged, so it is taken only after inside
7068
+ // has failed.
7069
+ for(const inside of [true,false]) for(const k of [0,1,-1,2,-2]){
7070
+ const u=Math.max(lo+(vert?w:h)/2, Math.min(hi-(vert?w:h)/2, u0+k*pitch));
7071
+ let bx,by;
7072
+ if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-PORT_IN-h:N.y+N.h+PORT_IN; }
7073
+ else if(side==='top'){ bx=u-w/2; by=inside?N.y+PORT_IN:N.y-PORT_IN-h; }
7074
+ else if(side==='left'){ bx=inside?N.x+PORT_IN:N.x-PORT_IN-w; by=u-h/2; }
7075
+ else { bx=inside?N.x+N.w-PORT_IN-w:N.x+N.w+PORT_IN; by=u-h/2; }
7076
+ out.push({x:bx+w/2, y:by+up, anchor:'middle', t:undefined, side,
7077
+ ex:Math.abs(k)*pitch, inPort:inside, hug:!inside,
7078
+ box:{x:bx,y:by,w,h}});
7079
+ }
7080
+ return out;
7081
+ };
5497
7082
  const placed=[];
5498
7083
  // A request may name a SECOND carrying segment (`alt`). Back edges do: the
5499
7084
  // stub leaving the source is the preferred carrier because it says which
@@ -5504,17 +7089,36 @@ function renderScene(doc,y0){
5504
7089
  // flat surcharge so it is taken only when the stub really has nowhere.
5505
7090
  if(lblReq.length) for(const r0 of lblReq){
5506
7091
  const carriers=[[r0.p,r0.q]].concat(r0.alt?[r0.alt]:[]);
5507
- let best=null,bestS=Infinity;
7092
+ let best=null,bestS=Infinity, anyBest=null,anyS=Infinity, offBest=null,offS=Infinity,
7093
+ farBest=null,farS=Infinity;
5508
7094
  for(let ci=0;ci<carriers.length;ci++){
5509
7095
  const r=ci?Object.assign({},r0,{p:carriers[ci][0],q:carriers[ci][1]}):r0;
5510
7096
  const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
5511
7097
  const across=Math.abs(dx)>=Math.abs(dy);
5512
- let sides, ts, tPref, apWant=null;
7098
+ let sides, ts, tPref, apWant=null, tScale=r.tw||70;
5513
7099
  if(r.kind==='end'){
5514
- // endpoint labels keep their historical spot as first choice
7100
+ // DISTANCE, NOT FRACTION (item 42). `END_D` is 16 px, chosen from the
7101
+ // instrument rather than from taste: F5's own filter (2) treats a
7102
+ // label within 18 px of a node border as one the reader ties to the
7103
+ // NODE and not to an edge-edge ambiguity, and that is precisely the
7104
+ // reading a port label wants. Anchoring at 16 px puts every endpoint
7105
+ // label inside that reading on every edge long enough to hold it.
7106
+ // The clamp is half the edge's whole run AND half the carrying
7107
+ // segment: a label may never reach the middle of the thing it slides
7108
+ // along, because the middle names no end. Below the clamp the label
7109
+ // sits as near its port as the geometry allows.
7110
+ const Lseg=Math.hypot(dx,dy)||1;
7111
+ const cap=Math.min(0.5*(r.tot||Lseg), 0.5*Lseg);
7112
+ const d0=Math.min(END_D,cap);
7113
+ // The push set is in PIXELS along the edge, outward-biased: a label
7114
+ // driven off its anchor by a collision walks INTO the span (it can
7115
+ // never walk off the end), and it stops at the clamp.
7116
+ const dset=[0,5,-4,10,-8,16,24].map(k=>d0+k).filter(d=>d>=2&&d<=cap+1e-9);
7117
+ if(!dset.length) dset.push(Math.max(1,cap));
7118
+ tPref=d0/Lseg;
7119
+ ts=dset.map(d=>d/Lseg);
7120
+ tScale=1.2*Lseg; // the pull back to the anchor is priced in PIXELS
5515
7121
  sides=['on'].concat(across?['above','below']:['right','left']);
5516
- tPref=r.t0;
5517
- ts=[r.t0,r.t0-0.06,r.t0+0.06,r.t0-0.12,r.t0+0.12].map(CLAMP);
5518
7122
  } else {
5519
7123
  sides=across?['above','below']:['right','left'];
5520
7124
  // ANTI-PARALLEL PAIRS: the label belongs on the OUTSIDE of its own
@@ -5553,17 +7157,86 @@ function renderScene(doc,y0){
5553
7157
  tPref=branch?0.22:0.5;
5554
7158
  ts=r.ts?r.ts:(branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72]);
5555
7159
  }
5556
- for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]){
5557
- const c=cand(r,t,sides[si],cl);
7160
+ // The perpendicular escape (item 42). When every in-line position is
7161
+ // blocked, an endpoint label must still be able to say which end it
7162
+ // names, and the only remaining freedom is ACROSS the line. 9 px is one
7163
+ // text line-height: enough to clear a co-located neighbour, priced per
7164
+ // pixel below like every other displacement, so it is taken only when
7165
+ // the in-line positions really have nowhere.
7166
+ const eps=r.kind==='end'?[0,9]:[0];
7167
+ // ── THE CANDIDATE FAMILY ──────────────────────────────────────────
7168
+ // Normally: sides x parameters x slope-clearance x escape, as above.
7169
+ // A PORT MARKER (item 57) adds a family in FRONT of it — positions
7170
+ // just inside its own node's border at the crossing — and keeps the
7171
+ // ordinary family behind, surcharged. That is the fallback the
7172
+ // direction asks for, and it is a PRICE rather than a test, so a
7173
+ // marker is pushed back outside exactly when inside is genuinely
7174
+ // blocked (a box too narrow to hold the text, or the node's own name
7175
+ // already there) and never merely because outside happens to be tidy.
7176
+ const cs=[];
7177
+ if(r.port) for(const c of portCands(r)) cs.push({c,si:0});
7178
+ for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]) for(const ep of eps)
7179
+ cs.push({c:cand(r,t,sides[si],cl,ep),si,out:!!r.port});
7180
+ for(const CS of cs){
7181
+ const c=CS.c, si=CS.si, t=(c.t===undefined?tPref:c.t);
7182
+ c.car=[r.p,r.q]; // the carrier this candidate rides (item 59)
7183
+ // ENDPOINT AFFINITY IS A HARD FILTER, NOT A PRICE. A tail label whose
7184
+ // box centre is nearer the head than the tail has stopped stating the
7185
+ // one fact it exists to state, and no amount of collision relief buys
7186
+ // that back. Candidates that fail are dropped; `bestAny` keeps the
7187
+ // least-bad one so a figure with no legal position still draws.
7188
+ let owns=true;
7189
+ if(r.kind==='end'&&r.other){
7190
+ const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
7191
+ owns=Math.hypot(ccx-r.p[0],ccy-r.p[1])<Math.hypot(ccx-r.other[0],ccy-r.other[1]);
7192
+ }
5558
7193
  let s=0;
5559
7194
  for(const b of placed) s+=3*ovl(c.box,b);
5560
- for(const o of obst) s+=(o.n===r.A||o.n===r.B?6:2.4)*ovl(c.box,o);
7195
+ // A PORT MARKER IS SUPPOSED TO BE INSIDE ITS OWN BOX (item 57), so
7196
+ // that one node is not charged for it. Every other box still is, at
7197
+ // the usual weights — a marker that has slid out of its own device
7198
+ // and into a neighbour is the defect this convention exists to end.
7199
+ for(const o of obst){ if(c.inPort&&o.n===r.port) continue;
7200
+ s+=(o.n===r.A||o.n===r.B?6:2.4)*ovl(c.box,o); }
7201
+ // ...but the node's own NAME is ink, and a port written across it is
7202
+ // the same misreading `band` captions were fixed for (item 49).
7203
+ //
7204
+ // MEASURED INK TO INK, not line box to line box. Everywhere else in
7205
+ // this pass a label is its generous LINE box, which is right when the
7206
+ // question is "is there room to put this somewhere". Here the two
7207
+ // boxes are already inside one 36 px node and the question is whether
7208
+ // the GLYPHS touch: a 13 px name's line box is 15 px tall and its
7209
+ // letters are about 9, so the line boxes overlap by a pixel or two in
7210
+ // every ordinary topology node and the marker was priced out of a box
7211
+ // it fits in perfectly well. `topology-a` measured 4 of 12 markers
7212
+ // inside on line boxes and 12 of 12 on ink.
7213
+ if(c.inPort){ const li=nodeInk(r.port);
7214
+ if(li) s+=6*ovl(inkBox(c.box,r.fs), {x:li.x,y:li.y+li.h*0.09,w:li.w,h:li.h*0.82}); }
7215
+ // the marker leaves its box only under duress, and leaves its PORT
7216
+ // only after that (PORT_HUG < PORT_OUT)
7217
+ s+=CS.out?PORT_OUT:(c.hug?PORT_HUG:0);
5561
7218
  for(const a of arrowBox) s+=4*ovl(c.box,a);
5562
7219
  // The label's OWN edge is charged like any other. It used to be exempt
5563
7220
  // (`g.e!==r.e`), which made a label lying across the line it names FREE
5564
7221
  // — and that is the single commonest way a label stops saying which
5565
7222
  // line it belongs to, so the exemption was paying for the defect.
5566
7223
  for(const g of edgeSegs) if(segHit(g.p,g.q,c.box)) s+=26;
7224
+ // THE PERPENDICULAR STEP STAYS CHEAP, AND THAT IS A MEASURED RESULT
7225
+ // (item 59). The direction was "relief slides along the shaft first,
7226
+ // steps away only within a cap", and the obvious way to write the
7227
+ // first half is to make the step dearer than the slide — the slide is
7228
+ // priced in parameter units, `tScale`/L per pixel, which is 0.35/px on
7229
+ // a 200 px run, exactly what the step costs. Tripling the step to
7230
+ // 0.9/px was built and measured, and the corpus refused it:
7231
+ // `showcase/tcp-state-machine` went lblcol 0 -> 4 and
7232
+ // `patterns/erd-a` 0 -> 1. The reason is that a mid-label's ENTIRE
7233
+ // perpendicular displacement is slope clearance — the smallest step
7234
+ // that gets the box off its own diagonal shaft — and a strike costs
7235
+ // 26 in this scorer, so at 0.9/px any clearance over 29 px is outbid
7236
+ // by lying across the line the label names. Pricing the step above
7237
+ // the strike buys strikes. So the along-first half of item 59 is
7238
+ // carried by the CAP below (a step can never exceed it) and by the
7239
+ // along-shaft candidate family, not by this coefficient.
5567
7240
  s+=0.35*c.ex; // price of the slope-clearance displacement
5568
7241
  s+=ci*30; // price of leaving the preferred carrier
5569
7242
  // Two identical texts sitting side by side is the defect in its purest
@@ -5579,16 +7252,203 @@ function renderScene(doc,y0){
5579
7252
  // offers parameters outside [0,1] states its own weight so its escape
5580
7253
  // positions cost what they are worth in pixels rather than being
5581
7254
  // priced out by the length of the thing they slide along.
5582
- s+=(r.tw||70)*Math.abs(t-tPref)+si*10;
5583
- if(c.box.x<2) s+=400; // would fall off the left margin
5584
- if(s<bestS-1e-9){ bestS=s; best=c; }
7255
+ s+=tScale*Math.abs(t-tPref)+si*10;
7256
+ // ASSOCIATION MARGIN, IN THE SCORER AT LAST (F5, §14).
7257
+ // The legibility floor asks how much NEARER a label is to the line it
7258
+ // names than to the next line, and until now that question was only
7259
+ // ever asked after the fact, by the lint. Two positions can both be
7260
+ // collision-free and one of them still sit in the gap BETWEEN two
7261
+ // lines, where the reader has to guess; nothing in the score saw the
7262
+ // difference and the side-order term then decided by index.
7263
+ // Measured on `showcase/arp-resolution` (item 58's acceptance
7264
+ // figure): `1: heard, ignored` scored 0 above its line and 0 below
7265
+ // it, took `above` by index, and parked in the 9.4 px gap between the
7266
+ // broadcast line and the reciprocal pair — which then left the ARP
7267
+ // REPLY's own 221 px label no clean position on a 194 px run, and it
7268
+ // drew struck. With the margin priced, that label takes the open
7269
+ // paper below and the reply's label follows it: `lblcol` 1 -> 0.
7270
+ // The bonus is capped so it can break a tie between two legal
7271
+ // positions and nothing more — it can never outrank an overlap,
7272
+ // which is an AREA and unbounded.
7273
+ { let own=Infinity, oth=Infinity;
7274
+ const perEdge=new Map();
7275
+ for(const g of edgeSegs){
7276
+ const d=segBoxD(g.p,g.q,c.box);
7277
+ if(g.e===r.e){ if(d<own) own=d; } else if(d<oth) oth=d;
7278
+ const cur=perEdge.get(g.e);
7279
+ if(cur===undefined||d<cur) perEdge.set(g.e,d);
7280
+ }
7281
+ if(own<Infinity&&oth<Infinity) s-=0.4*Math.min(45,Math.max(0,oth-own));
7282
+ // ...AND THE SCORER ALSO ASKS THE GATE'S OWN QUESTION (item 60).
7283
+ // The bonus above rewards a position that is NEARER ITS OWN line
7284
+ // than to the next one, and it is clamped at zero — so between two
7285
+ // positions that are BOTH nearer a foreign line, it says nothing,
7286
+ // and the figure falls back to the side-order index. The legibility
7287
+ // floor's F5 does not ask about ownership at that point: it asks
7288
+ // whether the two NEAREST lines are within M of each other, because
7289
+ // that is the reading where a human cannot tell which line the word
7290
+ // belongs to at all. Measured on `statechart/bfd-session` once the
7291
+ // admin transitions merged into one trunk (item 60): the green
7292
+ // "rx Init, rx Up" label sat 40.0 px from INIT's self-loop and
7293
+ // 40.0 px from UP's — margin 0.0, a coin toss — while sliding along
7294
+ // its own shaft to t=0.2 makes those 27 px and 53 px. Nothing in the
7295
+ // score could see the difference. The penalty is a CONSTANT, in the
7296
+ // band the identical-text and wrong-side terms already use, so it
7297
+ // can break the choice between two collision-free positions and can
7298
+ // never outbid an overlap.
7299
+ if(perEdge.size>1){
7300
+ const ds=[...perEdge.values()].sort((a,b)=>a-b);
7301
+ if(ds[1]-ds[0]<4) s+=26;
7302
+ } }
7303
+ // OFF THE CANVAS IS NOT A BAD PLACEMENT, IT IS NO PLACEMENT.
7304
+ // The canvas grows right and down and has no mechanism here to grow
7305
+ // up or left, so a box past those two margins is CLIPPED AWAY and the
7306
+ // text the author wrote is never seen. This was a score term worth
7307
+ // 400, and the note pass already records why a constant cannot do the
7308
+ // job: every other penalty is an AREA and grows without bound, so a
7309
+ // crowded right-hand alternative outbids the constant and the label
7310
+ // goes over the edge. Measured on `statechart/bfd-session` at
7311
+ // 0.4: "rx AdminDown / Detect expired, Echo failed" was
7312
+ // emitted at x = -88.6 and simply did not appear.
7313
+ // So it is a FILTER with its own fallback: an off-canvas candidate is
7314
+ // kept only as the last resort, behind every position that is on the
7315
+ // page at all, and behind the affinity filter for the same reason.
7316
+ // The LEFT margin only, which is the margin this pass has always
7317
+ // judged. The top is left alone deliberately: the two corridor labels
7318
+ // `statechart/dhcp-client` parks above its top rank sit at the canvas
7319
+ // origin BY DESIGN (the return rows run above the figure and the
7320
+ // scene is shifted down to make room), and filtering on `y` pushed
7321
+ // them into one band and cost an F5.
7322
+ const offCanvas=(c.box.x<2);
7323
+ if(offCanvas){ if(s<offS-1e-9){ offS=s; offBest=c; } continue; }
7324
+ // THE CAP IS A FILTER, NOT A PRICE (item 59), for the reason the
7325
+ // off-canvas term above records: every other penalty here is an AREA
7326
+ // and grows without bound, so a constant surcharge on distance is
7327
+ // always outbid by a crowded near position and the label goes for a
7328
+ // walk. A mid-label further from its own shaft than STANDOFF_CAP is
7329
+ // therefore held back behind every position that is inside the cap,
7330
+ // and if one of them wins anyway — the geometry really had nothing
7331
+ // closer — it is drawn with a LEADER back to the shaft. Never a free
7332
+ // float. Endpoint labels are exempt: their distance is governed by
7333
+ // the 16 px anchor and the affinity filter, both of item 42's.
7334
+ if(r.kind!=='end'&&segBoxGap(c.car[0],c.car[1],c.box)>STANDOFF_CAP){
7335
+ if(s<farS-1e-9){ farS=s; farBest=c; } continue; }
7336
+ if(s<anyS-1e-9){ anyS=s; anyBest=c; }
7337
+ if(owns&&s<bestS-1e-9){ bestS=s; best=c; }
5585
7338
  }
5586
7339
  }
7340
+ if(!best) best=anyBest||farBest||offBest; // no legal position — least bad
7341
+ r0.win=best; // kept for item 55's recolour pass
5587
7342
  lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,r0.halo);
7343
+ // THE LEADER (item 59). A mid-label that had to be placed past the cap
7344
+ // states its line explicitly: a hairline from the nearest point of its
7345
+ // box to the nearest point of its own carrying segment, in the label's
7346
+ // own colour. It is drawn UNDER nothing and over nothing — it is 1 px,
7347
+ // it starts at the box and it stops at the shaft — so it adds no ink
7348
+ // anywhere the reader is not already looking for the association.
7349
+ if(r0.kind!=='end'&&best.car&&segBoxGap(best.car[0],best.car[1],best.box)>STANDOFF_CAP){
7350
+ const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
7351
+ const P0=best.car[0], Q0=best.car[1];
7352
+ const vx=Q0[0]-P0[0], vy=Q0[1]-P0[1], L2=vx*vx+vy*vy;
7353
+ const t=L2?Math.max(0,Math.min(1,((cx-P0[0])*vx+(cy-P0[1])*vy)/L2)):0;
7354
+ const fx=P0[0]+t*vx, fy=P0[1]+t*vy;
7355
+ // the box's own boundary point on the ray toward that foot
7356
+ const dx=fx-cx, dy=fy-cy, ax=Math.abs(dx)||1e-9, ay=Math.abs(dy)||1e-9;
7357
+ const k=Math.min((b.w/2+2)/ax,(b.h/2+2)/ay);
7358
+ r0.leadIdx=lblsvg.length;
7359
+ lblsvg.push('<line x1="'+(cx+dx*k)+'" y1="'+(cy+dy*k)+'" x2="'+fx+'" y2="'+fy+
7360
+ '" stroke="'+r0.col+'" stroke-width="1" opacity="0.6"/>');
7361
+ }
5588
7362
  placed.push(Object.assign({text:r0.text},best.box));
5589
7363
  W=Math.max(W, best.box.x+best.box.w+4);
5590
7364
  Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
5591
7365
  }
7366
+ // ── A BUNDLE CAPTION SITS BESIDE ITS RING (item 43) ────────────────────
7367
+ // It used to be written at the ring's CENTRE, which is the one place on a
7368
+ // bundle ring that is guaranteed to be occupied: the waist is where the
7369
+ // member links cross. On `reference/topology` the `ECMP uplink set`
7370
+ // caption lay across three edge segments; on `patterns/topology-b`
7371
+ // `Link Bundle 1` lay on top of the `p3` endpoint label.
7372
+ //
7373
+ // So it is placed like every other free label in this pass, against the
7374
+ // same `placed` / `obst` / `arrowBox` / `edgeSegs` evidence, and it enters
7375
+ // AFTER the edge labels: a port label names one end of one link and has
7376
+ // one place to be, while a caption names a ring and has a whole rim to
7377
+ // choose from, so the caption is the one that yields. (Notes still enter
7378
+ // last — nothing yields to a note.)
7379
+ //
7380
+ // The candidate set is twelve stations around the RIM, in the ring's own
7381
+ // frame, so the family follows the ring's orientation instead of the
7382
+ // canvas's. `40*|cos phi|` prices the two stations at the ring's ENDS: the
7383
+ // ends are where the member links leave and where their nodes are, so a
7384
+ // caption there is reaching across the very links the ring encircles. The
7385
+ // flanks — across the members — are free, and between the two the
7386
+ // evidence decides which is clearer. A second tier, 30 px out with a short
7387
+ // dashed leader, exists for the figure where no flank is clear; it is
7388
+ // priced high enough that it is taken only when every rim station is
7389
+ // genuinely occupied.
7390
+ for(const t of doc.trunks||[]){
7391
+ const R=rings.get(t);
7392
+ if(!R||t.label===null||t.label===undefined||t.label==='') continue;
7393
+ const lines=String(t.label).split('\n'), nL=lines.length;
7394
+ const w=Math.max(...lines.map(cw))*6.5*11.5/11, lh=11.5*1.3;
7395
+ const h=(nL-1)*lh+11.5*1.1, up=(nL-1)*lh/2+11.5*0.85;
7396
+ let best=null, bestS=Infinity;
7397
+ for(const tier of [0,1]){
7398
+ const gap=tier?30:7;
7399
+ for(let k=0;k<12;k++){
7400
+ const ph=k*Math.PI/6, cp=Math.cos(ph), sp=Math.sin(ph);
7401
+ const dx=R.ux*cp+R.vx*sp, dy=R.uy*cp+R.vy*sp;
7402
+ const rim=1/Math.hypot(cp/R.rL, sp/R.rA);
7403
+ const out=rim+gap+Math.abs(dx)*w/2+Math.abs(dy)*h/2;
7404
+ const cb={x:R.cx+dx*out-w/2, y:R.cy+dy*out-h/2, w, h};
7405
+ let sc=0;
7406
+ for(const b of placed) sc+=3*ovl(cb,b);
7407
+ for(const o of obst) sc+=2.4*ovl(cb,o);
7408
+ // a group's NAME is ink too (item 45), and a caption written over it
7409
+ // reads as that band's second line
7410
+ for(const o of nameObs) sc+=2.4*ovl(cb,o);
7411
+ for(const a of arrowBox) sc+=4*ovl(cb,a);
7412
+ for(const g of edgeSegs) if(segHit(g.p,g.q,cb)) sc+=26;
7413
+ // A caption lying across ANOTHER bundle's ring has stopped saying
7414
+ // which bundle it names — F5's mechanism again, between two rings
7415
+ // this time. It is charged like a strikethrough because that is
7416
+ // what it is, and measured against the ring's OUTLINE rather than
7417
+ // its bounding box: a tilted ring's box is mostly empty paper, and
7418
+ // charging the box drove the reference figure's caption off a clear
7419
+ // corner and onto an endpoint label. Its OWN ring is never charged
7420
+ // — being beside that one is the whole point.
7421
+ for(const [t2,R2] of rings)
7422
+ if(t2!==t&&!R2.solo&&ringHitsRect(R2,R2.rL,R2.rA,cb)) sc+=26;
7423
+ sc+=40*Math.abs(cp); // the ring's ENDS belong to the links
7424
+ // THE CLEARER FLANK, when two flanks are equally legal. Both of
7425
+ // `patterns/topology-b`'s across-stations score exactly 0 — the
7426
+ // caption fits above the ring and below it — and "first index wins"
7427
+ // then chose by the loop's order rather than by the drawing. This
7428
+ // term is the tie-break and only the tie-break: a bonus of at most
7429
+ // 2, for the candidate with the most white around it, capped so it
7430
+ // can never outrank a single overlap, let alone a strikethrough.
7431
+ let clr=40;
7432
+ for(const b2 of placed) clr=Math.min(clr,boxGap(cb,b2));
7433
+ for(const o of obst) clr=Math.min(clr,boxGap(cb,o));
7434
+ for(const o of nameObs) clr=Math.min(clr,boxGap(cb,o));
7435
+ sc-=0.05*Math.max(0,clr);
7436
+ sc+=tier*400; // the leader is a last resort
7437
+ if(cb.x<2||cb.y<y0) sc+=400; // off the top or left margin
7438
+ if(sc<bestS-1e-9){ bestS=sc; best={cb,tier,dx,dy,rim,gap}; }
7439
+ }
7440
+ }
7441
+ ringLbl.set(t,best);
7442
+ if(best.tier){
7443
+ const p=[R.cx+best.dx*best.rim, R.cy+best.dy*best.rim];
7444
+ const q=[R.cx+best.dx*(best.rim+best.gap), R.cy+best.dy*(best.rim+best.gap)];
7445
+ ringLeads.push([p,q,t]);
7446
+ }
7447
+ placed.push({text:t.label, x:best.cb.x, y:best.cb.y, w, h});
7448
+ best.tx=best.cb.x+w/2; best.ty=best.cb.y+up;
7449
+ W=Math.max(W, best.cb.x+w+4);
7450
+ Hh=Math.max(Hh, best.cb.y+h+4-y0-20);
7451
+ }
5592
7452
  // ── note placement (`DRAWN-ANNOTATION-FORM`) — the SAME pass, entered LAST ────────────────
5593
7453
  // Four rules, and they are in the spec rather than only here because two
5594
7454
  // engines have to agree on them:
@@ -5624,11 +7484,12 @@ function renderScene(doc,y0){
5624
7484
  // metaclass the spelling is borrowed from (RULE 4.1).
5625
7485
  const noteCarriers=[];
5626
7486
  for(const n of nodes) if(n.note!==undefined&&n.note!==null&&!n.boundary)
5627
- noteCarriers.push({line:n.line,text:n.note,kind:'node',rect:{x:n.x,y:n.y,w:n.w,h:n.h},n});
7487
+ noteCarriers.push({line:n.line,text:n.note,kind:'node',ref:n.id,rect:{x:n.x,y:n.y,w:n.w,h:n.h},n,
7488
+ lbl:n.label});
5628
7489
  for(const g of doc.groups) if(g.note!==undefined&&g.note!==null){
5629
7490
  const B=gBox[g.id]; if(!B) continue;
5630
- noteCarriers.push({line:g.line,text:g.note,kind:'group',
5631
- rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}});
7491
+ noteCarriers.push({line:g.line,text:g.note,kind:'group',ref:g.id,
7492
+ rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA},lbl:g.label});
5632
7493
  }
5633
7494
  for(const e of doc.edges) if(e.note!==undefined&&e.note!==null){
5634
7495
  // An edge is a polyline, not a rect. Its carrier POINT is the midpoint of
@@ -5640,11 +7501,148 @@ function renderScene(doc,y0){
5640
7501
  const segs=edgeSegs.filter(g=>g.e===e); if(!segs.length) continue;
5641
7502
  const m=segs[Math.floor((segs.length-1)/2)];
5642
7503
  const cx=(m.p[0]+m.q[0])/2, cy=(m.p[1]+m.q[1])/2;
5643
- noteCarriers.push({line:e.line,text:e.note,kind:'edge',rect:{x:cx,y:cy,w:0,h:0},e});
7504
+ noteCarriers.push({line:e.line,text:e.note,kind:'edge',ref:e.line,rect:{x:cx,y:cy,w:0,h:0},e});
5644
7505
  }
5645
7506
  noteCarriers.sort((a,b)=>a.line-b.line);
7507
+ // ── ADJACENCY MUST BE DISCRIMINABLE (item 40) ─────────────────────────
7508
+ // "Adjacency where it can, a leader only when adjacency fails" was always
7509
+ // the rule; what was missing is that the failure test never asked whether
7510
+ // the adjacency SAYS ANYTHING. `annotated-datapath` — the `note=`
7511
+ // demonstrator itself — put the `mac` group note and the `pause` node note
7512
+ // at the same y, 3 px apart, one continuous strip that reads as a caption
7513
+ // row and binds neither box. Each was near-adjacent to the OTHER's target.
7514
+ //
7515
+ // This is F5's mechanism, a THIRD time (edge-label/edge = F5,
7516
+ // endpoint-label/endpoint = item 42, note/element = here): nearest versus
7517
+ // SECOND-nearest. A note is adjacent to its target only when
7518
+ // (a) it sits substantially OVER its target's span on the axis across
7519
+ // the side it takes — beside means beside THIS thing, and a box that
7520
+ // overhangs its target is already reaching for a neighbour; and
7521
+ // (b) its own target is the NEAREST annotatable element, by at least
7522
+ // NOTE_M, to the second-nearest.
7523
+ // `annotatable` is the set of things a note can be written ON — nodes,
7524
+ // groups, connectors — because those are exactly the readings a reader can
7525
+ // mistake it for. When both tests pass the note simply sits there, as
7526
+ // before. When either fails, adjacency has FAILED and the candidate is not
7527
+ // offered at all, so the pass falls through to the far tiers, which is
7528
+ // where the dashed leader already lives. No new drawing machinery.
7529
+ //
7530
+ // NOTE_M = 12 px, not F5's 4: a note is a BOX tens of pixels tall, and the
7531
+ // eye ties it to a neighbour long before the gap closes to a hair. 12 px
7532
+ // is one note line-height (NOTE_FS * 1.35 rounded) — below one line of the
7533
+ // note's own text, two elements are not told apart.
7534
+ const NOTE_M=12;
7535
+ const noteLeads=[];
7536
+ const annot=[];
7537
+ for(const n of nodes) if(!n.boundary) annot.push({k:'node',id:n.id,rect:{x:n.x,y:n.y,w:n.w,h:n.h}});
7538
+ for(const g of doc.groups){ const B=gBox[g.id];
7539
+ if(B) annot.push({k:'group',id:g.id,rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}}); }
7540
+ const rdist=(px,py,r)=>Math.hypot(Math.max(r.x-px,0,px-(r.x+r.w)),Math.max(r.y-py,0,py-(r.y+r.h)));
7541
+ const ptSegD=(px,py,p,q)=>{
7542
+ const vx=q[0]-p[0], vy=q[1]-p[1], L2=vx*vx+vy*vy;
7543
+ const t=L2?Math.max(0,Math.min(1,((px-p[0])*vx+(py-p[1])*vy)/L2)):0;
7544
+ return Math.hypot(px-(p[0]+t*vx), py-(p[1]+t*vy));
7545
+ };
7546
+ // A connector is annotatable too, and it is measured as the polyline it is.
7547
+ const edgeDist=(px,py,e)=>{ let d=Infinity;
7548
+ for(const g of edgeSegs) if(g.e===e)
7549
+ d=Math.min(d,ptSegD(px,py,g.p,g.q));
7550
+ return d; };
7551
+ const noteEdges=[];
7552
+ for(const e of doc.edges) if(edgeSegs.some(g=>g.e===e)) noteEdges.push(e);
7553
+ // Does this candidate box read as belonging to THIS carrier and no other?
7554
+ //
7555
+ // The test is on SPANS, not on centre-to-centre distance, because that is
7556
+ // how the eye reads a note: it looks back along the side the box sits on
7557
+ // and takes the first thing it lands on. So a competitor is any
7558
+ // annotatable element whose PROJECTION across that side overlaps the note
7559
+ // — the reading the filed case describes exactly, the `pause` note's left
7560
+ // edge reaching "under the `mac` band's horizontal projection" — and the
7561
+ // margin is measured along the side, own gap versus second-nearest gap.
7562
+ const spanOf=(r,across)=>across?[r.y,r.y+r.h]:[r.x,r.x+r.w];
7563
+ const gapTo=(cb,r,side)=>side==='below'?cb.y-(r.y+r.h)
7564
+ :side==='above'?r.y-(cb.y+cb.h)
7565
+ :side==='right'?cb.x-(r.x+r.w)
7566
+ :r.x-(cb.x+cb.w);
7567
+ const discriminable=(cb,c,side)=>{
7568
+ const across=(side==='left'||side==='right');
7569
+ // (a) the note sits substantially OVER its target's span. `beside` means
7570
+ // beside THIS thing; a box that hangs off its target is already halfway
7571
+ // to the neighbour. The denominator is the SMALLER of the two spans, so
7572
+ // a wide note fully covering a narrow target passes and a note sliding
7573
+ // off the end of a wide target fails.
7574
+ const [nA,nB]=spanOf(cb,across), [tA,tB]=spanOf(c.rect,across);
7575
+ const denom=Math.min(nB-nA,tB-tA);
7576
+ if(denom>1&&(Math.min(nB,tB)-Math.max(nA,tA))/denom<0.6) return false;
7577
+ // (b) nearest versus second-nearest, along the side, among the elements
7578
+ // whose projection the note actually lies across.
7579
+ const claim=r=>{ const [rA,rB]=spanOf(r,across);
7580
+ return Math.min(nB,rB)>Math.max(nA,rA); };
7581
+ let own=gapTo(cb,c.rect,side), second=Infinity;
7582
+ if(own<0) return false; // the note overlaps its own target
7583
+ for(const a of annot){
7584
+ if(c.kind===a.k&&c.ref===a.id) continue;
7585
+ if(!claim(a.rect)) continue;
7586
+ const g=gapTo(cb,a.rect,side);
7587
+ if(g>=-1) second=Math.min(second,g);
7588
+ }
7589
+ for(const e of noteEdges){
7590
+ if(c.kind==='edge'&&c.e===e) continue;
7591
+ for(const g0 of edgeSegs){ if(g0.e!==e) continue;
7592
+ const r={x:Math.min(g0.p[0],g0.q[0]),y:Math.min(g0.p[1],g0.q[1]),
7593
+ w:Math.abs(g0.q[0]-g0.p[0]),h:Math.abs(g0.q[1]-g0.p[1])};
7594
+ if(!claim(r)) continue;
7595
+ const g=gapTo(cb,r,side);
7596
+ if(g>=-1) second=Math.min(second,g);
7597
+ }
7598
+ }
7599
+ return own+NOTE_M<=second;
7600
+ };
7601
+ // ── AN ADJACENCY NOTE NAMES ITS TARGET; A LEADER NOTE DOES NOT (item 56b) ──
7602
+ // A note sited by ADJACENCY carries its whole association in a gap: the
7603
+ // box is beside a thing, and the reader has to guess which thing. The
7604
+ // drawing already knows the answer — `data-note-for` has carried the
7605
+ // resolved target since item 40b — so the note says it, in words: the
7606
+ // target's LABEL, bold, then a colon, then the note's own sentence
7607
+ // ("MAC layer: Runs on the recovered RX clock…"). It is derived, never
7608
+ // authored: `DOMAIN-CONVENTION-DIRECTIVES` keeps the drawing convention with the engine.
7609
+ //
7610
+ // A note with a DRAWN LEADER does not take the prefix, and that is the
7611
+ // whole of the split. The leader IS the association — a line from the box
7612
+ // to the thing — and repeating it in the text is ink for a fact the
7613
+ // drawing already states unambiguously.
7614
+ //
7615
+ // ONLY A LABELLED NODE OR GROUP can be named this way. A connector has no
7616
+ // id and no name (`data-note-for` carries its source LINE, which is not a
7617
+ // thing a reader can see), and the figure-level note on `title` names the
7618
+ // FIGURE — there is exactly one and it needs no naming.
7619
+ //
7620
+ // ORDER, because the two decisions are circular (the prefix changes the
7621
+ // box, the box changes whether adjacency succeeds): the note is sited
7622
+ // WITH the prefix first, since adjacency is the preferred outcome and the
7623
+ // prefixed box is the one that has to fit. If that siting takes a leader,
7624
+ // the note is re-sited WITHOUT the prefix, and THAT placement stands —
7625
+ // there is no third pass. It terminates by construction and it is
7626
+ // deterministic, which is what `RENDERING-DETERMINISM` needs of it.
7627
+ const notePrefix=c=>((c.kind==='node'||c.kind==='group')&&c.lbl&&String(c.lbl).trim()
7628
+ ?String(c.lbl).replace(/\n/g,' ')+':':null);
5646
7629
  for(const c of noteCarriers){
5647
- const box=noteBox(c.text);
7630
+ const pfx=notePrefix(c);
7631
+ let box=noteBox(c.text,pfx), sited=siteNote(c,box);
7632
+ if(pfx&&sited.best&&sited.best.far){ box=noteBox(c.text,null); sited=siteNote(c,box); }
7633
+ let best=sited.best;
7634
+ // Only if EVERY candidate was off-canvas: take the least-bad one and
7635
+ // clamp it on. It may overlap something; visible and overlapping is
7636
+ // recoverable by an author, invisible is not.
7637
+ if(!best){ best=sited.fallback; best.x=Math.max(0,best.x); best.y=Math.max(y0,best.y); }
7638
+ lblsvg.push(noteSvg(best.x,best.y,box,c));
7639
+ if(best.far){ lblsvg.push(noteLeader(best,box,c.rect));
7640
+ noteLeads.push(noteLeaderPts(best,box,c.rect)); }
7641
+ placed.push({x:best.x,y:best.y,w:box.w,h:box.h});
7642
+ W=Math.max(W, best.x+box.w+4);
7643
+ Hh=Math.max(Hh, best.y+box.h+4-y0-20);
7644
+ }
7645
+ function siteNote(c,box){
5648
7646
  // Adjacency first, then the same four sides pushed out far enough that a
5649
7647
  // leader is legible. `si` orders the sides; `far` is what decides the
5650
7648
  // leader, and it costs enough that adjacency wins every time adjacency is
@@ -5678,6 +7676,11 @@ function renderScene(doc,y0){
5678
7676
  by=side==='below'?c.rect.y+c.rect.h+gap:c.rect.y-gap-box.h;
5679
7677
  }
5680
7678
  const cb={x:bx,y:by,w:box.w,h:box.h};
7679
+ // ADJACENCY THAT SAYS NOTHING IS NOT ADJACENCY (item 40). Tier 0 is
7680
+ // the no-leader tier; a candidate there must actually bind the note
7681
+ // to its carrier. If it does not, it is withdrawn and the far tiers
7682
+ // — which draw the dashed leader — are what remain.
7683
+ if(!far&&!discriminable(cb,c,side)) continue;
5681
7684
  let s=0;
5682
7685
  // A NOTE YIELDS; NOTHING YIELDS TO A NOTE. `placed` holds the edge
5683
7686
  // labels and the notes already sited, and its weight is the HIGHEST
@@ -5690,6 +7693,7 @@ function renderScene(doc,y0){
5690
7693
  for(const o of obst) s+=6*ovl(cb,o);
5691
7694
  for(const a of arrowBox) s+=8*ovl(cb,a);
5692
7695
  for(const g of edgeSegs) if(segHit(g.p,g.q,cb)) s+=26;
7696
+ for(const g of noteLeads) if(segHit(g[0],g[1],cb)) s+=26;
5693
7697
  for(const g of doc.groups){ const B=gBox[g.id];
5694
7698
  if(B) s+=1.2*ovl(cb,{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}); }
5695
7699
  s+=si*12;
@@ -5719,19 +7723,176 @@ function renderScene(doc,y0){
5719
7723
  if(s<bestS-1e-9){ bestS=s; best={x:bx,y:by,far,side}; }
5720
7724
  }
5721
7725
  }
5722
- // Only if EVERY candidate was off-canvas: take the least-bad one and
5723
- // clamp it on. It may overlap something; visible and overlapping is
5724
- // recoverable by an author, invisible is not.
5725
- if(!best){ best=fallback; best.x=Math.max(0,best.x); best.y=Math.max(y0,best.y); }
5726
- lblsvg.push(noteSvg(best.x,best.y,box,c));
5727
- if(best.far) lblsvg.push(noteLeader(best,box,c.rect));
5728
- placed.push({x:best.x,y:best.y,w:box.w,h:box.h});
5729
- W=Math.max(W, best.x+box.w+4);
5730
- Hh=Math.max(Hh, best.y+box.h+4-y0-20);
7726
+ return {best,fallback};
7727
+ }
7728
+ // ── TONE PAIRING FOR CROWDED UNCLASSED EDGES (item 55) ────────────────
7729
+ // Where several plain lines cross the middle of a figure, every one of
7730
+ // them is `#555` and every one of their labels is `#555`, so the reader
7731
+ // pairs word to line by tracing. `statechart/dhcp-client` is the case:
7732
+ // five long diagonals through one region, five two-line labels sitting
7733
+ // among them.
7734
+ //
7735
+ // The engine assigns those lines DISTINCT TONES, and each label takes its
7736
+ // own line's tone. That is all the tones do.
7737
+ //
7738
+ // THE `PRESENTATION-AS-MEANING-CARRIER` BOUNDARY, and it is the whole reason the palette looks like
7739
+ // this. `PRESENTATION-AS-MEANING-CARRIER` says presentation is never the sole carrier of meaning: a
7740
+ // reader must never have to read a colour to learn a fact. These tones
7741
+ // carry NO fact. They are not derived from anything the author wrote,
7742
+ // they earn NO legend entry, no `class` and no `stroke=` names them, and
7743
+ // a reader who goes looking for what "the olive one" means finds nothing
7744
+ // stated anywhere — because there is nothing. They are DISCRIMINATION,
7745
+ // the same job a fan-out offset does when two messages share a node pair,
7746
+ // and the palette is what keeps them readable as that: four MUTED hues,
7747
+ // none of them a signal colour and none of them one of the plane or
7748
+ // class colours a legend can name. A classed edge is never touched, because there
7749
+ // the colour DOES carry the legend's meaning and a second scheme on top
7750
+ // of it would be two claims about one stroke.
7751
+ //
7752
+ // ELIGIBILITY. An edge takes a tone only if it (a) joins no class,
7753
+ // (b) sets no `stroke=` of its own — both would be an authored choice —
7754
+ // (c) draws a `mid` label, since a line with nothing to pair needs no
7755
+ // pairing, and (d) is not a merge-bus member, whose whole construct is
7756
+ // that several edges are ONE line.
7757
+ //
7758
+ // CROWDING is measured on the FINISHED drawing, after every label is
7759
+ // sited: an eligible edge is crowded when its label box comes within
7760
+ // TONE_PROX of another edge's shaft or another edge's label. 30 px is the
7761
+ // threshold — a little over two label line-heights, the distance at which
7762
+ // a reader stops being able to sweep from word to line without crossing
7763
+ // something else.
7764
+ //
7765
+ // ASSIGNMENT is a greedy pass in DOCUMENT ORDER, and it asks about the
7766
+ // EYE rather than about the array: "not the same as its neighbour" was
7767
+ // never the requirement — the palette this replaced had two entries 8.7
7768
+ // apart in CIE L*a*b*, which is different and still not TELLABLE apart at
7769
+ // a glance, and that was the whole job. Every crowded edge takes a tone
7770
+ // clearly apart from every neighbour already assigned, and among those
7771
+ // the least-spent one, so a figure uses its whole palette instead of
7772
+ // ping-ponging between two extremes (see the choice below). Ordering by
7773
+ // source line and breaking ties by palette index makes it deterministic:
7774
+ // same source, same tones.
7775
+ {
7776
+ const TONE_PROX=30;
7777
+ // The palette (item 61). Four muted hues at essentially ONE lightness —
7778
+ // L* 38.6, 39.7, 38.9, 40.9 — so they differ by HUE and nothing else,
7779
+ // and no tone can be read as heavier or more important than another:
7780
+ // slate blue #3f5b8c warm brown #7a5638
7781
+ // olive drab #55611f steel #206a72
7782
+ // Pairwise separation in CIE L*a*b* is 28.6 to 68.1, against the 8.7 of
7783
+ // the four slate-greys this replaced (a just-noticeable difference is
7784
+ // about 2.3, so those two were "different" and not tellable apart).
7785
+ // Because a label takes its line's tone and a label is TEXT, every one
7786
+ // is legible as type on white: 6.80, 6.53, 6.74 and 6.24 to 1, all above
7787
+ // the 4.5:1 floor for body text. A tone that discriminates by being too
7788
+ // faint to read has traded one defect for a worse one. None of them is
7789
+ // near a plane or class colour, and none is near the #555 default an
7790
+ // untoned line keeps: the nearest approach is warm brown at 26.
7791
+ const TONES=['#3f5b8c','#7a5638','#55611f','#206a72'];
7792
+ const busMem=new Set();
7793
+ for(const [e2] of busRoute) busMem.add(e2);
7794
+ for(const [e2,P2] of chPlan) if(P2.bus) busMem.add(e2);
7795
+ const lblOf=new Map();
7796
+ for(const q of lblReq) if(q.kind==='mid'&&q.win&&!lblOf.has(q.e)) lblOf.set(q.e,q.win.box);
7797
+ const elig=[];
7798
+ for(const e2 of edges){
7799
+ if(e2.stroke!==undefined||busMem.has(e2)) continue;
7800
+ const cl=e2.cls; if(cl!==undefined&&cl!==null&&(!Array.isArray(cl)||cl.length)) continue;
7801
+ if(!lblOf.has(e2)) continue;
7802
+ elig.push(e2);
7803
+ }
7804
+ const segD=(b,e2)=>{ let d=Infinity;
7805
+ for(const g of edgeSegs) if(g.e===e2) d=Math.min(d,segBoxD(g.p,g.q,b));
7806
+ return d; };
7807
+ const crowded=new Set(), near=new Map();
7808
+ for(const e2 of elig){
7809
+ const b=lblOf.get(e2);
7810
+ for(const f of edges){
7811
+ if(f===e2) continue;
7812
+ const hit=segD(b,f)<TONE_PROX ||
7813
+ (lblOf.has(f)&&boxGap(b,lblOf.get(f))<TONE_PROX);
7814
+ if(!hit) continue;
7815
+ crowded.add(e2);
7816
+ if(elig.indexOf(f)>=0){
7817
+ if(!near.has(e2)) near.set(e2,new Set()); near.get(e2).add(f);
7818
+ if(!near.has(f)) near.set(f,new Set()); near.get(f).add(e2);
7819
+ }
7820
+ }
7821
+ }
7822
+ // DISTANCE IS MEASURED IN THE EYE, NOT IN THE ARRAY (item 61). The old
7823
+ // palette was ordered by darkness, so "furthest position in the list"
7824
+ // was a fair proxy for "most tellable apart". A palette that separates
7825
+ // by HUE has no such order — steel is not "between" slate blue and
7826
+ // olive — so the assignment asks the question directly, in CIE L*a*b*,
7827
+ // where a difference of about 2.3 is the just-noticeable one and these
7828
+ // four sit 28.6 to 68.1 apart.
7829
+ const tLab=h=>{
7830
+ const v=[1,3,5].map(i=>parseInt(h.slice(i,i+2),16)/255)
7831
+ .map(c=>c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4));
7832
+ let X=(0.4124*v[0]+0.3576*v[1]+0.1805*v[2])/0.95047,
7833
+ Y=(0.2126*v[0]+0.7152*v[1]+0.0722*v[2]),
7834
+ Z=(0.0193*v[0]+0.1192*v[1]+0.9505*v[2])/1.08883;
7835
+ const f=t=>t>0.008856?Math.cbrt(t):(7.787*t+16/116);
7836
+ const fx=f(X), fy=f(Y), fz=f(Z);
7837
+ return [116*fy-16, 500*(fx-fy), 200*(fy-fz)];
7838
+ };
7839
+ const LAB=TONES.map(tLab);
7840
+ const dE=(i,j)=>Math.hypot(LAB[i][0]-LAB[j][0],LAB[i][1]-LAB[j][1],LAB[i][2]-LAB[j][2]);
7841
+ const tone=new Map(), used=TONES.map(()=>0);
7842
+ for(const e2 of elig.slice().sort((p,q)=>p.line-q.line)){
7843
+ if(!crowded.has(e2)) continue;
7844
+ const nb=[];
7845
+ for(const f of (near.get(e2)||[])) if(tone.has(f)) nb.push(TONES.indexOf(tone.get(f)));
7846
+ // ENOUGH IS ENOUGH, AND THEN SPREAD. Taking the FURTHEST tone from
7847
+ // each neighbour sounds right and collapses the palette: with one
7848
+ // assigned neighbour the furthest is always the same antipode, so a
7849
+ // figure whose crowding graph is a set of pairs paints itself in two
7850
+ // hues and the other two are never seen (dhcp-client, measured: 2 of
7851
+ // 4). Every pair in this palette is at least 28.6 apart — an order of
7852
+ // magnitude over the ~2.3 just-noticeable difference — so "furthest"
7853
+ // buys nothing a reader can use, while a THIRD identity in a crowded
7854
+ // corner buys a great deal. So: any tone clearly apart from every
7855
+ // neighbour (>= TONE_SEP) is admissible, and among the admissible the
7856
+ // LEAST-SPENT one wins; ties to the lowest index, so the same source
7857
+ // always paints the same figure. If nothing clears the bar — a future
7858
+ // palette with closer entries, or more crowding than tones — it falls
7859
+ // back to the plain furthest-from-the-neighbours choice.
7860
+ const TONE_SEP=25;
7861
+ let pick=-1, bestU=-1;
7862
+ for(let i=0;i<TONES.length;i++){
7863
+ const d=nb.length?Math.min(...nb.map(j=>dE(i,j))):Infinity;
7864
+ if(d<TONE_SEP) continue;
7865
+ const u=used[i]; // how many lines already wear it
7866
+ if(pick<0||u<bestU){ bestU=u; pick=i; }
7867
+ }
7868
+ if(pick<0){ let bestD=-1; pick=0;
7869
+ for(let i=0;i<TONES.length;i++){
7870
+ const d=nb.length?Math.min(...nb.map(j=>dE(i,j))):Infinity;
7871
+ if(d>bestD+1e-9){ bestD=d; pick=i; }
7872
+ } }
7873
+ tone.set(e2, TONES[pick]);
7874
+ used[pick]++;
7875
+ }
7876
+ if(tone.size>1) for(const [e2,col2] of tone){
7877
+ const key='data-edge="'+e2.line+'"';
7878
+ for(let i=0;i<esvg.length;i++)
7879
+ if(esvg[i].indexOf(key)>=0) esvg[i]=esvg[i].replace('stroke="#555"','stroke="'+col2+'"');
7880
+ for(const i of (arrowIdx.get(e2)||[]))
7881
+ lblsvg[i]=lblsvg[i].replace('fill="#555"','fill="'+col2+'"');
7882
+ for(const q of lblReq) if(q.e===e2&&q.win){
7883
+ lblsvg[q.idx]=textEl(q.win.x,q.win.y,q.fs,q.win.anchor,col2,q.text,q.halo);
7884
+ // a standoff leader (item 59) is part of the label, so it takes the
7885
+ // label's tone with it — otherwise the one line drawn to state the
7886
+ // association would be the one line still painted the old colour.
7887
+ if(q.leadIdx!==undefined)
7888
+ lblsvg[q.leadIdx]=lblsvg[q.leadIdx].replace('stroke="'+q.col+'"','stroke="'+col2+'"');
7889
+ }
7890
+ }
5731
7891
  }
5732
7892
  }
5733
7893
  // nodes on top (each wrapped in a draggable, identifiable group)
5734
7894
  const nsvg=[];
7895
+ const nodeLbl=new Map(); // node id -> its drawn label's box (item 49)
5735
7896
  for(const n of nodes){
5736
7897
  if(n.boundary){
5737
7898
  // never drawn as a shape (`EXTERNAL-EDGE-ENDPOINTS`): the edge already ended open at the
@@ -5789,32 +7950,43 @@ function renderScene(doc,y0){
5789
7950
  const avail=2*inscribedHalfW(shapeAxes(n),nl.length*8)-16;
5790
7951
  if(n.rigid && need>avail) fs=Math.max(8, FONT*avail/need);
5791
7952
  nsvg.push(textEl(n.x+n.w/2, n.y+n.h/2+fs*0.35, fs, 'middle', txt, n.label));
7953
+ // The node's own label as INK, for whoever else wants to write inside this
7954
+ // box — today the zone-band captions (item 49). Recorded here rather than
7955
+ // recomputed there, because `fs` is the shrink-to-fit size and only this
7956
+ // scope knows it.
7957
+ { const lw=need*fs/FONT, lh=nl.length*fs*1.15;
7958
+ nodeLbl.set(n.id,{x0:n.x+n.w/2-lw/2, x1:n.x+n.w/2+lw/2,
7959
+ y0:n.y+n.h/2-lh/2, y1:n.y+n.h/2+lh/2}); }
5792
7960
  nsvg.push('</g>');
5793
7961
  }
5794
7962
  // trunk rings (semantic LAG/ES bundles): the ellipse is DERIVED from the
5795
- // member links' midpoints — drag a node and the ring follows
7963
+ // member links — drag a node and the ring follows. Its geometry is computed
7964
+ // above (`ringOf`, item 43) because the caption has to be placed by the
7965
+ // label pass, which runs before this one; here the ring is only drawn.
7966
+ // `rL` is the semi-axis ALONG the members and `rA` the one ACROSS them, so
7967
+ // the SVG's rx/ry are the ring's own axes rotated onto the canvas.
5796
7968
  const tsvg=[];
5797
7969
  for(const t of zsort(doc.trunks)){
5798
- const mids=[];
5799
- for(const [a,b] of t.pairs){
5800
- const A=byId[a], B=byId[b]; if(!A||!B) continue;
5801
- const [x1,yy1]=borderPoint(A,B.x+B.w/2,B.y+B.h/2);
5802
- const [x2,yy2]=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
5803
- mids.push([(x1+x2)/2,(yy1+yy2)/2]);
5804
- }
5805
- if(!mids.length) continue;
5806
- const cx=mids.reduce((s,m)=>s+m[0],0)/mids.length;
5807
- const cy=mids.reduce((s,m)=>s+m[1],0)/mids.length;
5808
- const rx=Math.max(46, Math.max(...mids.map(m=>Math.abs(m[0]-cx)))+38);
5809
- const ry=Math.max(26, Math.max(...mids.map(m=>Math.abs(m[1]-cy)))+22);
7970
+ const R=rings.get(t); if(!R) continue;
5810
7971
  // 0.1 (§8.4): the ring has no interior — `stroke=` is the one
5811
7972
  // name for its channel; `fill=` on a bundle is now a line error, so the
5812
7973
  // model field is always absent and the old `t.fill` fallback is gone.
5813
7974
  const col=t.stroke||'#64748b';
5814
- tsvg.push('<ellipse cx="'+cx+'" cy="'+cy+'" rx="'+rx+'" ry="'+ry+'" fill="transparent" stroke="'+(t.stroke||col)+'"'+dashOf(t.style,'6 4')+' stroke-width="1.6"/>');
5815
- tsvg.push(textEl(cx, cy+4, 11.5, 'middle', col, t.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
5816
- W=Math.max(W,cx+rx); Hh=Math.max(Hh,cy+ry-y0-20);
7975
+ const deg=R.th*180/Math.PI;
7976
+ const rot=Math.abs(deg)<0.05?'':' transform="rotate('+rnd3(deg)+' '+rnd3(R.cx)+' '+rnd3(R.cy)+')"';
7977
+ // ONE MEMBER = NO ELLIPSE (item 53). The caption below is the whole
7978
+ // drawing, and an unlabelled one-member bundle draws nothing.
7979
+ if(!R.solo){
7980
+ tsvg.push('<ellipse cx="'+rnd3(R.cx)+'" cy="'+rnd3(R.cy)+'" rx="'+rnd3(R.rL)+'" ry="'+rnd3(R.rA)+'" fill="transparent" stroke="'+(t.stroke||col)+'"'+dashOf(t.style,'6 4')+' stroke-width="1.6"'+rot+'/>');
7981
+ W=Math.max(W,R.cx+R.hw); Hh=Math.max(Hh,R.cy+R.hh-y0-20);
7982
+ }
7983
+ const L=ringLbl.get(t);
7984
+ if(L) tsvg.push(textEl(rnd3(L.tx), rnd3(L.ty), 11.5, 'middle', col, t.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
5817
7985
  }
7986
+ // the caption's leader, when no rim station was clear (item 43)
7987
+ for(const [p,q,t] of ringLeads)
7988
+ tsvg.push('<line x1="'+rnd3(p[0])+'" y1="'+rnd3(p[1])+'" x2="'+rnd3(q[0])+'" y2="'+rnd3(q[1])
7989
+ +'" stroke="'+(t.stroke||'#64748b')+'" stroke-width="1" stroke-dasharray="3 3"/>');
5818
7990
  // threshold markers + labels (top layer). The target is a GROUP box or,
5819
7991
  // a single NODE box — the same two scopes `band` has always
5820
7992
  // taken (§2.6, `AUTHORING-INTENT-OVER-RENDERING`); a node's box is spelled in the gBox coordinate shape.
@@ -5839,17 +8011,84 @@ function renderScene(doc,y0){
5839
8011
  // band already owns an area, so the name needs no gutter and the canvas box
5840
8012
  // does not grow — W and Hh are untouched, which is why adding the label moves
5841
8013
  // no existing geometry in any figure.
8014
+ // ── A BAND CAPTION CLEARS THE NAME OF THE THING IT IS DRAWN INSIDE ──────
8015
+ // (item 49.) Centred-in-the-band is right when the band is the
8016
+ // only ink in that area and wrong when the band is drawn INSIDE a node,
8017
+ // because the node's own label is already centred there: `block-experimental`
8018
+ // put "Drained", "Q2" and "Headroom" on one baseline 1.6 px apart and the
8019
+ // reader got `DrainedQ2Headroom`. This is item 44/45's finding at the
8020
+ // intra-node scale — ink-blind placement — so the answer is theirs: the node
8021
+ // label, and every caption already placed on the same node, are OBSTACLES.
8022
+ //
8023
+ // The caption keeps its band-centred position unless that box comes within
8024
+ // 4 px of one (F6's margin, and F6 is the axis that would charge it). It
8025
+ // then slides by the SMALLEST amount that opens the gap — along the band's
8026
+ // OWN long axis first, which keeps it inside the band it names, then across
8027
+ // — staying within the host node. Only when the interior can hold no such
8028
+ // position does it move OUTSIDE, just beyond the band's outer edge, which is
8029
+ // where a threshold's caption already goes. A group- or region-scope band is
8030
+ // untouched: there is no centred label in the middle of a group box, and
8031
+ // every such figure in the corpus is byte-identical.
8032
+ const bandBoxes=new Map();
5842
8033
  for(const f of zsort(doc.bands)){
5843
8034
  const bn=byId[f.target];
5844
8035
  const B=gBox[f.target] || (bn ? {x0:bn.x, x1:bn.x+bn.w, yA:bn.y, yB:bn.y+bn.h} : null);
5845
8036
  if(!B) continue;
5846
8037
  const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
5847
- tsvg.push(textEl(bx+bw/2, by+bh/2+4, 11, 'middle', labelInk(f.fill,'#334155'), f.label,
8038
+ let tx=bx+bw/2, ty=by+bh/2+4, anchor='middle';
8039
+ const lw=cwMax(f.label)*6.5, M=4;
8040
+ const obst=[];
8041
+ if(bn){
8042
+ const nl=nodeLbl.get(bn.id); if(nl) obst.push(nl);
8043
+ for(const o of (bandBoxes.get(bn.id)||[])) obst.push(o);
8044
+ }
8045
+ const boxAt=(x,y)=>({x0:x-lw/2, x1:x+lw/2, y0:y-4-7.5, y1:y-4+3.5});
8046
+ const clear=b=>!obst.some(o=>b.x0<o.x1+M&&b.x1>o.x0-M&&b.y0<o.y1+M&&b.y1>o.y0-M);
8047
+ if(obst.length && !clear(boxAt(tx,ty))){
8048
+ // long axis first: a vertical slab slides up/down, a horizontal one
8049
+ // left/right. Inside the host node either way.
8050
+ const vert=(f.dir==='left'||f.dir==='right');
8051
+ const dirs=vert?[[0,-1],[0,1],[-1,0],[1,0]]:[[-1,0],[1,0],[0,-1],[0,1]];
8052
+ let done=false;
8053
+ for(let d=1; d<=160 && !done; d+=1){
8054
+ for(const [ux,uy] of dirs){
8055
+ const x=tx+ux*d, y=ty+uy*d, b=boxAt(x,y);
8056
+ // containment is asked of the axis being MOVED ALONG only. A
8057
+ // caption is routinely wider than the slab it names (a 30% band on
8058
+ // an 84 px node against a 45 px word), and it always was — sliding
8059
+ // it vertically must not be refused for an overhang the centred
8060
+ // placement already had.
8061
+ if(uy&&(b.y0<B.yA+1||b.y1>B.yB-1)) continue;
8062
+ if(ux&&(b.x0<B.x0+1||b.x1>B.x1-1)) continue;
8063
+ if(clear(b)){ tx=x; ty=y; done=true; break; }
8064
+ }
8065
+ }
8066
+ if(!done){
8067
+ // SIDE PLACEMENT: the interior cannot hold both, so the caption
8068
+ // leaves the node on the band's own outer side and keeps its
8069
+ // adjacency from outside.
8070
+ // `extend=` names the direction the band GROWS, so its outer side is
8071
+ // the edge it grew FROM: an `extend=right` band starts at the left
8072
+ // edge and leaves by the left.
8073
+ if(f.dir==='right') { anchor='end'; tx=B.x0-6; ty=by+bh/2+4; }
8074
+ else if(f.dir==='left') { anchor='start'; tx=B.x1+6; ty=by+bh/2+4; }
8075
+ else if(f.dir==='up') { anchor='middle'; tx=bx+bw/2; ty=B.yB+13; }
8076
+ else { anchor='middle'; tx=bx+bw/2; ty=B.yA-6; }
8077
+ W=Math.max(W, anchor==='end'?tx:tx+(anchor==='start'?lw:lw/2));
8078
+ Hh=Math.max(Hh, ty+4-y0-20);
8079
+ }
8080
+ }
8081
+ if(bn){
8082
+ const b=boxAt(anchor==='middle'?tx:(anchor==='end'?tx-lw/2:tx+lw/2), ty);
8083
+ if(!bandBoxes.has(bn.id)) bandBoxes.set(bn.id,[]);
8084
+ bandBoxes.get(bn.id).push(b);
8085
+ }
8086
+ tsvg.push(textEl(tx, ty, 11, anchor, labelInk(f.fill,'#334155'), f.label,
5848
8087
  ' paint-order="stroke" stroke="#fff" stroke-width="3"'));
5849
8088
  }
5850
8089
  const yEnd=y0+20+Hh+10;
5851
8090
  return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
5852
- meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}, errs:gErrs};
8091
+ meta:{W:W, top:y0+20+chShift+tShift, Hh:Hh, left:bShift+lShift}, errs:gErrs};
5853
8092
  }
5854
8093
  // borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
5855
8094
  // It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
@@ -6022,7 +8261,7 @@ function edgeRuns(v, p, n, span, ownAt){
6022
8261
  // `lifeline` declaration order is the COLUMN axis, left to right. Both axes
6023
8262
  // are declaration-ordered, and that is why this genre has no `flow` and no
6024
8263
  // `rank`: a key that reordered the drawing would make the picture disagree
6025
- // with the text (`SEQUENCE-SOURCE-STANDARD`-R182). `fragment` and `operand` lines are
8264
+ // with the text (`SEQUENCE-GENRE-VOCABULARY`). `fragment` and `operand` lines are
6026
8265
  // DECLARATIONS and carry no time position of their own; a container's drawn
6027
8266
  // extent is the span of its members' positions. Implementation: every
6028
8267
  // element carries its source line number, so the row order is recovered by
@@ -6960,6 +9199,25 @@ function shade(hex,f){
6960
9199
  return '#'+c(((v>>16)&255)*f)+c(((v>>8)&255)*f)+c((v&255)*f);
6961
9200
  }
6962
9201
  const CHART_PALETTE=['#3b82f6','#22c55e','#f59e0b','#ef4444','#a855f7','#14b8a6','#eab308','#64748b'];
9202
+ // Does an axis-aligned box overlap a CONVEX polygon? Separating-axis test:
9203
+ // two convex shapes miss each other iff some edge normal of either separates
9204
+ // them. The bar faces this is asked about are quads by construction (three
9205
+ // per bar), so the test is exact and needs no clipping library.
9206
+ function chartQuadHitsBox(poly,box){
9207
+ const bx=[[box.x0,box.y0],[box.x1,box.y0],[box.x1,box.y1],[box.x0,box.y1]];
9208
+ const shapes=[poly,bx];
9209
+ for(const sh of shapes){
9210
+ for(let i=0;i<sh.length;i++){
9211
+ const a=sh[i], c=sh[(i+1)%sh.length];
9212
+ const nx=-(c[1]-a[1]), ny=(c[0]-a[0]);
9213
+ let a0=Infinity,a1=-Infinity,b0=Infinity,b1=-Infinity;
9214
+ for(const q of poly){ const d=q[0]*nx+q[1]*ny; if(d<a0)a0=d; if(d>a1)a1=d; }
9215
+ for(const q of bx){ const d=q[0]*nx+q[1]*ny; if(d<b0)b0=d; if(d>b1)b1=d; }
9216
+ if(a1<b0||b1<a0) return false;
9217
+ }
9218
+ }
9219
+ return true;
9220
+ }
6963
9221
  function renderChart(b,y0,doc){
6964
9222
  const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
6965
9223
  const rows=t.rows.map(r=>r.cells.slice(1).map(c=>parseFloat(c.v)||0));
@@ -6984,41 +9242,114 @@ function renderChart(b,y0,doc){
6984
9242
  // gutter costs only the width it actually uses (0 when no label overhangs,
6985
9243
  // so every chart whose labels already fitted is byte-unchanged).
6986
9244
  const rLabPx=l=>cwMax(l)*6.5*10/11; // textEl draws these at font-size 10
6987
- const ox0=R*W2+8;
6988
- const gut=Math.max(0,...rLab.map((l,r)=>rLabPx(l)+4-(ox0-(r+0.65)*W2)));
6989
- const ox=ox0+gut, oy=y0+18+ZS*zmax+6;
6990
- const P=(r,c,z)=>[ox+(c-r)*W2, oy+(c+r)*H2-z*ZS];
6991
- const svg=[];
9245
+ const oy=y0+18+ZS*zmax+6;
9246
+ // EVERYTHING BELOW IS COMPUTED IN A FRAME WHOSE x IS RELATIVE TO THE FLOOR
9247
+ // ORIGIN `ox`, because the gutter that fixes `ox` is not known until the
9248
+ // labels have been placed, and label placement is invariant to it: a nudge
9249
+ // is a shift relative to the bars, and the bars shift with the origin.
9250
+ const PR=(r,c,z)=>[(c-r)*W2, oy+(c+r)*H2-z*ZS];
9251
+ const svg=[], lsvg=[];
6992
9252
  svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bar3d</text>');
6993
- // floor grid edges
6994
- const F=[P(0,0,0),P(R,0,0),P(R,C,0),P(0,C,0)];
6995
- svg.push('<polygon points="'+F.map(p=>p.join(',')).join(' ')+'" fill="#f6f5ef" stroke="#d6d4cc"/>');
6996
- // bars, far to near
6997
- const order=[];
9253
+ // bars, far to near — polygons kept for the label occlusion test below
9254
+ const order=[], faces=[];
6998
9255
  for(let r=0;r<R;r++)for(let c=0;c<C;c++)order.push([r,c]);
6999
9256
  order.sort((a,b2)=>(a[0]+a[1])-(b2[0]+b2[1]));
9257
+ const bars=[];
7000
9258
  for(const [r,c] of order){
7001
9259
  const h=rows[r][c]; if(h<=0) continue;
7002
9260
  const col=CHART_PALETTE[c%CHART_PALETTE.length];
7003
9261
  const i0=r+(1-BAR)/2, i1=r+(1+BAR)/2, j0=c+(1-BAR)/2, j1=c+(1+BAR)/2;
7004
- const A=P(i0,j0,h),Bp=P(i1,j0,h),Cp=P(i1,j1,h),D=P(i0,j1,h);
7005
- const B0=P(i1,j0,0),C0=P(i1,j1,0),D0=P(i0,j1,0);
7006
- svg.push('<polygon points="'+[Bp,Cp,C0,B0].map(p=>p.join(',')).join(' ')+'" fill="'+shade(col,0.72)+'"/>');
7007
- svg.push('<polygon points="'+[Cp,D,D0,C0].map(p=>p.join(',')).join(' ')+'" fill="'+shade(col,0.55)+'"/>');
7008
- svg.push('<polygon points="'+[A,Bp,Cp,D].map(p=>p.join(',')).join(' ')+'" fill="'+col+'"/>');
9262
+ const A=PR(i0,j0,h),Bp=PR(i1,j0,h),Cp=PR(i1,j1,h),D=PR(i0,j1,h);
9263
+ const B0=PR(i1,j0,0),C0=PR(i1,j1,0),D0=PR(i0,j1,0);
9264
+ bars.push([Bp,Cp,C0,B0],[Cp,D,D0,C0],[A,Bp,Cp,D]);
9265
+ faces.push([[Bp,Cp,C0,B0],shade(col,0.72)],[[Cp,D,D0,C0],shade(col,0.55)],[[A,Bp,Cp,D],col]);
9266
+ }
9267
+ // AXIS LABELS ARE PLACED LAST AND PAINTED LAST (backlog 48).
9268
+ // They used to be emitted in WORLD space at the floor's edge and then left
9269
+ // where they landed, which put them inside the projection's own footprint:
9270
+ // a tall bar rises up-and-left across exactly the row line whose name sits
9271
+ // out there, so the categories a tall bar celebrates were the ones it hid
9272
+ // (`telemetry-export`: "Sampler" on the 12000 bar; `queue-heatmap`: P0, P1
9273
+ // and P2 on the bar field, and the Q captions under the bar feet). Paint
9274
+ // order alone does not fix it — dark grey on a saturated fill is unread
9275
+ // whichever is on top — so the label LEANS OUT along its own grid line,
9276
+ // the one direction that keeps it a label OF that row or column: a row
9277
+ // name travels outward at constant r (decreasing c), a column name outward
9278
+ // at constant c (increasing r), each stepping 2 px until its box clears
9279
+ // every bar face. Leaning out is bounded (40 px); the halo is what carries
9280
+ // the rare label that cannot fully clear. The nudge changes no bar, no
9281
+ // floor and no ruler — only where a name sits on the line it already
9282
+ // belonged to.
9283
+ const HALO=' paint-order="stroke" stroke="#fff" stroke-width="3"';
9284
+ const LEAN=2, LEAN_MAX=96, CEIL=y0+18;
9285
+ function leanBox(px,py,wpx,anchor){
9286
+ return {x0:anchor==='end'?px-wpx:px, x1:anchor==='end'?px:px+wpx, y0:py-8, y1:py+2.5};
7009
9287
  }
7010
- // axis labels
7011
- rLab.forEach((l,r)=>{ const p=P(r+0.5,-0.15,0); svg.push(textEl(p[0]-4,p[1]+10,10,'end','#6f6e69',l)); });
7012
- cLab.forEach((l,c)=>{ const p=P(R+0.15,c+0.5,0); svg.push(textEl(p[0]+4,p[1]+10,10,'start','#6f6e69',l)); });
9288
+ function leanOut(x,y,wpx,anchor,dx,dy){
9289
+ const n=Math.hypot(dx,dy), ux=dx/n, uy=dy/n;
9290
+ let last=[x,y,leanBox(x,y,wpx,anchor),false];
9291
+ for(let k=0;k<=LEAN_MAX/LEAN;k++){
9292
+ const px=x+ux*k*LEAN, py=y+uy*k*LEAN;
9293
+ const box=leanBox(px,py,wpx,anchor);
9294
+ // never lean up past the section's own caption line: a name that
9295
+ // escapes the bars by leaving the chart is not a fix.
9296
+ if(box.y0<CEIL) break;
9297
+ last=[px,py,box];
9298
+ // 3 px of air, so "clear" reads as clear rather than as a name
9299
+ // ruled off by the bar it is standing against.
9300
+ const pad={x0:box.x0-3,x1:box.x1+3,y0:box.y0-2,y1:box.y1+2};
9301
+ let hit=false;
9302
+ for(const q of bars) if(chartQuadHitsBox(q,pad)){ hit=true; break; }
9303
+ // an axis label already placed is ink too: the column captions of
9304
+ // `telemetry-export` are 20 px apart in x and 10 px in y, so two long
9305
+ // ones ("Rate (pps)", "Drops/min") overlap by a line's descender unless
9306
+ // each leans clear of the last.
9307
+ if(!hit) for(const o of laid)
9308
+ if(pad.x0<o.x1&&pad.x1>o.x0&&pad.y0<o.y1&&pad.y1>o.y0){ hit=true; break; }
9309
+ if(!hit) return [px,py,box,true];
9310
+ }
9311
+ return [last[0],last[1],last[2],false];
9312
+ }
9313
+ let overhang=0, farRight=-Infinity, farDown=-Infinity;
9314
+ const placed=[], laid=[];
9315
+ rLab.forEach((l,r)=>{
9316
+ const p=PR(r+0.5,-0.15,0);
9317
+ const [x,y,box,ok]=leanOut(p[0]-4,p[1]+10,rLabPx(l),'end',-W2,-H2);
9318
+ placed.push([x,y,'end',l,ok]); laid.push(box);
9319
+ overhang=Math.max(overhang,-box.x0); farRight=Math.max(farRight,box.x1); farDown=Math.max(farDown,box.y1);
9320
+ });
9321
+ cLab.forEach((l,c)=>{
9322
+ const p=PR(R+0.15,c+0.5,0);
9323
+ const [x,y,box,ok]=leanOut(p[0]+4,p[1]+10,rLabPx(l),'start',-W2,H2);
9324
+ placed.push([x,y,'start',l,ok]); laid.push(box);
9325
+ overhang=Math.max(overhang,-box.x0); farRight=Math.max(farRight,box.x1); farDown=Math.max(farDown,box.y1);
9326
+ });
9327
+ const ox=Math.max(R*W2+8, overhang+4);
9328
+ for(const [poly,fill] of faces)
9329
+ svg.push('<polygon points="'+poly.map(p=>(p[0]+ox)+','+p[1]).join(' ')+'" fill="'+fill+'"/>');
9330
+ // THE HALO IS THE LAST RESORT, NOT THE HABIT. A label that found clear air
9331
+ // is drawn plainly: `tools/layout-lint.js` tells an EDGE label from every
9332
+ // other label by the halo twin (two <text> at one point), so haloing a chart
9333
+ // axis caption enrols it in F5's denominator, and an axis whose denominator
9334
+ // silently absorbs five non-edge labels is measuring something other than
9335
+ // what it prints. Only a caption the lean could not clear keeps the halo,
9336
+ // which is the one case where it is carrying the legibility.
9337
+ for(const [x,y,anchor,l,ok] of placed)
9338
+ lsvg.push(textEl(x+ox,y,10,anchor,'#6f6e69',l,ok?'':HALO));
9339
+ // floor grid edges — drawn UNDER the bars, so it is spliced in ahead of them
9340
+ const F=[PR(0,0,0),PR(R,0,0),PR(R,C,0),PR(0,C,0)];
9341
+ svg.splice(1,0,'<polygon points="'+F.map(p=>(p[0]+ox)+','+p[1]).join(' ')+'" fill="#f6f5ef" stroke="#d6d4cc"/>');
7013
9342
  // z ruler at the right-back corner
7014
- const zr=P(0,C,0);
9343
+ const zr=[PR(0,C,0)[0]+ox, PR(0,C,0)[1]];
7015
9344
  svg.push('<line x1="'+(zr[0]+14)+'" y1="'+zr[1]+'" x2="'+(zr[0]+14)+'" y2="'+(zr[1]-zmax*ZS)+'" stroke="#8a8880"/>');
7016
9345
  for(const z of [0, Math.round(zmax/2), Math.round(zmax)]){
7017
9346
  svg.push('<line x1="'+(zr[0]+11)+'" y1="'+(zr[1]-z*ZS)+'" x2="'+(zr[0]+17)+'" y2="'+(zr[1]-z*ZS)+'" stroke="#8a8880"/>');
7018
9347
  svg.push(textEl(zr[0]+21, zr[1]-z*ZS+3.5, 9.5, 'start', '#6f6e69', String(z)));
7019
9348
  }
7020
- const w=P(R,C,0)[0]+70, hgt=P(R,C,0)[1]+24-y0;
7021
- return {svg:svg.join(''), y:y0+hgt, w:w};
9349
+ const near=[PR(R,C,0)[0]+ox, PR(R,C,0)[1]];
9350
+ const w=Math.max(near[0]+70, farRight+ox+4);
9351
+ const hgt=Math.max(near[1]+24, farDown+6)-y0;
9352
+ return {svg:svg.join('')+lsvg.join(''), y:y0+hgt, w:w};
7022
9353
  }
7023
9354
 
7024
9355
  // ---- timing ----