figdown 0.4.1 → 0.5.0

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.mjs CHANGED
@@ -1,8 +1,8 @@
1
- // figdown.mjs — FigDown embeddable library (0.4.1)
1
+ // figdown.mjs — FigDown embeddable library (0.5.0)
2
2
  // GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
3
3
  // Regenerate with: node tools/make-lib.js
4
4
  'use strict';
5
- var VERSION = "0.4.1";
5
+ var VERSION = "0.5.0";
6
6
 
7
7
  // ---- engine (extracted verbatim from editor/figdown.html) ----
8
8
  var __engine = (function () {
@@ -16,7 +16,38 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
16
16
  // input to that promise, and under core §13 a 0.x renderer may differ from
17
17
  // the next — which makes the recorded version the only thing that can
18
18
  // explain a diff between two renderings of one source.
19
- const FIGDOWN_VERSION = '0.4.1';
19
+ const FIGDOWN_VERSION = '0.5.0';
20
+ // `TYPED-BLOCK-TITLE-CANVAS`: a `Z`-only dev bump — RENDERER ONLY, no keyword, no
21
+ // option key, no model field, so `figdown 0.5` still names one language. A
22
+ // typed block's own title (bitfield/table/timing/chart) now joins the
23
+ // section's canvas computation: the returned width is the union of the data
24
+ // extent and the title extent (`typedBlockTitleW`, shared by all four
25
+ // renderers), so a title wider than the data widens the canvas instead of
26
+ // running off it at x=0. Fixes backlog item 66 (FR-4). Corpus impact
27
+ // surveyed first: zero shipped drawings change (every typed-block title in
28
+ // the corpus already fit its data-derived canvas); the fix only reaches
29
+ // fixtures the corpus does not yet have. See spec/migrations.md.
30
+ // 0.5: EDITOR ONLY — no keyword, no option key, no model field moves.
31
+ // A genre-aware property inspector (buildInspector) for document/node/edge/
32
+ // group; edge/message editing now goes through the engine's own
33
+ // scanConnectorLine spans instead of a second grammar; quote-aware option
34
+ // targeting (authoredOptionSpan/optionSpanOutsideLabel) closes the
35
+ // label-corruption class; every layout emitter gains a sectionIndex
36
+ // parameter; tools/editor-check.js grows from 91 to 140 checks; and canvas
37
+ // hit-testing now excludes `[data-lasso]` (`LASSO-ENCLOSURE-TRUTH`) beside `[data-port-sq]`
38
+ // in the stopPropagation loop and both closest(...) exclusion
39
+ // lists — the lasso ellipse landed after this batch's own hit-test baseline
40
+ // was written. See spec/migrations.md for the full entry.
41
+ // `LASSO-ENCLOSURE-TRUTH`: a `Z`-only dev bump. One new geometry-time diagnostic (a
42
+ // bundle's lasso enclosing a non-member), one new `data-*` attribute on the
43
+ // output (`data-lasso`), and one more constraint on the separation pass — no
44
+ // keyword, no option key, no model field, so `figdown 0.5` still names one
45
+ // language. See the lasso-containment rule beside the group-band pass below.
46
+ // `CLASS-CHANNEL-COLLISION`: a `Z`-only dev bump, the same shape as `MEMBER-LIST-DUPLICATION`'s — one new
47
+ // diagnostic (two carried classes binding the same paint channel on one
48
+ // element), no keyword, no option key, no model field, so `figdown 0.5`
49
+ // still names one language. See the `clsChan` collision check below for the
50
+ // rule itself.
20
51
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
21
52
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
22
53
  // engine and only ever increases), so 0.1 is followed by
@@ -36,7 +67,14 @@ const FIGDOWN_VERSION = '0.4.1';
36
67
  // makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
37
68
  // no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
38
69
  // the dispatch point lands first and the vocabulary follows it.
39
- const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4'];
70
+ // `CONNECTOR-IDENTITY-KEY`: `figdown 0.5` joins the set, and the dev line crosses to
71
+ // 0.5 exactly the way it crossed to 0.2 at `STATECHART-GENRE-SCOPE` — the counter does NOT reset
72
+ // (core §13.0.4: `N` counts source states of the engine and only ever
73
+ // increases), so 0.4 is followed by 0.5. What moves the
74
+ // language number is `id=` on the four scene connectors: core §13.0 makes an
75
+ // added option key a `Y` and never a `Z`, because `figdown 0.4` must not name
76
+ // two languages — the one v0.4 published and the one with `id=`.
77
+ const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4', '0.5'];
40
78
  // Genres per declared language version. `Y` never removes (core §13.0), so
41
79
  // each row is a superset of the one above it, and `figdown 0.1 <anything>`
42
80
  // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
@@ -58,7 +96,13 @@ const GENRES_BY_VERSION = {
58
96
  // it. The genre still has NO RENDERER: a valid `sequence` document parses to
59
97
  // a model and draws an empty canvas, which is the state this increment means
60
98
  // to land and is pinned by a fixture rather than left to be noticed.
61
- '0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
99
+ '0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence'],
100
+ // `CONNECTOR-IDENTITY-KEY`: `0.5` adds NO genre. It is here because the row must
101
+ // exist for every accepted version — a genre list is looked up by the
102
+ // declared version and an absent row would narrow nothing — and it is a copy
103
+ // of `0.4`'s because `Y` never removes and this release adds an OPTION KEY,
104
+ // not a dispatch point. The version moved for `id=`, and `id=` is genre-free.
105
+ '0.5': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
62
106
  };
63
107
  // The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
64
108
  // device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
@@ -69,8 +113,23 @@ const GENRES_BY_VERSION = {
69
113
  // Accepting it silently under a `figdown 0.2` header would repaint a document
70
114
  // whose author meant a never-drawn tooltip as one that puts ink on the page —
71
115
  // core §13.0.1's named hazard, "a figure that looks right and means something
72
- // else". A key that had never been spelled before would carry no such risk.
73
- const OPT_MIN_VERSION={note:'0.3'};
116
+ // else".
117
+ //
118
+ // THE SENTENCE THAT USED TO CLOSE THIS COMMENT IS WITHDRAWN (`CONNECTOR-IDENTITY-KEY`,
119
+ // 0.5). It read: "A key that had never been spelled before would carry
120
+ // no such risk." It is kept visible here, as core §13.7.2 keeps it, because a
121
+ // deleted mistake teaches nothing — but it is not the rule. `note=`'s prior
122
+ // meaning is why its DIAGNOSTIC says what it says; it was never why the gate
123
+ // exists. The gate engages on ADDING A SPELLING TO THE ACCEPTED SURFACE: a
124
+ // document's header is the contract it is read against, `read/<X.Y>/` is the
125
+ // frozen text of that contract, and `archive/`'s own engine for a version would
126
+ // refuse a key that version never named. Silently accepting a newer key under
127
+ // an older header makes the declared version stop determining the surface,
128
+ // which is the declaration decaying into a comment. So EVERY new option key
129
+ // gates, and `id=` (`CONNECTOR-IDENTITY-KEY`) is the first key gated under the restated rule.
130
+ // `CONNECTOR-IDENTITY-KEY`: `id=` — the connector's optional handle. It has no prior
131
+ // meaning of any kind; it is gated because it is a new spelling.
132
+ const OPT_MIN_VERSION={note:'0.3',id:'0.5'};
74
133
  // True when the document's declared version is older than the key's own.
75
134
  // A document with no parsable header has already been diagnosed on line 1, so
76
135
  // an absent version never gates a second time.
@@ -297,7 +356,13 @@ function splitList(t,off){
297
356
  // value grammar is a range, so it is also the key that fixes the language's
298
357
  // ONE range spelling at `..` (`RANGE-SPELLING` moves `band` off the hyphen in the same
299
358
  // release).
300
- const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in','plane','layer','label',
359
+ // `CONNECTOR-IDENTITY-KEY`: `id` joins the registry as the CONNECTOR's optional
360
+ // handle — the one element kind in the language that had no way to be named.
361
+ // RULE 4.1 is satisfied without a coinage: `id` is already the EBNF production
362
+ // name and the model field name for every other element's handle, so minting a
363
+ // second word for it would have been the violation. Its acceptors are the four
364
+ // scene connectors and nothing else (DIRECTIVE_OPTS below).
365
+ const OPT_KEYS=new Set(['id','kind','type','shape','fill','color','stroke','text','in','plane','layer','label',
301
366
  'style','z','z-index','at','offset','w','h','width','height','unit','word','note','description','present','index','labels','data','numbering','from','to','gap',
302
367
  'dir','extend','level','taillabel','headlabel','class','via','points','routing','src','dst','tailport','headport']);
303
368
  // Applicable option keys per directive. Keys with dedicated diagnostics
@@ -360,12 +425,16 @@ const DIRECTIVE_OPTS={
360
425
  // empty array is the declaration: every key falls through to the generic
361
426
  // `external does not take <k>=`.
362
427
  external:[],
363
- edge:['style','class','fill','stroke','label','taillabel','headlabel','note'],
428
+ // `CONNECTOR-IDENTITY-KEY`: `id` is on all four connector rows. Under `GENRE-VOCABULARY-OBLIGATION`/`SUBJECT-VOCABULARY-SCOPE` an
429
+ // option key is declared per genre, so the acceptor list is four separate
430
+ // declarations of one key — the `note=` and `class=` pattern exactly, and for
431
+ // the same reason: the referent is THIS CONNECTOR, which is genre-independent.
432
+ edge:['style','class','fill','stroke','label','taillabel','headlabel','note','id'],
364
433
  // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: same rename argument — the connector's option set is one set
365
434
  // under three spellings, listed three times only because the tables are
366
435
  // keyed by the surface word an author actually wrote.
367
- flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
368
- transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
436
+ flowline:['style','class','fill','stroke','label','taillabel','headlabel','note','id'],
437
+ transition:['style','class','fill','stroke','label','taillabel','headlabel','note','id'],
369
438
  // `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's four own rows. `message` is
370
439
  // the fourth connector spelling and takes the connector set — `fill=` and
371
440
  // the three retired label keys stay listed for the same reason they are
@@ -374,7 +443,14 @@ const DIRECTIVE_OPTS={
374
443
  // message occurs inside) and `description=`. It does NOT gain a key of its
375
444
  // own: `lost=` was proposed and refused (`UNDELIVERED-MESSAGE-MARKING`), and `OPT_KEYS` is unchanged
376
445
  // by this whole increment.
377
- message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description'],
446
+ // `CONNECTOR-IDENTITY-KEY`: `message` is the FOURTH connector and takes `id=` with
447
+ // the other three. The proposal recommended refusing it here; the ruling
448
+ // widened the acceptor list, and the reason it can is that an id is a HANDLE
449
+ // and nothing else. It states no order, no equivalence and no identity
450
+ // between two occurrences — §6.4's occurrence-identity question stays shut,
451
+ // and the diff still aligns messages as a multiset, because naming a thing is
452
+ // not the same act as claiming two things are one.
453
+ message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description','id'],
378
454
  // A lifeline is drawn as a head box over a dashed line, so it has an
379
455
  // interior and takes `fill=`. `in=` is sense 1.
380
456
  lifeline:['class','fill','stroke','style','in','note','description'],
@@ -584,6 +660,22 @@ const NOTE_VERSION=(have)=>
584
660
  'annotation: an explanation the human reader must SEE. Raise the header to '+
585
661
  'figdown 0.3, or write description= if you meant prose only a machine reads '+
586
662
  '(MIGRATIONS 0.3)';
663
+ // `CONNECTOR-IDENTITY-KEY`: the SAME device for `id=`, and the message says what the
664
+ // restated rule says. `note=`'s gate names a prior meaning because `note=` HAS
665
+ // one; `id=` has none, and it is gated anyway — the gate engages on adding a
666
+ // spelling to the accepted surface, not on what the spelling used to mean. So
667
+ // this message argues from the declaration rather than from repainting: the
668
+ // header is the contract, `read/<X.Y>/` is that contract's frozen text, and the
669
+ // archived engine for a version would refuse a key that version never named.
670
+ // The one-step fix is named, exactly as `KEYWORD-RENAME-SCOPE`'s device requires.
671
+ const ID_VERSION=(have)=>
672
+ 'id= requires figdown 0.5 (this document declares '+have+'): a connector has '+
673
+ 'no id= spelling under figdown '+have+', so an engine held to the version '+
674
+ 'this document declares — the archived '+have+' engine, or a reader working '+
675
+ 'from the frozen read/'+have+' contract — would refuse this line. A header '+
676
+ 'that stops determining what the language accepts is a comment, not a '+
677
+ 'declaration. Raise the header to figdown 0.5, or delete the key: an '+
678
+ 'anonymous connector stays legal and claims nothing less (MIGRATIONS 0.5)';
587
679
  // (b) The directive is `field`, which refuses the key at EVERY version. The
588
680
  // bitfield genre already has `description=` for machine-facing prose, and
589
681
  // no measured figure needs a DRAWN per-field aside — granting a directive
@@ -770,7 +862,7 @@ const DD_ID='"--" is not allowed inside an id — it is the link operator (edge
770
862
  // gates necessity.
771
863
  // whitespace-delimited string position -> quotes MANDATORY
772
864
  // comma-delimited list element -> quotes only when the element
773
- // contains whitespace , " or #
865
+ // contains whitespace , " ( ) or #
774
866
  // (redundant quotes stay legal)
775
867
  // [ ] edge label -> quotes only to enable escapes
776
868
  // The whitespace row is not a style preference: whitespace is ALSO the
@@ -782,6 +874,20 @@ const Q_WHY='whitespace also separates positionals, so a bare token cannot expre
782
874
  const ID_RULE='ids are bare and match [A-Za-z_][A-Za-z0-9_-]* — text with spaces or punctuation belongs in the label: node <id> "your text"';
783
875
  // isId: the whole id test, used at every id position in the language.
784
876
  const isId=v=>typeof v==='string'&&ID_RE.test(v)&&!v.includes('--');
877
+ // `CONNECTOR-IDENTITY-KEY`: what `data-edge` carries (core §7). It is the AUTHORED
878
+ // id where the connector has one, and the 1-based source line where it does
879
+ // not. The two can never be confused: an id must start with a letter or an
880
+ // underscore, so no id is a decimal number and no line number is an id.
881
+ //
882
+ // The change is to the VALUE, not to the attribute: `data-edge` stays the one
883
+ // name in the profile's `data-*` enumeration and `gate:safesvg` is untouched.
884
+ // What it buys is the thing ADV-20 said the channel could not honestly supply —
885
+ // inserting a line at the top of a document changes every source-line
886
+ // `data-edge` in it, which is a handle that moves when nothing about the figure
887
+ // moved. For a named connector it no longer does; for an anonymous one the
888
+ // attribute keeps its old job and its old caveat, and the fallback is stated
889
+ // rather than implied.
890
+ const edgeRef=e=>(e && e.id!==undefined && e.id!==null) ? e.id : e.line;
785
891
  // idErr(value, quotedFlag, missingMsg) -> the message for an id position, or
786
892
  // null when the id is well formed. `missing` fires only when nothing at all
787
893
  // was written; a written-but-illegal id always gets ID_RULE or DD_ID, never
@@ -888,7 +994,7 @@ function parseClassList(v,els){
888
994
  // core §12.2 and genres/bitfield.md, pinned by golden 420.
889
995
  //
890
996
  // Returns {ok, val, err}. `val` is the model shape: an object with `first`
891
- // and `last` being a NUMBER when the run is determinate and a STRING
997
+ // and `last`, `last` being a NUMBER when the run is determinate and a STRING
892
998
  // when it is prose. `index=""` yields `{}` — a written value that claims
893
999
  // repetition and states no index at all.
894
1000
  const IDX_INT=/^\d+$/;
@@ -1418,6 +1524,74 @@ function seqModel(doc){
1418
1524
  return {rows,cont,owned,extent,chain,cycles};
1419
1525
  }
1420
1526
 
1527
+ // `XML-CHARACTER-LEGALITY` (core §15.5) — XML-ILLEGAL CHARACTERS ARE A LINE ERROR.
1528
+ //
1529
+ // Every artifact is an XML document (core §7 embeds the source in one), and
1530
+ // XML 1.0 forbids a fixed set of code points OUTRIGHT: they cannot appear
1531
+ // literally, and they cannot be written as a character reference either, so
1532
+ // there is no escaping that rescues them. Until this rule the engine passed
1533
+ // them through: a label containing a raw U+0000 parsed clean, rendered clean,
1534
+ // and produced an `.svg` that NO conforming XML reader will open. Nothing
1535
+ // caught it — not the parser, which had no opinion, and not any gate, because
1536
+ // every document in the tree was clean.
1537
+ //
1538
+ // Refusing is the §8 principle, not a new one: a document with errors renders
1539
+ // NOTHING, because a drawing the engine cannot stand behind is worse than no
1540
+ // drawing. An artifact that is not well-formed is exactly that case — the
1541
+ // figure is unreadable by the machine reader the whole format exists to serve
1542
+ // — so it costs a line error, at parse time, before anything is drawn.
1543
+ // Determinism over convenience.
1544
+ //
1545
+ // THE SET, and why it is stated as ranges rather than "control characters":
1546
+ // U+0000–U+0008, U+000B, U+000C, U+000E–U+001F C0 controls, EXCEPT
1547
+ // U+0009 tab, U+000A LF and
1548
+ // U+000D CR, which are legal
1549
+ // and stay legal
1550
+ // U+FFFE, U+FFFF permanently unassigned
1551
+ // an UNPAIRED surrogate (U+D800–U+DFFF) not a character at all; a
1552
+ // correctly paired astral
1553
+ // character is legal and is
1554
+ // NOT flagged
1555
+ // U+007F DEL and the C1 range are legal XML 1.0 characters and are NOT
1556
+ // refused — this rule enforces XML's list, not a taste for printability.
1557
+ const XML_ILLEGAL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/;
1558
+ function xmlIllegalErrors(text){
1559
+ const out=[];
1560
+ const lines=String(text).split('\n');
1561
+ for(let li=0; li<lines.length; li++){
1562
+ const line=lines[li];
1563
+ const seen=new Set();
1564
+ for(let i=0;i<line.length;i++){
1565
+ const c=line.charCodeAt(i);
1566
+ let bad=false;
1567
+ if(XML_ILLEGAL.test(line[i])) bad=true;
1568
+ else if(c>=0xD800&&c<=0xDBFF){ // high surrogate: needs a low one
1569
+ const n=line.charCodeAt(i+1);
1570
+ if(!(n>=0xDC00&&n<=0xDFFF)) bad=true; else i++;
1571
+ }
1572
+ else if(c>=0xDC00&&c<=0xDFFF) bad=true; // lone low surrogate
1573
+ if(!bad) continue;
1574
+ // One error per DISTINCT character per line. A 1 MB label of NULs is one
1575
+ // defect and one fix, and 1 000 000 identical lines would bury every
1576
+ // other diagnostic in the pass — the same reason §8.1 caps duplication.
1577
+ const cp='U+'+c.toString(16).toUpperCase().padStart(4,'0');
1578
+ if(seen.has(cp)) continue;
1579
+ seen.add(cp);
1580
+ // The tail names the RULE the character broke, and there are two rules,
1581
+ // so there are two tails. One generic sentence would be wrong for half
1582
+ // the set — "only tab, newline and carriage return are allowed" says
1583
+ // nothing true about U+FFFF — and a diagnostic that misdescribes the
1584
+ // rule sends the author to the wrong fix.
1585
+ const tail = c<0x20
1586
+ ? ' (below U+0020 only tab, newline and carriage return are allowed)'
1587
+ : ' (U+FFFE, U+FFFF and unpaired surrogates are not characters — no escape writes one)';
1588
+ out.push('Line '+(li+1)+': illegal character '+cp+
1589
+ ' — XML forbids it, so the artifact could not be well-formed'+tail);
1590
+ }
1591
+ }
1592
+ return out;
1593
+ }
1594
+
1421
1595
  // parse(text) -> {doc, errs, docs}
1422
1596
  // Single-section: docs=[doc] (backward-compatible doc/errs).
1423
1597
  // Multi-section: one doc per figdown header; errs use full-file line numbers;
@@ -1430,12 +1604,20 @@ function parse(text){
1430
1604
  // makes the normative rule ("a BOM at the very start of the document is
1431
1605
  // ignored") implementable from the spec alone. Behaviour is unchanged.
1432
1606
  text=String(text).replace(/^\uFEFF/,'');
1607
+ // `XML-CHARACTER-LEGALITY`: the XML-illegal-character sweep runs HERE \u2014 over the whole document,
1608
+ // before it is split into sections \u2014 for two reasons. Its line numbers are
1609
+ // already full-file, which is what every other message is re-based to below;
1610
+ // and the rule is about the SOURCE, not about any directive, so it must see
1611
+ // comments and blank lines too. Its findings JOIN the pass rather than
1612
+ // replacing it (\u00A78: all errors report in one pass), so an author fixing a
1613
+ // NUL still sees the typo on the next line in the same run.
1614
+ const xmlErrs=xmlIllegalErrors(text);
1433
1615
  const secs=splitFigdownSections(text);
1434
1616
  if(!secs){
1435
1617
  const r=parseOne(String(text));
1436
- return {doc:r.doc, errs:r.errs, docs:[r.doc]};
1618
+ return {doc:r.doc, errs:xmlErrs.concat(r.errs), docs:[r.doc]};
1437
1619
  }
1438
- const docs=[]; const errs=[];
1620
+ const docs=[]; const errs=xmlErrs.slice();
1439
1621
  for(const sec of secs){
1440
1622
  const r=parseOne(sec.text);
1441
1623
  // A section's element `.line` values are section-local, and a GEOMETRY-time
@@ -1453,6 +1635,197 @@ function parse(text){
1453
1635
  return {doc:docs[0], errs, docs};
1454
1636
  }
1455
1637
 
1638
+ // ── THE CONNECTOR SCANNER, WITH A RETURN VALUE ────────────────
1639
+ // `EDGE-LABEL-PLACEMENT`/`REVERSE-ARROW-OPERATOR`'s connector grammar — `<kw> <id> [tail] <op> [head] <id>` — used to
1640
+ // live entirely INSIDE `parseOne`, as `parseEdgeLine`'s local scanner, and it
1641
+ // reported only through `err()` and a push into `doc.edges`/`doc.messages`.
1642
+ // That put it out of reach of the editor's GUI half: the code in the UI
1643
+ // section needs the SOURCE OFFSETS of each part of a connector line to edit
1644
+ // one label, one operator or one endpoint in place, leaving every other byte of
1645
+ // line alone (the editor's edge-editing requirement). A GUI cannot answer that
1646
+ // with a pattern of its own — a second grammar is a second answer to
1647
+ // `edge a -[hop #1]-> b`, to `edge a--b`, to `["]"]`, to `-[x]->` and to every
1648
+ // other case this scanner already settles, and the two answers would drift the
1649
+ // first time either side moved. That is the drift class `NODE_KW_ALT` was
1650
+ // created to close for node lines and it is the same class here.
1651
+ //
1652
+ // So the tokenizer moved OUT and grew a return value; NOTHING about the
1653
+ // grammar changed. There is still exactly ONE connector grammar, now with two
1654
+ // callers: `parseEdgeLine`, which adds the option pass, every semantic check
1655
+ // and the model write, and `scanConnectorLine`, which adds nothing and writes
1656
+ // nothing. Every syntax diagnostic is produced HERE and merely relayed by the
1657
+ // parser, so the two callers cannot disagree about what is legal.
1658
+ //
1659
+ // Offsets are UTF-16 indices into the string passed in — the parser passes the
1660
+ // line already trimmed, exactly as it did before.
1661
+ function scanConnector(s,kw){
1662
+ kw=kw||'edge';
1663
+ let i=kw.length; // past the connector keyword
1664
+ const bad=(m)=>({ok:false,error:m});
1665
+ const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
1666
+ // `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
1667
+ // followed by a second one, because `--` is the link operator. This is
1668
+ // what lets `edge a--b` mean the same thing as `bundle t1 a--b`; before
1669
+ // the ban the greedy scanner ate `a--b` as one id and then reported
1670
+ // "edge needs an operator", so the same token read oppositely two lines
1671
+ // apart (SYNTAX-STYLE §6.3).
1672
+ const readId=()=>{ const m=/^[A-Za-z_](?:[A-Za-z0-9_]|-(?!-))*/.exec(s.slice(i));
1673
+ if(!m) return null; i+=m[0].length; return m[0]; };
1674
+ // `QUOTED-IDS`: an endpoint is an id position. A quoted token or a spelling that
1675
+ // is not an id gets the ID RULE — not "edge needs <id> …", which named
1676
+ // the wrong thing when the operator was plainly there.
1677
+ const idHere=()=>i<s.length&&!/[\s[\-<>]/.test(s[i]);
1678
+ const readLbl=()=>{ // called at '['
1679
+ i++;
1680
+ if(s[i]==='"'){ // ["..."] — quoted content
1681
+ i++; let v='';
1682
+ while(i<s.length&&s[i]!=='"'){
1683
+ if(s[i]==='\\'){ const e=s[i+1];
1684
+ if(e==='n'){ v+='\n'; i+=2; continue; }
1685
+ if(e==='"'){ v+='"'; i+=2; continue; }
1686
+ if(e==='\\'){ v+='\\'; i+=2; continue; }
1687
+ return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'}; }
1688
+ v+=s[i]; i++;
1689
+ }
1690
+ if(i>=s.length) return {error:'unterminated string in [label]'};
1691
+ i++;
1692
+ if(s[i]!==']') return {error:'expected ] after quoted label'};
1693
+ i++;
1694
+ if(!v) return {error:'empty [label]'};
1695
+ return {v};
1696
+ }
1697
+ let depth=1,v='';
1698
+ while(i<s.length){
1699
+ const c=s[i];
1700
+ if(c==='[') depth++;
1701
+ else if(c===']'){ depth--; if(!depth){ i++;
1702
+ v=v.trim();
1703
+ if(!v) return {error:'empty [label]'};
1704
+ return {v}; } }
1705
+ v+=c; i++;
1706
+ }
1707
+ return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
1708
+ };
1709
+ // Spans are recorded in SOURCE ORDER and are half-open [start,end). A label
1710
+ // span covers its BRACKETS as well as its text, because the edit a GUI makes
1711
+ // to a written label is a replacement of the whole `[...]` (deleting one
1712
+ // means deleting its brackets too); `mid` is the one span nested inside
1713
+ // another, since `-[x]->` is one operator with a label in the middle of it.
1714
+ // An ABSENT label gets no span at all, which is how a caller tells
1715
+ // `edge a -> b` from `edge a [t] -> b` — the value is `null` in both the
1716
+ // field and the span, and an empty `[]` is a line error, never a third state.
1717
+ const sp={};
1718
+ ws();
1719
+ if(s[i]==='"') return bad(ID_RULE);
1720
+ const aAt=i;
1721
+ const a=readId();
1722
+ if(!a) return bad(idHere()?ID_RULE:kw+' needs <id> ->|<-|--|<-> <id>');
1723
+ if(idHere()) return bad(ID_RULE);
1724
+ sp.a={start:aAt,end:i};
1725
+ ws(); let tail=null;
1726
+ if(s[i]==='['){ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
1727
+ tail=r.v; sp.tail={start:at,end:i}; }
1728
+ ws();
1729
+ const opAt=i;
1730
+ let lh=null;
1731
+ if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
1732
+ else if(s[i]==='-'){ lh='-'; i++; }
1733
+ else return bad(kw+' needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)');
1734
+ let mid=null, op=null;
1735
+ if(s[i]==='['){
1736
+ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
1737
+ mid=r.v; sp.mid={start:at,end:i};
1738
+ if(s.startsWith('->',i)){ op=lh==='<-'?'<->':'->'; i+=2; }
1739
+ else if(s[i]==='-'){ op=lh==='<-'?'<-':'--'; i++; }
1740
+ else return bad('expected - or -> to close the operator after [label]');
1741
+ } else if(lh==='<-'){
1742
+ if(s[i]==='>'){ op='<->'; i++; } else op='<-';
1743
+ } else {
1744
+ if(s[i]==='-'){ op='--'; i++; }
1745
+ else if(s[i]==='>'){ op='->'; i++; }
1746
+ else return bad(kw+' needs an operator: -> <- -- <->');
1747
+ }
1748
+ sp.connector={start:opAt,end:i};
1749
+ ws(); let head=null;
1750
+ if(s[i]==='['){ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
1751
+ head=r.v; sp.head={start:at,end:i}; }
1752
+ ws();
1753
+ if(s[i]==='"') return bad(ID_RULE);
1754
+ const bAt=i;
1755
+ const b=readId();
1756
+ if(!b) return bad(idHere()?ID_RULE:kw+' needs a target id after the operator');
1757
+ if(idHere()) return bad(ID_RULE);
1758
+ sp.b={start:bAt,end:i};
1759
+ // Everything after endpoint `b` is the OPTION REGION, and it starts at `b`'s
1760
+ // last byte, not at the next non-space: an option edit that began anywhere
1761
+ // earlier could reach back into `[head]`, which is the one thing the caller
1762
+ // asked this span to make impossible. The parser tokenizes exactly this
1763
+ // slice, and `message`'s trailing quoted label lives in it too.
1764
+ sp.options={start:i,end:s.length};
1765
+ return {ok:true,keyword:kw,a,b,op,tail,mid,head,spans:sp};
1766
+ }
1767
+
1768
+ // The connector word this genre spells at this version, or the diagnostic that
1769
+ // says so. `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`'s answer lives here rather than at the dispatch below
1770
+ // because the parser and `scanConnectorLine` must give the same one: a GUI
1771
+ // that offered to edit a `flowline` line in a `block` document would be
1772
+ // offering to edit a line error.
1773
+ const connectorWordError=(surf,genre,version)=>{
1774
+ const want=connectorKwAt(genre,version);
1775
+ if(!want) return '"'+surf+'" is not allowed in genre '+genre;
1776
+ if(surf===want) return null;
1777
+ const need=CONNECTOR_MIN_VERSION[surf];
1778
+ // The author wrote a word this genre really does use — just not in
1779
+ // the version they declared. That is a different mistake from the
1780
+ // wrong domain's word, and it has a different one-step fix.
1781
+ if(need && version && need>version && GENRE_CONNECTOR_KW[genre]===surf)
1782
+ return WRONG_VERSION_WORD(surf,want,genre,need,version);
1783
+ return WRONG_WORD(surf,want,genre);
1784
+ };
1785
+
1786
+ // scanConnectorLine(code, genre, version) — the connector grammar, READ-ONLY,
1787
+ // for a caller that has one line and wants to know what is where in it.
1788
+ //
1789
+ // {ok:true, keyword, a, b, op, tail, mid, head,
1790
+ // spans:{a, tail?, connector, mid?, head?, b, options}}
1791
+ // {ok:false, error}
1792
+ //
1793
+ // `code` is a CODE-ONLY line: the caller has already cut any trailing comment
1794
+ // (`findComment`), because a `#` inside `[a hop #1]` is not a comment and only
1795
+ // the caller knows where its line came from. Leading and trailing whitespace
1796
+ // are tolerated and the offsets account for them, so a span can be spliced
1797
+ // straight back into the string that was passed in.
1798
+ //
1799
+ // It mutates nothing, appends to no error array, and never throws for input:
1800
+ // a line that is not a connector at all — or is one the genre does not use, or
1801
+ // is malformed — comes back as `{ok:false,error}` carrying the same sentence
1802
+ // the parser would have reported for it.
1803
+ function scanConnectorLine(code, genre, version){
1804
+ if(typeof code!=='string') return {ok:false,error:'connector line must be a string'};
1805
+ const s=code.trim();
1806
+ const off=code.length-code.replace(/^\s+/,'').length;
1807
+ const m=CONN_LINE_RE.exec(s);
1808
+ if(!m) return {ok:false,error:'not a connector line — it does not begin with '+
1809
+ [...CONNECTOR_SPELLINGS].join(', ')};
1810
+ const surf=m[1];
1811
+ // The genre gate is OPTIONAL: with no genre named, any of the four spellings
1812
+ // scans, which is what a caller inspecting a fragment out of context needs.
1813
+ if(genre && GENRE_KW[genre]){
1814
+ const e=connectorWordError(surf,genre,version);
1815
+ if(e) return {ok:false,error:e};
1816
+ }
1817
+ const r=scanConnector(s,surf);
1818
+ if(!r.ok) return r;
1819
+ for(const k in r.spans){
1820
+ r.spans[k]={start:r.spans[k].start+off,end:r.spans[k].end+off};
1821
+ }
1822
+ // The option region runs to the END of the line the caller passed, trailing
1823
+ // whitespace included, so replacing that span replaces every option and
1824
+ // nothing else.
1825
+ r.spans.options.end=code.length;
1826
+ return r;
1827
+ }
1828
+
1456
1829
  function parseOne(text){
1457
1830
  const errs=[];
1458
1831
  // `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
@@ -1485,13 +1858,21 @@ function parseOne(text){
1485
1858
  // rule), so no existing golden moves a byte.
1486
1859
  lifelines:[],messages:[],states:[],fragments:[],operands:[]};
1487
1860
  const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
1488
- bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
1861
+ bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set(),
1862
+ // `CONNECTOR-IDENTITY-KEY`: the connector ids written with `id=`. They are a
1863
+ // SET beside the others only for bookkeeping — for UNIQUENESS they are
1864
+ // part of the one shared namespace `dupId` tests, because a `bundle`
1865
+ // member that is a bare token must resolve to exactly one kind of
1866
+ // thing. A separate namespace would have made `bundle t1 "T" x` mean
1867
+ // one thing when `x` is a node and another when it is a connector, and
1868
+ // both at once when it is both.
1869
+ edgeIds=new Set();
1489
1870
  // §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
1490
1871
  // typed blocks (bitfield/table/timing) share ONE namespace, so a bare id in
1491
1872
  // `edge`/`pin`/`chart` can never be ambiguous. `plane`, `class` and
1492
1873
  // `bundle` keep their own namespaces: each is referenced through a dedicated
1493
1874
  // option or keyword, never as a bare id.
1494
- const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id);
1875
+ const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id)||edgeIds.has(id);
1495
1876
  let cur=null; // current typed block (bitfield/table/timing)
1496
1877
  // `REPEATED-DIRECTIVE-HANDLING`: `title`, `flow`, `layout` and a per-id `pin` are SINGLE-VALUED — a
1497
1878
  // repetition is a line error on the second occurrence, never a silent
@@ -1525,86 +1906,19 @@ function parseOne(text){
1525
1906
  // `edge`, `flowline` or `transition`. One scanner, three words: every
1526
1907
  // message names the word on the line, and nothing downstream of here knows
1527
1908
  // the difference (the model records a connector, not a spelling).
1909
+ //
1910
+ // 0.4: the scanner itself is `scanConnector`, at top level, so the
1911
+ // editor's GUI half can read the same grammar through `scanConnectorLine`.
1912
+ // What is left here is everything a SCAN cannot do: the option pass, the
1913
+ // semantic checks, and the model write. Every syntax diagnostic below the
1914
+ // call is relayed verbatim from the scanner, so this function reports
1915
+ // exactly what it reported when the scanner was local to it.
1528
1916
  function parseEdgeLine(s,n,kw){
1529
1917
  kw=kw||'edge';
1530
- let i=kw.length; // past the connector keyword
1531
- const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
1532
- // `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
1533
- // followed by a second one, because `--` is the link operator. This is
1534
- // what lets `edge a--b` mean the same thing as `bundle t1 a--b`; before
1535
- // the ban the greedy scanner ate `a--b` as one id and then reported
1536
- // "edge needs an operator", so the same token read oppositely two lines
1537
- // apart (SYNTAX-STYLE §6.3).
1538
- const readId=()=>{ const m=/^[A-Za-z_](?:[A-Za-z0-9_]|-(?!-))*/.exec(s.slice(i));
1539
- if(!m) return null; i+=m[0].length; return m[0]; };
1540
- // `QUOTED-IDS`: an endpoint is an id position. A quoted token or a spelling that
1541
- // is not an id gets the ID RULE — not "edge needs <id> …", which named
1542
- // the wrong thing when the operator was plainly there.
1543
- const idHere=()=>i<s.length&&!/[\s[\-<>]/.test(s[i]);
1544
- const readLbl=()=>{ // called at '['
1545
- i++;
1546
- if(s[i]==='"'){ // ["..."] — quoted content
1547
- i++; let v='';
1548
- while(i<s.length&&s[i]!=='"'){
1549
- if(s[i]==='\\'){ const e=s[i+1];
1550
- if(e==='n'){ v+='\n'; i+=2; continue; }
1551
- if(e==='"'){ v+='"'; i+=2; continue; }
1552
- if(e==='\\'){ v+='\\'; i+=2; continue; }
1553
- return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'}; }
1554
- v+=s[i]; i++;
1555
- }
1556
- if(i>=s.length) return {error:'unterminated string in [label]'};
1557
- i++;
1558
- if(s[i]!==']') return {error:'expected ] after quoted label'};
1559
- i++;
1560
- if(!v) return {error:'empty [label]'};
1561
- return {v};
1562
- }
1563
- let depth=1,v='';
1564
- while(i<s.length){
1565
- const c=s[i];
1566
- if(c==='[') depth++;
1567
- else if(c===']'){ depth--; if(!depth){ i++;
1568
- v=v.trim();
1569
- if(!v) return {error:'empty [label]'};
1570
- return {v}; } }
1571
- v+=c; i++;
1572
- }
1573
- return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
1574
- };
1575
- ws();
1576
- if(s[i]==='"'){ err(n,ID_RULE); return; }
1577
- const a=readId();
1578
- if(!a){ err(n, idHere()?ID_RULE:kw+' needs <id> ->|<-|--|<-> <id>'); return; }
1579
- if(idHere()){ err(n,ID_RULE); return; }
1580
- ws(); let tail=null;
1581
- if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } tail=r.v; }
1582
- ws();
1583
- let lh=null;
1584
- if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
1585
- else if(s[i]==='-'){ lh='-'; i++; }
1586
- else { err(n,kw+' needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)'); return; }
1587
- let mid=null, op=null;
1588
- if(s[i]==='['){
1589
- const r=readLbl(); if(r.error){ err(n,r.error); return; } mid=r.v;
1590
- if(s.startsWith('->',i)){ op=lh==='<-'?'<->':'->'; i+=2; }
1591
- else if(s[i]==='-'){ op=lh==='<-'?'<-':'--'; i++; }
1592
- else { err(n,'expected - or -> to close the operator after [label]'); return; }
1593
- } else if(lh==='<-'){
1594
- if(s[i]==='>'){ op='<->'; i++; } else op='<-';
1595
- } else {
1596
- if(s[i]==='-'){ op='--'; i++; }
1597
- else if(s[i]==='>'){ op='->'; i++; }
1598
- else { err(n,kw+' needs an operator: -> <- -- <->'); return; }
1599
- }
1600
- ws(); let head=null;
1601
- if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } head=r.v; }
1602
- ws();
1603
- if(s[i]==='"'){ err(n,ID_RULE); return; }
1604
- const b=readId();
1605
- if(!b){ err(n, idHere()?ID_RULE:kw+' needs a target id after the operator'); return; }
1606
- if(idHere()){ err(n,ID_RULE); return; }
1607
- const tk2=tokenize(s.slice(i).trim());
1918
+ const sc=scanConnector(s,kw);
1919
+ if(!sc.ok){ err(n,sc.error); return; }
1920
+ const {a,b,op,tail,mid,head}=sc;
1921
+ const tk2=tokenize(s.slice(sc.spans.options.start).trim());
1608
1922
  if(tk2.error){ err(n,tk2.error); return; }
1609
1923
  const {pos:p2,posq:pq2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
1610
1924
  if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
@@ -1667,6 +1981,26 @@ function parseOne(text){
1667
1981
  if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); return; }
1668
1982
  if(!optQ(oT2,'note')){ err(n,'note= must be quoted: note="'+o2.note+'" — '+Q_WHY); return; }
1669
1983
  }
1984
+ // `CONNECTOR-IDENTITY-KEY`: `id=` — the connector's optional handle. Three checks
1985
+ // in the order an author's next move is decided by:
1986
+ // 1. the VERSION GATE, first, so a 0.4 document is told the one thing it
1987
+ // can do about the line rather than being told its id is malformed;
1988
+ // 2. the ID LEXIS, `QUOTED-IDS`'s one wording for every id position, so a
1989
+ // connector id is spelled exactly as a node id is (bare, no `--`);
1990
+ // 3. UNIQUENESS. A connector id joins the SECTION's existing
1991
+ // node/group/external/region namespace, which is what makes a bare
1992
+ // single-token `bundle` member decidable — a member either resolves to
1993
+ // one connector or names something that is not one, and never both. So
1994
+ // the diagnostic is `duplicate id`, the one §8.1 already has, and not a
1995
+ // new one: this is the same namespace, not a parallel one.
1996
+ let eid;
1997
+ if(o2.id!==undefined){
1998
+ if(belowOptVersion('id',doc.version)){ err(n,ID_VERSION(doc.version)); return; }
1999
+ const e=idErr(o2.id, optHasQ(oT2,'id'), 'id= needs an id: '+kw+' '+a+' '+op+' '+b+' id=<id>');
2000
+ if(e){ err(n,e); return; }
2001
+ if(dupId(o2.id)){ err(n,'duplicate id "'+o2.id+'"'); return; }
2002
+ eid=o2.id;
2003
+ }
1670
2004
  // `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
1671
2005
  // Checked before the value, exactly as `badOpts` does it.
1672
2006
  if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
@@ -1703,7 +2037,8 @@ function parseOne(text){
1703
2037
  // ruling taken here: the draft settles the trailing form and says
1704
2038
  // nothing about the brackets. `[tail]` and `[head]` are kept — they are
1705
2039
  // different positions, not a second spelling of the same one.
1706
- doc.messages.push({a,b,op,tail,head,
2040
+ if(eid!==undefined) edgeIds.add(eid);
2041
+ doc.messages.push({id:eid,a,b,op,tail,head,
1707
2042
  label:seqLabel!==null?seqLabel:mid,
1708
2043
  style:o2.style,cls:ecls,stroke:o2.stroke,note:o2.note,
1709
2044
  desc:o2.description,in:o2['in']||null,line:n});
@@ -1712,7 +2047,8 @@ function parseOne(text){
1712
2047
  // §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
1713
2048
  // and `fill=` name the same channel (`stroke=` wins when both are
1714
2049
  // written); `text=` colours the [tail]/[mid]/[head] labels.
1715
- doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:ecls,
2050
+ if(eid!==undefined) edgeIds.add(eid);
2051
+ doc.edges.push({id:eid,a,b,op,tail,mid,head,style:o2.style,cls:ecls,
1716
2052
  stroke:o2.stroke,note:o2.note,
1717
2053
  plane:o2.plane||'base',line:n});
1718
2054
  }
@@ -1897,20 +2233,13 @@ function parseOne(text){
1897
2233
  // `KEYWORD-RENAME-SCOPE`: the word this genre uses is read AT THE DECLARED VERSION, not
1898
2234
  // at the newest one. A genre with no connector at all (bitfield, table,
1899
2235
  // timing) still gets the allowlist message.
2236
+ // 0.4: the three answers this gate can give are spelled once, in
2237
+ // `connectorWordError`, because `scanConnectorLine` has to give the same
2238
+ // three to the GUI. The `sawHeader` guard stays here: it is about where
2239
+ // the parser is in the document, which no scanner of one line can know.
1900
2240
  if(sawHeader && doc.genre && GENRE_KW[doc.genre]){
1901
- const want=connectorKwAt(doc.genre, doc.version);
1902
- if(!want){ err(n,'"'+ckw+'" is not allowed in genre '+doc.genre); continue; }
1903
- if(ckw!==want){
1904
- const need=CONNECTOR_MIN_VERSION[ckw];
1905
- // The author wrote a word this genre really does use — just not in
1906
- // the version they declared. That is a different mistake from the
1907
- // wrong domain's word, and it has a different one-step fix.
1908
- if(need && doc.version && need>doc.version &&
1909
- GENRE_CONNECTOR_KW[doc.genre]===ckw)
1910
- err(n, WRONG_VERSION_WORD(ckw,want,doc.genre,need,doc.version));
1911
- else
1912
- err(n, WRONG_WORD(ckw,want,doc.genre));
1913
- continue; }
2241
+ const e=connectorWordError(ckw, doc.genre, doc.version);
2242
+ if(e){ err(n,e); continue; }
1914
2243
  }
1915
2244
  parseEdgeLine(raw.trim(),n,ckw);
1916
2245
  continue;
@@ -2396,7 +2725,7 @@ function parseOne(text){
2396
2725
  if(/[2-9]/.test(lane)){ err(n,RETIRED_LANE_DIGIT); continue; }
2397
2726
  if(!/^[01pnx=.]+$/.test(lane)){ err(n,'lane may contain only 0 1 p n x = .'); continue; }
2398
2727
  // / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
2399
- // members (a,b), or a count that does not match the lane's `=`
2728
+ // members (a,,b), or a count that does not match the lane's `=`
2400
2729
  // cells are all line errors — never silent drop or shift.
2401
2730
  // Spelled `labels=` until 0.1 (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
2402
2731
  // `data`, "an array of signal labels" naming every value cell, and
@@ -2718,26 +3047,53 @@ function parseOne(text){
2718
3047
  // the half-converted line the old tolerance could not report.
2719
3048
  if(restT.length>1){
2720
3049
  err(n,'bundle members take ONE comma-delimited token: write bundle '+id+(tlabel===null?'':' "'+tlabel+'"')+' '+joinListForm(restT.map(t=>t.v))+' — the space form is retired (MIGRATIONS 0.1)'); break; }
2721
- const pairs=[]; let badp=null;
3050
+ // `CONNECTOR-IDENTITY-KEY`: a member is an endpoint PAIR or a connector ID,
3051
+ // in the same comma list, told apart by LEXIS and not by a flag: `--`
3052
+ // cannot occur inside an id (`LINK-OPERATOR-IN-IDS`), so a member either contains it and
3053
+ // is a pair or does not and is an id. RULE 5 is satisfied because each
3054
+ // form reaches input the other cannot — the pair form reaches ANONYMOUS
3055
+ // connectors, which have no id to name; the id form reaches PARALLEL
3056
+ // connectors, which no pair can address — so neither is a spelling
3057
+ // variant of the other. Mixed lists need no rule of their own: every
3058
+ // member resolves independently.
3059
+ //
3060
+ // `members` is the AUTHORED list in source order and is what the model
3061
+ // projects. `pairs` stays what it always was — the endpoint pairs the
3062
+ // ring geometry reads — and an id member appends its resolved pair to
3063
+ // it in the semantic pass below, once the connector it names exists.
3064
+ const pairs=[], members=[]; let badp=null;
2722
3065
  outerB:
2723
3066
  for(const t of restT){
2724
3067
  for(const s of splitList(t,0)){
2725
3068
  const mem=s.v.trim(); if(!mem) continue;
2726
3069
  if(s.h){ badp=ID_RULE; break outerB; }
3070
+ if(!mem.includes('--')){
3071
+ // A bare token. Under figdown 0.4 and below there is no id form,
3072
+ // so the answer is the one it always was: this is a malformed
3073
+ // pair. Naming the id form to a document that cannot write it
3074
+ // would send the author to a key their header does not have.
3075
+ if(belowOptVersion('id',doc.version)){
3076
+ badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
3077
+ if(!isId(mem)){
3078
+ badp='bad member "'+mem+'" (expected A--B or a connector id)'; break outerB; }
3079
+ members.push({id:mem});
3080
+ continue;
3081
+ }
2727
3082
  const parts=mem.split('--');
2728
3083
  if(parts.length!==2||!isId(parts[0])||!isId(parts[1])){
2729
3084
  badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
2730
3085
  pairs.push([parts[0],parts[1]]);
3086
+ members.push({a:parts[0],b:parts[1]});
2731
3087
  }
2732
3088
  }
2733
3089
  if(badp){ err(n,badp); break; }
2734
- if(!pairs.length){ err(n,'bundle needs at least one member link A--B'); break; }
3090
+ if(!members.length){ err(n,'bundle needs at least one member link A--B'); break; }
2735
3091
  bundleIds.add(id);
2736
3092
  // §5 on the derived ring: `fill=` stays the ring colour (stroke +
2737
3093
  // label) as before, `stroke=`/`text=` split it, `style=` picks the
2738
3094
  // dash (the conventional default is dashed), `plane=` orders this
2739
3095
  // ring against the other rings.
2740
- doc.trunks.push({id,label:tlabel,pairs,stroke:opts.stroke,
3096
+ doc.trunks.push({id,label:tlabel,pairs,members,stroke:opts.stroke,
2741
3097
  style:opts.style,plane:opts.plane,line:n});
2742
3098
  break;
2743
3099
  }
@@ -3181,13 +3537,85 @@ function parseOne(text){
3181
3537
  for(const f of doc.bands)
3182
3538
  if(!groupIds.has(f.target)&&!nodeIds.has(f.target)&&!regionTarget(f.target))
3183
3539
  errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
3184
- for(const t of doc.trunks) for(const [a,b] of t.pairs){
3185
- if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
3186
- const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a)).length;
3187
- if(matches===0)
3188
- errs.push('Line '+t.line+': no edge between "'+a+'" and "'+b+'" for bundle member');
3189
- else if(matches>1)
3190
- errs.push('Line '+t.line+': "'+a+'--'+b+'" is ambiguous ('+matches+' parallel edges); parallel edges are out of scope for v0.1');
3540
+ // `MEMBER-LIST-DUPLICATION`: a bundle member repeated in ONE member list is a
3541
+ // line error the identity-model-proposal's probe 5, a live defect
3542
+ // independent of edge identity. `bundle t1 "T" a--b,a--b` used to parse
3543
+ // silently into a two-member bundle over one link. The duplicate test is
3544
+ // on the RESOLVED edge (the same *Edge* object each pair member resolves
3545
+ // to), not on the spelling, so `a--b,b--a` is caught too — two spellings
3546
+ // of an undirected pair that resolve to one edge are one member named
3547
+ // twice, not two. Stating the rule this way, rather than as "the same
3548
+ // string written twice", is deliberate: it is the form that survives the
3549
+ // future id-form member (§4.1 of the proposal) without a rewrite, because
3550
+ // an id and a pair spelling can resolve to the same edge too. `seen` is
3551
+ // per BUNDLE — each trunk's member list is checked against itself, not
3552
+ // against any other bundle's.
3553
+ //
3554
+ // `CONNECTOR-IDENTITY-KEY`: the loop walks the AUTHORED member list, so a member is
3555
+ // resolved by the form it was written in and the diagnostic names what the
3556
+ // author wrote. The duplicate test does not change and did not need to: it
3557
+ // was already bound to the resolved *Edge* object, which is exactly why an id
3558
+ // member and a pair member naming ONE connector are caught by it without a
3559
+ // second rule — the whole reason `MEMBER-LIST-DUPLICATION` was written that way.
3560
+ for(const t of doc.trunks){
3561
+ const seen=new Set();
3562
+ for(const m of (t.members||t.pairs.map(p=>({a:p[0],b:p[1]})))){
3563
+ // The written spelling, for every message this loop can produce.
3564
+ const spelled=m.id!==undefined?m.id:m.a+'--'+m.b;
3565
+ let hit=null;
3566
+ if(m.id!==undefined){
3567
+ // The id form. It resolves against the connectors of THIS SECTION and
3568
+ // nothing else: ids are unique per section (core §1) and a
3569
+ // cross-section reference is a line error by design, so an id from a
3570
+ // neighbouring section arrives here as an id this section never
3571
+ // declared — which is what the second message says, in the words that
3572
+ // are true for it.
3573
+ const named=doc.edges.filter(e=>e.id===m.id);
3574
+ // What the id DOES name, when it does not name a connector. The kind is
3575
+ // in the message because it is the whole of the author's next move: a
3576
+ // node id in a member list is a `bundle b "B" a--b` written short, and
3577
+ // a class id there is a different mistake entirely.
3578
+ const kind=nodeIds.has(m.id)&&!edgeIds.has(m.id)?'a node':
3579
+ groupIds.has(m.id)?'a group':
3580
+ boundaryIds.has(m.id)?'an external endpoint':
3581
+ blockIds.has(m.id)?'a region':
3582
+ classIds.has(m.id)?'a class':
3583
+ bundleIds.has(m.id)?'a bundle':null;
3584
+ if(named.length===1) hit=named[0];
3585
+ else if(kind){
3586
+ errs.push('Line '+t.line+': bundle member "'+m.id+'" is '+kind+', not a connector — a bundle collects LINKS, so write the pair A--B or give the connector you mean an id= and name that');
3587
+ continue;
3588
+ } else {
3589
+ errs.push('Line '+t.line+': bundle member "'+m.id+'" is not a declared connector in this section (ids are unique per section, and a bare member names a connector by its id=)');
3590
+ continue;
3591
+ }
3592
+ // The ring reads endpoint pairs, so a resolved id member contributes
3593
+ // the pair its connector runs between. Nothing about the drawing is
3594
+ // authored here (`DOMAIN-CONVENTION-DIRECTIVES`) — which links the ring encloses is.
3595
+ t.pairs.push([hit.a,hit.b]);
3596
+ } else {
3597
+ const a=m.a, b=m.b;
3598
+ if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
3599
+ const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a));
3600
+ if(matches.length===0){
3601
+ errs.push('Line '+t.line+': no edge between "'+a+'" and "'+b+'" for bundle member'); continue; }
3602
+ if(matches.length>1){
3603
+ // `CONNECTOR-IDENTITY-KEY` REPLACES probe 6's message. The old one cited `v0.1` in a 0.4
3604
+ // engine, stated a SCOPE EXCLUSION where the truth is "say which one
3605
+ // you mean", and named no remedy. Parallel links are the defining
3606
+ // case of a LAG, so a bundle that cannot name one member of three is
3607
+ // a construct whose definition and whose grammar disagree; now there
3608
+ // is a way out and the message is the place the author meets it.
3609
+ errs.push('Line '+t.line+': "'+a+'--'+b+'" matches '+matches.length+
3610
+ ' edges — the pair form addresses a connector only where ONE runs between the two nodes. Give the connector you mean an id= and write that id as the member (figdown 0.5)');
3611
+ continue; }
3612
+ hit=matches[0];
3613
+ }
3614
+ if(seen.has(hit))
3615
+ errs.push('Line '+t.line+': duplicate bundle member "'+spelled+'" — each member is stated once');
3616
+ else
3617
+ seen.add(hit);
3618
+ }
3191
3619
  }
3192
3620
  // `ELEMENT-GEOMETRY-DIRECTIVE`: `pin` has a SPLIT DOMAIN, and both halves are checked
3193
3621
  // here because both need the finished id sets.
@@ -3252,7 +3680,7 @@ function parseOne(text){
3252
3680
  // is that genre's designed idiom, not an oversight.
3253
3681
  //
3254
3682
  // `INTERIOR-LESS-ELEMENT-PAINT`'s half stands and now reaches EVERY collection that accepts
3255
- // `class=` (`CLASS-CHANNEL-REACH`). Until this release the loop below ran over `doc.edges`
3683
+ // `class=` (`CLASS-CHANNEL-REACH`). Until 0.4 the loop below ran over `doc.edges`
3256
3684
  // alone, so `class k "K" fill=#eee` plus `message c -> s "m" class=k` was
3257
3685
  // accepted, painted nothing, and put the class in the legend — a message
3258
3686
  // has its own collection because it has a position in time (`SEQUENCE-ORDER-MODEL`), and
@@ -3303,6 +3731,7 @@ function parseOne(text){
3303
3731
  };
3304
3732
  const clsChan=(x,kind)=>{
3305
3733
  const K=CLASS_CHANNELS[kind];
3734
+ const claimed={}; // channel -> class id that already set it on THIS element (`CLASS-CHANNEL-COLLISION`)
3306
3735
  for(const cid of (x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]))){
3307
3736
  const c=doc.classes.find(y=>y.id===cid);
3308
3737
  if(!c) continue; // unknown id: its own error
@@ -3311,8 +3740,29 @@ function parseOne(text){
3311
3740
  if(!K.has.includes('fill')&&c.fill!==undefined&&c.stroke===undefined){
3312
3741
  errs.push('Line '+x.line+': class "'+cid+'" sets fill= but no stroke=, and '+K.a+' has no interior — add stroke= to the class (it paints '+K.a.replace(/^an? /,'the ')+'; fill= keeps painting members that have an interior) (MIGRATIONS 0.1)');
3313
3742
  continue; }
3314
- if(!decl.some(k=>K.has.includes(k)))
3743
+ if(!decl.some(k=>K.has.includes(k))){
3315
3744
  errs.push('Line '+x.line+': class "'+cid+'" declares only '+decl.map(k=>k+'=').join(' and ')+', and '+K.a+' has no such channel — add '+K.has.map(k=>k+'=').join(' or ')+' to the class (they paint '+K.a.replace(/^an? /,'the ')+'; the key it declares keeps painting members that have that channel) (MIGRATIONS 0.4)');
3745
+ continue; }
3746
+ // `CLASS-CHANNEL-COLLISION`: two CARRIED CLASSES binding the SAME paint
3747
+ // channel on one element is a line error — §8's own rule ("a repeated
3748
+ // option key on ONE line is a line error, never last-wins") one level
3749
+ // up, closing the hole a class boundary opened in it. The check is
3750
+ // per element, per channel the member actually has (K.has): the
3751
+ // first carried class to set a channel CLAIMS it, and a later class
3752
+ // repeating a claimed channel is refused by name. Element-direct
3753
+ // `fill=`/`stroke=`/`style=` is untouched — it already overrides
3754
+ // every class (rigidity, `LAYOUT-STABILITY`) and is not a second class SOURCE.
3755
+ // `class=hot,deprecated` in conformance case 308 is the shape this
3756
+ // must keep legal: each channel comes from exactly one class.
3757
+ for(const k of K.has){
3758
+ if(c[k]===undefined) continue;
3759
+ if(claimed[k]!==undefined){
3760
+ const other=K.has.filter(o=>o!==k).map(o=>o+'=').join('/');
3761
+ errs.push('Line '+x.line+': classes "'+claimed[k]+'" and "'+cid+'" both set '+k+'= on this element — one channel, one class: move one class\'s paint to a different channel ('+other+'), or carry only one of them');
3762
+ } else {
3763
+ claimed[k]=cid;
3764
+ }
3765
+ }
3316
3766
  }
3317
3767
  };
3318
3768
  for(const x of doc.nodes) clsChan(x,'node');
@@ -3730,8 +4180,9 @@ function noteSvg(x,y,box,carrier){
3730
4180
  // about "a group" and no more — so the association the source states was
3731
4181
  // lost on the way to the page even when the placement was perfect. The pair
3732
4182
  // (kind, ref) is unambiguous: `ref` is the element's identity in this very
3733
- // drawing — a `data-node` id, a `data-group` id, or the `data-edge` line
3734
- // number a connector is keyed by, since a connector has no id to carry.
4183
+ // drawing — a `data-node` id, a `data-group` id, or whatever `data-edge`
4184
+ // keys a connector by, which from `CONNECTOR-IDENTITY-KEY` is the connector's
4185
+ // AUTHORED id where it has one and its source line where it does not.
3735
4186
  // `title` is the one kind with no ref and needs none: it names the FIGURE,
3736
4187
  // and there is exactly one. The attribute changes no pixel.
3737
4188
  const ref=carrier&&carrier.ref!==undefined&&carrier.ref!==null?' data-note-for="'+esc(String(carrier.ref))+'"':'';
@@ -4860,6 +5311,190 @@ function renderScene(doc,y0){
4860
5311
  }
4861
5312
  }
4862
5313
  }
5314
+ // ── THE RING GEOMETRY IS DEFINED HERE BECAUSE TWO PASSES NEED IT ───────
5315
+ // (item 69.) `ringOf` used to sit with the ring DRAWING, a
5316
+ // thousand lines below, because the ring is derived from final coordinates
5317
+ // and nothing before the draw had a use for it. The lasso-containment rule
5318
+ // gives it a second caller ABOVE: the separation pass immediately below has
5319
+ // to know where each bundle's lasso will fall so it can keep a non-member
5320
+ // out of it, exactly as it already keeps one out of a group band. The
5321
+ // definition moved; not one character of the derivation changed, and the
5322
+ // ring is still COMPUTED (`rings`, below the label pass) at the geometry it
5323
+ // is drawn from, so what the separation pass sees is a PROJECTION and what
5324
+ // the truth check sees is the ring itself.
5325
+ // ── A BUNDLE RING IS ORIENTED BY ITS MEMBERS (item 43) ───────────────────
5326
+ // Until now the ring was derived from the member links' MIDPOINTS and
5327
+ // nothing else: `rx = max(46, x-spread + 38)`, `ry = max(26, y-spread + 22)`.
5328
+ // Two facts about that formula are the defect. It is DIRECTION-BLIND — the
5329
+ // axes are the canvas's, never the links' — and its floors are large enough
5330
+ // that a bundle of two short legs draws a near-circle whatever the legs do.
5331
+ // Measured on `patterns/topology-b`: rx 46 / ry 42.7, aspect
5332
+ // 1.07, sitting on two legs that fan VERTICALLY, with the caption lying
5333
+ // across the waist and on top of the `p3` endpoint label.
5334
+ //
5335
+ // The drawing convention for a link bundle is a loop THROUGH which the
5336
+ // links run: narrow along the links, long across them. So the ring is now
5337
+ // derived from the members' own frame.
5338
+ //
5339
+ // 1. DIRECTION. The mean UNDIRECTED direction of the members, by the
5340
+ // doubled-angle circular mean — doubling is what makes `a--b` and
5341
+ // `b--a` the same direction, so a bundle does not change shape when an
5342
+ // author writes a member the other way round (conformance 394).
5343
+ // 2. THE BAND. The ring sits at the MIDPOINT OF THE MEMBERS' SHARED RUN
5344
+ // along that direction — the stretch of the corridor every member is
5345
+ // actually in. That is the placement rule because it is the only band
5346
+ // at which "the spread of the members" is a fact about all of them: a
5347
+ // fanning set (the reference topology's ECMP pair, which leaves two
5348
+ // different spines and arrives at one leaf) has a different spread at
5349
+ // every station, and the shared run is the interval over which the
5350
+ // question is even well posed. Members with no shared run at all fall
5351
+ // back to the mean of their midpoints, which is the old centre.
5352
+ // 3. THE AXES. MINOR along the mean direction, sized to clear the strokes;
5353
+ // MAJOR across it, the members' spread at the band plus padding. A ring
5354
+ // needs a long axis to read as a ring, and it takes it from the spread
5355
+ // when the members fan (`rA >= rL`) and from its own RUN when they do
5356
+ // not — a single-member bundle (`reference/topology`'s multi-home link)
5357
+ // has zero spread, and the shape that hugs one link is an ellipse lying
5358
+ // ALONG it, never a circle straddling it.
5359
+ // 4. NO NODE CONTACT. The ring is shrunk along its minor axis until it
5360
+ // clears every node box, and if it cannot it reverts to the pre-0.4
5361
+ // geometry rather than inventing a shape (no corpus figure does).
5362
+ //
5363
+ // The caption is NOT placed here — it is registered with the label pass
5364
+ // below, so it can see the endpoint labels and the edges it has to avoid.
5365
+ const rnd3=v=>Math.round(v*1000)/1000; // ring coordinates, printed short
5366
+ const RING_ALONG=15, // semi-minor: the ring's body along the links
5367
+ RING_ACROSS=20, // clearance beyond the outermost member
5368
+ RING_LONG=34, // the long axis a ring needs to read as a ring
5369
+ RING_FAN=2, // below this spread the members are one line
5370
+ RING_FLOOR=7, // how thin the minor axis may be squeezed
5371
+ RING_SOLO=4; // a ONE-member bundle: the caption's stand-off
5372
+ // ── A ONE-MEMBER BUNDLE IS ITS CAPTION (item 53) ─────────────────────────
5373
+ // `bundle mh "multi-home" l1--l2` is legal and means something real (a
5374
+ // one-link LAG, an Ethernet Segment with a single member), so the
5375
+ // declaration is not an error and is not dropped. But a ring exists to
5376
+ // UNITE lines, and around ONE line it unites nothing: it is ink that adds
5377
+ // no fact, and on the reference topology it also bought the seam a 104 px
5378
+ // corridor demand (2*RX_MIN + clearance) for a shape nobody had to see.
5379
+ //
5380
+ // So a single-member bundle DRAWS NO ELLIPSE. What it draws is its
5381
+ // CAPTION, bundle-styled (the trunk's own stroke, the ring caption's type)
5382
+ // and placed BY THE LINK — which is exactly the statement the construct
5383
+ // makes: this link is the bundle, and here is its name. The frame below is
5384
+ // therefore kept as a PLACEMENT frame and not as a drawing: the caption
5385
+ // pass already sites a caption around a ring's rim, so a ring squeezed to
5386
+ // `RING_SOLO` across gives the same pass a candidate family hugging the
5387
+ // link. Nothing else in the pass changes.
5388
+ //
5389
+ // AN EMPTY CAPTION ON A ONE-MEMBER BUNDLE THEREFORE DRAWS NOTHING AT ALL.
5390
+ // That is a real consequence and it is not hidden: the reference figure
5391
+ // keeps `bundle unlabelled "" s2--l2` — it is the form demonstrator for
5392
+ // the empty label — and states in a comment that this form is now
5393
+ // MODEL-ONLY. The model still carries the trunk, `read` still reports it,
5394
+ // and the drawing says nothing because there is nothing a ring around one
5395
+ // unnamed line could say.
5396
+ // Does the ring's disc meet an axis-aligned rect? Both are mapped into the
5397
+ // frame where the ring is the unit circle; the rect becomes a convex quad,
5398
+ // and the test is "is the quad within 1 of the origin".
5399
+ const ringHitsRect=(R,RL,RA,b)=>{
5400
+ 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=>{
5401
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
5402
+ return [(R.ux*dx+R.uy*dy)/RL, (R.vx*dx+R.vy*dy)/RA];
5403
+ });
5404
+ let inside=false;
5405
+ for(let i=0,j=3;i<4;j=i++)
5406
+ if((q[i][1]>0)!==(q[j][1]>0) &&
5407
+ 0 < (q[j][0]-q[i][0])*(0-q[i][1])/(q[j][1]-q[i][1])+q[i][0]) inside=!inside;
5408
+ if(inside) return true;
5409
+ for(let i=0,j=3;i<4;j=i++){
5410
+ const vx=q[j][0]-q[i][0], vy=q[j][1]-q[i][1], L2=vx*vx+vy*vy;
5411
+ const t=L2?Math.max(0,Math.min(1,-(q[i][0]*vx+q[i][1]*vy)/L2)):0;
5412
+ if(Math.hypot(q[i][0]+t*vx, q[i][1]+t*vy)<1) return true;
5413
+ }
5414
+ return false;
5415
+ };
5416
+ const ringOf=t=>{
5417
+ const segs=[];
5418
+ for(const [a,b] of t.pairs){
5419
+ const A=byId[a], B=byId[b]; if(!A||!B) continue;
5420
+ 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);
5421
+ if(Math.hypot(q[0]-p[0],q[1]-p[1])>1e-9) segs.push([p,q]);
5422
+ }
5423
+ if(!segs.length) return null;
5424
+ // (1) mean undirected direction
5425
+ let c2=0,s2=0;
5426
+ for(const [p,q] of segs){
5427
+ const L=Math.hypot(q[0]-p[0],q[1]-p[1]);
5428
+ const c=(q[0]-p[0])/L, s=(q[1]-p[1])/L;
5429
+ c2+=c*c-s*s; s2+=2*c*s;
5430
+ }
5431
+ const th=Math.hypot(c2,s2)<1e-9
5432
+ ? Math.atan2(segs[0][1][1]-segs[0][0][1], segs[0][1][0]-segs[0][0][0])
5433
+ : 0.5*Math.atan2(s2,c2);
5434
+ const ux=Math.cos(th), uy=Math.sin(th), vx=-uy, vy=ux;
5435
+ // (2) the band: the midpoint of the shared run
5436
+ const iv=segs.map(([p,q])=>{
5437
+ const a=ux*p[0]+uy*p[1], b=ux*q[0]+uy*q[1];
5438
+ return a<=b?[a,b,p,q]:[b,a,q,p];
5439
+ });
5440
+ const lo=Math.max(...iv.map(z=>z[0])), hi=Math.min(...iv.map(z=>z[1]));
5441
+ const s=lo<hi ? (lo+hi)/2 : iv.reduce((x,z)=>x+(z[0]+z[1])/2,0)/iv.length;
5442
+ const P=iv.map(z=>{
5443
+ const f=Math.min(1,Math.max(0,(s-z[0])/((z[1]-z[0])||1)));
5444
+ return [z[2][0]+(z[3][0]-z[2][0])*f, z[2][1]+(z[3][1]-z[2][1])*f];
5445
+ });
5446
+ const cx=P.reduce((a,p)=>a+p[0],0)/P.length, cy=P.reduce((a,p)=>a+p[1],0)/P.length;
5447
+ const spread=Math.max(...P.map(p=>Math.abs(vx*(p[0]-cx)+vy*(p[1]-cy))));
5448
+ const runHalf=Math.min(...iv.map(z=>Math.min(s-z[0],z[1]-s)));
5449
+ // (3) axes. Two shapes, and which one is drawn is decided by whether the
5450
+ // members FAN at all. A set that fans is CROSSED by the ring: thin along
5451
+ // the links, long enough across them to take the whole spread with room
5452
+ // to spare, so each member passes through a part of the rim where the
5453
+ // ring is still visibly open. A set that does not fan — one link, or
5454
+ // members lying on top of each other — has no spread to take, and the
5455
+ // shape that says "this link is the bundle" is an ellipse lying ALONG it.
5456
+ let rA, rL;
5457
+ if(spread<RING_FAN){ rL=Math.max(RING_ALONG,Math.min(RING_LONG,Math.max(0,runHalf))); rA=RING_ACROSS; }
5458
+ else { rL=RING_ALONG; rA=Math.max(spread+RING_ACROSS,RING_LONG); }
5459
+ const R={cx,cy,ux,uy,vx,vy,th};
5460
+ // (0) ONE MEMBER (item 53): no ellipse is drawn, so this is a caption
5461
+ // frame and not a shape. It is squeezed across to `RING_SOLO` so the
5462
+ // caption pass's rim stations sit beside the link rather than a ring's
5463
+ // radius away from it, and the node-clearance step below is skipped —
5464
+ // there is no ink to keep out of a box.
5465
+ if(segs.length===1){
5466
+ R.solo=true; R.rL=rL; R.rA=RING_SOLO;
5467
+ R.hw=Math.hypot(rL*ux, RING_SOLO*vx); R.hh=Math.hypot(rL*uy, RING_SOLO*vy);
5468
+ return R;
5469
+ }
5470
+ // (4) out of every node box
5471
+ const boxes=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
5472
+ if(boxes.some(b=>ringHitsRect(R,rL,rA,b))){
5473
+ let ok=false;
5474
+ for(let k=1;k<=24&&!ok;k++){
5475
+ const RL=rL-(rL-RING_FLOOR)*k/24;
5476
+ if(!boxes.some(b=>ringHitsRect(R,RL,rA,b))){ rL=RL; ok=true; }
5477
+ }
5478
+ // No orientation of this ring clears the drawing. Rather than publish a
5479
+ // ring lying over a node, revert to the pre-0.4 derivation, which is at
5480
+ // least the shape every earlier artifact recorded. No corpus figure
5481
+ // takes this branch; it exists so that a hostile geometry degrades to
5482
+ // the old defect instead of a new one.
5483
+ if(!ok){
5484
+ const M=segs.map(([p,q])=>[(p[0]+q[0])/2,(p[1]+q[1])/2]);
5485
+ const mx=M.reduce((a,m)=>a+m[0],0)/M.length, my=M.reduce((a,m)=>a+m[1],0)/M.length;
5486
+ const lx=Math.max(46,Math.max(...M.map(m=>Math.abs(m[0]-mx)))+38);
5487
+ const ly=Math.max(26,Math.max(...M.map(m=>Math.abs(m[1]-my)))+22);
5488
+ return {cx:mx, cy:my, ux:1, uy:0, vx:0, vy:1, th:0, legacy:true,
5489
+ rL:lx, rA:ly, hw:lx, hh:ly};
5490
+ }
5491
+ }
5492
+ R.rL=rL; R.rA=rA;
5493
+ // the axis-aligned box the caption is placed beside and the canvas grows to
5494
+ R.hw=Math.hypot(rL*ux, rA*vx); R.hh=Math.hypot(rL*uy, rA*vy);
5495
+ return R;
5496
+ };
5497
+
4863
5498
  // ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
4864
5499
  // A group's band is the BOUNDING BOX of its members (see gBox below), and
4865
5500
  // until this pass nothing checked that the box contained only members. A
@@ -4914,6 +5549,44 @@ function renderScene(doc,y0){
4914
5549
  const canMove=u=>u.every(n=>!pinned(n.id))
4915
5550
  && !(u[0].group&&doc.pins[u[0].group]&&doc.pins[u[0].group].fx!==null);
4916
5551
  const said=new Set();
5552
+ // ── THE SAME REGION RULE FOR A BUNDLE'S LASSO (item 69) ────
5553
+ // A band and a lasso are the same statement drawn two ways: each is a
5554
+ // shape DERIVED from a declared membership, so whatever the shape contains
5555
+ // reads as a member. This pass already keeps a non-member out of a band;
5556
+ // the lasso is added to the same convergence rather than given a pass of
5557
+ // its own, because the two constraints interact — a node pushed out of a
5558
+ // lasso can land in a band, and one pushed out of a band can land in a
5559
+ // lasso — and only a shared loop can settle both.
5560
+ //
5561
+ // CONTAINMENT IS THE WHOLE BOX, NOT ITS CENTRE, which is the band's own
5562
+ // discipline (`inBand` tests the box's extent against the band's, never a
5563
+ // point). The two differ in STRICTNESS, and the difference is forced by
5564
+ // the shapes: a band is FILLED, so a box that merely overlaps it is
5565
+ // already sitting on painted group territory; a lasso is a RING with no
5566
+ // interior (§8.4), and `ringOf` step (4) has already shrunk it clear of
5567
+ // every node box, so a box can only be wholly inside or wholly outside.
5568
+ // Full containment is therefore not a weaker rule here, it is the only
5569
+ // reachable one — and it is the same rule the complete-cover check uses
5570
+ // one construct over (`inside`).
5571
+ //
5572
+ // A ONE-MEMBER BUNDLE DRAWS NO ELLIPSE (item 53) and therefore makes no
5573
+ // containment claim: there is no shape, so there is nothing to be inside,
5574
+ // and `lassoOf` returns null for it. Its caption is a label like any other.
5575
+ // PORT SQUARES need no separate test: a square straddles the border of the
5576
+ // node it belongs to, so a node clear of the ring carries its fittings out
5577
+ // with it. EDGES are not tested at all — a lasso exists to be crossed by
5578
+ // lines, and every member link runs through it by construction.
5579
+ const trunks=(doc.trunks||[]).filter(t=>t.pairs&&t.pairs.length);
5580
+ const lMem=t=>{ const s=new Set(); for(const [a,b] of t.pairs){ s.add(a); s.add(b); } return s; };
5581
+ const lassoOf=t=>{ const R=ringOf(t); return (R&&!R.solo)?R:null; };
5582
+ const inLasso=(n,R)=>{
5583
+ for(const p of [[n.x,n.y],[n.x+n.w,n.y],[n.x,n.y+n.h],[n.x+n.w,n.y+n.h]]){
5584
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
5585
+ if(Math.hypot((R.ux*dx+R.uy*dy)/R.rL,(R.vx*dx+R.vy*dy)/R.rA)>1) return false;
5586
+ }
5587
+ return true;
5588
+ };
5589
+ const lKey=(t,n)=>'bundle '+t.id+' '+n.id;
4917
5590
  const collect=()=>{
4918
5591
  const out=[];
4919
5592
  for(const g of groups){
@@ -4923,8 +5596,50 @@ function renderScene(doc,y0){
4923
5596
  if(inBand(n,B)) out.push({g,n});
4924
5597
  }
4925
5598
  }
5599
+ for(const t of trunks){
5600
+ const R=lassoOf(t); if(!R) continue;
5601
+ const mem=lMem(t);
5602
+ for(const n of real){
5603
+ if(mem.has(n.id)||said.has(lKey(t,n))) continue;
5604
+ if(inLasso(n,R)) out.push({t,n,mem});
5605
+ }
5606
+ }
4926
5607
  return out;
4927
5608
  };
5609
+ // The MOVE, shared by both regions. `unit` travels; `obst` is the cross-axis
5610
+ // interval it must end up clear of; `keep` is everything that must NOT be
5611
+ // dragged along with it.
5612
+ const push=(unit,obst,keep)=>{
5613
+ const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
5614
+ const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
5615
+ // NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
5616
+ // whichever way is shorter — sends the unit past the layout's own
5617
+ // starting edge often enough to matter (`reference/topology` put L1 at
5618
+ // x=-90 and the viewBox clipped it away). Growing the canvas the other
5619
+ // way is not available either: the only uniform-shift machinery this
5620
+ // renderer has moves PINNED nodes with everything else, and a pinned
5621
+ // node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
5622
+ // stability violation this engine has already paid for once. So the
5623
+ // constraint is applied HERE, to the choice: the negative direction is
5624
+ // taken only when the unit still lands inside the envelope the layout
5625
+ // had before this pass ran. Nothing outside the mover ever moves.
5626
+ const dNegOK=uLo+dNeg>=cross0;
5627
+ const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
5628
+ const ranks=new Set(unit.map(n=>n.rank));
5629
+ // Everything the mover would be pushed ONTO travels with it: same rank,
5630
+ // same side, clear of the obstacle. Relative order and spacing inside a
5631
+ // lane are preserved, so the fix cannot manufacture an overlap.
5632
+ // A node that BELONGS to a group never travels this way — a group moves
5633
+ // whole or not at all, and dragging half of one along would reshape its
5634
+ // band, which is the same defect one group further on.
5635
+ for(const m of lay){
5636
+ if(keep.has(m)||!ranks.has(m.rank)) continue;
5637
+ if(!m.virtual&&m.group) continue;
5638
+ const mLo=cLo(m), mHi=mLo+cSz(m);
5639
+ if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
5640
+ }
5641
+ for(const n of unit) mv(n,d);
5642
+ };
4928
5643
  let left=[];
4929
5644
  // EVERY conflict gets attention on every pass, and the band is recomputed
4930
5645
  // immediately before each resolution. Taking only the first conflict each
@@ -4935,6 +5650,49 @@ function renderScene(doc,y0){
4935
5650
  left=collect();
4936
5651
  if(!left.length) break;
4937
5652
  for(const c of left){
5653
+ if(c.t){
5654
+ // A LASSO CONFLICT. Only the intruder ever yields. The band's second
5655
+ // option — move the GROUP instead — has no honest analogue here: a
5656
+ // bundle's members are links between devices that other links also
5657
+ // touch, so "move the members" relocates half the figure and reshapes
5658
+ // the very ring it is trying to fix. When the intruder cannot move,
5659
+ // an author coordinate is what fixed it, and the rule below (at the
5660
+ // final geometry) says so rather than the engine overriding a pin.
5661
+ const R=lassoOf(c.t);
5662
+ if(!R||!inLasso(c.n,R)) continue; // an earlier resolution cleared it
5663
+ const unit=unitOf(c.n);
5664
+ if(!canMove(unit)){ said.add(lKey(c.t,c.n)); continue; }
5665
+ // Clear of the ring's AXIS-ALIGNED extent, not of the ellipse: a box
5666
+ // outside the bounding box is outside the ring for certain, whatever
5667
+ // the ring's rotation, and the pass stays monotone (always outward).
5668
+ const obst=horiz?{lo:R.cy-R.hh, hi:R.cy+R.hh}:{lo:R.cx-R.hw, hi:R.cx+R.hw};
5669
+ // AND IT MOVES NOTHING BUT THE INTRUDER. The band's push takes what
5670
+ // it would land on along with it; a lasso's cannot, and the reason is
5671
+ // the shape. A band's extent stops at its members' boxes, so a mover
5672
+ // pushed clear of it lands in free space. A lasso's extent, when the
5673
+ // ring falls back to the pre-0.4 derivation — which is what a bundle
5674
+ // whose links pass THROUGH intermediate devices always does, since
5675
+ // the ring cannot clear their boxes — spans the whole fan, so "clear
5676
+ // of the ring" is exactly where the outermost members already are.
5677
+ // Dragging them along was measured: it walks the top member off the
5678
+ // canvas (y=-26 on the three-tap stress figure) and the ring grows
5679
+ // with them, so the intruder is enclosed again on the next pass. So
5680
+ // the move is taken ONLY into room that is already free, and when
5681
+ // there is none the pass declines and the rule below refuses the
5682
+ // figure. A separation that has to damage the drawing to succeed is
5683
+ // not a separation; not drawn beats drawn wrongly, here as well.
5684
+ const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
5685
+ const ranks=new Set(unit.map(n=>n.rank)), inUnit=new Set(unit);
5686
+ const others=lay.filter(m=>!inUnit.has(m)&&ranks.has(m.rank));
5687
+ const roomFor=d=>uLo+d>=cross0 &&
5688
+ others.every(m=>{ const mLo=cLo(m), mHi=mLo+cSz(m);
5689
+ return mHi<=uLo+d-SEP||mLo>=uHi+d+SEP; });
5690
+ const go=[(obst.lo-SEP)-uHi,(obst.hi+SEP)-uLo]
5691
+ .filter(roomFor).sort((p,q)=>Math.abs(p)-Math.abs(q));
5692
+ if(!go.length){ said.add(lKey(c.t,c.n)); continue; }
5693
+ for(const n of unit) mv(n,go[0]);
5694
+ continue;
5695
+ }
4938
5696
  const B=bandOf(c.g);
4939
5697
  if(!inBand(c.n,B)) continue; // an earlier resolution cleared it
4940
5698
  const gMem=memOf(c.g.id);
@@ -4958,36 +5716,7 @@ function renderScene(doc,y0){
4958
5716
  said.add(c.g.id+' '+c.n.id); continue;
4959
5717
  }
4960
5718
  }
4961
- const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
4962
- const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
4963
- // NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
4964
- // whichever way is shorter — sends the unit past the layout's own
4965
- // starting edge often enough to matter (`reference/topology` put L1 at
4966
- // x=-90 and the viewBox clipped it away). Growing the canvas the other
4967
- // way is not available either: the only uniform-shift machinery this
4968
- // renderer has moves PINNED nodes with everything else, and a pinned
4969
- // node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
4970
- // stability violation this engine has already paid for once. So the
4971
- // constraint is applied HERE, to the choice: the negative direction is
4972
- // taken only when the unit still lands inside the envelope the layout
4973
- // had before this pass ran. Nothing outside the mover ever moves.
4974
- const dNegOK=uLo+dNeg>=cross0;
4975
- const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
4976
- const ranks=new Set(unit.map(n=>n.rank));
4977
- const keep=new Set(unit.concat(unit===gMem?[]:gMem));
4978
- // Everything the mover would be pushed ONTO travels with it: same rank,
4979
- // same side, clear of the obstacle. Relative order and spacing inside a
4980
- // lane are preserved, so the fix cannot manufacture an overlap.
4981
- // A node that BELONGS to a group never travels this way — a group moves
4982
- // whole or not at all, and dragging half of one along would reshape its
4983
- // band, which is the same defect one group further on.
4984
- for(const m of lay){
4985
- if(keep.has(m)||!ranks.has(m.rank)) continue;
4986
- if(!m.virtual&&m.group) continue;
4987
- const mLo=cLo(m), mHi=mLo+cSz(m);
4988
- if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
4989
- }
4990
- for(const n of unit) mv(n,d);
5719
+ push(unit,obst,new Set(unit.concat(unit===gMem?[]:gMem)));
4991
5720
  }
4992
5721
  }
4993
5722
  left=collect();
@@ -4995,6 +5724,13 @@ function renderScene(doc,y0){
4995
5724
  // is named. A figure that reaches this line with a hit is a defect in this
4996
5725
  // pass, and saying so beats drawing the false statement quietly.
4997
5726
  for(const c of left){
5727
+ // A LASSO residue is NOT reported here. This pass sees a PROJECTION of
5728
+ // the ring, taken before the boundary anchors are seated and before the
5729
+ // uniform canvas shifts; the ring the reader sees is the one computed at
5730
+ // the final geometry, and that is where its truth is judged (item 69,
5731
+ // below the label pass). Reporting twice from two geometries is how a
5732
+ // figure gets refused for a ring that was never drawn.
5733
+ if(c.t) continue;
4998
5734
  if(said.has(c.g.id+' '+c.n.id)) continue;
4999
5735
  gErrs.push('Line '+srcLine(c.g.line)+': group "'+c.g.id+'" would enclose non-member "'
5000
5736
  +c.n.id+'" and the layout pass could not separate them; the figure is not drawn rather '
@@ -5797,185 +6533,98 @@ function renderScene(doc,y0){
5797
6533
  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});
5798
6534
  const lblObs=e=>extObs.filter(o=>o.id!==e.a&&o.id!==e.b).map(padded)
5799
6535
  .concat(nameObs.map(padded));
5800
- // ── A BUNDLE RING IS ORIENTED BY ITS MEMBERS (item 43) ───────────────────
5801
- // Until now the ring was derived from the member links' MIDPOINTS and
5802
- // nothing else: `rx = max(46, x-spread + 38)`, `ry = max(26, y-spread + 22)`.
5803
- // Two facts about that formula are the defect. It is DIRECTION-BLIND the
5804
- // axes are the canvas's, never the links' and its floors are large enough
5805
- // that a bundle of two short legs draws a near-circle whatever the legs do.
5806
- // Measured on `patterns/topology-b`: rx 46 / ry 42.7, aspect
5807
- // 1.07, sitting on two legs that fan VERTICALLY, with the caption lying
5808
- // across the waist and on top of the `p3` endpoint label.
5809
- //
5810
- // The drawing convention for a link bundle is a loop THROUGH which the
5811
- // links run: narrow along the links, long across them. So the ring is now
5812
- // derived from the members' own frame.
5813
- //
5814
- // 1. DIRECTION. The mean UNDIRECTED direction of the members, by the
5815
- // doubled-angle circular mean — doubling is what makes `a--b` and
5816
- // `b--a` the same direction, so a bundle does not change shape when an
5817
- // author writes a member the other way round (conformance 394).
5818
- // 2. THE BAND. The ring sits at the MIDPOINT OF THE MEMBERS' SHARED RUN
5819
- // along that direction — the stretch of the corridor every member is
5820
- // actually in. That is the placement rule because it is the only band
5821
- // at which "the spread of the members" is a fact about all of them: a
5822
- // fanning set (the reference topology's ECMP pair, which leaves two
5823
- // different spines and arrives at one leaf) has a different spread at
5824
- // every station, and the shared run is the interval over which the
5825
- // question is even well posed. Members with no shared run at all fall
5826
- // back to the mean of their midpoints, which is the old centre.
5827
- // 3. THE AXES. MINOR along the mean direction, sized to clear the strokes;
5828
- // MAJOR across it, the members' spread at the band plus padding. A ring
5829
- // needs a long axis to read as a ring, and it takes it from the spread
5830
- // when the members fan (`rA >= rL`) and from its own RUN when they do
5831
- // not — a single-member bundle (`reference/topology`'s multi-home link)
5832
- // has zero spread, and the shape that hugs one link is an ellipse lying
5833
- // ALONG it, never a circle straddling it.
5834
- // 4. NO NODE CONTACT. The ring is shrunk along its minor axis until it
5835
- // clears every node box, and if it cannot it reverts to the pre-0.4
5836
- // geometry rather than inventing a shape (no corpus figure does).
5837
- //
5838
- // The caption is NOT placed here — it is registered with the label pass
5839
- // below, so it can see the endpoint labels and the edges it has to avoid.
5840
- const rnd3=v=>Math.round(v*1000)/1000; // ring coordinates, printed short
5841
- const RING_ALONG=15, // semi-minor: the ring's body along the links
5842
- RING_ACROSS=20, // clearance beyond the outermost member
5843
- RING_LONG=34, // the long axis a ring needs to read as a ring
5844
- RING_FAN=2, // below this spread the members are one line
5845
- RING_FLOOR=7, // how thin the minor axis may be squeezed
5846
- RING_SOLO=4; // a ONE-member bundle: the caption's stand-off
5847
- // ── A ONE-MEMBER BUNDLE IS ITS CAPTION (item 53) ─────────────────────────
5848
- // `bundle mh "multi-home" l1--l2` is legal and means something real (a
5849
- // one-link LAG, an Ethernet Segment with a single member), so the
5850
- // declaration is not an error and is not dropped. But a ring exists to
5851
- // UNITE lines, and around ONE line it unites nothing: it is ink that adds
5852
- // no fact, and on the reference topology it also bought the seam a 104 px
5853
- // corridor demand (2*RX_MIN + clearance) for a shape nobody had to see.
6536
+ const rings=new Map();
6537
+ for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
6538
+ // ── A LASSO THAT ENCLOSES A NON-MEMBER IS A FALSE DRAWING (item 69) ──────
6539
+ // The group-band rule, one construct over, and the reason it is owed is a
6540
+ // measurement: a three-link aggregation drawn from the UNH-IOL LACP test
6541
+ // suite (Test Setup 2, fig 06 of that validation exercise) put a lasso at
6542
+ // cx 592 cy 455, rx 231 ry 162 around six member links — and fully inside
6543
+ // it, at normalised radius 0.70, sat two test stations that are not in the
6544
+ // aggregation at all. The figure told the reader they were. The engine said
6545
+ // nothing and `layout-lint` scored it 0, while the IDENTICAL claim written
6546
+ // as a `group` band was refused four times by the pass above. Backlog 43 and
6547
+ // 53 were about lasso SHAPE; this is lasso TRUTH.
5854
6548
  //
5855
- // So a single-member bundle DRAWS NO ELLIPSE. What it draws is its
5856
- // CAPTION, bundle-styled (the trunk's own stroke, the ring caption's type)
5857
- // and placed BY THE LINK which is exactly the statement the construct
5858
- // makes: this link is the bundle, and here is its name. The frame below is
5859
- // therefore kept as a PLACEMENT frame and not as a drawing: the caption
5860
- // pass already sites a caption around a ring's rim, so a ring squeezed to
5861
- // `RING_SOLO` across gives the same pass a candidate family hugging the
5862
- // link. Nothing else in the pass changes.
6549
+ // WHOEVER CHOSE THE POSITION BEARS THE RESPONSIBILITY the band's principle,
6550
+ // inherited whole, and its boundary inherited with it. The complete-cover
6551
+ // check (backlog 47b) fires only when at least one of the two
6552
+ // boxes is at a coordinate the author never wrote; this rule is that same
6553
+ // question asked the other way round. Where NO author coordinate is involved
6554
+ // the engine had the freedom and MUST use it, so the separation pass above
6555
+ // has already moved the intruder out and nothing reaches here. What reaches
6556
+ // here is a figure whose geometry an author fixed: the intruder is pinned,
6557
+ // or a member endpoint is, or the separation pass ran out of room. Then the
6558
+ // engine reports and the artifact is not written, because it will not draw a
6559
+ // membership the source did not declare.
5863
6560
  //
5864
- // AN EMPTY CAPTION ON A ONE-MEMBER BUNDLE THEREFORE DRAWS NOTHING AT ALL.
5865
- // That is a real consequence and it is not hidden: the reference figure
5866
- // keeps `bundle unlabelled "" s2--l2` — it is the form demonstrator for
5867
- // the empty label — and states in a comment that this form is now
5868
- // MODEL-ONLY. The model still carries the trunk, `read` still reports it,
5869
- // and the drawing says nothing because there is nothing a ring around one
5870
- // unnamed line could say.
5871
- // Does the ring's disc meet an axis-aligned rect? Both are mapped into the
5872
- // frame where the ring is the unit circle; the rect becomes a convex quad,
5873
- // and the test is "is the quad within 1 of the origin".
5874
- const ringHitsRect=(R,RL,RA,b)=>{
5875
- 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=>{
5876
- const dx=p[0]-R.cx, dy=p[1]-R.cy;
5877
- return [(R.ux*dx+R.uy*dy)/RL, (R.vx*dx+R.vy*dy)/RA];
5878
- });
5879
- let inside=false;
5880
- for(let i=0,j=3;i<4;j=i++)
5881
- if((q[i][1]>0)!==(q[j][1]>0) &&
5882
- 0 < (q[j][0]-q[i][0])*(0-q[i][1])/(q[j][1]-q[i][1])+q[i][0]) inside=!inside;
5883
- if(inside) return true;
5884
- for(let i=0,j=3;i<4;j=i++){
5885
- const vx=q[j][0]-q[i][0], vy=q[j][1]-q[i][1], L2=vx*vx+vy*vy;
5886
- const t=L2?Math.max(0,Math.min(1,-(q[i][0]*vx+q[i][1]*vy)/L2)):0;
5887
- if(Math.hypot(q[i][0]+t*vx, q[i][1]+t*vy)<1) return true;
5888
- }
5889
- return false;
5890
- };
5891
- const ringOf=t=>{
5892
- const segs=[];
5893
- for(const [a,b] of t.pairs){
5894
- const A=byId[a], B=byId[b]; if(!A||!B) continue;
5895
- 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);
5896
- if(Math.hypot(q[0]-p[0],q[1]-p[1])>1e-9) segs.push([p,q]);
5897
- }
5898
- if(!segs.length) return null;
5899
- // (1) mean undirected direction
5900
- let c2=0,s2=0;
5901
- for(const [p,q] of segs){
5902
- const L=Math.hypot(q[0]-p[0],q[1]-p[1]);
5903
- const c=(q[0]-p[0])/L, s=(q[1]-p[1])/L;
5904
- c2+=c*c-s*s; s2+=2*c*s;
5905
- }
5906
- const th=Math.hypot(c2,s2)<1e-9
5907
- ? Math.atan2(segs[0][1][1]-segs[0][0][1], segs[0][1][0]-segs[0][0][0])
5908
- : 0.5*Math.atan2(s2,c2);
5909
- const ux=Math.cos(th), uy=Math.sin(th), vx=-uy, vy=ux;
5910
- // (2) the band: the midpoint of the shared run
5911
- const iv=segs.map(([p,q])=>{
5912
- const a=ux*p[0]+uy*p[1], b=ux*q[0]+uy*q[1];
5913
- return a<=b?[a,b,p,q]:[b,a,q,p];
5914
- });
5915
- const lo=Math.max(...iv.map(z=>z[0])), hi=Math.min(...iv.map(z=>z[1]));
5916
- const s=lo<hi ? (lo+hi)/2 : iv.reduce((x,z)=>x+(z[0]+z[1])/2,0)/iv.length;
5917
- const P=iv.map(z=>{
5918
- const f=Math.min(1,Math.max(0,(s-z[0])/((z[1]-z[0])||1)));
5919
- return [z[2][0]+(z[3][0]-z[2][0])*f, z[2][1]+(z[3][1]-z[2][1])*f];
5920
- });
5921
- const cx=P.reduce((a,p)=>a+p[0],0)/P.length, cy=P.reduce((a,p)=>a+p[1],0)/P.length;
5922
- const spread=Math.max(...P.map(p=>Math.abs(vx*(p[0]-cx)+vy*(p[1]-cy))));
5923
- const runHalf=Math.min(...iv.map(z=>Math.min(s-z[0],z[1]-s)));
5924
- // (3) axes. Two shapes, and which one is drawn is decided by whether the
5925
- // members FAN at all. A set that fans is CROSSED by the ring: thin along
5926
- // the links, long enough across them to take the whole spread with room
5927
- // to spare, so each member passes through a part of the rim where the
5928
- // ring is still visibly open. A set that does not fan — one link, or
5929
- // members lying on top of each other — has no spread to take, and the
5930
- // shape that says "this link is the bundle" is an ellipse lying ALONG it.
5931
- let rA, rL;
5932
- if(spread<RING_FAN){ rL=Math.max(RING_ALONG,Math.min(RING_LONG,Math.max(0,runHalf))); rA=RING_ACROSS; }
5933
- else { rL=RING_ALONG; rA=Math.max(spread+RING_ACROSS,RING_LONG); }
5934
- const R={cx,cy,ux,uy,vx,vy,th};
5935
- // (0) ONE MEMBER (item 53): no ellipse is drawn, so this is a caption
5936
- // frame and not a shape. It is squeezed across to `RING_SOLO` so the
5937
- // caption pass's rim stations sit beside the link rather than a ring's
5938
- // radius away from it, and the node-clearance step below is skipped —
5939
- // there is no ink to keep out of a box.
5940
- if(segs.length===1){
5941
- R.solo=true; R.rL=rL; R.rA=RING_SOLO;
5942
- R.hw=Math.hypot(rL*ux, RING_SOLO*vx); R.hh=Math.hypot(rL*uy, RING_SOLO*vy);
5943
- return R;
5944
- }
5945
- // (4) out of every node box
5946
- const boxes=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
5947
- if(boxes.some(b=>ringHitsRect(R,rL,rA,b))){
5948
- let ok=false;
5949
- for(let k=1;k<=24&&!ok;k++){
5950
- const RL=rL-(rL-RING_FLOOR)*k/24;
5951
- if(!boxes.some(b=>ringHitsRect(R,RL,rA,b))){ rL=RL; ok=true; }
5952
- }
5953
- // No orientation of this ring clears the drawing. Rather than publish a
5954
- // ring lying over a node, revert to the pre-0.4 derivation, which is at
5955
- // least the shape every earlier artifact recorded. No corpus figure
5956
- // takes this branch; it exists so that a hostile geometry degrades to
5957
- // the old defect instead of a new one.
5958
- if(!ok){
5959
- const M=segs.map(([p,q])=>[(p[0]+q[0])/2,(p[1]+q[1])/2]);
5960
- const mx=M.reduce((a,m)=>a+m[0],0)/M.length, my=M.reduce((a,m)=>a+m[1],0)/M.length;
5961
- const lx=Math.max(46,Math.max(...M.map(m=>Math.abs(m[0]-mx)))+38);
5962
- const ly=Math.max(26,Math.max(...M.map(m=>Math.abs(m[1]-my)))+22);
5963
- return {cx:mx, cy:my, ux:1, uy:0, vx:0, vy:1, th:0, legacy:true,
5964
- rL:lx, rA:ly, hw:lx, hh:ly};
6561
+ // The test is at the FINAL geometry the same ring, from the same `ringOf`,
6562
+ // that is drawn a thousand lines below because the ring the reader reads is
6563
+ // the only one whose truth is at stake.
6564
+ {
6565
+ const real=nodes.filter(n=>!n.boundary&&n.w>0&&n.h>0);
6566
+ for(const t of doc.trunks||[]){
6567
+ const R=rings.get(t);
6568
+ if(!R||R.solo) continue; // one member draws no ellipse (item 53)
6569
+ const mem=new Set(); for(const [a,b] of t.pairs){ mem.add(a); mem.add(b); }
6570
+ const memPinned=[...mem].some(id=>pinned(id));
6571
+ for(const n of real){
6572
+ if(mem.has(n.id)) continue;
6573
+ let inside=true;
6574
+ for(const p of [[n.x,n.y],[n.x+n.w,n.y],[n.x,n.y+n.h],[n.x+n.w,n.y+n.h]]){
6575
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
6576
+ if(Math.hypot((R.ux*dx+R.uy*dy)/R.rL,(R.vx*dx+R.vy*dy)/R.rA)>1){ inside=false; break; }
6577
+ }
6578
+ if(!inside) continue;
6579
+ const ln=srcLine(t.line);
6580
+ // Two voices, chosen by who chose the coordinate — the band's own
6581
+ // split. A pin is named as a pin so the author knows which line to
6582
+ // edit; a figure the engine could not separate says so, and admits it.
6583
+ gErrs.push(pinned(n.id)||memPinned
6584
+ ? 'Line '+ln+': the lasso for bundle "'+t.id+'" would enclose non-member "'+n.id
6585
+ +'" — a lasso is the drawn extent of the bundle\'s members, so this draws "'+n.id
6586
+ +'" as one of them. Move the pin clear of the bundle\'s extent, or change what "'
6587
+ +t.id+'" collects so the lasso is its members\' own. The figure is not drawn '
6588
+ +'rather than drawn wrongly.'
6589
+ : 'Line '+ln+': the lasso for bundle "'+t.id+'" would enclose non-member "'+n.id
6590
+ +'" and the layout pass could not separate them; the figure is not drawn rather '
6591
+ +'than drawn wrongly. Give "'+n.id+'" a pin outside the bundle\'s extent, or add '
6592
+ +'its link to "'+t.id+'".');
5965
6593
  }
5966
6594
  }
5967
- R.rL=rL; R.rA=rA;
5968
- // the axis-aligned box the caption is placed beside and the canvas grows to
5969
- R.hw=Math.hypot(rL*ux, rA*vx); R.hh=Math.hypot(rL*uy, rA*vy);
5970
- return R;
5971
- };
5972
- const rings=new Map();
5973
- for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
6595
+ }
6596
+ // WHOSE EXTENT THE LASSO IS, WRITTEN INTO THE DRAWING (item 69).
6597
+ // `data-lasso` follows the `data-port-sq` pattern exactly: ONE
6598
+ // attribute name, carried by the INK and by the thing the ink is about, with
6599
+ // the same string on both, so a reader — `tools/layout-lint.js` first — reads
6600
+ // the association instead of inferring it. The ellipse carries the bundle's
6601
+ // id; every node the ellipse was derived from carries the ids of the lassos
6602
+ // it is a member of (a node may be in more than one, space-separated). The
6603
+ // geometry itself is NOT duplicated into the attribute: `cx/cy/rx/ry` and the
6604
+ // `rotate()` are already the drawn truth, and a second copy could disagree
6605
+ // with the first. Only bundles that DRAW an ellipse appear — a one-member
6606
+ // bundle draws none (item 53) and claims no extent, so it stamps nothing.
6607
+ const lassoMem=new Map();
6608
+ for(const [t,R] of rings){
6609
+ if(R.solo) continue;
6610
+ for(const [a,b] of t.pairs) for(const id of [a,b]){
6611
+ if(!lassoMem.has(id)) lassoMem.set(id,[]);
6612
+ if(!lassoMem.get(id).includes(t.id)) lassoMem.get(id).push(t.id);
6613
+ }
6614
+ }
5974
6615
  // filled by the label pass below, consumed by the ring drawing further down
5975
6616
  const ringLbl=new Map(), ringLeads=[];
5976
6617
  // edges (sorted by plane z, then doc order)
5977
6618
  const edges=zsort(doc.edges);
5978
6619
  const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
6620
+ // THE PORT SQUARES GET THEIR OWN LAYER (item 57b), between the
6621
+ // node bodies and the labels. Over the nodes, because half of each square is
6622
+ // meant to be seen INSIDE the box and the whole point of the notation is
6623
+ // that the fitting sits ON the boundary; over the edges, because the
6624
+ // connector ATTACHES to the square rather than passing through it; under
6625
+ // everything in `lblsvg`, because a port name — and an arrowhead, which
6626
+ // lives in that layer too — must never be occluded by the fitting it names.
6627
+ const sqsvg=[];
5979
6628
  // ── deferred edge-label placement ───────────────────────────────────────
5980
6629
  // An edge label is not written where it is emitted. Each emission reserves
5981
6630
  // its slot in lblsvg (so the paint order is unchanged) and registers the
@@ -6032,8 +6681,9 @@ function renderScene(doc,y0){
6032
6681
  // box and repeating one label three times is what this removes.
6033
6682
  //
6034
6683
  // Each member still emits its OWN full path from its source outline to the
6035
- // target outline — shape-check asserts exactly that, and `data-edge` carries
6036
- // one source line so the shared trunk is stroked once per member. That
6684
+ // target outline — shape-check asserts exactly that, and `data-edge` keys
6685
+ // each one (its id, or its source line when anonymous; `CONNECTOR-IDENTITY-KEY`) so the shared
6686
+ // trunk is stroked once per member. That
6037
6687
  // coincidence is the convention and not a defect, and the members say so:
6038
6688
  // every bus path carries `data-bus="<target>"`, which is what lets a reader
6039
6689
  // (and layout-lint) tell a deliberate trunk from two edges hidden under each
@@ -6393,7 +7043,7 @@ function renderScene(doc,y0){
6393
7043
  // pattern to a period boundary at the path's end.
6394
7044
  const per=e.style==='dashed'?10:(e.style==='dotted'?6:0);
6395
7045
  const doff=per?' stroke-dashoffset="'+(((per-(runLen(pts)%per))%per).toFixed(2))+'"':'';
6396
- 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)+'"/>');
7046
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+doff+' data-bus="'+esc(bus.bus)+'"/>');
6397
7047
  noteSegs(e,pts);
6398
7048
  for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
6399
7049
  if(bus.dots) for(const d of bus.dots)
@@ -6426,7 +7076,26 @@ function renderScene(doc,y0){
6426
7076
  if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
6427
7077
  continue;
6428
7078
  }
6429
- if(chBack(e)&&!pinned(e.a)&&!pinned(e.b)){
7079
+ // A SELF-TRANSITION IS EXEMPT FROM THE PINNED-ENDPOINT EXCLUSION
7080
+ // (item 64). The `!pinned` guard belongs to the CHANNEL
7081
+ // route below it: a channel back edge is planned in `chPlan`, which is
7082
+ // built over auto-layout lanes and has nothing to say about a box the
7083
+ // author placed, so a pinned back edge correctly falls through to the
7084
+ // straight route. A SELF-loop has no such dependency — its geometry is a
7085
+ // function of ONE box and a free side, both of which a pinned box has —
7086
+ // and sweeping it into the same exclusion made `pin` silently delete the
7087
+ // drawing: `borderPoint(A, A's own centre)` twice is the same point, so
7088
+ // the straight route emitted a zero-length `<line>` at the state's centre
7089
+ // with the trigger label printed across the state's name. Measured on
7090
+ // 0.4 over a pinned three-state triangle and a pinned two-state
7091
+ // pair: every self-transition length 0.0. The rule the exemption keeps is
7092
+ // one line — a self-transition draws the same way whether its state's
7093
+ // coordinate came from a pin or from the layout pass — so the side
7094
+ // selection below is REUSED as-is rather than duplicated for pins; a
7095
+ // second selection rule would be a second convention, and `DOMAIN-CONVENTION-DIRECTIVES` gives the
7096
+ // engine one. (`chBack` is always true here: `isBack` takes every
7097
+ // self-edge, and the `recip` pair test skips `e.a===e.b`.)
7098
+ if(chBack(e)&&(A===B||(!pinned(e.a)&&!pinned(e.b)))){
6430
7099
  // ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
6431
7100
  // Edge labels are DEFERRED: every label is registered against its
6432
7101
  // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
@@ -6483,7 +7152,7 @@ function renderScene(doc,y0){
6483
7152
  for(const sd of ['r','l','b','t']){ const pp=mkLoop(sd); if(!loopHit(pp)){ sp=pp; break; } }
6484
7153
  if(!sp) sp=mkLoop('r');
6485
7154
  for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
6486
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7155
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6487
7156
  noteSegs(e,sp);
6488
7157
  // A self-loop's outer run is 16 px long, so sliding the label ALONG it
6489
7158
  // buys ~15 px and no escape at all from a line crossing it — and a
@@ -6623,7 +7292,7 @@ function renderScene(doc,y0){
6623
7292
  // it is what lets a reader (and `layout-lint`'s coincident term) tell a
6624
7293
  // deliberate shared trunk from two lines hidden under each other. Written
6625
7294
  // LAST, after stroke-width, so no existing reader breaks.
6626
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash
7295
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash
6627
7296
  +(P.bus?' data-bus="'+esc(P.bus)+'"':'')+'/>');
6628
7297
  noteSegs(e,pts);
6629
7298
  if(P.busTail){
@@ -6662,7 +7331,7 @@ function renderScene(doc,y0){
6662
7331
  const lrPts=longRoute(e);
6663
7332
  if(lrPts){
6664
7333
  for(const p of lrPts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
6665
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(lrPts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7334
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(lrPts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6666
7335
  noteSegs(e,lrPts);
6667
7336
  if(e.mid){
6668
7337
  // WHICH LEG CARRIES THE LABEL, and it is the OPPOSITE of the channel
@@ -6791,7 +7460,7 @@ function renderScene(doc,y0){
6791
7460
  }
6792
7461
  }
6793
7462
  }
6794
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7463
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6795
7464
  noteSegs(e,pts);
6796
7465
  if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
6797
7466
  if(e.tail) seg(p0,pts[1],e.tail,10,p1,runLen(pts));
@@ -6825,7 +7494,7 @@ function renderScene(doc,y0){
6825
7494
  }
6826
7495
  }
6827
7496
  if(route){
6828
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7497
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6829
7498
  noteSegs(e,route);
6830
7499
  if(e.mid){ // the longest segment carries the mid label
6831
7500
  let bi=0,bl=-1;
@@ -6842,7 +7511,7 @@ function renderScene(doc,y0){
6842
7511
  for(const pP of route){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+4-y0-20); }
6843
7512
  continue;
6844
7513
  }
6845
- esvg.push('<line data-edge="'+e.line+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7514
+ esvg.push('<line data-edge="'+edgeRef(e)+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6846
7515
  noteSegs(e,[[x1,yy1],[x2,yy2]]);
6847
7516
  if(e.mid)
6848
7517
  reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
@@ -7010,6 +7679,68 @@ function renderScene(doc,y0){
7010
7679
  // how near a corner a marker may be written; the offsets are one marker
7011
7680
  // width apart, which is exactly the pitch two ports need to be two.
7012
7681
  const PORT_IN=2, PORT_END=4, PORT_HUG=25, PORT_OUT=60;
7682
+ // ── THE STRADDLING PORT SQUARE (item 57b) ─────────────────
7683
+ // OMG UML 2.5.1 §11.4's composite-structure port notation: a small square
7684
+ // drawn ON the classifier's boundary, half inside and half outside, with
7685
+ // the connector attaching to it and the port name labelling it. Here the
7686
+ // classifier is the device box, the connector is the link, and the name is
7687
+ // the interface. Item 57 put the NAME at the crossing; this puts a MARK
7688
+ // there, so the crossing is stated by the drawing rather than inferred
7689
+ // from where a word happens to sit.
7690
+ //
7691
+ // `PORT_SQ` is 7 px — the CAP-HEIGHT OF THE PORT NAME ITSELF (the endpoint
7692
+ // font is 10-11 px, so its capitals are about 7), which is what makes the
7693
+ // fitting read as the name's own mark rather than a second little box: it
7694
+ // is a fifth of an ordinary 36 px topology node's height and it never
7695
+ // competes with the device outline. The size was MEASURED, not chosen:
7696
+ // swept 7/8/9/10 px against layout-lint over this corpus, and 7 is the
7697
+ // only value that regresses no figure — `reference/topology` scores 6 at
7698
+ // 9 px, 4 at 8 px and 2 at 7 px against the pre-square 4, because every
7699
+ // extra pixel of fitting is a pixel of along-border room the name no
7700
+ // longer has on a small device.
7701
+ //
7702
+ // It is stroked in the OWNING NODE's own stroke at the node's own 1 px
7703
+ // weight and filled with the node's own fill, so it belongs to the device
7704
+ // that owns the port and to nothing else. `PORT_SQ_GAP` is the engine's
7705
+ // own 3 px label standoff, reused rather than reinvented; it is also the
7706
+ // width of the FORBIDDEN BAND around a foreign square (see `portCands`).
7707
+ const PORT_SQ=7, PORT_SQ_GAP=3;
7708
+ // Where the square goes: the crossing point snapped onto the nearest side
7709
+ // of the owner's box, clamped so a square can never hang off a corner.
7710
+ // The side comes back with it — the placement family needs it.
7711
+ const portSq=(N,p)=>{
7712
+ const dl=Math.abs(p[0]-N.x), dr=Math.abs(p[0]-(N.x+N.w)),
7713
+ dt=Math.abs(p[1]-N.y), db=Math.abs(p[1]-(N.y+N.h));
7714
+ const m=Math.min(dl,dr,dt,db);
7715
+ const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
7716
+ const vert=(side==='top'||side==='bottom');
7717
+ const h2=PORT_SQ/2;
7718
+ let cx,cy;
7719
+ // A CURVED OR ANGLED OUTLINE IS STILL THE OUTLINE. Snapping to the
7720
+ // bounding box is right for the rectangle family and WRONG for
7721
+ // everything else: `showcase/srl-evpn-irb`'s EVPN-VXLAN cloud is an
7722
+ // ellipse 620 x 110, and its `irb0.24` port snapped to the box bottom
7723
+ // came out 8 px BELOW the drawn curve — a fitting floating in space
7724
+ // beside the thing it is fitted to. The edge already ends on the true
7725
+ // outline (`borderPoint` put it there), so for a non-rectangular shape
7726
+ // the crossing point IS the answer and the square is simply centred on
7727
+ // it, axis-aligned. It straddles a slanted or curved border at a slight
7728
+ // angle to it, which is how every hand-drawn figure does it too.
7729
+ const boxy=!N.shape||N.shape==='box'||N.shape==='rounded'||N.shape==='cylinder';
7730
+ if(!boxy){ cx=Math.max(N.x+h2,Math.min(N.x+N.w-h2,p[0]));
7731
+ cy=Math.max(N.y+h2,Math.min(N.y+N.h-h2,p[1])); }
7732
+ else if(vert){ cx=Math.max(N.x+h2,Math.min(N.x+N.w-h2,p[0]));
7733
+ cy=side==='top'?N.y:N.y+N.h; }
7734
+ else { cy=Math.max(N.y+h2,Math.min(N.y+N.h-h2,p[1]));
7735
+ cx=side==='left'?N.x:N.x+N.w; }
7736
+ return {cx,cy,side,vert,key:cx.toFixed(2)+','+cy.toFixed(2),
7737
+ box:{x:cx-h2,y:cy-h2,w:PORT_SQ,h:PORT_SQ}};
7738
+ };
7739
+ // gap between two boxes, 0 when they meet — the same quantity `segBoxGap`
7740
+ // measures between a segment and a box, and it is what the standoff rule
7741
+ // below is written in.
7742
+ const boxSep=(a,b)=>Math.hypot(Math.max(b.x-(a.x+a.w),0,a.x-(b.x+b.w)),
7743
+ Math.max(b.y-(a.y+a.h),0,a.y-(b.y+b.h)));
7013
7744
  // The node's own drawn label, computed by the SAME formulas the node pass
7014
7745
  // emits it with (shrink-to-fit included), so the obstacle and the drawing
7015
7746
  // cannot disagree about where a name is.
@@ -7026,12 +7757,19 @@ function renderScene(doc,y0){
7026
7757
  // the glyph band inside that, and the two questions are different (see the
7027
7758
  // own-name term in the scorer).
7028
7759
  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)});
7760
+ // A label request's own text box, in the one place both the slot pass and
7761
+ // the candidate family can read it — they must agree about how wide a name
7762
+ // is or the fan below spaces names by a width nobody draws.
7763
+ const lblDims=r=>{
7764
+ const lines=String(r.text).split('\n'), nL=lines.length;
7765
+ const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
7766
+ const lh=r.fs*1.3;
7767
+ return {w, h:(nL-1)*lh+r.fs*1.1, up:(nL-1)*lh/2+r.fs*0.85};
7768
+ };
7029
7769
  const portCands=r=>{
7030
7770
  const N=r.port, P=r.p, out=[];
7031
7771
  if(!N) return out;
7032
- const lines=String(r.text).split('\n'), nL=lines.length;
7033
- const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
7034
- const lh=r.fs*1.3, h=(nL-1)*lh+r.fs*1.1, up=(nL-1)*lh/2+r.fs*0.85;
7772
+ const D=lblDims(r), w=D.w, h=D.h, up=D.up;
7035
7773
  // Which border did the link cross? The nearest side of the box.
7036
7774
  const dl=Math.abs(P[0]-N.x), dr=Math.abs(P[0]-(N.x+N.w)),
7037
7775
  dt=Math.abs(P[1]-N.y), db=Math.abs(P[1]-(N.y+N.h));
@@ -7039,13 +7777,28 @@ function renderScene(doc,y0){
7039
7777
  const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
7040
7778
  const vert=(side==='top'||side==='bottom');
7041
7779
  // ROOM. A marker that cannot be written between the two corners of the
7042
- // side it crosses has no inside form at all; the ordinary outside
7043
- // family is then the only one offered, which is the stated fallback.
7780
+ // side it crosses has no INSIDE form at all but it still has an
7781
+ // outside one, hugging the border it could not get into, and that is a
7782
+ // far better answer than the along-the-shaft family it used to fall
7783
+ // through to (an unclamped `roomIn` return sent every name on a narrow
7784
+ // device out into the seam between its links, which is the defect the
7785
+ // port convention exists to end). So the shortage is now a filter on
7786
+ // the INSIDE ring only.
7044
7787
  const span=vert?N.w:N.h, need=(vert?w:h)+2*PORT_END;
7045
- if(span<need) return out;
7788
+ const roomIn=span>=need;
7046
7789
  const lo=(vert?N.x:N.y)+PORT_END, hi=(vert?N.x+N.w:N.y+N.h)-PORT_END;
7047
7790
  const pitch=(vert?w:h)+4;
7048
- const u0=vert?P[0]:P[1];
7791
+ // THE NAME IS ANCHORED TO ITS OWN SQUARE, not to the raw
7792
+ // crossing: `u0` is the square's centreline along the border, so the
7793
+ // corner clamp inside `portSq` can never leave a name off the mark it
7794
+ // names. `base` is the smallest along-border offset at which the two
7795
+ // marks are two — half the square, the 3 px standoff, half the text —
7796
+ // and `deep` is the same clearance taken PERPENDICULARLY, past the
7797
+ // square's far face.
7798
+ const SQ=portSq(N,P);
7799
+ const u0=vert?SQ.cx:SQ.cy;
7800
+ const base=PORT_SQ/2+PORT_SQ_GAP+(vert?w:h)/2;
7801
+ const deep=PORT_SQ/2+PORT_SQ_GAP;
7049
7802
  // TWO RINGS OF CANDIDATES, AND BOTH ARE AT THE CROSSING.
7050
7803
  // in = inside the border, the convention proper;
7051
7804
  // out = the mirror image, just OUTSIDE the same border.
@@ -7058,19 +7811,162 @@ function renderScene(doc,y0){
7058
7811
  // could not get inside keeps it on its own port, which is the fact it
7059
7812
  // exists to state. It is surcharged, so it is taken only after inside
7060
7813
  // has failed.
7061
- for(const inside of [true,false]) for(const k of [0,1,-1,2,-2]){
7062
- const u=Math.max(lo+(vert?w:h)/2, Math.min(hi-(vert?w:h)/2, u0+k*pitch));
7814
+ //
7815
+ // WITH A SQUARE AT THE CROSSING THE ANCHOR CHANGES, which is the
7816
+ // placement half of the maintainer's ruling ("A/B 皆有埠名文字定位不夠
7817
+ // 理想" — in both styles the port name is not sited well enough):
7818
+ //
7819
+ // 1. THE NAME IS ANCHORED TO ITS OWN SQUARE, on the square's
7820
+ // centreline along the border. When the port is alone on that side
7821
+ // the anchor IS `u0` and the name sits squarely on its fitting's
7822
+ // line — which is the whole reading the notation buys.
7823
+ // 2. WHEN THE SIDE CARRIES SEVERAL PORTS the anchor is the SLOT the
7824
+ // side pass assigned (`r.portU`): the same ideal, projected so that
7825
+ // consecutive names clear each other IN SQUARE ORDER. Order is the
7826
+ // property that matters and it is why the slot is computed for the
7827
+ // whole side at once instead of being negotiated one label at a
7828
+ // time by a greedy scorer — a greedy pass produced `p1 p3 p2` over
7829
+ // squares in the order p1 p2 p3 on `patterns/topology-a`, and every
7830
+ // one of those three labels is legible while two of them are wrong.
7831
+ //
7832
+ // The perpendicular offset follows from the anchor: a name still over
7833
+ // its own square's along-border extent must clear the square's face
7834
+ // (`deep`), and one that has stepped clear of it along the border only
7835
+ // needs the ordinary 2 px (`PORT_IN`).
7836
+ //
7837
+ // The ladder (±one text pitch at a time, from the slot) stays behind
7838
+ // that as relief for a name whose slot is occupied by something else —
7839
+ // the node's own name, a neighbour's label. Each of these exists inside
7840
+ // the border and outside it. INSIDE IS THE CONVENTION PROPER; the
7841
+ // outside ring is the mirror image, hugging the same border, and it is
7842
+ // surcharged (`hug`, PORT_HUG) so it is taken only after inside has
7843
+ // failed — but it is far cheaper than the ordinary along-the-shaft
7844
+ // family (PORT_OUT), which is the ruling's second clause: a crowded
7845
+ // interior sends the name to the OUTSIDE FLANK of its own port before it
7846
+ // ever sends it out onto the shaft.
7847
+ const uS=(r.portU===undefined?u0:r.portU);
7848
+ const dpFor=u=>(Math.abs(u-u0)<base-1e-9?deep:PORT_IN);
7849
+ const fam=[];
7850
+ for(const inside of [true,false]) fam.push({inside,u:uS,dp:dpFor(uS),ex:0});
7851
+ // OUTSIDE, THE NAME MAY ALSO STAND OFF THE BORDER. Inside, the far wall
7852
+ // of the box ends the argument; outside there is open paper, and one
7853
+ // step of it is often the whole difference. `05-lacp`'s DUT is 49.6 px
7854
+ // wide, carries three ports on its left face and their names are 41.4 px
7855
+ // — every name has to hug the outside, and every shaft converging on
7856
+ // those three fittings runs through the hug ring on its way in. Two
7857
+ // 8 px steps outward (priced like any other displacement, and past the
7858
+ // second one the standoff rule below draws the association as a leader)
7859
+ // let the fan open out to where the shafts have separated.
7860
+ for(const d of [8,16]) fam.push({inside:false,u:uS,dp:dpFor(uS)+d,ex:d});
7861
+ for(const inside of [true,false]) for(const sg of [1,-1]) for(const k of [0,1,2])
7862
+ fam.push({inside,u:uS+sg*(base+k*pitch),dp:PORT_IN,ex:base+k*pitch});
7863
+ for(const F of fam){
7864
+ const inside=F.inside, IN=F.dp;
7865
+ if(inside&&!roomIn) continue;
7866
+ // THE CORNERS BIND INSIDE AND DO NOT BIND OUTSIDE. Inside, a name is
7867
+ // written between the two corners of the face it belongs to or it is
7868
+ // written on another face; outside there is no face to run out of, so
7869
+ // clamping the outside ring to the node's own extent was simply
7870
+ // collapsing a fanned side back into a stack. A node 36 px tall with
7871
+ // three links arriving 8 px apart has no inside answer at all, and
7872
+ // its outside answer is the fan — which needs to reach past the
7873
+ // corner to exist.
7874
+ const half=(vert?w:h)/2;
7875
+ const u=inside?Math.max(lo+half, Math.min(hi-half, F.u)):F.u;
7063
7876
  let bx,by;
7064
- if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-PORT_IN-h:N.y+N.h+PORT_IN; }
7065
- else if(side==='top'){ bx=u-w/2; by=inside?N.y+PORT_IN:N.y-PORT_IN-h; }
7066
- else if(side==='left'){ bx=inside?N.x+PORT_IN:N.x-PORT_IN-w; by=u-h/2; }
7067
- else { bx=inside?N.x+N.w-PORT_IN-w:N.x+N.w+PORT_IN; by=u-h/2; }
7877
+ if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-IN-h:N.y+N.h+IN; }
7878
+ else if(side==='top'){ bx=u-w/2; by=inside?N.y+IN:N.y-IN-h; }
7879
+ else if(side==='left'){ bx=inside?N.x+IN:N.x-IN-w; by=u-h/2; }
7880
+ else { bx=inside?N.x+N.w-IN-w:N.x+N.w+IN; by=u-h/2; }
7068
7881
  out.push({x:bx+w/2, y:by+up, anchor:'middle', t:undefined, side,
7069
- ex:Math.abs(k)*pitch, inPort:inside, hug:!inside,
7882
+ sq:SQ.box, sqk:SQ.key, atBorder:true,
7883
+ ex:F.ex, inPort:inside, hug:!inside,
7070
7884
  box:{x:bx,y:by,w,h}});
7071
7885
  }
7072
7886
  return out;
7073
7887
  };
7888
+ // ── DRAW EVERY PORT SQUARE BEFORE ANY NAME IS PLACED ──────────────────
7889
+ // The squares are a function of geometry alone (owner box + crossing
7890
+ // point), so they are all known before the greedy placement pass starts.
7891
+ // Computing them here rather than inside that loop buys the property that
7892
+ // matters: EVERY name is scored against EVERY square, including squares
7893
+ // belonging to names that have not been placed yet. A name written across
7894
+ // a later port's fitting would say the wrong thing about which link it
7895
+ // belongs to, and a one-pass greedy loop that emitted squares as it went
7896
+ // could only ever avoid the ones already drawn.
7897
+ //
7898
+ // Two crossings can coincide (co-located links on one border, a bundle's
7899
+ // members leaving through one face): the square is keyed on its snapped
7900
+ // centre so one fitting is drawn once, not stroked n times. That key is
7901
+ // also the square's IDENTITY in the drawing — it is written on the rect
7902
+ // as `data-port-sq` and on the name that belongs to it, so a reader (and
7903
+ // `tools/layout-lint.js`) can tell a name's own fitting from a foreign one
7904
+ // by reading rather than by guessing at distances.
7905
+ const sqBox=[], sqSeen=new Set();
7906
+ for(const r of lblReq){
7907
+ if(!r.port) continue;
7908
+ const S=portSq(r.port,r.p);
7909
+ if(sqSeen.has(S.key)) continue;
7910
+ sqSeen.add(S.key);
7911
+ sqBox.push(Object.assign({key:S.key},S.box));
7912
+ const N=r.port;
7913
+ sqsvg.push('<rect x="'+S.box.x.toFixed(2)+'" y="'+S.box.y.toFixed(2)+
7914
+ '" width="'+PORT_SQ+'" height="'+PORT_SQ+'" fill="'+(N.fill||'#fff')+
7915
+ '" stroke="'+(N.stroke||'#8a8880')+'" data-port-sq="'+S.key+'"/>');
7916
+ W=Math.max(W, S.box.x+S.box.w+4);
7917
+ Hh=Math.max(Hh, S.box.y+S.box.h+4-y0-20);
7918
+ }
7919
+ // ── ONE SIDE'S NAMES ARE ORDERED TOGETHER, NOT ONE AT A TIME ──────────
7920
+ // "Same side, several ports: the names serialize along the border in
7921
+ // SQUARE ORDER" is a property of the SIDE, and a greedy scorer cannot hold
7922
+ // it — it places one label at a time against what is already on the paper,
7923
+ // so the second name takes the cheapest gap rather than its own place in
7924
+ // the row. Measured: `patterns/topology-a`'s aggregation node came out
7925
+ // `p1 p3 p2` over squares in the order p1 p2 p3. Every one of those labels
7926
+ // is legible and two of them are wrong, which is worse than a collision:
7927
+ // a collision announces itself and a swapped pair does not.
7928
+ //
7929
+ // So each (node, side) group is projected ONCE, here, before any placement
7930
+ // is scored. The projection is the smallest order-preserving spreading of
7931
+ // the ideal positions: walk the group in square order, push each name just
7932
+ // far enough to clear the previous one, then slide the whole run back so
7933
+ // it stays centred on where the squares actually are. A side with one port
7934
+ // is unmoved by construction — its slot IS its square's centreline — so
7935
+ // this pass costs the ordinary figure nothing.
7936
+ //
7937
+ // Separation is measured in TEXT, not in squares: half of each of the two
7938
+ // names plus the 3 px standoff. Two fittings 8 px apart on a 36 px device
7939
+ // (three links into one small node, which is `05-lacp`'s DUT) cannot give
7940
+ // their names 8 px and be read as two names; the names take the room the
7941
+ // TEXT needs and the reader maps name to square by ORDER, which is exactly
7942
+ // what the order-preserving projection guarantees.
7943
+ {
7944
+ const sides=new Map();
7945
+ for(const r of lblReq){
7946
+ if(!r.port) continue;
7947
+ const S=portSq(r.port,r.p);
7948
+ const k=(r.port.id===undefined?'?':r.port.id)+'|'+S.side;
7949
+ if(!sides.has(k)) sides.set(k,[]);
7950
+ const D=lblDims(r);
7951
+ sides.get(k).push({r, u0:S.vert?S.cx:S.cy, ext:S.vert?D.w:D.h});
7952
+ }
7953
+ for(const g of sides.values()){
7954
+ if(g.length<2) continue;
7955
+ // stable: equal crossings keep registration (edge) order
7956
+ g.forEach((e,i)=>{e.i=i;});
7957
+ g.sort((a,b)=>a.u0-b.u0||a.i-b.i);
7958
+ let u=g[0].u0, sum=0;
7959
+ g[0].u=u;
7960
+ for(let i=1;i<g.length;i++){
7961
+ const sep=(g[i-1].ext+g[i].ext)/2+PORT_SQ_GAP;
7962
+ u=Math.max(g[i].u0, u+sep);
7963
+ g[i].u=u;
7964
+ }
7965
+ for(const e of g) sum+=e.u-e.u0;
7966
+ const shift=sum/g.length; // re-centre on the squares
7967
+ for(const e of g) e.r.portU=e.u-shift;
7968
+ }
7969
+ }
7074
7970
  const placed=[];
7075
7971
  // A request may name a SECOND carrying segment (`alt`). Back edges do: the
7076
7972
  // stub leaving the source is the preferred carrier because it says which
@@ -7167,8 +8063,19 @@ function renderScene(doc,y0){
7167
8063
  // already there) and never merely because outside happens to be tidy.
7168
8064
  const cs=[];
7169
8065
  if(r.port) for(const c of portCands(r)) cs.push({c,si:0});
7170
- for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]) for(const ep of eps)
7171
- cs.push({c:cand(r,t,sides[si],cl,ep),si,out:!!r.port});
8066
+ // THE OWN SQUARE TRAVELS WITH THE REQUEST, NOT WITH THE FAMILY
8067
+ // A port name driven all the way out to the along-shaft
8068
+ // family still names its fitting and must still be told apart from the
8069
+ // next one, so those candidates carry the same square the border family
8070
+ // does — the forbidden band, the affinity filter and the standoff rule
8071
+ // below then apply to every position this label can take, instead of
8072
+ // stopping at the border.
8073
+ const RSQ=r.port?portSq(r.port,r.p):null;
8074
+ for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]) for(const ep of eps){
8075
+ const c=cand(r,t,sides[si],cl,ep);
8076
+ if(RSQ){ c.sq=RSQ.box; c.sqk=RSQ.key; }
8077
+ cs.push({c,si,out:!!r.port});
8078
+ }
7172
8079
  for(const CS of cs){
7173
8080
  const c=CS.c, si=CS.si, t=(c.t===undefined?tPref:c.t);
7174
8081
  c.car=[r.p,r.q]; // the carrier this candidate rides (item 59)
@@ -7177,13 +8084,82 @@ function renderScene(doc,y0){
7177
8084
  // one fact it exists to state, and no amount of collision relief buys
7178
8085
  // that back. Candidates that fail are dropped; `bestAny` keeps the
7179
8086
  // least-bad one so a figure with no legal position still draws.
8087
+ // A DRAWN ASSOCIATION REPLACES AN INFERRED ONE. Every
8088
+ // filter in this pass is a proxy for one question — can the reader
8089
+ // tell what this word belongs to — and each answers it by PROXIMITY,
8090
+ // because proximity is all an unadorned label has. A port name past
8091
+ // the standoff cap is not unadorned: it takes a leader (below), and a
8092
+ // line from the word to the fitting states the association outright.
8093
+ // So `lead` is computed first and the proximity proxies stand down
8094
+ // for it. The BAND does not: a name written across a foreign fitting
8095
+ // is a wrong statement no leader can correct.
8096
+ const lead=!!(c.sq&&boxSep(c.box,c.sq)>STANDOFF_CAP);
7180
8097
  let owns=true;
7181
- if(r.kind==='end'&&r.other){
8098
+ if(r.kind==='end'&&r.other&&!lead){
7182
8099
  const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
7183
8100
  owns=Math.hypot(ccx-r.p[0],ccy-r.p[1])<Math.hypot(ccx-r.other[0],ccy-r.other[1]);
7184
8101
  }
8102
+ // A FOREIGN SQUARE'S BAND IS FORBIDDEN SPACE, NOT A PRICE
8103
+ // Once a fitting is drawn, a port name no longer names
8104
+ // "this end of this link" — it names THAT SQUARE, and the only thing
8105
+ // that says which square is proximity. On the first squared render of
8106
+ // `patterns/topology-a` the aggregation node's three names came out
8107
+ // `p1 p3 p2` over squares in the order p1 p2 p3, because the
8108
+ // free-space terms priced the second name into the gap past its own
8109
+ // fitting: every one of those labels is legible and two of the three
8110
+ // are wrong. No collision relief buys that back, so this is a filter,
8111
+ // exactly as endpoint affinity above is, and it has two halves that
8112
+ // apply to DIFFERENT candidates:
8113
+ // • the BAND — the foreign square grown by the 3 px standoff — is
8114
+ // space NO port name may enter, wherever it was going to be
8115
+ // written. A name across a neighbour's fitting is the wrong
8116
+ // statement whether it got there from the border ring or from the
8117
+ // shaft, so this half is unconditional.
8118
+ // • the AFFINITY — nearer a foreign square's centre than its own —
8119
+ // applies only AT THE BORDER, where proximity is the only thing
8120
+ // saying which square a name belongs to. Out on the shaft the
8121
+ // name is associated by the LINE it rides (and, past the standoff
8122
+ // cap, by a drawn leader), and item 42's own-endpoint filter
8123
+ // already governs that ring. Applying it there was built and
8124
+ // measured: on `05-lacp`, three names whose squares sit 14.5 px
8125
+ // apart have their own shaft running through the middle of the
8126
+ // 7 px window the affinity test leaves them, so every clean
8127
+ // position was disqualified and the figure took the strikes
8128
+ // instead (lblcol 3 -> 5).
8129
+ if(c.sq){
8130
+ const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
8131
+ const d0=Math.hypot(ccx-(c.sq.x+c.sq.w/2), ccy-(c.sq.y+c.sq.h/2));
8132
+ const G=PORT_SQ_GAP;
8133
+ for(const b of sqBox){
8134
+ if(b.key===c.sqk) continue;
8135
+ if(c.box.x<b.x+b.w+G&&b.x-G<c.box.x+c.box.w&&
8136
+ c.box.y<b.y+b.h+G&&b.y-G<c.box.y+c.box.h){ owns=false; break; }
8137
+ if(c.atBorder&&
8138
+ Math.hypot(ccx-(b.x+b.w/2),ccy-(b.y+b.h/2))<d0-1e-9){ owns=false; break; }
8139
+ }
8140
+ }
7185
8141
  let s=0;
7186
8142
  for(const b of placed) s+=3*ovl(c.box,b);
8143
+ // A NAME WRITTEN ACROSS ITS OWN FITTING is the one arrangement the
8144
+ // notation cannot survive, and unlike a foreign square this one is
8145
+ // PRICED rather than forbidden: the offset family already starts the
8146
+ // name clear of its own square, so the only way back onto it is the
8147
+ // corner clamp on a device too small to hold the name anywhere else —
8148
+ // and on that device the least-bad answer still has to exist. Charged
8149
+ // at the `placed` weight, because a drawn square is drawn ink of the
8150
+ // same order, and measured on the LINE box, because a 7 px square and
8151
+ // a 12 px line box brushing at all is already too close to read as
8152
+ // two marks.
8153
+ if(c.sq) s+=3*ovl(c.box,c.sq);
8154
+ // THE LEADER'S OWN PRICE, AND THE BAND IT HAS TO SIT IN. A leader is
8155
+ // extra ink and a second thing to follow, so it must be dearer than
8156
+ // every adjacency that needs none — and it must be CHEAPER THAN A
8157
+ // STRIKETHROUGH, which costs 26 here, or the pass would keep choosing
8158
+ // a name lying across a line over the same name standing clear with
8159
+ // its association drawn. 15 is the middle of that band and it is the
8160
+ // whole of the tuning: below 26 by construction, above the 0-12 px
8161
+ // displacement prices that separate one clean position from another.
8162
+ if(lead) s+=15;
7187
8163
  // A PORT MARKER IS SUPPOSED TO BE INSIDE ITS OWN BOX (item 57), so
7188
8164
  // that one node is not charged for it. Every other box still is, at
7189
8165
  // the usual weights — a marker that has slid out of its own device
@@ -7325,31 +8301,64 @@ function renderScene(doc,y0){
7325
8301
  // the 16 px anchor and the affinity filter, both of item 42's.
7326
8302
  if(r.kind!=='end'&&segBoxGap(c.car[0],c.car[1],c.box)>STANDOFF_CAP){
7327
8303
  if(s<farS-1e-9){ farS=s; farBest=c; } continue; }
8304
+ // A PORT NAME OWES ITS SQUARE THE SAME STANDOFF, AND IT
8305
+ // PAYS FOR IT WITH A LEADER RATHER THAN WITH A FILTER. Item 59 makes
8306
+ // the cap a filter for mid-labels because their displacement is
8307
+ // unbounded — a mid-label can wander anywhere along a 900 px channel.
8308
+ // A port name's family is bounded by construction (the border rings
8309
+ // are at the border; the shaft ring is 16-24 px along its own edge),
8310
+ // and the tier prices above — PORT_HUG then PORT_OUT — already order
8311
+ // it. Making the cap a filter HERE was built and measured, and the
8312
+ // corpus refused it: on `05-lacp` it disqualified the whole shaft ring
8313
+ // for six names whose only near positions lie across their own shaft,
8314
+ // and the figure took six strikethroughs rather than six clean names
8315
+ // 20 px out (lblcol 5 -> 7). So the cap does not decide WHERE the name
8316
+ // goes; it decides whether the association is drawn, below.
7328
8317
  if(s<anyS-1e-9){ anyS=s; anyBest=c; }
7329
8318
  if(owns&&s<bestS-1e-9){ bestS=s; best=c; }
7330
8319
  }
7331
8320
  }
7332
8321
  if(!best) best=anyBest||farBest||offBest; // no legal position — least bad
7333
8322
  r0.win=best; // kept for item 55's recolour pass
7334
- lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,r0.halo);
7335
- // THE LEADER (item 59). A mid-label that had to be placed past the cap
7336
- // states its line explicitly: a hairline from the nearest point of its
7337
- // box to the nearest point of its own carrying segment, in the label's
7338
- // own colour. It is drawn UNDER nothing and over nothing it is 1 px,
7339
- // it starts at the box and it stops at the shaft so it adds no ink
7340
- // anywhere the reader is not already looking for the association.
7341
- if(r0.kind!=='end'&&best.car&&segBoxGap(best.car[0],best.car[1],best.box)>STANDOFF_CAP){
8323
+ // A PORT NAME CARRIES ITS SQUARE'S KEY. The name and the
8324
+ // fitting it names are one statement drawn as two marks, and nothing in
8325
+ // the finished SVG said they were a pair a reader (and the layout
8326
+ // gate, which now scores a foreign name written across a fitting) had
8327
+ // only proximity to go on, which is the very thing the notation exists
8328
+ // to stop relying on. The key is the square's snapped centre, so the two
8329
+ // elements carry the same string and the association is READ.
8330
+ lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,
8331
+ r0.halo+(best.sqk?' data-port-sq="'+best.sqk+'"':''));
8332
+ // THE LEADER (item 59), AND ITS SECOND CALLER. A label that
8333
+ // had to be placed past the cap states its referent explicitly: a
8334
+ // hairline from the nearest point of its box to that referent, in the
8335
+ // label's own colour. It is drawn UNDER nothing and over nothing — it is
8336
+ // 1 px, it starts at the box and it stops at the thing — so it adds no
8337
+ // ink anywhere the reader is not already looking for the association.
8338
+ //
8339
+ // TWO REFERENTS, ONE LEADER. A mid-label's is the nearest point of its
8340
+ // own carrying segment; a port name's is the CENTRE OF ITS OWN SQUARE,
8341
+ // because that is the mark it names. The drawing is otherwise identical
8342
+ // and is therefore drawn by one closure rather than copied — a second
8343
+ // leader mechanism is a second set of rules about when a leader appears,
8344
+ // and this figure only ever needs one.
8345
+ const leadTo=(fx,fy)=>{
7342
8346
  const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
7343
- const P0=best.car[0], Q0=best.car[1];
7344
- const vx=Q0[0]-P0[0], vy=Q0[1]-P0[1], L2=vx*vx+vy*vy;
7345
- const t=L2?Math.max(0,Math.min(1,((cx-P0[0])*vx+(cy-P0[1])*vy)/L2)):0;
7346
- const fx=P0[0]+t*vx, fy=P0[1]+t*vy;
7347
- // the box's own boundary point on the ray toward that foot
8347
+ // the box's own boundary point on the ray toward the referent
7348
8348
  const dx=fx-cx, dy=fy-cy, ax=Math.abs(dx)||1e-9, ay=Math.abs(dy)||1e-9;
7349
8349
  const k=Math.min((b.w/2+2)/ax,(b.h/2+2)/ay);
7350
8350
  r0.leadIdx=lblsvg.length;
7351
8351
  lblsvg.push('<line x1="'+(cx+dx*k)+'" y1="'+(cy+dy*k)+'" x2="'+fx+'" y2="'+fy+
7352
8352
  '" stroke="'+r0.col+'" stroke-width="1" opacity="0.6"/>');
8353
+ };
8354
+ if(r0.kind!=='end'&&best.car&&segBoxGap(best.car[0],best.car[1],best.box)>STANDOFF_CAP){
8355
+ const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
8356
+ const P0=best.car[0], Q0=best.car[1];
8357
+ const vx=Q0[0]-P0[0], vy=Q0[1]-P0[1], L2=vx*vx+vy*vy;
8358
+ const t=L2?Math.max(0,Math.min(1,((cx-P0[0])*vx+(cy-P0[1])*vy)/L2)):0;
8359
+ leadTo(P0[0]+t*vx, P0[1]+t*vy);
8360
+ } else if(best.sq&&boxSep(best.box,best.sq)>STANDOFF_CAP){
8361
+ leadTo(best.sq.x+best.sq.w/2, best.sq.y+best.sq.h/2);
7353
8362
  }
7354
8363
  placed.push(Object.assign({text:r0.text},best.box));
7355
8364
  W=Math.max(W, best.box.x+best.box.w+4);
@@ -7866,7 +8875,7 @@ function renderScene(doc,y0){
7866
8875
  used[pick]++;
7867
8876
  }
7868
8877
  if(tone.size>1) for(const [e2,col2] of tone){
7869
- const key='data-edge="'+e2.line+'"';
8878
+ const key='data-edge="'+edgeRef(e2)+'"';
7870
8879
  for(let i=0;i<esvg.length;i++)
7871
8880
  if(esvg[i].indexOf(key)>=0) esvg[i]=esvg[i].replace('stroke="#555"','stroke="'+col2+'"');
7872
8881
  for(const i of (arrowIdx.get(e2)||[]))
@@ -7907,7 +8916,8 @@ function renderScene(doc,y0){
7907
8916
  } else lblsvg.push(textEl(cx,cy-10,10,'middle',bcol,n.label,bhalo));
7908
8917
  continue;
7909
8918
  }
7910
- nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'" style="cursor:move">');
8919
+ nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'"'
8920
+ +(lassoMem.has(n.id)?' data-lasso="'+lassoMem.get(n.id).join(' ')+'"':'')+' style="cursor:move">');
7911
8921
  const fill=n.fill||'#fff', stroke=n.stroke||'#8a8880', txt=labelInk(fill,'#1d1d1b');
7912
8922
  const ndash=n.style==='dashed'?' stroke-dasharray="6 4"':(n.style==='dotted'?' stroke-dasharray="2 4"':'');
7913
8923
  if(n.shape==='diamond'){
@@ -7969,7 +8979,7 @@ function renderScene(doc,y0){
7969
8979
  // ONE MEMBER = NO ELLIPSE (item 53). The caption below is the whole
7970
8980
  // drawing, and an unlabelled one-member bundle draws nothing.
7971
8981
  if(!R.solo){
7972
- 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+'/>');
8982
+ tsvg.push('<ellipse cx="'+rnd3(R.cx)+'" cy="'+rnd3(R.cy)+'" rx="'+rnd3(R.rL)+'" ry="'+rnd3(R.rA)+'" data-lasso="'+t.id+'" fill="transparent" stroke="'+(t.stroke||col)+'"'+dashOf(t.style,'6 4')+' stroke-width="1.6"'+rot+'/>');
7973
8983
  W=Math.max(W,R.cx+R.hw); Hh=Math.max(Hh,R.cy+R.hh-y0-20);
7974
8984
  }
7975
8985
  const L=ringLbl.get(t);
@@ -8079,7 +9089,7 @@ function renderScene(doc,y0){
8079
9089
  ' paint-order="stroke" stroke="#fff" stroke-width="3"'));
8080
9090
  }
8081
9091
  const yEnd=y0+20+Hh+10;
8082
- return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
9092
+ return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+sqsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
8083
9093
  meta:{W:W, top:y0+20+chShift+tShift, Hh:Hh, left:bShift+lShift}, errs:gErrs};
8084
9094
  }
8085
9095
  // borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
@@ -8631,6 +9641,26 @@ function renderSequence(doc,y0){
8631
9641
  // would put a 17-digit tail in the artifact for no reader's benefit.
8632
9642
  function r2(v){ return Math.round(v*100)/100; }
8633
9643
 
9644
+ // `TYPED-BLOCK-TITLE-CANVAS` (backlog 66): A TYPED BLOCK'S TITLE JOINS ITS OWN CANVAS.
9645
+ //
9646
+ // Each of the four typed-block renderers (bitfield/table/timing/chart) sizes
9647
+ // its section from its DATA — cells, ruler, lanes, floor — and draws its own
9648
+ // caption over that canvas afterwards, at x=0, without ever measuring the
9649
+ // caption into the width it returns. A caption wider than the data therefore
9650
+ // ran past the right edge of the section's own `<svg>` box, and a section
9651
+ // canvas grows right and down only, so text at a negative coordinate is
9652
+ // CLIPPED, never merely misplaced (`LABEL-PLACEMENT-METRIC`, which fixed exactly this
9653
+ // for a `table` caption and a `chart` row/column label gutter but not for a
9654
+ // bitfield/timing title or a chart's own top caption — production field
9655
+ // report FR-4, decisions/registry.md).
9656
+ //
9657
+ // ONE MEASUREMENT, used by all four renderers below, so the calibration lives
9658
+ // in one place rather than four. Bold text at this size is measured ~8%
9659
+ // wider than the plain per-character estimate `cwMax(...)*CH` gives at
9660
+ // regular weight — verified against the raster, not assumed, the same
9661
+ // allowance `renderTable`'s caption fix already established.
9662
+ function typedBlockTitleW(label){ return cwMax(label)*CH*1.08+2; }
9663
+
8634
9664
  // ---- bitfield ----
8635
9665
  function renderBitfield(b,y0){
8636
9666
  const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
@@ -9061,7 +10091,12 @@ function renderBitfield(b,y0){
9061
10091
  }
9062
10092
  yb+=2;
9063
10093
  }
9064
- return {svg:svg.join(''), y:yb, w:wb,
10094
+ // THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE CAPTION IS INK (`TYPED-BLOCK-TITLE-CANVAS`).
10095
+ // `wb` is the DATA extent alone (cells, ruler, `present=` captions) — the
10096
+ // BOX below stays measured against it, because a region-scope
10097
+ // `threshold`/`band` is a statement about the data, not about the title
10098
+ // that names it. Only the returned canvas width grows for the caption.
10099
+ return {svg:svg.join(''), y:yb, w:Math.max(wb, typedBlockTitleW(b.label)),
9065
10100
  box:{x0:0, x1:wb, yA:y0+18, yB:yb}};
9066
10101
  }
9067
10102
 
@@ -9178,9 +10213,10 @@ function renderTable(t,y0){
9178
10213
  // grid is not the figure; the caption is not decoration.
9179
10214
  // Bold at 13 px is wider than `CH` (a regular-weight advance), so the caption
9180
10215
  // is measured with the same 8% allowance the raster needed — verified by
9181
- // rendering, not assumed.
9182
- const capW=cwMax(t.label)*CH*1.08+2;
9183
- return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,capW),
10216
+ // rendering, not assumed. `typedBlockTitleW` (`TYPED-BLOCK-TITLE-CANVAS`) is that
10217
+ // same measurement, now shared by all four typed-block renderers rather
10218
+ // than kept as this one's own private copy.
10219
+ return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,typedBlockTitleW(t.label)),
9184
10220
  box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
9185
10221
  }
9186
10222
 
@@ -9339,7 +10375,14 @@ function renderChart(b,y0,doc){
9339
10375
  svg.push(textEl(zr[0]+21, zr[1]-z*ZS+3.5, 9.5, 'start', '#6f6e69', String(z)));
9340
10376
  }
9341
10377
  const near=[PR(R,C,0)[0]+ox, PR(R,C,0)[1]];
9342
- const w=Math.max(near[0]+70, farRight+ox+4);
10378
+ // THE TOP CAPTION IS THE SECTION'S OWN INK TOO (`TYPED-BLOCK-TITLE-CANVAS`). The
10379
+ // row/column axis-label gutter (`ox`, above) was widened for exactly this
10380
+ // reason, but that pass measured the LEANED-OUT axis labels
10381
+ // and never the caption drawn at the section's fixed x=0 top-left corner —
10382
+ // a `chart` whose table name is long enough still ran the caption off the
10383
+ // right edge under its own data floor. The caption is never shifted (it is
10384
+ // already the section's leftmost, topmost ink); only the canvas grows.
10385
+ const w=Math.max(near[0]+70, farRight+ox+4, typedBlockTitleW(t.label+' — bar3d'));
9343
10386
  const hgt=Math.max(near[1]+24, farDown+6)-y0;
9344
10387
  return {svg:svg.join('')+lsvg.join(''), y:y0+hgt, w:w};
9345
10388
  }
@@ -9396,7 +10439,12 @@ function renderTiming(w,y0){
9396
10439
  svg.push('<path d="M'+x+','+(y+4)+' q4,'+(hTotal/4)+' 0,'+(hTotal/2)+' q-4,'+(hTotal/4)+' 0,'+(hTotal/2)+'" fill="none" stroke="#999" stroke-width="2"/>');
9397
10440
  }
9398
10441
  const H=y+8+w.signals.length*(laneH+laneGap);
9399
- return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2,
10442
+ // THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE TITLE IS INK (`TYPED-BLOCK-TITLE-CANVAS`,
10443
+ // 0.5). `nameW+cycles*cycleW+2` is the DATA extent alone (the
10444
+ // signal-name gutter plus the lane grid); the BOX below stays measured
10445
+ // against it, unchanged, for the same reason a table's region box stays
10446
+ // measured against the grid and not the caption.
10447
+ return {svg:svg.join(''), y:H, w:Math.max(nameW+cycles*cycleW+2, typedBlockTitleW(w.label)),
9400
10448
  box:{x0:nameW, x1:nameW+cycles*cycleW, yA:y0+18, yB:H}};
9401
10449
  }
9402
10450
 
@@ -9474,14 +10522,27 @@ function __stackSectionSvgs(results) {
9474
10522
  // the default does NOT (embedded figures almost always sit under the
9475
10523
  // host document's caption — the majority case).
9476
10524
  // Multi-section sources are stacked vertically into a single SVG (MULTI-FIGURE-DOCUMENTS).
10525
+ //
10526
+ // TWO ERROR CHANNELS REACH ONE errors ARRAY. parse cannot see a
10527
+ // coordinate, so a document whose SOURCE is impeccable can still draw a false
10528
+ // statement — a group band enclosing a non-member, a pin covering a node
10529
+ // completely. The engine reports those from render (as .errs on its render
10530
+ // result), and core §8 requires a caller to treat a non-empty render
10531
+ // diagnostic list EXACTLY as it treats a parse error list. Until 0.4
10532
+ // this wrapper discarded that channel and returned errors: [] with an SVG of
10533
+ // the picture the engine had just said was wrong — the one copy of the engine
10534
+ // a require('figdown') user actually gets. Both channels now land here, and
10535
+ // either withholds the SVG.
9477
10536
  function render(text, opts) {
9478
10537
  var p = parse(text);
9479
10538
  if (p.errors.length) return { svg: null, errors: p.errors };
9480
- if (p.docs.length > 1) {
9481
- var rs = p.docs.map(function (d) { return __engine.render(d, opts); });
9482
- return { svg: __engine.stackSectionSvgs(rs), errors: [] };
9483
- }
9484
- return { svg: __engine.render(p.doc, opts).svg, errors: [] };
10539
+ if (!p.docs.length) return { svg: null, errors: [] };
10540
+ var rs = p.docs.map(function (d) { return __engine.render(d, opts); });
10541
+ var errs = [];
10542
+ for (var i = 0; i < rs.length; i++) errs = errs.concat(rs[i].errs || []);
10543
+ if (errs.length) return { svg: null, errors: errs };
10544
+ var svg = rs.length > 1 ? __engine.stackSectionSvgs(rs) : rs[0].svg;
10545
+ return { svg: svg, errors: [] };
9485
10546
  }
9486
10547
  // renderDoc(doc, opts) -> svg string, for an already-validated doc from parse().
9487
10548
  // For multi-section, pass parse().docs to renderDocs instead.
@@ -9494,10 +10555,13 @@ function renderDocs(docs, opts) {
9494
10555
  return __engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); }));
9495
10556
  }
9496
10557
  // artifact(text) -> { svg, errors } svg is the full self-carrying SVG:
9497
- // the render plus a <metadata id="figdown-source"> block embedding the
9498
- // source text, the SHA-256 OF THAT SOURCE, and the engine version that
9499
- // rendered it (same convention as tools/build-svg.js; spec core §7).
9500
- // svg is null when there are errors.
10558
+ // the render plus a <metadata id="figdown-source"> block
10559
+ // embedding the source text, the SHA-256 OF THAT SOURCE, and the engine
10560
+ // version that rendered it (same convention as tools/build-svg.js; spec §7).
10561
+ // svg is null when there are errors — parse-time OR geometry-time, on
10562
+ // tools/build-svg.js's contract (core §8): a non-empty render diagnostic list
10563
+ // refuses the artifact exactly as a parse error does, because writing it
10564
+ // anyway publishes the picture the engine has just said is wrong.
9501
10565
  function artifact(text, opts) {
9502
10566
  var src = String(text);
9503
10567
  var p = render(src, opts);