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.js CHANGED
@@ -1,4 +1,4 @@
1
- // figdown.js — FigDown embeddable library (0.4.1)
1
+ // figdown.js — 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
  (function (root, factory) {
@@ -10,7 +10,7 @@
10
10
  }
11
11
  }(typeof globalThis !== 'undefined' ? globalThis : this, function () {
12
12
  'use strict';
13
- var VERSION = "0.4.1";
13
+ var VERSION = "0.5.0";
14
14
 
15
15
  // ---- engine (extracted verbatim from editor/figdown.html) ----
16
16
  var __engine = (function () {
@@ -24,7 +24,38 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
24
24
  // input to that promise, and under core §13 a 0.x renderer may differ from
25
25
  // the next — which makes the recorded version the only thing that can
26
26
  // explain a diff between two renderings of one source.
27
- const FIGDOWN_VERSION = '0.4.1';
27
+ const FIGDOWN_VERSION = '0.5.0';
28
+ // `TYPED-BLOCK-TITLE-CANVAS`: a `Z`-only dev bump — RENDERER ONLY, no keyword, no
29
+ // option key, no model field, so `figdown 0.5` still names one language. A
30
+ // typed block's own title (bitfield/table/timing/chart) now joins the
31
+ // section's canvas computation: the returned width is the union of the data
32
+ // extent and the title extent (`typedBlockTitleW`, shared by all four
33
+ // renderers), so a title wider than the data widens the canvas instead of
34
+ // running off it at x=0. Fixes backlog item 66 (FR-4). Corpus impact
35
+ // surveyed first: zero shipped drawings change (every typed-block title in
36
+ // the corpus already fit its data-derived canvas); the fix only reaches
37
+ // fixtures the corpus does not yet have. See spec/migrations.md.
38
+ // 0.5: EDITOR ONLY — no keyword, no option key, no model field moves.
39
+ // A genre-aware property inspector (buildInspector) for document/node/edge/
40
+ // group; edge/message editing now goes through the engine's own
41
+ // scanConnectorLine spans instead of a second grammar; quote-aware option
42
+ // targeting (authoredOptionSpan/optionSpanOutsideLabel) closes the
43
+ // label-corruption class; every layout emitter gains a sectionIndex
44
+ // parameter; tools/editor-check.js grows from 91 to 140 checks; and canvas
45
+ // hit-testing now excludes `[data-lasso]` (`LASSO-ENCLOSURE-TRUTH`) beside `[data-port-sq]`
46
+ // in the stopPropagation loop and both closest(...) exclusion
47
+ // lists — the lasso ellipse landed after this batch's own hit-test baseline
48
+ // was written. See spec/migrations.md for the full entry.
49
+ // `LASSO-ENCLOSURE-TRUTH`: a `Z`-only dev bump. One new geometry-time diagnostic (a
50
+ // bundle's lasso enclosing a non-member), one new `data-*` attribute on the
51
+ // output (`data-lasso`), and one more constraint on the separation pass — no
52
+ // keyword, no option key, no model field, so `figdown 0.5` still names one
53
+ // language. See the lasso-containment rule beside the group-band pass below.
54
+ // `CLASS-CHANNEL-COLLISION`: a `Z`-only dev bump, the same shape as `MEMBER-LIST-DUPLICATION`'s — one new
55
+ // diagnostic (two carried classes binding the same paint channel on one
56
+ // element), no keyword, no option key, no model field, so `figdown 0.5`
57
+ // still names one language. See the `clsChan` collision check below for the
58
+ // rule itself.
28
59
  // `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
29
60
  // counter does NOT reset (core §13.0.4 — `N` counts source states of the
30
61
  // engine and only ever increases), so 0.1 is followed by
@@ -44,7 +75,14 @@ const FIGDOWN_VERSION = '0.4.1';
44
75
  // makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
45
76
  // no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
46
77
  // the dispatch point lands first and the vocabulary follows it.
47
- const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4'];
78
+ // `CONNECTOR-IDENTITY-KEY`: `figdown 0.5` joins the set, and the dev line crosses to
79
+ // 0.5 exactly the way it crossed to 0.2 at `STATECHART-GENRE-SCOPE` — the counter does NOT reset
80
+ // (core §13.0.4: `N` counts source states of the engine and only ever
81
+ // increases), so 0.4 is followed by 0.5. What moves the
82
+ // language number is `id=` on the four scene connectors: core §13.0 makes an
83
+ // added option key a `Y` and never a `Z`, because `figdown 0.4` must not name
84
+ // two languages — the one v0.4 published and the one with `id=`.
85
+ const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4', '0.5'];
48
86
  // Genres per declared language version. `Y` never removes (core §13.0), so
49
87
  // each row is a superset of the one above it, and `figdown 0.1 <anything>`
50
88
  // resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
@@ -66,7 +104,13 @@ const GENRES_BY_VERSION = {
66
104
  // it. The genre still has NO RENDERER: a valid `sequence` document parses to
67
105
  // a model and draws an empty canvas, which is the state this increment means
68
106
  // to land and is pinned by a fixture rather than left to be noticed.
69
- '0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
107
+ '0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence'],
108
+ // `CONNECTOR-IDENTITY-KEY`: `0.5` adds NO genre. It is here because the row must
109
+ // exist for every accepted version — a genre list is looked up by the
110
+ // declared version and an absent row would narrow nothing — and it is a copy
111
+ // of `0.4`'s because `Y` never removes and this release adds an OPTION KEY,
112
+ // not a dispatch point. The version moved for `id=`, and `id=` is genre-free.
113
+ '0.5': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
70
114
  };
71
115
  // The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
72
116
  // device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
@@ -77,8 +121,23 @@ const GENRES_BY_VERSION = {
77
121
  // Accepting it silently under a `figdown 0.2` header would repaint a document
78
122
  // whose author meant a never-drawn tooltip as one that puts ink on the page —
79
123
  // core §13.0.1's named hazard, "a figure that looks right and means something
80
- // else". A key that had never been spelled before would carry no such risk.
81
- const OPT_MIN_VERSION={note:'0.3'};
124
+ // else".
125
+ //
126
+ // THE SENTENCE THAT USED TO CLOSE THIS COMMENT IS WITHDRAWN (`CONNECTOR-IDENTITY-KEY`,
127
+ // 0.5). It read: "A key that had never been spelled before would carry
128
+ // no such risk." It is kept visible here, as core §13.7.2 keeps it, because a
129
+ // deleted mistake teaches nothing — but it is not the rule. `note=`'s prior
130
+ // meaning is why its DIAGNOSTIC says what it says; it was never why the gate
131
+ // exists. The gate engages on ADDING A SPELLING TO THE ACCEPTED SURFACE: a
132
+ // document's header is the contract it is read against, `read/<X.Y>/` is the
133
+ // frozen text of that contract, and `archive/`'s own engine for a version would
134
+ // refuse a key that version never named. Silently accepting a newer key under
135
+ // an older header makes the declared version stop determining the surface,
136
+ // which is the declaration decaying into a comment. So EVERY new option key
137
+ // gates, and `id=` (`CONNECTOR-IDENTITY-KEY`) is the first key gated under the restated rule.
138
+ // `CONNECTOR-IDENTITY-KEY`: `id=` — the connector's optional handle. It has no prior
139
+ // meaning of any kind; it is gated because it is a new spelling.
140
+ const OPT_MIN_VERSION={note:'0.3',id:'0.5'};
82
141
  // True when the document's declared version is older than the key's own.
83
142
  // A document with no parsable header has already been diagnosed on line 1, so
84
143
  // an absent version never gates a second time.
@@ -305,7 +364,13 @@ function splitList(t,off){
305
364
  // value grammar is a range, so it is also the key that fixes the language's
306
365
  // ONE range spelling at `..` (`RANGE-SPELLING` moves `band` off the hyphen in the same
307
366
  // release).
308
- const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in','plane','layer','label',
367
+ // `CONNECTOR-IDENTITY-KEY`: `id` joins the registry as the CONNECTOR's optional
368
+ // handle — the one element kind in the language that had no way to be named.
369
+ // RULE 4.1 is satisfied without a coinage: `id` is already the EBNF production
370
+ // name and the model field name for every other element's handle, so minting a
371
+ // second word for it would have been the violation. Its acceptors are the four
372
+ // scene connectors and nothing else (DIRECTIVE_OPTS below).
373
+ const OPT_KEYS=new Set(['id','kind','type','shape','fill','color','stroke','text','in','plane','layer','label',
309
374
  'style','z','z-index','at','offset','w','h','width','height','unit','word','note','description','present','index','labels','data','numbering','from','to','gap',
310
375
  'dir','extend','level','taillabel','headlabel','class','via','points','routing','src','dst','tailport','headport']);
311
376
  // Applicable option keys per directive. Keys with dedicated diagnostics
@@ -368,12 +433,16 @@ const DIRECTIVE_OPTS={
368
433
  // empty array is the declaration: every key falls through to the generic
369
434
  // `external does not take <k>=`.
370
435
  external:[],
371
- edge:['style','class','fill','stroke','label','taillabel','headlabel','note'],
436
+ // `CONNECTOR-IDENTITY-KEY`: `id` is on all four connector rows. Under `GENRE-VOCABULARY-OBLIGATION`/`SUBJECT-VOCABULARY-SCOPE` an
437
+ // option key is declared per genre, so the acceptor list is four separate
438
+ // declarations of one key — the `note=` and `class=` pattern exactly, and for
439
+ // the same reason: the referent is THIS CONNECTOR, which is genre-independent.
440
+ edge:['style','class','fill','stroke','label','taillabel','headlabel','note','id'],
372
441
  // `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: same rename argument — the connector's option set is one set
373
442
  // under three spellings, listed three times only because the tables are
374
443
  // keyed by the surface word an author actually wrote.
375
- flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
376
- transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
444
+ flowline:['style','class','fill','stroke','label','taillabel','headlabel','note','id'],
445
+ transition:['style','class','fill','stroke','label','taillabel','headlabel','note','id'],
377
446
  // `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's four own rows. `message` is
378
447
  // the fourth connector spelling and takes the connector set — `fill=` and
379
448
  // the three retired label keys stay listed for the same reason they are
@@ -382,7 +451,14 @@ const DIRECTIVE_OPTS={
382
451
  // message occurs inside) and `description=`. It does NOT gain a key of its
383
452
  // own: `lost=` was proposed and refused (`UNDELIVERED-MESSAGE-MARKING`), and `OPT_KEYS` is unchanged
384
453
  // by this whole increment.
385
- message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description'],
454
+ // `CONNECTOR-IDENTITY-KEY`: `message` is the FOURTH connector and takes `id=` with
455
+ // the other three. The proposal recommended refusing it here; the ruling
456
+ // widened the acceptor list, and the reason it can is that an id is a HANDLE
457
+ // and nothing else. It states no order, no equivalence and no identity
458
+ // between two occurrences — §6.4's occurrence-identity question stays shut,
459
+ // and the diff still aligns messages as a multiset, because naming a thing is
460
+ // not the same act as claiming two things are one.
461
+ message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description','id'],
386
462
  // A lifeline is drawn as a head box over a dashed line, so it has an
387
463
  // interior and takes `fill=`. `in=` is sense 1.
388
464
  lifeline:['class','fill','stroke','style','in','note','description'],
@@ -592,6 +668,22 @@ const NOTE_VERSION=(have)=>
592
668
  'annotation: an explanation the human reader must SEE. Raise the header to '+
593
669
  'figdown 0.3, or write description= if you meant prose only a machine reads '+
594
670
  '(MIGRATIONS 0.3)';
671
+ // `CONNECTOR-IDENTITY-KEY`: the SAME device for `id=`, and the message says what the
672
+ // restated rule says. `note=`'s gate names a prior meaning because `note=` HAS
673
+ // one; `id=` has none, and it is gated anyway — the gate engages on adding a
674
+ // spelling to the accepted surface, not on what the spelling used to mean. So
675
+ // this message argues from the declaration rather than from repainting: the
676
+ // header is the contract, `read/<X.Y>/` is that contract's frozen text, and the
677
+ // archived engine for a version would refuse a key that version never named.
678
+ // The one-step fix is named, exactly as `KEYWORD-RENAME-SCOPE`'s device requires.
679
+ const ID_VERSION=(have)=>
680
+ 'id= requires figdown 0.5 (this document declares '+have+'): a connector has '+
681
+ 'no id= spelling under figdown '+have+', so an engine held to the version '+
682
+ 'this document declares — the archived '+have+' engine, or a reader working '+
683
+ 'from the frozen read/'+have+' contract — would refuse this line. A header '+
684
+ 'that stops determining what the language accepts is a comment, not a '+
685
+ 'declaration. Raise the header to figdown 0.5, or delete the key: an '+
686
+ 'anonymous connector stays legal and claims nothing less (MIGRATIONS 0.5)';
595
687
  // (b) The directive is `field`, which refuses the key at EVERY version. The
596
688
  // bitfield genre already has `description=` for machine-facing prose, and
597
689
  // no measured figure needs a DRAWN per-field aside — granting a directive
@@ -778,7 +870,7 @@ const DD_ID='"--" is not allowed inside an id — it is the link operator (edge
778
870
  // gates necessity.
779
871
  // whitespace-delimited string position -> quotes MANDATORY
780
872
  // comma-delimited list element -> quotes only when the element
781
- // contains whitespace , " or #
873
+ // contains whitespace , " ( ) or #
782
874
  // (redundant quotes stay legal)
783
875
  // [ ] edge label -> quotes only to enable escapes
784
876
  // The whitespace row is not a style preference: whitespace is ALSO the
@@ -790,6 +882,20 @@ const Q_WHY='whitespace also separates positionals, so a bare token cannot expre
790
882
  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"';
791
883
  // isId: the whole id test, used at every id position in the language.
792
884
  const isId=v=>typeof v==='string'&&ID_RE.test(v)&&!v.includes('--');
885
+ // `CONNECTOR-IDENTITY-KEY`: what `data-edge` carries (core §7). It is the AUTHORED
886
+ // id where the connector has one, and the 1-based source line where it does
887
+ // not. The two can never be confused: an id must start with a letter or an
888
+ // underscore, so no id is a decimal number and no line number is an id.
889
+ //
890
+ // The change is to the VALUE, not to the attribute: `data-edge` stays the one
891
+ // name in the profile's `data-*` enumeration and `gate:safesvg` is untouched.
892
+ // What it buys is the thing ADV-20 said the channel could not honestly supply —
893
+ // inserting a line at the top of a document changes every source-line
894
+ // `data-edge` in it, which is a handle that moves when nothing about the figure
895
+ // moved. For a named connector it no longer does; for an anonymous one the
896
+ // attribute keeps its old job and its old caveat, and the fallback is stated
897
+ // rather than implied.
898
+ const edgeRef=e=>(e && e.id!==undefined && e.id!==null) ? e.id : e.line;
793
899
  // idErr(value, quotedFlag, missingMsg) -> the message for an id position, or
794
900
  // null when the id is well formed. `missing` fires only when nothing at all
795
901
  // was written; a written-but-illegal id always gets ID_RULE or DD_ID, never
@@ -896,7 +1002,7 @@ function parseClassList(v,els){
896
1002
  // core §12.2 and genres/bitfield.md, pinned by golden 420.
897
1003
  //
898
1004
  // Returns {ok, val, err}. `val` is the model shape: an object with `first`
899
- // and `last` being a NUMBER when the run is determinate and a STRING
1005
+ // and `last`, `last` being a NUMBER when the run is determinate and a STRING
900
1006
  // when it is prose. `index=""` yields `{}` — a written value that claims
901
1007
  // repetition and states no index at all.
902
1008
  const IDX_INT=/^\d+$/;
@@ -1426,6 +1532,74 @@ function seqModel(doc){
1426
1532
  return {rows,cont,owned,extent,chain,cycles};
1427
1533
  }
1428
1534
 
1535
+ // `XML-CHARACTER-LEGALITY` (core §15.5) — XML-ILLEGAL CHARACTERS ARE A LINE ERROR.
1536
+ //
1537
+ // Every artifact is an XML document (core §7 embeds the source in one), and
1538
+ // XML 1.0 forbids a fixed set of code points OUTRIGHT: they cannot appear
1539
+ // literally, and they cannot be written as a character reference either, so
1540
+ // there is no escaping that rescues them. Until this rule the engine passed
1541
+ // them through: a label containing a raw U+0000 parsed clean, rendered clean,
1542
+ // and produced an `.svg` that NO conforming XML reader will open. Nothing
1543
+ // caught it — not the parser, which had no opinion, and not any gate, because
1544
+ // every document in the tree was clean.
1545
+ //
1546
+ // Refusing is the §8 principle, not a new one: a document with errors renders
1547
+ // NOTHING, because a drawing the engine cannot stand behind is worse than no
1548
+ // drawing. An artifact that is not well-formed is exactly that case — the
1549
+ // figure is unreadable by the machine reader the whole format exists to serve
1550
+ // — so it costs a line error, at parse time, before anything is drawn.
1551
+ // Determinism over convenience.
1552
+ //
1553
+ // THE SET, and why it is stated as ranges rather than "control characters":
1554
+ // U+0000–U+0008, U+000B, U+000C, U+000E–U+001F C0 controls, EXCEPT
1555
+ // U+0009 tab, U+000A LF and
1556
+ // U+000D CR, which are legal
1557
+ // and stay legal
1558
+ // U+FFFE, U+FFFF permanently unassigned
1559
+ // an UNPAIRED surrogate (U+D800–U+DFFF) not a character at all; a
1560
+ // correctly paired astral
1561
+ // character is legal and is
1562
+ // NOT flagged
1563
+ // U+007F DEL and the C1 range are legal XML 1.0 characters and are NOT
1564
+ // refused — this rule enforces XML's list, not a taste for printability.
1565
+ const XML_ILLEGAL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/;
1566
+ function xmlIllegalErrors(text){
1567
+ const out=[];
1568
+ const lines=String(text).split('\n');
1569
+ for(let li=0; li<lines.length; li++){
1570
+ const line=lines[li];
1571
+ const seen=new Set();
1572
+ for(let i=0;i<line.length;i++){
1573
+ const c=line.charCodeAt(i);
1574
+ let bad=false;
1575
+ if(XML_ILLEGAL.test(line[i])) bad=true;
1576
+ else if(c>=0xD800&&c<=0xDBFF){ // high surrogate: needs a low one
1577
+ const n=line.charCodeAt(i+1);
1578
+ if(!(n>=0xDC00&&n<=0xDFFF)) bad=true; else i++;
1579
+ }
1580
+ else if(c>=0xDC00&&c<=0xDFFF) bad=true; // lone low surrogate
1581
+ if(!bad) continue;
1582
+ // One error per DISTINCT character per line. A 1 MB label of NULs is one
1583
+ // defect and one fix, and 1 000 000 identical lines would bury every
1584
+ // other diagnostic in the pass — the same reason §8.1 caps duplication.
1585
+ const cp='U+'+c.toString(16).toUpperCase().padStart(4,'0');
1586
+ if(seen.has(cp)) continue;
1587
+ seen.add(cp);
1588
+ // The tail names the RULE the character broke, and there are two rules,
1589
+ // so there are two tails. One generic sentence would be wrong for half
1590
+ // the set — "only tab, newline and carriage return are allowed" says
1591
+ // nothing true about U+FFFF — and a diagnostic that misdescribes the
1592
+ // rule sends the author to the wrong fix.
1593
+ const tail = c<0x20
1594
+ ? ' (below U+0020 only tab, newline and carriage return are allowed)'
1595
+ : ' (U+FFFE, U+FFFF and unpaired surrogates are not characters — no escape writes one)';
1596
+ out.push('Line '+(li+1)+': illegal character '+cp+
1597
+ ' — XML forbids it, so the artifact could not be well-formed'+tail);
1598
+ }
1599
+ }
1600
+ return out;
1601
+ }
1602
+
1429
1603
  // parse(text) -> {doc, errs, docs}
1430
1604
  // Single-section: docs=[doc] (backward-compatible doc/errs).
1431
1605
  // Multi-section: one doc per figdown header; errs use full-file line numbers;
@@ -1438,12 +1612,20 @@ function parse(text){
1438
1612
  // makes the normative rule ("a BOM at the very start of the document is
1439
1613
  // ignored") implementable from the spec alone. Behaviour is unchanged.
1440
1614
  text=String(text).replace(/^\uFEFF/,'');
1615
+ // `XML-CHARACTER-LEGALITY`: the XML-illegal-character sweep runs HERE \u2014 over the whole document,
1616
+ // before it is split into sections \u2014 for two reasons. Its line numbers are
1617
+ // already full-file, which is what every other message is re-based to below;
1618
+ // and the rule is about the SOURCE, not about any directive, so it must see
1619
+ // comments and blank lines too. Its findings JOIN the pass rather than
1620
+ // replacing it (\u00A78: all errors report in one pass), so an author fixing a
1621
+ // NUL still sees the typo on the next line in the same run.
1622
+ const xmlErrs=xmlIllegalErrors(text);
1441
1623
  const secs=splitFigdownSections(text);
1442
1624
  if(!secs){
1443
1625
  const r=parseOne(String(text));
1444
- return {doc:r.doc, errs:r.errs, docs:[r.doc]};
1626
+ return {doc:r.doc, errs:xmlErrs.concat(r.errs), docs:[r.doc]};
1445
1627
  }
1446
- const docs=[]; const errs=[];
1628
+ const docs=[]; const errs=xmlErrs.slice();
1447
1629
  for(const sec of secs){
1448
1630
  const r=parseOne(sec.text);
1449
1631
  // A section's element `.line` values are section-local, and a GEOMETRY-time
@@ -1461,6 +1643,197 @@ function parse(text){
1461
1643
  return {doc:docs[0], errs, docs};
1462
1644
  }
1463
1645
 
1646
+ // ── THE CONNECTOR SCANNER, WITH A RETURN VALUE ────────────────
1647
+ // `EDGE-LABEL-PLACEMENT`/`REVERSE-ARROW-OPERATOR`'s connector grammar — `<kw> <id> [tail] <op> [head] <id>` — used to
1648
+ // live entirely INSIDE `parseOne`, as `parseEdgeLine`'s local scanner, and it
1649
+ // reported only through `err()` and a push into `doc.edges`/`doc.messages`.
1650
+ // That put it out of reach of the editor's GUI half: the code in the UI
1651
+ // section needs the SOURCE OFFSETS of each part of a connector line to edit
1652
+ // one label, one operator or one endpoint in place, leaving every other byte of
1653
+ // line alone (the editor's edge-editing requirement). A GUI cannot answer that
1654
+ // with a pattern of its own — a second grammar is a second answer to
1655
+ // `edge a -[hop #1]-> b`, to `edge a--b`, to `["]"]`, to `-[x]->` and to every
1656
+ // other case this scanner already settles, and the two answers would drift the
1657
+ // first time either side moved. That is the drift class `NODE_KW_ALT` was
1658
+ // created to close for node lines and it is the same class here.
1659
+ //
1660
+ // So the tokenizer moved OUT and grew a return value; NOTHING about the
1661
+ // grammar changed. There is still exactly ONE connector grammar, now with two
1662
+ // callers: `parseEdgeLine`, which adds the option pass, every semantic check
1663
+ // and the model write, and `scanConnectorLine`, which adds nothing and writes
1664
+ // nothing. Every syntax diagnostic is produced HERE and merely relayed by the
1665
+ // parser, so the two callers cannot disagree about what is legal.
1666
+ //
1667
+ // Offsets are UTF-16 indices into the string passed in — the parser passes the
1668
+ // line already trimmed, exactly as it did before.
1669
+ function scanConnector(s,kw){
1670
+ kw=kw||'edge';
1671
+ let i=kw.length; // past the connector keyword
1672
+ const bad=(m)=>({ok:false,error:m});
1673
+ const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
1674
+ // `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
1675
+ // followed by a second one, because `--` is the link operator. This is
1676
+ // what lets `edge a--b` mean the same thing as `bundle t1 a--b`; before
1677
+ // the ban the greedy scanner ate `a--b` as one id and then reported
1678
+ // "edge needs an operator", so the same token read oppositely two lines
1679
+ // apart (SYNTAX-STYLE §6.3).
1680
+ const readId=()=>{ const m=/^[A-Za-z_](?:[A-Za-z0-9_]|-(?!-))*/.exec(s.slice(i));
1681
+ if(!m) return null; i+=m[0].length; return m[0]; };
1682
+ // `QUOTED-IDS`: an endpoint is an id position. A quoted token or a spelling that
1683
+ // is not an id gets the ID RULE — not "edge needs <id> …", which named
1684
+ // the wrong thing when the operator was plainly there.
1685
+ const idHere=()=>i<s.length&&!/[\s[\-<>]/.test(s[i]);
1686
+ const readLbl=()=>{ // called at '['
1687
+ i++;
1688
+ if(s[i]==='"'){ // ["..."] — quoted content
1689
+ i++; let v='';
1690
+ while(i<s.length&&s[i]!=='"'){
1691
+ if(s[i]==='\\'){ const e=s[i+1];
1692
+ if(e==='n'){ v+='\n'; i+=2; continue; }
1693
+ if(e==='"'){ v+='"'; i+=2; continue; }
1694
+ if(e==='\\'){ v+='\\'; i+=2; continue; }
1695
+ return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'}; }
1696
+ v+=s[i]; i++;
1697
+ }
1698
+ if(i>=s.length) return {error:'unterminated string in [label]'};
1699
+ i++;
1700
+ if(s[i]!==']') return {error:'expected ] after quoted label'};
1701
+ i++;
1702
+ if(!v) return {error:'empty [label]'};
1703
+ return {v};
1704
+ }
1705
+ let depth=1,v='';
1706
+ while(i<s.length){
1707
+ const c=s[i];
1708
+ if(c==='[') depth++;
1709
+ else if(c===']'){ depth--; if(!depth){ i++;
1710
+ v=v.trim();
1711
+ if(!v) return {error:'empty [label]'};
1712
+ return {v}; } }
1713
+ v+=c; i++;
1714
+ }
1715
+ return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
1716
+ };
1717
+ // Spans are recorded in SOURCE ORDER and are half-open [start,end). A label
1718
+ // span covers its BRACKETS as well as its text, because the edit a GUI makes
1719
+ // to a written label is a replacement of the whole `[...]` (deleting one
1720
+ // means deleting its brackets too); `mid` is the one span nested inside
1721
+ // another, since `-[x]->` is one operator with a label in the middle of it.
1722
+ // An ABSENT label gets no span at all, which is how a caller tells
1723
+ // `edge a -> b` from `edge a [t] -> b` — the value is `null` in both the
1724
+ // field and the span, and an empty `[]` is a line error, never a third state.
1725
+ const sp={};
1726
+ ws();
1727
+ if(s[i]==='"') return bad(ID_RULE);
1728
+ const aAt=i;
1729
+ const a=readId();
1730
+ if(!a) return bad(idHere()?ID_RULE:kw+' needs <id> ->|<-|--|<-> <id>');
1731
+ if(idHere()) return bad(ID_RULE);
1732
+ sp.a={start:aAt,end:i};
1733
+ ws(); let tail=null;
1734
+ if(s[i]==='['){ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
1735
+ tail=r.v; sp.tail={start:at,end:i}; }
1736
+ ws();
1737
+ const opAt=i;
1738
+ let lh=null;
1739
+ if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
1740
+ else if(s[i]==='-'){ lh='-'; i++; }
1741
+ else return bad(kw+' needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)');
1742
+ let mid=null, op=null;
1743
+ if(s[i]==='['){
1744
+ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
1745
+ mid=r.v; sp.mid={start:at,end:i};
1746
+ if(s.startsWith('->',i)){ op=lh==='<-'?'<->':'->'; i+=2; }
1747
+ else if(s[i]==='-'){ op=lh==='<-'?'<-':'--'; i++; }
1748
+ else return bad('expected - or -> to close the operator after [label]');
1749
+ } else if(lh==='<-'){
1750
+ if(s[i]==='>'){ op='<->'; i++; } else op='<-';
1751
+ } else {
1752
+ if(s[i]==='-'){ op='--'; i++; }
1753
+ else if(s[i]==='>'){ op='->'; i++; }
1754
+ else return bad(kw+' needs an operator: -> <- -- <->');
1755
+ }
1756
+ sp.connector={start:opAt,end:i};
1757
+ ws(); let head=null;
1758
+ if(s[i]==='['){ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
1759
+ head=r.v; sp.head={start:at,end:i}; }
1760
+ ws();
1761
+ if(s[i]==='"') return bad(ID_RULE);
1762
+ const bAt=i;
1763
+ const b=readId();
1764
+ if(!b) return bad(idHere()?ID_RULE:kw+' needs a target id after the operator');
1765
+ if(idHere()) return bad(ID_RULE);
1766
+ sp.b={start:bAt,end:i};
1767
+ // Everything after endpoint `b` is the OPTION REGION, and it starts at `b`'s
1768
+ // last byte, not at the next non-space: an option edit that began anywhere
1769
+ // earlier could reach back into `[head]`, which is the one thing the caller
1770
+ // asked this span to make impossible. The parser tokenizes exactly this
1771
+ // slice, and `message`'s trailing quoted label lives in it too.
1772
+ sp.options={start:i,end:s.length};
1773
+ return {ok:true,keyword:kw,a,b,op,tail,mid,head,spans:sp};
1774
+ }
1775
+
1776
+ // The connector word this genre spells at this version, or the diagnostic that
1777
+ // says so. `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`'s answer lives here rather than at the dispatch below
1778
+ // because the parser and `scanConnectorLine` must give the same one: a GUI
1779
+ // that offered to edit a `flowline` line in a `block` document would be
1780
+ // offering to edit a line error.
1781
+ const connectorWordError=(surf,genre,version)=>{
1782
+ const want=connectorKwAt(genre,version);
1783
+ if(!want) return '"'+surf+'" is not allowed in genre '+genre;
1784
+ if(surf===want) return null;
1785
+ const need=CONNECTOR_MIN_VERSION[surf];
1786
+ // The author wrote a word this genre really does use — just not in
1787
+ // the version they declared. That is a different mistake from the
1788
+ // wrong domain's word, and it has a different one-step fix.
1789
+ if(need && version && need>version && GENRE_CONNECTOR_KW[genre]===surf)
1790
+ return WRONG_VERSION_WORD(surf,want,genre,need,version);
1791
+ return WRONG_WORD(surf,want,genre);
1792
+ };
1793
+
1794
+ // scanConnectorLine(code, genre, version) — the connector grammar, READ-ONLY,
1795
+ // for a caller that has one line and wants to know what is where in it.
1796
+ //
1797
+ // {ok:true, keyword, a, b, op, tail, mid, head,
1798
+ // spans:{a, tail?, connector, mid?, head?, b, options}}
1799
+ // {ok:false, error}
1800
+ //
1801
+ // `code` is a CODE-ONLY line: the caller has already cut any trailing comment
1802
+ // (`findComment`), because a `#` inside `[a hop #1]` is not a comment and only
1803
+ // the caller knows where its line came from. Leading and trailing whitespace
1804
+ // are tolerated and the offsets account for them, so a span can be spliced
1805
+ // straight back into the string that was passed in.
1806
+ //
1807
+ // It mutates nothing, appends to no error array, and never throws for input:
1808
+ // a line that is not a connector at all — or is one the genre does not use, or
1809
+ // is malformed — comes back as `{ok:false,error}` carrying the same sentence
1810
+ // the parser would have reported for it.
1811
+ function scanConnectorLine(code, genre, version){
1812
+ if(typeof code!=='string') return {ok:false,error:'connector line must be a string'};
1813
+ const s=code.trim();
1814
+ const off=code.length-code.replace(/^\s+/,'').length;
1815
+ const m=CONN_LINE_RE.exec(s);
1816
+ if(!m) return {ok:false,error:'not a connector line — it does not begin with '+
1817
+ [...CONNECTOR_SPELLINGS].join(', ')};
1818
+ const surf=m[1];
1819
+ // The genre gate is OPTIONAL: with no genre named, any of the four spellings
1820
+ // scans, which is what a caller inspecting a fragment out of context needs.
1821
+ if(genre && GENRE_KW[genre]){
1822
+ const e=connectorWordError(surf,genre,version);
1823
+ if(e) return {ok:false,error:e};
1824
+ }
1825
+ const r=scanConnector(s,surf);
1826
+ if(!r.ok) return r;
1827
+ for(const k in r.spans){
1828
+ r.spans[k]={start:r.spans[k].start+off,end:r.spans[k].end+off};
1829
+ }
1830
+ // The option region runs to the END of the line the caller passed, trailing
1831
+ // whitespace included, so replacing that span replaces every option and
1832
+ // nothing else.
1833
+ r.spans.options.end=code.length;
1834
+ return r;
1835
+ }
1836
+
1464
1837
  function parseOne(text){
1465
1838
  const errs=[];
1466
1839
  // `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
@@ -1493,13 +1866,21 @@ function parseOne(text){
1493
1866
  // rule), so no existing golden moves a byte.
1494
1867
  lifelines:[],messages:[],states:[],fragments:[],operands:[]};
1495
1868
  const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
1496
- bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
1869
+ bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set(),
1870
+ // `CONNECTOR-IDENTITY-KEY`: the connector ids written with `id=`. They are a
1871
+ // SET beside the others only for bookkeeping — for UNIQUENESS they are
1872
+ // part of the one shared namespace `dupId` tests, because a `bundle`
1873
+ // member that is a bare token must resolve to exactly one kind of
1874
+ // thing. A separate namespace would have made `bundle t1 "T" x` mean
1875
+ // one thing when `x` is a node and another when it is a connector, and
1876
+ // both at once when it is both.
1877
+ edgeIds=new Set();
1497
1878
  // §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
1498
1879
  // typed blocks (bitfield/table/timing) share ONE namespace, so a bare id in
1499
1880
  // `edge`/`pin`/`chart` can never be ambiguous. `plane`, `class` and
1500
1881
  // `bundle` keep their own namespaces: each is referenced through a dedicated
1501
1882
  // option or keyword, never as a bare id.
1502
- const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id);
1883
+ const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id)||edgeIds.has(id);
1503
1884
  let cur=null; // current typed block (bitfield/table/timing)
1504
1885
  // `REPEATED-DIRECTIVE-HANDLING`: `title`, `flow`, `layout` and a per-id `pin` are SINGLE-VALUED — a
1505
1886
  // repetition is a line error on the second occurrence, never a silent
@@ -1533,86 +1914,19 @@ function parseOne(text){
1533
1914
  // `edge`, `flowline` or `transition`. One scanner, three words: every
1534
1915
  // message names the word on the line, and nothing downstream of here knows
1535
1916
  // the difference (the model records a connector, not a spelling).
1917
+ //
1918
+ // 0.4: the scanner itself is `scanConnector`, at top level, so the
1919
+ // editor's GUI half can read the same grammar through `scanConnectorLine`.
1920
+ // What is left here is everything a SCAN cannot do: the option pass, the
1921
+ // semantic checks, and the model write. Every syntax diagnostic below the
1922
+ // call is relayed verbatim from the scanner, so this function reports
1923
+ // exactly what it reported when the scanner was local to it.
1536
1924
  function parseEdgeLine(s,n,kw){
1537
1925
  kw=kw||'edge';
1538
- let i=kw.length; // past the connector keyword
1539
- const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
1540
- // `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
1541
- // followed by a second one, because `--` is the link operator. This is
1542
- // what lets `edge a--b` mean the same thing as `bundle t1 a--b`; before
1543
- // the ban the greedy scanner ate `a--b` as one id and then reported
1544
- // "edge needs an operator", so the same token read oppositely two lines
1545
- // apart (SYNTAX-STYLE §6.3).
1546
- const readId=()=>{ const m=/^[A-Za-z_](?:[A-Za-z0-9_]|-(?!-))*/.exec(s.slice(i));
1547
- if(!m) return null; i+=m[0].length; return m[0]; };
1548
- // `QUOTED-IDS`: an endpoint is an id position. A quoted token or a spelling that
1549
- // is not an id gets the ID RULE — not "edge needs <id> …", which named
1550
- // the wrong thing when the operator was plainly there.
1551
- const idHere=()=>i<s.length&&!/[\s[\-<>]/.test(s[i]);
1552
- const readLbl=()=>{ // called at '['
1553
- i++;
1554
- if(s[i]==='"'){ // ["..."] — quoted content
1555
- i++; let v='';
1556
- while(i<s.length&&s[i]!=='"'){
1557
- if(s[i]==='\\'){ const e=s[i+1];
1558
- if(e==='n'){ v+='\n'; i+=2; continue; }
1559
- if(e==='"'){ v+='"'; i+=2; continue; }
1560
- if(e==='\\'){ v+='\\'; i+=2; continue; }
1561
- return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'}; }
1562
- v+=s[i]; i++;
1563
- }
1564
- if(i>=s.length) return {error:'unterminated string in [label]'};
1565
- i++;
1566
- if(s[i]!==']') return {error:'expected ] after quoted label'};
1567
- i++;
1568
- if(!v) return {error:'empty [label]'};
1569
- return {v};
1570
- }
1571
- let depth=1,v='';
1572
- while(i<s.length){
1573
- const c=s[i];
1574
- if(c==='[') depth++;
1575
- else if(c===']'){ depth--; if(!depth){ i++;
1576
- v=v.trim();
1577
- if(!v) return {error:'empty [label]'};
1578
- return {v}; } }
1579
- v+=c; i++;
1580
- }
1581
- return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
1582
- };
1583
- ws();
1584
- if(s[i]==='"'){ err(n,ID_RULE); return; }
1585
- const a=readId();
1586
- if(!a){ err(n, idHere()?ID_RULE:kw+' needs <id> ->|<-|--|<-> <id>'); return; }
1587
- if(idHere()){ err(n,ID_RULE); return; }
1588
- ws(); let tail=null;
1589
- if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } tail=r.v; }
1590
- ws();
1591
- let lh=null;
1592
- if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
1593
- else if(s[i]==='-'){ lh='-'; i++; }
1594
- else { err(n,kw+' needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)'); return; }
1595
- let mid=null, op=null;
1596
- if(s[i]==='['){
1597
- const r=readLbl(); if(r.error){ err(n,r.error); return; } mid=r.v;
1598
- if(s.startsWith('->',i)){ op=lh==='<-'?'<->':'->'; i+=2; }
1599
- else if(s[i]==='-'){ op=lh==='<-'?'<-':'--'; i++; }
1600
- else { err(n,'expected - or -> to close the operator after [label]'); return; }
1601
- } else if(lh==='<-'){
1602
- if(s[i]==='>'){ op='<->'; i++; } else op='<-';
1603
- } else {
1604
- if(s[i]==='-'){ op='--'; i++; }
1605
- else if(s[i]==='>'){ op='->'; i++; }
1606
- else { err(n,kw+' needs an operator: -> <- -- <->'); return; }
1607
- }
1608
- ws(); let head=null;
1609
- if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } head=r.v; }
1610
- ws();
1611
- if(s[i]==='"'){ err(n,ID_RULE); return; }
1612
- const b=readId();
1613
- if(!b){ err(n, idHere()?ID_RULE:kw+' needs a target id after the operator'); return; }
1614
- if(idHere()){ err(n,ID_RULE); return; }
1615
- const tk2=tokenize(s.slice(i).trim());
1926
+ const sc=scanConnector(s,kw);
1927
+ if(!sc.ok){ err(n,sc.error); return; }
1928
+ const {a,b,op,tail,mid,head}=sc;
1929
+ const tk2=tokenize(s.slice(sc.spans.options.start).trim());
1616
1930
  if(tk2.error){ err(n,tk2.error); return; }
1617
1931
  const {pos:p2,posq:pq2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
1618
1932
  if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
@@ -1675,6 +1989,26 @@ function parseOne(text){
1675
1989
  if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); return; }
1676
1990
  if(!optQ(oT2,'note')){ err(n,'note= must be quoted: note="'+o2.note+'" — '+Q_WHY); return; }
1677
1991
  }
1992
+ // `CONNECTOR-IDENTITY-KEY`: `id=` — the connector's optional handle. Three checks
1993
+ // in the order an author's next move is decided by:
1994
+ // 1. the VERSION GATE, first, so a 0.4 document is told the one thing it
1995
+ // can do about the line rather than being told its id is malformed;
1996
+ // 2. the ID LEXIS, `QUOTED-IDS`'s one wording for every id position, so a
1997
+ // connector id is spelled exactly as a node id is (bare, no `--`);
1998
+ // 3. UNIQUENESS. A connector id joins the SECTION's existing
1999
+ // node/group/external/region namespace, which is what makes a bare
2000
+ // single-token `bundle` member decidable — a member either resolves to
2001
+ // one connector or names something that is not one, and never both. So
2002
+ // the diagnostic is `duplicate id`, the one §8.1 already has, and not a
2003
+ // new one: this is the same namespace, not a parallel one.
2004
+ let eid;
2005
+ if(o2.id!==undefined){
2006
+ if(belowOptVersion('id',doc.version)){ err(n,ID_VERSION(doc.version)); return; }
2007
+ const e=idErr(o2.id, optHasQ(oT2,'id'), 'id= needs an id: '+kw+' '+a+' '+op+' '+b+' id=<id>');
2008
+ if(e){ err(n,e); return; }
2009
+ if(dupId(o2.id)){ err(n,'duplicate id "'+o2.id+'"'); return; }
2010
+ eid=o2.id;
2011
+ }
1678
2012
  // `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
1679
2013
  // Checked before the value, exactly as `badOpts` does it.
1680
2014
  if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
@@ -1711,7 +2045,8 @@ function parseOne(text){
1711
2045
  // ruling taken here: the draft settles the trailing form and says
1712
2046
  // nothing about the brackets. `[tail]` and `[head]` are kept — they are
1713
2047
  // different positions, not a second spelling of the same one.
1714
- doc.messages.push({a,b,op,tail,head,
2048
+ if(eid!==undefined) edgeIds.add(eid);
2049
+ doc.messages.push({id:eid,a,b,op,tail,head,
1715
2050
  label:seqLabel!==null?seqLabel:mid,
1716
2051
  style:o2.style,cls:ecls,stroke:o2.stroke,note:o2.note,
1717
2052
  desc:o2.description,in:o2['in']||null,line:n});
@@ -1720,7 +2055,8 @@ function parseOne(text){
1720
2055
  // §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
1721
2056
  // and `fill=` name the same channel (`stroke=` wins when both are
1722
2057
  // written); `text=` colours the [tail]/[mid]/[head] labels.
1723
- doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:ecls,
2058
+ if(eid!==undefined) edgeIds.add(eid);
2059
+ doc.edges.push({id:eid,a,b,op,tail,mid,head,style:o2.style,cls:ecls,
1724
2060
  stroke:o2.stroke,note:o2.note,
1725
2061
  plane:o2.plane||'base',line:n});
1726
2062
  }
@@ -1905,20 +2241,13 @@ function parseOne(text){
1905
2241
  // `KEYWORD-RENAME-SCOPE`: the word this genre uses is read AT THE DECLARED VERSION, not
1906
2242
  // at the newest one. A genre with no connector at all (bitfield, table,
1907
2243
  // timing) still gets the allowlist message.
2244
+ // 0.4: the three answers this gate can give are spelled once, in
2245
+ // `connectorWordError`, because `scanConnectorLine` has to give the same
2246
+ // three to the GUI. The `sawHeader` guard stays here: it is about where
2247
+ // the parser is in the document, which no scanner of one line can know.
1908
2248
  if(sawHeader && doc.genre && GENRE_KW[doc.genre]){
1909
- const want=connectorKwAt(doc.genre, doc.version);
1910
- if(!want){ err(n,'"'+ckw+'" is not allowed in genre '+doc.genre); continue; }
1911
- if(ckw!==want){
1912
- const need=CONNECTOR_MIN_VERSION[ckw];
1913
- // The author wrote a word this genre really does use — just not in
1914
- // the version they declared. That is a different mistake from the
1915
- // wrong domain's word, and it has a different one-step fix.
1916
- if(need && doc.version && need>doc.version &&
1917
- GENRE_CONNECTOR_KW[doc.genre]===ckw)
1918
- err(n, WRONG_VERSION_WORD(ckw,want,doc.genre,need,doc.version));
1919
- else
1920
- err(n, WRONG_WORD(ckw,want,doc.genre));
1921
- continue; }
2249
+ const e=connectorWordError(ckw, doc.genre, doc.version);
2250
+ if(e){ err(n,e); continue; }
1922
2251
  }
1923
2252
  parseEdgeLine(raw.trim(),n,ckw);
1924
2253
  continue;
@@ -2404,7 +2733,7 @@ function parseOne(text){
2404
2733
  if(/[2-9]/.test(lane)){ err(n,RETIRED_LANE_DIGIT); continue; }
2405
2734
  if(!/^[01pnx=.]+$/.test(lane)){ err(n,'lane may contain only 0 1 p n x = .'); continue; }
2406
2735
  // / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
2407
- // members (a,b), or a count that does not match the lane's `=`
2736
+ // members (a,,b), or a count that does not match the lane's `=`
2408
2737
  // cells are all line errors — never silent drop or shift.
2409
2738
  // Spelled `labels=` until 0.1 (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
2410
2739
  // `data`, "an array of signal labels" naming every value cell, and
@@ -2726,26 +3055,53 @@ function parseOne(text){
2726
3055
  // the half-converted line the old tolerance could not report.
2727
3056
  if(restT.length>1){
2728
3057
  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; }
2729
- const pairs=[]; let badp=null;
3058
+ // `CONNECTOR-IDENTITY-KEY`: a member is an endpoint PAIR or a connector ID,
3059
+ // in the same comma list, told apart by LEXIS and not by a flag: `--`
3060
+ // cannot occur inside an id (`LINK-OPERATOR-IN-IDS`), so a member either contains it and
3061
+ // is a pair or does not and is an id. RULE 5 is satisfied because each
3062
+ // form reaches input the other cannot — the pair form reaches ANONYMOUS
3063
+ // connectors, which have no id to name; the id form reaches PARALLEL
3064
+ // connectors, which no pair can address — so neither is a spelling
3065
+ // variant of the other. Mixed lists need no rule of their own: every
3066
+ // member resolves independently.
3067
+ //
3068
+ // `members` is the AUTHORED list in source order and is what the model
3069
+ // projects. `pairs` stays what it always was — the endpoint pairs the
3070
+ // ring geometry reads — and an id member appends its resolved pair to
3071
+ // it in the semantic pass below, once the connector it names exists.
3072
+ const pairs=[], members=[]; let badp=null;
2730
3073
  outerB:
2731
3074
  for(const t of restT){
2732
3075
  for(const s of splitList(t,0)){
2733
3076
  const mem=s.v.trim(); if(!mem) continue;
2734
3077
  if(s.h){ badp=ID_RULE; break outerB; }
3078
+ if(!mem.includes('--')){
3079
+ // A bare token. Under figdown 0.4 and below there is no id form,
3080
+ // so the answer is the one it always was: this is a malformed
3081
+ // pair. Naming the id form to a document that cannot write it
3082
+ // would send the author to a key their header does not have.
3083
+ if(belowOptVersion('id',doc.version)){
3084
+ badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
3085
+ if(!isId(mem)){
3086
+ badp='bad member "'+mem+'" (expected A--B or a connector id)'; break outerB; }
3087
+ members.push({id:mem});
3088
+ continue;
3089
+ }
2735
3090
  const parts=mem.split('--');
2736
3091
  if(parts.length!==2||!isId(parts[0])||!isId(parts[1])){
2737
3092
  badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
2738
3093
  pairs.push([parts[0],parts[1]]);
3094
+ members.push({a:parts[0],b:parts[1]});
2739
3095
  }
2740
3096
  }
2741
3097
  if(badp){ err(n,badp); break; }
2742
- if(!pairs.length){ err(n,'bundle needs at least one member link A--B'); break; }
3098
+ if(!members.length){ err(n,'bundle needs at least one member link A--B'); break; }
2743
3099
  bundleIds.add(id);
2744
3100
  // §5 on the derived ring: `fill=` stays the ring colour (stroke +
2745
3101
  // label) as before, `stroke=`/`text=` split it, `style=` picks the
2746
3102
  // dash (the conventional default is dashed), `plane=` orders this
2747
3103
  // ring against the other rings.
2748
- doc.trunks.push({id,label:tlabel,pairs,stroke:opts.stroke,
3104
+ doc.trunks.push({id,label:tlabel,pairs,members,stroke:opts.stroke,
2749
3105
  style:opts.style,plane:opts.plane,line:n});
2750
3106
  break;
2751
3107
  }
@@ -3189,13 +3545,85 @@ function parseOne(text){
3189
3545
  for(const f of doc.bands)
3190
3546
  if(!groupIds.has(f.target)&&!nodeIds.has(f.target)&&!regionTarget(f.target))
3191
3547
  errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
3192
- for(const t of doc.trunks) for(const [a,b] of t.pairs){
3193
- if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
3194
- const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a)).length;
3195
- if(matches===0)
3196
- errs.push('Line '+t.line+': no edge between "'+a+'" and "'+b+'" for bundle member');
3197
- else if(matches>1)
3198
- errs.push('Line '+t.line+': "'+a+'--'+b+'" is ambiguous ('+matches+' parallel edges); parallel edges are out of scope for v0.1');
3548
+ // `MEMBER-LIST-DUPLICATION`: a bundle member repeated in ONE member list is a
3549
+ // line error the identity-model-proposal's probe 5, a live defect
3550
+ // independent of edge identity. `bundle t1 "T" a--b,a--b` used to parse
3551
+ // silently into a two-member bundle over one link. The duplicate test is
3552
+ // on the RESOLVED edge (the same *Edge* object each pair member resolves
3553
+ // to), not on the spelling, so `a--b,b--a` is caught too — two spellings
3554
+ // of an undirected pair that resolve to one edge are one member named
3555
+ // twice, not two. Stating the rule this way, rather than as "the same
3556
+ // string written twice", is deliberate: it is the form that survives the
3557
+ // future id-form member (§4.1 of the proposal) without a rewrite, because
3558
+ // an id and a pair spelling can resolve to the same edge too. `seen` is
3559
+ // per BUNDLE — each trunk's member list is checked against itself, not
3560
+ // against any other bundle's.
3561
+ //
3562
+ // `CONNECTOR-IDENTITY-KEY`: the loop walks the AUTHORED member list, so a member is
3563
+ // resolved by the form it was written in and the diagnostic names what the
3564
+ // author wrote. The duplicate test does not change and did not need to: it
3565
+ // was already bound to the resolved *Edge* object, which is exactly why an id
3566
+ // member and a pair member naming ONE connector are caught by it without a
3567
+ // second rule — the whole reason `MEMBER-LIST-DUPLICATION` was written that way.
3568
+ for(const t of doc.trunks){
3569
+ const seen=new Set();
3570
+ for(const m of (t.members||t.pairs.map(p=>({a:p[0],b:p[1]})))){
3571
+ // The written spelling, for every message this loop can produce.
3572
+ const spelled=m.id!==undefined?m.id:m.a+'--'+m.b;
3573
+ let hit=null;
3574
+ if(m.id!==undefined){
3575
+ // The id form. It resolves against the connectors of THIS SECTION and
3576
+ // nothing else: ids are unique per section (core §1) and a
3577
+ // cross-section reference is a line error by design, so an id from a
3578
+ // neighbouring section arrives here as an id this section never
3579
+ // declared — which is what the second message says, in the words that
3580
+ // are true for it.
3581
+ const named=doc.edges.filter(e=>e.id===m.id);
3582
+ // What the id DOES name, when it does not name a connector. The kind is
3583
+ // in the message because it is the whole of the author's next move: a
3584
+ // node id in a member list is a `bundle b "B" a--b` written short, and
3585
+ // a class id there is a different mistake entirely.
3586
+ const kind=nodeIds.has(m.id)&&!edgeIds.has(m.id)?'a node':
3587
+ groupIds.has(m.id)?'a group':
3588
+ boundaryIds.has(m.id)?'an external endpoint':
3589
+ blockIds.has(m.id)?'a region':
3590
+ classIds.has(m.id)?'a class':
3591
+ bundleIds.has(m.id)?'a bundle':null;
3592
+ if(named.length===1) hit=named[0];
3593
+ else if(kind){
3594
+ 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');
3595
+ continue;
3596
+ } else {
3597
+ 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=)');
3598
+ continue;
3599
+ }
3600
+ // The ring reads endpoint pairs, so a resolved id member contributes
3601
+ // the pair its connector runs between. Nothing about the drawing is
3602
+ // authored here (`DOMAIN-CONVENTION-DIRECTIVES`) — which links the ring encloses is.
3603
+ t.pairs.push([hit.a,hit.b]);
3604
+ } else {
3605
+ const a=m.a, b=m.b;
3606
+ if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
3607
+ const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a));
3608
+ if(matches.length===0){
3609
+ errs.push('Line '+t.line+': no edge between "'+a+'" and "'+b+'" for bundle member'); continue; }
3610
+ if(matches.length>1){
3611
+ // `CONNECTOR-IDENTITY-KEY` REPLACES probe 6's message. The old one cited `v0.1` in a 0.4
3612
+ // engine, stated a SCOPE EXCLUSION where the truth is "say which one
3613
+ // you mean", and named no remedy. Parallel links are the defining
3614
+ // case of a LAG, so a bundle that cannot name one member of three is
3615
+ // a construct whose definition and whose grammar disagree; now there
3616
+ // is a way out and the message is the place the author meets it.
3617
+ errs.push('Line '+t.line+': "'+a+'--'+b+'" matches '+matches.length+
3618
+ ' 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)');
3619
+ continue; }
3620
+ hit=matches[0];
3621
+ }
3622
+ if(seen.has(hit))
3623
+ errs.push('Line '+t.line+': duplicate bundle member "'+spelled+'" — each member is stated once');
3624
+ else
3625
+ seen.add(hit);
3626
+ }
3199
3627
  }
3200
3628
  // `ELEMENT-GEOMETRY-DIRECTIVE`: `pin` has a SPLIT DOMAIN, and both halves are checked
3201
3629
  // here because both need the finished id sets.
@@ -3260,7 +3688,7 @@ function parseOne(text){
3260
3688
  // is that genre's designed idiom, not an oversight.
3261
3689
  //
3262
3690
  // `INTERIOR-LESS-ELEMENT-PAINT`'s half stands and now reaches EVERY collection that accepts
3263
- // `class=` (`CLASS-CHANNEL-REACH`). Until this release the loop below ran over `doc.edges`
3691
+ // `class=` (`CLASS-CHANNEL-REACH`). Until 0.4 the loop below ran over `doc.edges`
3264
3692
  // alone, so `class k "K" fill=#eee` plus `message c -> s "m" class=k` was
3265
3693
  // accepted, painted nothing, and put the class in the legend — a message
3266
3694
  // has its own collection because it has a position in time (`SEQUENCE-ORDER-MODEL`), and
@@ -3311,6 +3739,7 @@ function parseOne(text){
3311
3739
  };
3312
3740
  const clsChan=(x,kind)=>{
3313
3741
  const K=CLASS_CHANNELS[kind];
3742
+ const claimed={}; // channel -> class id that already set it on THIS element (`CLASS-CHANNEL-COLLISION`)
3314
3743
  for(const cid of (x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]))){
3315
3744
  const c=doc.classes.find(y=>y.id===cid);
3316
3745
  if(!c) continue; // unknown id: its own error
@@ -3319,8 +3748,29 @@ function parseOne(text){
3319
3748
  if(!K.has.includes('fill')&&c.fill!==undefined&&c.stroke===undefined){
3320
3749
  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)');
3321
3750
  continue; }
3322
- if(!decl.some(k=>K.has.includes(k)))
3751
+ if(!decl.some(k=>K.has.includes(k))){
3323
3752
  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)');
3753
+ continue; }
3754
+ // `CLASS-CHANNEL-COLLISION`: two CARRIED CLASSES binding the SAME paint
3755
+ // channel on one element is a line error — §8's own rule ("a repeated
3756
+ // option key on ONE line is a line error, never last-wins") one level
3757
+ // up, closing the hole a class boundary opened in it. The check is
3758
+ // per element, per channel the member actually has (K.has): the
3759
+ // first carried class to set a channel CLAIMS it, and a later class
3760
+ // repeating a claimed channel is refused by name. Element-direct
3761
+ // `fill=`/`stroke=`/`style=` is untouched — it already overrides
3762
+ // every class (rigidity, `LAYOUT-STABILITY`) and is not a second class SOURCE.
3763
+ // `class=hot,deprecated` in conformance case 308 is the shape this
3764
+ // must keep legal: each channel comes from exactly one class.
3765
+ for(const k of K.has){
3766
+ if(c[k]===undefined) continue;
3767
+ if(claimed[k]!==undefined){
3768
+ const other=K.has.filter(o=>o!==k).map(o=>o+'=').join('/');
3769
+ 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');
3770
+ } else {
3771
+ claimed[k]=cid;
3772
+ }
3773
+ }
3324
3774
  }
3325
3775
  };
3326
3776
  for(const x of doc.nodes) clsChan(x,'node');
@@ -3738,8 +4188,9 @@ function noteSvg(x,y,box,carrier){
3738
4188
  // about "a group" and no more — so the association the source states was
3739
4189
  // lost on the way to the page even when the placement was perfect. The pair
3740
4190
  // (kind, ref) is unambiguous: `ref` is the element's identity in this very
3741
- // drawing — a `data-node` id, a `data-group` id, or the `data-edge` line
3742
- // number a connector is keyed by, since a connector has no id to carry.
4191
+ // drawing — a `data-node` id, a `data-group` id, or whatever `data-edge`
4192
+ // keys a connector by, which from `CONNECTOR-IDENTITY-KEY` is the connector's
4193
+ // AUTHORED id where it has one and its source line where it does not.
3743
4194
  // `title` is the one kind with no ref and needs none: it names the FIGURE,
3744
4195
  // and there is exactly one. The attribute changes no pixel.
3745
4196
  const ref=carrier&&carrier.ref!==undefined&&carrier.ref!==null?' data-note-for="'+esc(String(carrier.ref))+'"':'';
@@ -4868,6 +5319,190 @@ function renderScene(doc,y0){
4868
5319
  }
4869
5320
  }
4870
5321
  }
5322
+ // ── THE RING GEOMETRY IS DEFINED HERE BECAUSE TWO PASSES NEED IT ───────
5323
+ // (item 69.) `ringOf` used to sit with the ring DRAWING, a
5324
+ // thousand lines below, because the ring is derived from final coordinates
5325
+ // and nothing before the draw had a use for it. The lasso-containment rule
5326
+ // gives it a second caller ABOVE: the separation pass immediately below has
5327
+ // to know where each bundle's lasso will fall so it can keep a non-member
5328
+ // out of it, exactly as it already keeps one out of a group band. The
5329
+ // definition moved; not one character of the derivation changed, and the
5330
+ // ring is still COMPUTED (`rings`, below the label pass) at the geometry it
5331
+ // is drawn from, so what the separation pass sees is a PROJECTION and what
5332
+ // the truth check sees is the ring itself.
5333
+ // ── A BUNDLE RING IS ORIENTED BY ITS MEMBERS (item 43) ───────────────────
5334
+ // Until now the ring was derived from the member links' MIDPOINTS and
5335
+ // nothing else: `rx = max(46, x-spread + 38)`, `ry = max(26, y-spread + 22)`.
5336
+ // Two facts about that formula are the defect. It is DIRECTION-BLIND — the
5337
+ // axes are the canvas's, never the links' — and its floors are large enough
5338
+ // that a bundle of two short legs draws a near-circle whatever the legs do.
5339
+ // Measured on `patterns/topology-b`: rx 46 / ry 42.7, aspect
5340
+ // 1.07, sitting on two legs that fan VERTICALLY, with the caption lying
5341
+ // across the waist and on top of the `p3` endpoint label.
5342
+ //
5343
+ // The drawing convention for a link bundle is a loop THROUGH which the
5344
+ // links run: narrow along the links, long across them. So the ring is now
5345
+ // derived from the members' own frame.
5346
+ //
5347
+ // 1. DIRECTION. The mean UNDIRECTED direction of the members, by the
5348
+ // doubled-angle circular mean — doubling is what makes `a--b` and
5349
+ // `b--a` the same direction, so a bundle does not change shape when an
5350
+ // author writes a member the other way round (conformance 394).
5351
+ // 2. THE BAND. The ring sits at the MIDPOINT OF THE MEMBERS' SHARED RUN
5352
+ // along that direction — the stretch of the corridor every member is
5353
+ // actually in. That is the placement rule because it is the only band
5354
+ // at which "the spread of the members" is a fact about all of them: a
5355
+ // fanning set (the reference topology's ECMP pair, which leaves two
5356
+ // different spines and arrives at one leaf) has a different spread at
5357
+ // every station, and the shared run is the interval over which the
5358
+ // question is even well posed. Members with no shared run at all fall
5359
+ // back to the mean of their midpoints, which is the old centre.
5360
+ // 3. THE AXES. MINOR along the mean direction, sized to clear the strokes;
5361
+ // MAJOR across it, the members' spread at the band plus padding. A ring
5362
+ // needs a long axis to read as a ring, and it takes it from the spread
5363
+ // when the members fan (`rA >= rL`) and from its own RUN when they do
5364
+ // not — a single-member bundle (`reference/topology`'s multi-home link)
5365
+ // has zero spread, and the shape that hugs one link is an ellipse lying
5366
+ // ALONG it, never a circle straddling it.
5367
+ // 4. NO NODE CONTACT. The ring is shrunk along its minor axis until it
5368
+ // clears every node box, and if it cannot it reverts to the pre-0.4
5369
+ // geometry rather than inventing a shape (no corpus figure does).
5370
+ //
5371
+ // The caption is NOT placed here — it is registered with the label pass
5372
+ // below, so it can see the endpoint labels and the edges it has to avoid.
5373
+ const rnd3=v=>Math.round(v*1000)/1000; // ring coordinates, printed short
5374
+ const RING_ALONG=15, // semi-minor: the ring's body along the links
5375
+ RING_ACROSS=20, // clearance beyond the outermost member
5376
+ RING_LONG=34, // the long axis a ring needs to read as a ring
5377
+ RING_FAN=2, // below this spread the members are one line
5378
+ RING_FLOOR=7, // how thin the minor axis may be squeezed
5379
+ RING_SOLO=4; // a ONE-member bundle: the caption's stand-off
5380
+ // ── A ONE-MEMBER BUNDLE IS ITS CAPTION (item 53) ─────────────────────────
5381
+ // `bundle mh "multi-home" l1--l2` is legal and means something real (a
5382
+ // one-link LAG, an Ethernet Segment with a single member), so the
5383
+ // declaration is not an error and is not dropped. But a ring exists to
5384
+ // UNITE lines, and around ONE line it unites nothing: it is ink that adds
5385
+ // no fact, and on the reference topology it also bought the seam a 104 px
5386
+ // corridor demand (2*RX_MIN + clearance) for a shape nobody had to see.
5387
+ //
5388
+ // So a single-member bundle DRAWS NO ELLIPSE. What it draws is its
5389
+ // CAPTION, bundle-styled (the trunk's own stroke, the ring caption's type)
5390
+ // and placed BY THE LINK — which is exactly the statement the construct
5391
+ // makes: this link is the bundle, and here is its name. The frame below is
5392
+ // therefore kept as a PLACEMENT frame and not as a drawing: the caption
5393
+ // pass already sites a caption around a ring's rim, so a ring squeezed to
5394
+ // `RING_SOLO` across gives the same pass a candidate family hugging the
5395
+ // link. Nothing else in the pass changes.
5396
+ //
5397
+ // AN EMPTY CAPTION ON A ONE-MEMBER BUNDLE THEREFORE DRAWS NOTHING AT ALL.
5398
+ // That is a real consequence and it is not hidden: the reference figure
5399
+ // keeps `bundle unlabelled "" s2--l2` — it is the form demonstrator for
5400
+ // the empty label — and states in a comment that this form is now
5401
+ // MODEL-ONLY. The model still carries the trunk, `read` still reports it,
5402
+ // and the drawing says nothing because there is nothing a ring around one
5403
+ // unnamed line could say.
5404
+ // Does the ring's disc meet an axis-aligned rect? Both are mapped into the
5405
+ // frame where the ring is the unit circle; the rect becomes a convex quad,
5406
+ // and the test is "is the quad within 1 of the origin".
5407
+ const ringHitsRect=(R,RL,RA,b)=>{
5408
+ 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=>{
5409
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
5410
+ return [(R.ux*dx+R.uy*dy)/RL, (R.vx*dx+R.vy*dy)/RA];
5411
+ });
5412
+ let inside=false;
5413
+ for(let i=0,j=3;i<4;j=i++)
5414
+ if((q[i][1]>0)!==(q[j][1]>0) &&
5415
+ 0 < (q[j][0]-q[i][0])*(0-q[i][1])/(q[j][1]-q[i][1])+q[i][0]) inside=!inside;
5416
+ if(inside) return true;
5417
+ for(let i=0,j=3;i<4;j=i++){
5418
+ const vx=q[j][0]-q[i][0], vy=q[j][1]-q[i][1], L2=vx*vx+vy*vy;
5419
+ const t=L2?Math.max(0,Math.min(1,-(q[i][0]*vx+q[i][1]*vy)/L2)):0;
5420
+ if(Math.hypot(q[i][0]+t*vx, q[i][1]+t*vy)<1) return true;
5421
+ }
5422
+ return false;
5423
+ };
5424
+ const ringOf=t=>{
5425
+ const segs=[];
5426
+ for(const [a,b] of t.pairs){
5427
+ const A=byId[a], B=byId[b]; if(!A||!B) continue;
5428
+ 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);
5429
+ if(Math.hypot(q[0]-p[0],q[1]-p[1])>1e-9) segs.push([p,q]);
5430
+ }
5431
+ if(!segs.length) return null;
5432
+ // (1) mean undirected direction
5433
+ let c2=0,s2=0;
5434
+ for(const [p,q] of segs){
5435
+ const L=Math.hypot(q[0]-p[0],q[1]-p[1]);
5436
+ const c=(q[0]-p[0])/L, s=(q[1]-p[1])/L;
5437
+ c2+=c*c-s*s; s2+=2*c*s;
5438
+ }
5439
+ const th=Math.hypot(c2,s2)<1e-9
5440
+ ? Math.atan2(segs[0][1][1]-segs[0][0][1], segs[0][1][0]-segs[0][0][0])
5441
+ : 0.5*Math.atan2(s2,c2);
5442
+ const ux=Math.cos(th), uy=Math.sin(th), vx=-uy, vy=ux;
5443
+ // (2) the band: the midpoint of the shared run
5444
+ const iv=segs.map(([p,q])=>{
5445
+ const a=ux*p[0]+uy*p[1], b=ux*q[0]+uy*q[1];
5446
+ return a<=b?[a,b,p,q]:[b,a,q,p];
5447
+ });
5448
+ const lo=Math.max(...iv.map(z=>z[0])), hi=Math.min(...iv.map(z=>z[1]));
5449
+ const s=lo<hi ? (lo+hi)/2 : iv.reduce((x,z)=>x+(z[0]+z[1])/2,0)/iv.length;
5450
+ const P=iv.map(z=>{
5451
+ const f=Math.min(1,Math.max(0,(s-z[0])/((z[1]-z[0])||1)));
5452
+ return [z[2][0]+(z[3][0]-z[2][0])*f, z[2][1]+(z[3][1]-z[2][1])*f];
5453
+ });
5454
+ const cx=P.reduce((a,p)=>a+p[0],0)/P.length, cy=P.reduce((a,p)=>a+p[1],0)/P.length;
5455
+ const spread=Math.max(...P.map(p=>Math.abs(vx*(p[0]-cx)+vy*(p[1]-cy))));
5456
+ const runHalf=Math.min(...iv.map(z=>Math.min(s-z[0],z[1]-s)));
5457
+ // (3) axes. Two shapes, and which one is drawn is decided by whether the
5458
+ // members FAN at all. A set that fans is CROSSED by the ring: thin along
5459
+ // the links, long enough across them to take the whole spread with room
5460
+ // to spare, so each member passes through a part of the rim where the
5461
+ // ring is still visibly open. A set that does not fan — one link, or
5462
+ // members lying on top of each other — has no spread to take, and the
5463
+ // shape that says "this link is the bundle" is an ellipse lying ALONG it.
5464
+ let rA, rL;
5465
+ if(spread<RING_FAN){ rL=Math.max(RING_ALONG,Math.min(RING_LONG,Math.max(0,runHalf))); rA=RING_ACROSS; }
5466
+ else { rL=RING_ALONG; rA=Math.max(spread+RING_ACROSS,RING_LONG); }
5467
+ const R={cx,cy,ux,uy,vx,vy,th};
5468
+ // (0) ONE MEMBER (item 53): no ellipse is drawn, so this is a caption
5469
+ // frame and not a shape. It is squeezed across to `RING_SOLO` so the
5470
+ // caption pass's rim stations sit beside the link rather than a ring's
5471
+ // radius away from it, and the node-clearance step below is skipped —
5472
+ // there is no ink to keep out of a box.
5473
+ if(segs.length===1){
5474
+ R.solo=true; R.rL=rL; R.rA=RING_SOLO;
5475
+ R.hw=Math.hypot(rL*ux, RING_SOLO*vx); R.hh=Math.hypot(rL*uy, RING_SOLO*vy);
5476
+ return R;
5477
+ }
5478
+ // (4) out of every node box
5479
+ const boxes=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
5480
+ if(boxes.some(b=>ringHitsRect(R,rL,rA,b))){
5481
+ let ok=false;
5482
+ for(let k=1;k<=24&&!ok;k++){
5483
+ const RL=rL-(rL-RING_FLOOR)*k/24;
5484
+ if(!boxes.some(b=>ringHitsRect(R,RL,rA,b))){ rL=RL; ok=true; }
5485
+ }
5486
+ // No orientation of this ring clears the drawing. Rather than publish a
5487
+ // ring lying over a node, revert to the pre-0.4 derivation, which is at
5488
+ // least the shape every earlier artifact recorded. No corpus figure
5489
+ // takes this branch; it exists so that a hostile geometry degrades to
5490
+ // the old defect instead of a new one.
5491
+ if(!ok){
5492
+ const M=segs.map(([p,q])=>[(p[0]+q[0])/2,(p[1]+q[1])/2]);
5493
+ const mx=M.reduce((a,m)=>a+m[0],0)/M.length, my=M.reduce((a,m)=>a+m[1],0)/M.length;
5494
+ const lx=Math.max(46,Math.max(...M.map(m=>Math.abs(m[0]-mx)))+38);
5495
+ const ly=Math.max(26,Math.max(...M.map(m=>Math.abs(m[1]-my)))+22);
5496
+ return {cx:mx, cy:my, ux:1, uy:0, vx:0, vy:1, th:0, legacy:true,
5497
+ rL:lx, rA:ly, hw:lx, hh:ly};
5498
+ }
5499
+ }
5500
+ R.rL=rL; R.rA=rA;
5501
+ // the axis-aligned box the caption is placed beside and the canvas grows to
5502
+ R.hw=Math.hypot(rL*ux, rA*vx); R.hh=Math.hypot(rL*uy, rA*vy);
5503
+ return R;
5504
+ };
5505
+
4871
5506
  // ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
4872
5507
  // A group's band is the BOUNDING BOX of its members (see gBox below), and
4873
5508
  // until this pass nothing checked that the box contained only members. A
@@ -4922,6 +5557,44 @@ function renderScene(doc,y0){
4922
5557
  const canMove=u=>u.every(n=>!pinned(n.id))
4923
5558
  && !(u[0].group&&doc.pins[u[0].group]&&doc.pins[u[0].group].fx!==null);
4924
5559
  const said=new Set();
5560
+ // ── THE SAME REGION RULE FOR A BUNDLE'S LASSO (item 69) ────
5561
+ // A band and a lasso are the same statement drawn two ways: each is a
5562
+ // shape DERIVED from a declared membership, so whatever the shape contains
5563
+ // reads as a member. This pass already keeps a non-member out of a band;
5564
+ // the lasso is added to the same convergence rather than given a pass of
5565
+ // its own, because the two constraints interact — a node pushed out of a
5566
+ // lasso can land in a band, and one pushed out of a band can land in a
5567
+ // lasso — and only a shared loop can settle both.
5568
+ //
5569
+ // CONTAINMENT IS THE WHOLE BOX, NOT ITS CENTRE, which is the band's own
5570
+ // discipline (`inBand` tests the box's extent against the band's, never a
5571
+ // point). The two differ in STRICTNESS, and the difference is forced by
5572
+ // the shapes: a band is FILLED, so a box that merely overlaps it is
5573
+ // already sitting on painted group territory; a lasso is a RING with no
5574
+ // interior (§8.4), and `ringOf` step (4) has already shrunk it clear of
5575
+ // every node box, so a box can only be wholly inside or wholly outside.
5576
+ // Full containment is therefore not a weaker rule here, it is the only
5577
+ // reachable one — and it is the same rule the complete-cover check uses
5578
+ // one construct over (`inside`).
5579
+ //
5580
+ // A ONE-MEMBER BUNDLE DRAWS NO ELLIPSE (item 53) and therefore makes no
5581
+ // containment claim: there is no shape, so there is nothing to be inside,
5582
+ // and `lassoOf` returns null for it. Its caption is a label like any other.
5583
+ // PORT SQUARES need no separate test: a square straddles the border of the
5584
+ // node it belongs to, so a node clear of the ring carries its fittings out
5585
+ // with it. EDGES are not tested at all — a lasso exists to be crossed by
5586
+ // lines, and every member link runs through it by construction.
5587
+ const trunks=(doc.trunks||[]).filter(t=>t.pairs&&t.pairs.length);
5588
+ const lMem=t=>{ const s=new Set(); for(const [a,b] of t.pairs){ s.add(a); s.add(b); } return s; };
5589
+ const lassoOf=t=>{ const R=ringOf(t); return (R&&!R.solo)?R:null; };
5590
+ const inLasso=(n,R)=>{
5591
+ 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]]){
5592
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
5593
+ if(Math.hypot((R.ux*dx+R.uy*dy)/R.rL,(R.vx*dx+R.vy*dy)/R.rA)>1) return false;
5594
+ }
5595
+ return true;
5596
+ };
5597
+ const lKey=(t,n)=>'bundle '+t.id+' '+n.id;
4925
5598
  const collect=()=>{
4926
5599
  const out=[];
4927
5600
  for(const g of groups){
@@ -4931,8 +5604,50 @@ function renderScene(doc,y0){
4931
5604
  if(inBand(n,B)) out.push({g,n});
4932
5605
  }
4933
5606
  }
5607
+ for(const t of trunks){
5608
+ const R=lassoOf(t); if(!R) continue;
5609
+ const mem=lMem(t);
5610
+ for(const n of real){
5611
+ if(mem.has(n.id)||said.has(lKey(t,n))) continue;
5612
+ if(inLasso(n,R)) out.push({t,n,mem});
5613
+ }
5614
+ }
4934
5615
  return out;
4935
5616
  };
5617
+ // The MOVE, shared by both regions. `unit` travels; `obst` is the cross-axis
5618
+ // interval it must end up clear of; `keep` is everything that must NOT be
5619
+ // dragged along with it.
5620
+ const push=(unit,obst,keep)=>{
5621
+ const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
5622
+ const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
5623
+ // NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
5624
+ // whichever way is shorter — sends the unit past the layout's own
5625
+ // starting edge often enough to matter (`reference/topology` put L1 at
5626
+ // x=-90 and the viewBox clipped it away). Growing the canvas the other
5627
+ // way is not available either: the only uniform-shift machinery this
5628
+ // renderer has moves PINNED nodes with everything else, and a pinned
5629
+ // node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
5630
+ // stability violation this engine has already paid for once. So the
5631
+ // constraint is applied HERE, to the choice: the negative direction is
5632
+ // taken only when the unit still lands inside the envelope the layout
5633
+ // had before this pass ran. Nothing outside the mover ever moves.
5634
+ const dNegOK=uLo+dNeg>=cross0;
5635
+ const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
5636
+ const ranks=new Set(unit.map(n=>n.rank));
5637
+ // Everything the mover would be pushed ONTO travels with it: same rank,
5638
+ // same side, clear of the obstacle. Relative order and spacing inside a
5639
+ // lane are preserved, so the fix cannot manufacture an overlap.
5640
+ // A node that BELONGS to a group never travels this way — a group moves
5641
+ // whole or not at all, and dragging half of one along would reshape its
5642
+ // band, which is the same defect one group further on.
5643
+ for(const m of lay){
5644
+ if(keep.has(m)||!ranks.has(m.rank)) continue;
5645
+ if(!m.virtual&&m.group) continue;
5646
+ const mLo=cLo(m), mHi=mLo+cSz(m);
5647
+ if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
5648
+ }
5649
+ for(const n of unit) mv(n,d);
5650
+ };
4936
5651
  let left=[];
4937
5652
  // EVERY conflict gets attention on every pass, and the band is recomputed
4938
5653
  // immediately before each resolution. Taking only the first conflict each
@@ -4943,6 +5658,49 @@ function renderScene(doc,y0){
4943
5658
  left=collect();
4944
5659
  if(!left.length) break;
4945
5660
  for(const c of left){
5661
+ if(c.t){
5662
+ // A LASSO CONFLICT. Only the intruder ever yields. The band's second
5663
+ // option — move the GROUP instead — has no honest analogue here: a
5664
+ // bundle's members are links between devices that other links also
5665
+ // touch, so "move the members" relocates half the figure and reshapes
5666
+ // the very ring it is trying to fix. When the intruder cannot move,
5667
+ // an author coordinate is what fixed it, and the rule below (at the
5668
+ // final geometry) says so rather than the engine overriding a pin.
5669
+ const R=lassoOf(c.t);
5670
+ if(!R||!inLasso(c.n,R)) continue; // an earlier resolution cleared it
5671
+ const unit=unitOf(c.n);
5672
+ if(!canMove(unit)){ said.add(lKey(c.t,c.n)); continue; }
5673
+ // Clear of the ring's AXIS-ALIGNED extent, not of the ellipse: a box
5674
+ // outside the bounding box is outside the ring for certain, whatever
5675
+ // the ring's rotation, and the pass stays monotone (always outward).
5676
+ const obst=horiz?{lo:R.cy-R.hh, hi:R.cy+R.hh}:{lo:R.cx-R.hw, hi:R.cx+R.hw};
5677
+ // AND IT MOVES NOTHING BUT THE INTRUDER. The band's push takes what
5678
+ // it would land on along with it; a lasso's cannot, and the reason is
5679
+ // the shape. A band's extent stops at its members' boxes, so a mover
5680
+ // pushed clear of it lands in free space. A lasso's extent, when the
5681
+ // ring falls back to the pre-0.4 derivation — which is what a bundle
5682
+ // whose links pass THROUGH intermediate devices always does, since
5683
+ // the ring cannot clear their boxes — spans the whole fan, so "clear
5684
+ // of the ring" is exactly where the outermost members already are.
5685
+ // Dragging them along was measured: it walks the top member off the
5686
+ // canvas (y=-26 on the three-tap stress figure) and the ring grows
5687
+ // with them, so the intruder is enclosed again on the next pass. So
5688
+ // the move is taken ONLY into room that is already free, and when
5689
+ // there is none the pass declines and the rule below refuses the
5690
+ // figure. A separation that has to damage the drawing to succeed is
5691
+ // not a separation; not drawn beats drawn wrongly, here as well.
5692
+ const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
5693
+ const ranks=new Set(unit.map(n=>n.rank)), inUnit=new Set(unit);
5694
+ const others=lay.filter(m=>!inUnit.has(m)&&ranks.has(m.rank));
5695
+ const roomFor=d=>uLo+d>=cross0 &&
5696
+ others.every(m=>{ const mLo=cLo(m), mHi=mLo+cSz(m);
5697
+ return mHi<=uLo+d-SEP||mLo>=uHi+d+SEP; });
5698
+ const go=[(obst.lo-SEP)-uHi,(obst.hi+SEP)-uLo]
5699
+ .filter(roomFor).sort((p,q)=>Math.abs(p)-Math.abs(q));
5700
+ if(!go.length){ said.add(lKey(c.t,c.n)); continue; }
5701
+ for(const n of unit) mv(n,go[0]);
5702
+ continue;
5703
+ }
4946
5704
  const B=bandOf(c.g);
4947
5705
  if(!inBand(c.n,B)) continue; // an earlier resolution cleared it
4948
5706
  const gMem=memOf(c.g.id);
@@ -4966,36 +5724,7 @@ function renderScene(doc,y0){
4966
5724
  said.add(c.g.id+' '+c.n.id); continue;
4967
5725
  }
4968
5726
  }
4969
- const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
4970
- const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
4971
- // NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
4972
- // whichever way is shorter — sends the unit past the layout's own
4973
- // starting edge often enough to matter (`reference/topology` put L1 at
4974
- // x=-90 and the viewBox clipped it away). Growing the canvas the other
4975
- // way is not available either: the only uniform-shift machinery this
4976
- // renderer has moves PINNED nodes with everything else, and a pinned
4977
- // node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
4978
- // stability violation this engine has already paid for once. So the
4979
- // constraint is applied HERE, to the choice: the negative direction is
4980
- // taken only when the unit still lands inside the envelope the layout
4981
- // had before this pass ran. Nothing outside the mover ever moves.
4982
- const dNegOK=uLo+dNeg>=cross0;
4983
- const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
4984
- const ranks=new Set(unit.map(n=>n.rank));
4985
- const keep=new Set(unit.concat(unit===gMem?[]:gMem));
4986
- // Everything the mover would be pushed ONTO travels with it: same rank,
4987
- // same side, clear of the obstacle. Relative order and spacing inside a
4988
- // lane are preserved, so the fix cannot manufacture an overlap.
4989
- // A node that BELONGS to a group never travels this way — a group moves
4990
- // whole or not at all, and dragging half of one along would reshape its
4991
- // band, which is the same defect one group further on.
4992
- for(const m of lay){
4993
- if(keep.has(m)||!ranks.has(m.rank)) continue;
4994
- if(!m.virtual&&m.group) continue;
4995
- const mLo=cLo(m), mHi=mLo+cSz(m);
4996
- if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
4997
- }
4998
- for(const n of unit) mv(n,d);
5727
+ push(unit,obst,new Set(unit.concat(unit===gMem?[]:gMem)));
4999
5728
  }
5000
5729
  }
5001
5730
  left=collect();
@@ -5003,6 +5732,13 @@ function renderScene(doc,y0){
5003
5732
  // is named. A figure that reaches this line with a hit is a defect in this
5004
5733
  // pass, and saying so beats drawing the false statement quietly.
5005
5734
  for(const c of left){
5735
+ // A LASSO residue is NOT reported here. This pass sees a PROJECTION of
5736
+ // the ring, taken before the boundary anchors are seated and before the
5737
+ // uniform canvas shifts; the ring the reader sees is the one computed at
5738
+ // the final geometry, and that is where its truth is judged (item 69,
5739
+ // below the label pass). Reporting twice from two geometries is how a
5740
+ // figure gets refused for a ring that was never drawn.
5741
+ if(c.t) continue;
5006
5742
  if(said.has(c.g.id+' '+c.n.id)) continue;
5007
5743
  gErrs.push('Line '+srcLine(c.g.line)+': group "'+c.g.id+'" would enclose non-member "'
5008
5744
  +c.n.id+'" and the layout pass could not separate them; the figure is not drawn rather '
@@ -5805,185 +6541,98 @@ function renderScene(doc,y0){
5805
6541
  const padded=o=>({x:o.x-LBL_PAD,y:o.y-LBL_PAD,w:o.w+2*LBL_PAD,h:o.h+2*LBL_PAD});
5806
6542
  const lblObs=e=>extObs.filter(o=>o.id!==e.a&&o.id!==e.b).map(padded)
5807
6543
  .concat(nameObs.map(padded));
5808
- // ── A BUNDLE RING IS ORIENTED BY ITS MEMBERS (item 43) ───────────────────
5809
- // Until now the ring was derived from the member links' MIDPOINTS and
5810
- // nothing else: `rx = max(46, x-spread + 38)`, `ry = max(26, y-spread + 22)`.
5811
- // Two facts about that formula are the defect. It is DIRECTION-BLIND the
5812
- // axes are the canvas's, never the links' and its floors are large enough
5813
- // that a bundle of two short legs draws a near-circle whatever the legs do.
5814
- // Measured on `patterns/topology-b`: rx 46 / ry 42.7, aspect
5815
- // 1.07, sitting on two legs that fan VERTICALLY, with the caption lying
5816
- // across the waist and on top of the `p3` endpoint label.
5817
- //
5818
- // The drawing convention for a link bundle is a loop THROUGH which the
5819
- // links run: narrow along the links, long across them. So the ring is now
5820
- // derived from the members' own frame.
5821
- //
5822
- // 1. DIRECTION. The mean UNDIRECTED direction of the members, by the
5823
- // doubled-angle circular mean — doubling is what makes `a--b` and
5824
- // `b--a` the same direction, so a bundle does not change shape when an
5825
- // author writes a member the other way round (conformance 394).
5826
- // 2. THE BAND. The ring sits at the MIDPOINT OF THE MEMBERS' SHARED RUN
5827
- // along that direction — the stretch of the corridor every member is
5828
- // actually in. That is the placement rule because it is the only band
5829
- // at which "the spread of the members" is a fact about all of them: a
5830
- // fanning set (the reference topology's ECMP pair, which leaves two
5831
- // different spines and arrives at one leaf) has a different spread at
5832
- // every station, and the shared run is the interval over which the
5833
- // question is even well posed. Members with no shared run at all fall
5834
- // back to the mean of their midpoints, which is the old centre.
5835
- // 3. THE AXES. MINOR along the mean direction, sized to clear the strokes;
5836
- // MAJOR across it, the members' spread at the band plus padding. A ring
5837
- // needs a long axis to read as a ring, and it takes it from the spread
5838
- // when the members fan (`rA >= rL`) and from its own RUN when they do
5839
- // not — a single-member bundle (`reference/topology`'s multi-home link)
5840
- // has zero spread, and the shape that hugs one link is an ellipse lying
5841
- // ALONG it, never a circle straddling it.
5842
- // 4. NO NODE CONTACT. The ring is shrunk along its minor axis until it
5843
- // clears every node box, and if it cannot it reverts to the pre-0.4
5844
- // geometry rather than inventing a shape (no corpus figure does).
5845
- //
5846
- // The caption is NOT placed here — it is registered with the label pass
5847
- // below, so it can see the endpoint labels and the edges it has to avoid.
5848
- const rnd3=v=>Math.round(v*1000)/1000; // ring coordinates, printed short
5849
- const RING_ALONG=15, // semi-minor: the ring's body along the links
5850
- RING_ACROSS=20, // clearance beyond the outermost member
5851
- RING_LONG=34, // the long axis a ring needs to read as a ring
5852
- RING_FAN=2, // below this spread the members are one line
5853
- RING_FLOOR=7, // how thin the minor axis may be squeezed
5854
- RING_SOLO=4; // a ONE-member bundle: the caption's stand-off
5855
- // ── A ONE-MEMBER BUNDLE IS ITS CAPTION (item 53) ─────────────────────────
5856
- // `bundle mh "multi-home" l1--l2` is legal and means something real (a
5857
- // one-link LAG, an Ethernet Segment with a single member), so the
5858
- // declaration is not an error and is not dropped. But a ring exists to
5859
- // UNITE lines, and around ONE line it unites nothing: it is ink that adds
5860
- // no fact, and on the reference topology it also bought the seam a 104 px
5861
- // corridor demand (2*RX_MIN + clearance) for a shape nobody had to see.
6544
+ const rings=new Map();
6545
+ for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
6546
+ // ── A LASSO THAT ENCLOSES A NON-MEMBER IS A FALSE DRAWING (item 69) ──────
6547
+ // The group-band rule, one construct over, and the reason it is owed is a
6548
+ // measurement: a three-link aggregation drawn from the UNH-IOL LACP test
6549
+ // suite (Test Setup 2, fig 06 of that validation exercise) put a lasso at
6550
+ // cx 592 cy 455, rx 231 ry 162 around six member links — and fully inside
6551
+ // it, at normalised radius 0.70, sat two test stations that are not in the
6552
+ // aggregation at all. The figure told the reader they were. The engine said
6553
+ // nothing and `layout-lint` scored it 0, while the IDENTICAL claim written
6554
+ // as a `group` band was refused four times by the pass above. Backlog 43 and
6555
+ // 53 were about lasso SHAPE; this is lasso TRUTH.
5862
6556
  //
5863
- // So a single-member bundle DRAWS NO ELLIPSE. What it draws is its
5864
- // CAPTION, bundle-styled (the trunk's own stroke, the ring caption's type)
5865
- // and placed BY THE LINK which is exactly the statement the construct
5866
- // makes: this link is the bundle, and here is its name. The frame below is
5867
- // therefore kept as a PLACEMENT frame and not as a drawing: the caption
5868
- // pass already sites a caption around a ring's rim, so a ring squeezed to
5869
- // `RING_SOLO` across gives the same pass a candidate family hugging the
5870
- // link. Nothing else in the pass changes.
6557
+ // WHOEVER CHOSE THE POSITION BEARS THE RESPONSIBILITY the band's principle,
6558
+ // inherited whole, and its boundary inherited with it. The complete-cover
6559
+ // check (backlog 47b) fires only when at least one of the two
6560
+ // boxes is at a coordinate the author never wrote; this rule is that same
6561
+ // question asked the other way round. Where NO author coordinate is involved
6562
+ // the engine had the freedom and MUST use it, so the separation pass above
6563
+ // has already moved the intruder out and nothing reaches here. What reaches
6564
+ // here is a figure whose geometry an author fixed: the intruder is pinned,
6565
+ // or a member endpoint is, or the separation pass ran out of room. Then the
6566
+ // engine reports and the artifact is not written, because it will not draw a
6567
+ // membership the source did not declare.
5871
6568
  //
5872
- // AN EMPTY CAPTION ON A ONE-MEMBER BUNDLE THEREFORE DRAWS NOTHING AT ALL.
5873
- // That is a real consequence and it is not hidden: the reference figure
5874
- // keeps `bundle unlabelled "" s2--l2` — it is the form demonstrator for
5875
- // the empty label — and states in a comment that this form is now
5876
- // MODEL-ONLY. The model still carries the trunk, `read` still reports it,
5877
- // and the drawing says nothing because there is nothing a ring around one
5878
- // unnamed line could say.
5879
- // Does the ring's disc meet an axis-aligned rect? Both are mapped into the
5880
- // frame where the ring is the unit circle; the rect becomes a convex quad,
5881
- // and the test is "is the quad within 1 of the origin".
5882
- const ringHitsRect=(R,RL,RA,b)=>{
5883
- const q=[[b.x,b.y],[b.x+b.w,b.y],[b.x+b.w,b.y+b.h],[b.x,b.y+b.h]].map(p=>{
5884
- const dx=p[0]-R.cx, dy=p[1]-R.cy;
5885
- return [(R.ux*dx+R.uy*dy)/RL, (R.vx*dx+R.vy*dy)/RA];
5886
- });
5887
- let inside=false;
5888
- for(let i=0,j=3;i<4;j=i++)
5889
- if((q[i][1]>0)!==(q[j][1]>0) &&
5890
- 0 < (q[j][0]-q[i][0])*(0-q[i][1])/(q[j][1]-q[i][1])+q[i][0]) inside=!inside;
5891
- if(inside) return true;
5892
- for(let i=0,j=3;i<4;j=i++){
5893
- const vx=q[j][0]-q[i][0], vy=q[j][1]-q[i][1], L2=vx*vx+vy*vy;
5894
- const t=L2?Math.max(0,Math.min(1,-(q[i][0]*vx+q[i][1]*vy)/L2)):0;
5895
- if(Math.hypot(q[i][0]+t*vx, q[i][1]+t*vy)<1) return true;
5896
- }
5897
- return false;
5898
- };
5899
- const ringOf=t=>{
5900
- const segs=[];
5901
- for(const [a,b] of t.pairs){
5902
- const A=byId[a], B=byId[b]; if(!A||!B) continue;
5903
- const p=borderPoint(A,B.x+B.w/2,B.y+B.h/2), q=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
5904
- if(Math.hypot(q[0]-p[0],q[1]-p[1])>1e-9) segs.push([p,q]);
5905
- }
5906
- if(!segs.length) return null;
5907
- // (1) mean undirected direction
5908
- let c2=0,s2=0;
5909
- for(const [p,q] of segs){
5910
- const L=Math.hypot(q[0]-p[0],q[1]-p[1]);
5911
- const c=(q[0]-p[0])/L, s=(q[1]-p[1])/L;
5912
- c2+=c*c-s*s; s2+=2*c*s;
5913
- }
5914
- const th=Math.hypot(c2,s2)<1e-9
5915
- ? Math.atan2(segs[0][1][1]-segs[0][0][1], segs[0][1][0]-segs[0][0][0])
5916
- : 0.5*Math.atan2(s2,c2);
5917
- const ux=Math.cos(th), uy=Math.sin(th), vx=-uy, vy=ux;
5918
- // (2) the band: the midpoint of the shared run
5919
- const iv=segs.map(([p,q])=>{
5920
- const a=ux*p[0]+uy*p[1], b=ux*q[0]+uy*q[1];
5921
- return a<=b?[a,b,p,q]:[b,a,q,p];
5922
- });
5923
- const lo=Math.max(...iv.map(z=>z[0])), hi=Math.min(...iv.map(z=>z[1]));
5924
- const s=lo<hi ? (lo+hi)/2 : iv.reduce((x,z)=>x+(z[0]+z[1])/2,0)/iv.length;
5925
- const P=iv.map(z=>{
5926
- const f=Math.min(1,Math.max(0,(s-z[0])/((z[1]-z[0])||1)));
5927
- return [z[2][0]+(z[3][0]-z[2][0])*f, z[2][1]+(z[3][1]-z[2][1])*f];
5928
- });
5929
- const cx=P.reduce((a,p)=>a+p[0],0)/P.length, cy=P.reduce((a,p)=>a+p[1],0)/P.length;
5930
- const spread=Math.max(...P.map(p=>Math.abs(vx*(p[0]-cx)+vy*(p[1]-cy))));
5931
- const runHalf=Math.min(...iv.map(z=>Math.min(s-z[0],z[1]-s)));
5932
- // (3) axes. Two shapes, and which one is drawn is decided by whether the
5933
- // members FAN at all. A set that fans is CROSSED by the ring: thin along
5934
- // the links, long enough across them to take the whole spread with room
5935
- // to spare, so each member passes through a part of the rim where the
5936
- // ring is still visibly open. A set that does not fan — one link, or
5937
- // members lying on top of each other — has no spread to take, and the
5938
- // shape that says "this link is the bundle" is an ellipse lying ALONG it.
5939
- let rA, rL;
5940
- if(spread<RING_FAN){ rL=Math.max(RING_ALONG,Math.min(RING_LONG,Math.max(0,runHalf))); rA=RING_ACROSS; }
5941
- else { rL=RING_ALONG; rA=Math.max(spread+RING_ACROSS,RING_LONG); }
5942
- const R={cx,cy,ux,uy,vx,vy,th};
5943
- // (0) ONE MEMBER (item 53): no ellipse is drawn, so this is a caption
5944
- // frame and not a shape. It is squeezed across to `RING_SOLO` so the
5945
- // caption pass's rim stations sit beside the link rather than a ring's
5946
- // radius away from it, and the node-clearance step below is skipped —
5947
- // there is no ink to keep out of a box.
5948
- if(segs.length===1){
5949
- R.solo=true; R.rL=rL; R.rA=RING_SOLO;
5950
- R.hw=Math.hypot(rL*ux, RING_SOLO*vx); R.hh=Math.hypot(rL*uy, RING_SOLO*vy);
5951
- return R;
5952
- }
5953
- // (4) out of every node box
5954
- const boxes=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
5955
- if(boxes.some(b=>ringHitsRect(R,rL,rA,b))){
5956
- let ok=false;
5957
- for(let k=1;k<=24&&!ok;k++){
5958
- const RL=rL-(rL-RING_FLOOR)*k/24;
5959
- if(!boxes.some(b=>ringHitsRect(R,RL,rA,b))){ rL=RL; ok=true; }
5960
- }
5961
- // No orientation of this ring clears the drawing. Rather than publish a
5962
- // ring lying over a node, revert to the pre-0.4 derivation, which is at
5963
- // least the shape every earlier artifact recorded. No corpus figure
5964
- // takes this branch; it exists so that a hostile geometry degrades to
5965
- // the old defect instead of a new one.
5966
- if(!ok){
5967
- const M=segs.map(([p,q])=>[(p[0]+q[0])/2,(p[1]+q[1])/2]);
5968
- const mx=M.reduce((a,m)=>a+m[0],0)/M.length, my=M.reduce((a,m)=>a+m[1],0)/M.length;
5969
- const lx=Math.max(46,Math.max(...M.map(m=>Math.abs(m[0]-mx)))+38);
5970
- const ly=Math.max(26,Math.max(...M.map(m=>Math.abs(m[1]-my)))+22);
5971
- return {cx:mx, cy:my, ux:1, uy:0, vx:0, vy:1, th:0, legacy:true,
5972
- rL:lx, rA:ly, hw:lx, hh:ly};
6569
+ // The test is at the FINAL geometry the same ring, from the same `ringOf`,
6570
+ // that is drawn a thousand lines below because the ring the reader reads is
6571
+ // the only one whose truth is at stake.
6572
+ {
6573
+ const real=nodes.filter(n=>!n.boundary&&n.w>0&&n.h>0);
6574
+ for(const t of doc.trunks||[]){
6575
+ const R=rings.get(t);
6576
+ if(!R||R.solo) continue; // one member draws no ellipse (item 53)
6577
+ const mem=new Set(); for(const [a,b] of t.pairs){ mem.add(a); mem.add(b); }
6578
+ const memPinned=[...mem].some(id=>pinned(id));
6579
+ for(const n of real){
6580
+ if(mem.has(n.id)) continue;
6581
+ let inside=true;
6582
+ 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]]){
6583
+ const dx=p[0]-R.cx, dy=p[1]-R.cy;
6584
+ if(Math.hypot((R.ux*dx+R.uy*dy)/R.rL,(R.vx*dx+R.vy*dy)/R.rA)>1){ inside=false; break; }
6585
+ }
6586
+ if(!inside) continue;
6587
+ const ln=srcLine(t.line);
6588
+ // Two voices, chosen by who chose the coordinate — the band's own
6589
+ // split. A pin is named as a pin so the author knows which line to
6590
+ // edit; a figure the engine could not separate says so, and admits it.
6591
+ gErrs.push(pinned(n.id)||memPinned
6592
+ ? 'Line '+ln+': the lasso for bundle "'+t.id+'" would enclose non-member "'+n.id
6593
+ +'" — a lasso is the drawn extent of the bundle\'s members, so this draws "'+n.id
6594
+ +'" as one of them. Move the pin clear of the bundle\'s extent, or change what "'
6595
+ +t.id+'" collects so the lasso is its members\' own. The figure is not drawn '
6596
+ +'rather than drawn wrongly.'
6597
+ : 'Line '+ln+': the lasso for bundle "'+t.id+'" would enclose non-member "'+n.id
6598
+ +'" and the layout pass could not separate them; the figure is not drawn rather '
6599
+ +'than drawn wrongly. Give "'+n.id+'" a pin outside the bundle\'s extent, or add '
6600
+ +'its link to "'+t.id+'".');
5973
6601
  }
5974
6602
  }
5975
- R.rL=rL; R.rA=rA;
5976
- // the axis-aligned box the caption is placed beside and the canvas grows to
5977
- R.hw=Math.hypot(rL*ux, rA*vx); R.hh=Math.hypot(rL*uy, rA*vy);
5978
- return R;
5979
- };
5980
- const rings=new Map();
5981
- for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
6603
+ }
6604
+ // WHOSE EXTENT THE LASSO IS, WRITTEN INTO THE DRAWING (item 69).
6605
+ // `data-lasso` follows the `data-port-sq` pattern exactly: ONE
6606
+ // attribute name, carried by the INK and by the thing the ink is about, with
6607
+ // the same string on both, so a reader — `tools/layout-lint.js` first — reads
6608
+ // the association instead of inferring it. The ellipse carries the bundle's
6609
+ // id; every node the ellipse was derived from carries the ids of the lassos
6610
+ // it is a member of (a node may be in more than one, space-separated). The
6611
+ // geometry itself is NOT duplicated into the attribute: `cx/cy/rx/ry` and the
6612
+ // `rotate()` are already the drawn truth, and a second copy could disagree
6613
+ // with the first. Only bundles that DRAW an ellipse appear — a one-member
6614
+ // bundle draws none (item 53) and claims no extent, so it stamps nothing.
6615
+ const lassoMem=new Map();
6616
+ for(const [t,R] of rings){
6617
+ if(R.solo) continue;
6618
+ for(const [a,b] of t.pairs) for(const id of [a,b]){
6619
+ if(!lassoMem.has(id)) lassoMem.set(id,[]);
6620
+ if(!lassoMem.get(id).includes(t.id)) lassoMem.get(id).push(t.id);
6621
+ }
6622
+ }
5982
6623
  // filled by the label pass below, consumed by the ring drawing further down
5983
6624
  const ringLbl=new Map(), ringLeads=[];
5984
6625
  // edges (sorted by plane z, then doc order)
5985
6626
  const edges=zsort(doc.edges);
5986
6627
  const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
6628
+ // THE PORT SQUARES GET THEIR OWN LAYER (item 57b), between the
6629
+ // node bodies and the labels. Over the nodes, because half of each square is
6630
+ // meant to be seen INSIDE the box and the whole point of the notation is
6631
+ // that the fitting sits ON the boundary; over the edges, because the
6632
+ // connector ATTACHES to the square rather than passing through it; under
6633
+ // everything in `lblsvg`, because a port name — and an arrowhead, which
6634
+ // lives in that layer too — must never be occluded by the fitting it names.
6635
+ const sqsvg=[];
5987
6636
  // ── deferred edge-label placement ───────────────────────────────────────
5988
6637
  // An edge label is not written where it is emitted. Each emission reserves
5989
6638
  // its slot in lblsvg (so the paint order is unchanged) and registers the
@@ -6040,8 +6689,9 @@ function renderScene(doc,y0){
6040
6689
  // box and repeating one label three times is what this removes.
6041
6690
  //
6042
6691
  // Each member still emits its OWN full path from its source outline to the
6043
- // target outline — shape-check asserts exactly that, and `data-edge` carries
6044
- // one source line so the shared trunk is stroked once per member. That
6692
+ // target outline — shape-check asserts exactly that, and `data-edge` keys
6693
+ // each one (its id, or its source line when anonymous; `CONNECTOR-IDENTITY-KEY`) so the shared
6694
+ // trunk is stroked once per member. That
6045
6695
  // coincidence is the convention and not a defect, and the members say so:
6046
6696
  // every bus path carries `data-bus="<target>"`, which is what lets a reader
6047
6697
  // (and layout-lint) tell a deliberate trunk from two edges hidden under each
@@ -6401,7 +7051,7 @@ function renderScene(doc,y0){
6401
7051
  // pattern to a period boundary at the path's end.
6402
7052
  const per=e.style==='dashed'?10:(e.style==='dotted'?6:0);
6403
7053
  const doff=per?' stroke-dashoffset="'+(((per-(runLen(pts)%per))%per).toFixed(2))+'"':'';
6404
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+doff+' data-bus="'+esc(bus.bus)+'"/>');
7054
+ 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)+'"/>');
6405
7055
  noteSegs(e,pts);
6406
7056
  for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
6407
7057
  if(bus.dots) for(const d of bus.dots)
@@ -6434,7 +7084,26 @@ function renderScene(doc,y0){
6434
7084
  if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
6435
7085
  continue;
6436
7086
  }
6437
- if(chBack(e)&&!pinned(e.a)&&!pinned(e.b)){
7087
+ // A SELF-TRANSITION IS EXEMPT FROM THE PINNED-ENDPOINT EXCLUSION
7088
+ // (item 64). The `!pinned` guard belongs to the CHANNEL
7089
+ // route below it: a channel back edge is planned in `chPlan`, which is
7090
+ // built over auto-layout lanes and has nothing to say about a box the
7091
+ // author placed, so a pinned back edge correctly falls through to the
7092
+ // straight route. A SELF-loop has no such dependency — its geometry is a
7093
+ // function of ONE box and a free side, both of which a pinned box has —
7094
+ // and sweeping it into the same exclusion made `pin` silently delete the
7095
+ // drawing: `borderPoint(A, A's own centre)` twice is the same point, so
7096
+ // the straight route emitted a zero-length `<line>` at the state's centre
7097
+ // with the trigger label printed across the state's name. Measured on
7098
+ // 0.4 over a pinned three-state triangle and a pinned two-state
7099
+ // pair: every self-transition length 0.0. The rule the exemption keeps is
7100
+ // one line — a self-transition draws the same way whether its state's
7101
+ // coordinate came from a pin or from the layout pass — so the side
7102
+ // selection below is REUSED as-is rather than duplicated for pins; a
7103
+ // second selection rule would be a second convention, and `DOMAIN-CONVENTION-DIRECTIVES` gives the
7104
+ // engine one. (`chBack` is always true here: `isBack` takes every
7105
+ // self-edge, and the `recip` pair test skips `e.a===e.b`.)
7106
+ if(chBack(e)&&(A===B||(!pinned(e.a)&&!pinned(e.b)))){
6438
7107
  // ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
6439
7108
  // Edge labels are DEFERRED: every label is registered against its
6440
7109
  // FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
@@ -6491,7 +7160,7 @@ function renderScene(doc,y0){
6491
7160
  for(const sd of ['r','l','b','t']){ const pp=mkLoop(sd); if(!loopHit(pp)){ sp=pp; break; } }
6492
7161
  if(!sp) sp=mkLoop('r');
6493
7162
  for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
6494
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7163
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6495
7164
  noteSegs(e,sp);
6496
7165
  // A self-loop's outer run is 16 px long, so sliding the label ALONG it
6497
7166
  // buys ~15 px and no escape at all from a line crossing it — and a
@@ -6631,7 +7300,7 @@ function renderScene(doc,y0){
6631
7300
  // it is what lets a reader (and `layout-lint`'s coincident term) tell a
6632
7301
  // deliberate shared trunk from two lines hidden under each other. Written
6633
7302
  // LAST, after stroke-width, so no existing reader breaks.
6634
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash
7303
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash
6635
7304
  +(P.bus?' data-bus="'+esc(P.bus)+'"':'')+'/>');
6636
7305
  noteSegs(e,pts);
6637
7306
  if(P.busTail){
@@ -6670,7 +7339,7 @@ function renderScene(doc,y0){
6670
7339
  const lrPts=longRoute(e);
6671
7340
  if(lrPts){
6672
7341
  for(const p of lrPts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
6673
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(lrPts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7342
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(lrPts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6674
7343
  noteSegs(e,lrPts);
6675
7344
  if(e.mid){
6676
7345
  // WHICH LEG CARRIES THE LABEL, and it is the OPPOSITE of the channel
@@ -6799,7 +7468,7 @@ function renderScene(doc,y0){
6799
7468
  }
6800
7469
  }
6801
7470
  }
6802
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7471
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6803
7472
  noteSegs(e,pts);
6804
7473
  if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
6805
7474
  if(e.tail) seg(p0,pts[1],e.tail,10,p1,runLen(pts));
@@ -6833,7 +7502,7 @@ function renderScene(doc,y0){
6833
7502
  }
6834
7503
  }
6835
7504
  if(route){
6836
- esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7505
+ esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6837
7506
  noteSegs(e,route);
6838
7507
  if(e.mid){ // the longest segment carries the mid label
6839
7508
  let bi=0,bl=-1;
@@ -6850,7 +7519,7 @@ function renderScene(doc,y0){
6850
7519
  for(const pP of route){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+4-y0-20); }
6851
7520
  continue;
6852
7521
  }
6853
- esvg.push('<line data-edge="'+e.line+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
7522
+ esvg.push('<line data-edge="'+edgeRef(e)+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
6854
7523
  noteSegs(e,[[x1,yy1],[x2,yy2]]);
6855
7524
  if(e.mid)
6856
7525
  reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
@@ -7018,6 +7687,68 @@ function renderScene(doc,y0){
7018
7687
  // how near a corner a marker may be written; the offsets are one marker
7019
7688
  // width apart, which is exactly the pitch two ports need to be two.
7020
7689
  const PORT_IN=2, PORT_END=4, PORT_HUG=25, PORT_OUT=60;
7690
+ // ── THE STRADDLING PORT SQUARE (item 57b) ─────────────────
7691
+ // OMG UML 2.5.1 §11.4's composite-structure port notation: a small square
7692
+ // drawn ON the classifier's boundary, half inside and half outside, with
7693
+ // the connector attaching to it and the port name labelling it. Here the
7694
+ // classifier is the device box, the connector is the link, and the name is
7695
+ // the interface. Item 57 put the NAME at the crossing; this puts a MARK
7696
+ // there, so the crossing is stated by the drawing rather than inferred
7697
+ // from where a word happens to sit.
7698
+ //
7699
+ // `PORT_SQ` is 7 px — the CAP-HEIGHT OF THE PORT NAME ITSELF (the endpoint
7700
+ // font is 10-11 px, so its capitals are about 7), which is what makes the
7701
+ // fitting read as the name's own mark rather than a second little box: it
7702
+ // is a fifth of an ordinary 36 px topology node's height and it never
7703
+ // competes with the device outline. The size was MEASURED, not chosen:
7704
+ // swept 7/8/9/10 px against layout-lint over this corpus, and 7 is the
7705
+ // only value that regresses no figure — `reference/topology` scores 6 at
7706
+ // 9 px, 4 at 8 px and 2 at 7 px against the pre-square 4, because every
7707
+ // extra pixel of fitting is a pixel of along-border room the name no
7708
+ // longer has on a small device.
7709
+ //
7710
+ // It is stroked in the OWNING NODE's own stroke at the node's own 1 px
7711
+ // weight and filled with the node's own fill, so it belongs to the device
7712
+ // that owns the port and to nothing else. `PORT_SQ_GAP` is the engine's
7713
+ // own 3 px label standoff, reused rather than reinvented; it is also the
7714
+ // width of the FORBIDDEN BAND around a foreign square (see `portCands`).
7715
+ const PORT_SQ=7, PORT_SQ_GAP=3;
7716
+ // Where the square goes: the crossing point snapped onto the nearest side
7717
+ // of the owner's box, clamped so a square can never hang off a corner.
7718
+ // The side comes back with it — the placement family needs it.
7719
+ const portSq=(N,p)=>{
7720
+ const dl=Math.abs(p[0]-N.x), dr=Math.abs(p[0]-(N.x+N.w)),
7721
+ dt=Math.abs(p[1]-N.y), db=Math.abs(p[1]-(N.y+N.h));
7722
+ const m=Math.min(dl,dr,dt,db);
7723
+ const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
7724
+ const vert=(side==='top'||side==='bottom');
7725
+ const h2=PORT_SQ/2;
7726
+ let cx,cy;
7727
+ // A CURVED OR ANGLED OUTLINE IS STILL THE OUTLINE. Snapping to the
7728
+ // bounding box is right for the rectangle family and WRONG for
7729
+ // everything else: `showcase/srl-evpn-irb`'s EVPN-VXLAN cloud is an
7730
+ // ellipse 620 x 110, and its `irb0.24` port snapped to the box bottom
7731
+ // came out 8 px BELOW the drawn curve — a fitting floating in space
7732
+ // beside the thing it is fitted to. The edge already ends on the true
7733
+ // outline (`borderPoint` put it there), so for a non-rectangular shape
7734
+ // the crossing point IS the answer and the square is simply centred on
7735
+ // it, axis-aligned. It straddles a slanted or curved border at a slight
7736
+ // angle to it, which is how every hand-drawn figure does it too.
7737
+ const boxy=!N.shape||N.shape==='box'||N.shape==='rounded'||N.shape==='cylinder';
7738
+ if(!boxy){ cx=Math.max(N.x+h2,Math.min(N.x+N.w-h2,p[0]));
7739
+ cy=Math.max(N.y+h2,Math.min(N.y+N.h-h2,p[1])); }
7740
+ else if(vert){ cx=Math.max(N.x+h2,Math.min(N.x+N.w-h2,p[0]));
7741
+ cy=side==='top'?N.y:N.y+N.h; }
7742
+ else { cy=Math.max(N.y+h2,Math.min(N.y+N.h-h2,p[1]));
7743
+ cx=side==='left'?N.x:N.x+N.w; }
7744
+ return {cx,cy,side,vert,key:cx.toFixed(2)+','+cy.toFixed(2),
7745
+ box:{x:cx-h2,y:cy-h2,w:PORT_SQ,h:PORT_SQ}};
7746
+ };
7747
+ // gap between two boxes, 0 when they meet — the same quantity `segBoxGap`
7748
+ // measures between a segment and a box, and it is what the standoff rule
7749
+ // below is written in.
7750
+ const boxSep=(a,b)=>Math.hypot(Math.max(b.x-(a.x+a.w),0,a.x-(b.x+b.w)),
7751
+ Math.max(b.y-(a.y+a.h),0,a.y-(b.y+b.h)));
7021
7752
  // The node's own drawn label, computed by the SAME formulas the node pass
7022
7753
  // emits it with (shrink-to-fit included), so the obstacle and the drawing
7023
7754
  // cannot disagree about where a name is.
@@ -7034,12 +7765,19 @@ function renderScene(doc,y0){
7034
7765
  // the glyph band inside that, and the two questions are different (see the
7035
7766
  // own-name term in the scorer).
7036
7767
  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)});
7768
+ // A label request's own text box, in the one place both the slot pass and
7769
+ // the candidate family can read it — they must agree about how wide a name
7770
+ // is or the fan below spaces names by a width nobody draws.
7771
+ const lblDims=r=>{
7772
+ const lines=String(r.text).split('\n'), nL=lines.length;
7773
+ const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
7774
+ const lh=r.fs*1.3;
7775
+ return {w, h:(nL-1)*lh+r.fs*1.1, up:(nL-1)*lh/2+r.fs*0.85};
7776
+ };
7037
7777
  const portCands=r=>{
7038
7778
  const N=r.port, P=r.p, out=[];
7039
7779
  if(!N) return out;
7040
- const lines=String(r.text).split('\n'), nL=lines.length;
7041
- const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
7042
- const lh=r.fs*1.3, h=(nL-1)*lh+r.fs*1.1, up=(nL-1)*lh/2+r.fs*0.85;
7780
+ const D=lblDims(r), w=D.w, h=D.h, up=D.up;
7043
7781
  // Which border did the link cross? The nearest side of the box.
7044
7782
  const dl=Math.abs(P[0]-N.x), dr=Math.abs(P[0]-(N.x+N.w)),
7045
7783
  dt=Math.abs(P[1]-N.y), db=Math.abs(P[1]-(N.y+N.h));
@@ -7047,13 +7785,28 @@ function renderScene(doc,y0){
7047
7785
  const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
7048
7786
  const vert=(side==='top'||side==='bottom');
7049
7787
  // ROOM. A marker that cannot be written between the two corners of the
7050
- // side it crosses has no inside form at all; the ordinary outside
7051
- // family is then the only one offered, which is the stated fallback.
7788
+ // side it crosses has no INSIDE form at all but it still has an
7789
+ // outside one, hugging the border it could not get into, and that is a
7790
+ // far better answer than the along-the-shaft family it used to fall
7791
+ // through to (an unclamped `roomIn` return sent every name on a narrow
7792
+ // device out into the seam between its links, which is the defect the
7793
+ // port convention exists to end). So the shortage is now a filter on
7794
+ // the INSIDE ring only.
7052
7795
  const span=vert?N.w:N.h, need=(vert?w:h)+2*PORT_END;
7053
- if(span<need) return out;
7796
+ const roomIn=span>=need;
7054
7797
  const lo=(vert?N.x:N.y)+PORT_END, hi=(vert?N.x+N.w:N.y+N.h)-PORT_END;
7055
7798
  const pitch=(vert?w:h)+4;
7056
- const u0=vert?P[0]:P[1];
7799
+ // THE NAME IS ANCHORED TO ITS OWN SQUARE, not to the raw
7800
+ // crossing: `u0` is the square's centreline along the border, so the
7801
+ // corner clamp inside `portSq` can never leave a name off the mark it
7802
+ // names. `base` is the smallest along-border offset at which the two
7803
+ // marks are two — half the square, the 3 px standoff, half the text —
7804
+ // and `deep` is the same clearance taken PERPENDICULARLY, past the
7805
+ // square's far face.
7806
+ const SQ=portSq(N,P);
7807
+ const u0=vert?SQ.cx:SQ.cy;
7808
+ const base=PORT_SQ/2+PORT_SQ_GAP+(vert?w:h)/2;
7809
+ const deep=PORT_SQ/2+PORT_SQ_GAP;
7057
7810
  // TWO RINGS OF CANDIDATES, AND BOTH ARE AT THE CROSSING.
7058
7811
  // in = inside the border, the convention proper;
7059
7812
  // out = the mirror image, just OUTSIDE the same border.
@@ -7066,19 +7819,162 @@ function renderScene(doc,y0){
7066
7819
  // could not get inside keeps it on its own port, which is the fact it
7067
7820
  // exists to state. It is surcharged, so it is taken only after inside
7068
7821
  // has failed.
7069
- for(const inside of [true,false]) for(const k of [0,1,-1,2,-2]){
7070
- const u=Math.max(lo+(vert?w:h)/2, Math.min(hi-(vert?w:h)/2, u0+k*pitch));
7822
+ //
7823
+ // WITH A SQUARE AT THE CROSSING THE ANCHOR CHANGES, which is the
7824
+ // placement half of the maintainer's ruling ("A/B 皆有埠名文字定位不夠
7825
+ // 理想" — in both styles the port name is not sited well enough):
7826
+ //
7827
+ // 1. THE NAME IS ANCHORED TO ITS OWN SQUARE, on the square's
7828
+ // centreline along the border. When the port is alone on that side
7829
+ // the anchor IS `u0` and the name sits squarely on its fitting's
7830
+ // line — which is the whole reading the notation buys.
7831
+ // 2. WHEN THE SIDE CARRIES SEVERAL PORTS the anchor is the SLOT the
7832
+ // side pass assigned (`r.portU`): the same ideal, projected so that
7833
+ // consecutive names clear each other IN SQUARE ORDER. Order is the
7834
+ // property that matters and it is why the slot is computed for the
7835
+ // whole side at once instead of being negotiated one label at a
7836
+ // time by a greedy scorer — a greedy pass produced `p1 p3 p2` over
7837
+ // squares in the order p1 p2 p3 on `patterns/topology-a`, and every
7838
+ // one of those three labels is legible while two of them are wrong.
7839
+ //
7840
+ // The perpendicular offset follows from the anchor: a name still over
7841
+ // its own square's along-border extent must clear the square's face
7842
+ // (`deep`), and one that has stepped clear of it along the border only
7843
+ // needs the ordinary 2 px (`PORT_IN`).
7844
+ //
7845
+ // The ladder (±one text pitch at a time, from the slot) stays behind
7846
+ // that as relief for a name whose slot is occupied by something else —
7847
+ // the node's own name, a neighbour's label. Each of these exists inside
7848
+ // the border and outside it. INSIDE IS THE CONVENTION PROPER; the
7849
+ // outside ring is the mirror image, hugging the same border, and it is
7850
+ // surcharged (`hug`, PORT_HUG) so it is taken only after inside has
7851
+ // failed — but it is far cheaper than the ordinary along-the-shaft
7852
+ // family (PORT_OUT), which is the ruling's second clause: a crowded
7853
+ // interior sends the name to the OUTSIDE FLANK of its own port before it
7854
+ // ever sends it out onto the shaft.
7855
+ const uS=(r.portU===undefined?u0:r.portU);
7856
+ const dpFor=u=>(Math.abs(u-u0)<base-1e-9?deep:PORT_IN);
7857
+ const fam=[];
7858
+ for(const inside of [true,false]) fam.push({inside,u:uS,dp:dpFor(uS),ex:0});
7859
+ // OUTSIDE, THE NAME MAY ALSO STAND OFF THE BORDER. Inside, the far wall
7860
+ // of the box ends the argument; outside there is open paper, and one
7861
+ // step of it is often the whole difference. `05-lacp`'s DUT is 49.6 px
7862
+ // wide, carries three ports on its left face and their names are 41.4 px
7863
+ // — every name has to hug the outside, and every shaft converging on
7864
+ // those three fittings runs through the hug ring on its way in. Two
7865
+ // 8 px steps outward (priced like any other displacement, and past the
7866
+ // second one the standoff rule below draws the association as a leader)
7867
+ // let the fan open out to where the shafts have separated.
7868
+ for(const d of [8,16]) fam.push({inside:false,u:uS,dp:dpFor(uS)+d,ex:d});
7869
+ for(const inside of [true,false]) for(const sg of [1,-1]) for(const k of [0,1,2])
7870
+ fam.push({inside,u:uS+sg*(base+k*pitch),dp:PORT_IN,ex:base+k*pitch});
7871
+ for(const F of fam){
7872
+ const inside=F.inside, IN=F.dp;
7873
+ if(inside&&!roomIn) continue;
7874
+ // THE CORNERS BIND INSIDE AND DO NOT BIND OUTSIDE. Inside, a name is
7875
+ // written between the two corners of the face it belongs to or it is
7876
+ // written on another face; outside there is no face to run out of, so
7877
+ // clamping the outside ring to the node's own extent was simply
7878
+ // collapsing a fanned side back into a stack. A node 36 px tall with
7879
+ // three links arriving 8 px apart has no inside answer at all, and
7880
+ // its outside answer is the fan — which needs to reach past the
7881
+ // corner to exist.
7882
+ const half=(vert?w:h)/2;
7883
+ const u=inside?Math.max(lo+half, Math.min(hi-half, F.u)):F.u;
7071
7884
  let bx,by;
7072
- if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-PORT_IN-h:N.y+N.h+PORT_IN; }
7073
- else if(side==='top'){ bx=u-w/2; by=inside?N.y+PORT_IN:N.y-PORT_IN-h; }
7074
- else if(side==='left'){ bx=inside?N.x+PORT_IN:N.x-PORT_IN-w; by=u-h/2; }
7075
- else { bx=inside?N.x+N.w-PORT_IN-w:N.x+N.w+PORT_IN; by=u-h/2; }
7885
+ if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-IN-h:N.y+N.h+IN; }
7886
+ else if(side==='top'){ bx=u-w/2; by=inside?N.y+IN:N.y-IN-h; }
7887
+ else if(side==='left'){ bx=inside?N.x+IN:N.x-IN-w; by=u-h/2; }
7888
+ else { bx=inside?N.x+N.w-IN-w:N.x+N.w+IN; by=u-h/2; }
7076
7889
  out.push({x:bx+w/2, y:by+up, anchor:'middle', t:undefined, side,
7077
- ex:Math.abs(k)*pitch, inPort:inside, hug:!inside,
7890
+ sq:SQ.box, sqk:SQ.key, atBorder:true,
7891
+ ex:F.ex, inPort:inside, hug:!inside,
7078
7892
  box:{x:bx,y:by,w,h}});
7079
7893
  }
7080
7894
  return out;
7081
7895
  };
7896
+ // ── DRAW EVERY PORT SQUARE BEFORE ANY NAME IS PLACED ──────────────────
7897
+ // The squares are a function of geometry alone (owner box + crossing
7898
+ // point), so they are all known before the greedy placement pass starts.
7899
+ // Computing them here rather than inside that loop buys the property that
7900
+ // matters: EVERY name is scored against EVERY square, including squares
7901
+ // belonging to names that have not been placed yet. A name written across
7902
+ // a later port's fitting would say the wrong thing about which link it
7903
+ // belongs to, and a one-pass greedy loop that emitted squares as it went
7904
+ // could only ever avoid the ones already drawn.
7905
+ //
7906
+ // Two crossings can coincide (co-located links on one border, a bundle's
7907
+ // members leaving through one face): the square is keyed on its snapped
7908
+ // centre so one fitting is drawn once, not stroked n times. That key is
7909
+ // also the square's IDENTITY in the drawing — it is written on the rect
7910
+ // as `data-port-sq` and on the name that belongs to it, so a reader (and
7911
+ // `tools/layout-lint.js`) can tell a name's own fitting from a foreign one
7912
+ // by reading rather than by guessing at distances.
7913
+ const sqBox=[], sqSeen=new Set();
7914
+ for(const r of lblReq){
7915
+ if(!r.port) continue;
7916
+ const S=portSq(r.port,r.p);
7917
+ if(sqSeen.has(S.key)) continue;
7918
+ sqSeen.add(S.key);
7919
+ sqBox.push(Object.assign({key:S.key},S.box));
7920
+ const N=r.port;
7921
+ sqsvg.push('<rect x="'+S.box.x.toFixed(2)+'" y="'+S.box.y.toFixed(2)+
7922
+ '" width="'+PORT_SQ+'" height="'+PORT_SQ+'" fill="'+(N.fill||'#fff')+
7923
+ '" stroke="'+(N.stroke||'#8a8880')+'" data-port-sq="'+S.key+'"/>');
7924
+ W=Math.max(W, S.box.x+S.box.w+4);
7925
+ Hh=Math.max(Hh, S.box.y+S.box.h+4-y0-20);
7926
+ }
7927
+ // ── ONE SIDE'S NAMES ARE ORDERED TOGETHER, NOT ONE AT A TIME ──────────
7928
+ // "Same side, several ports: the names serialize along the border in
7929
+ // SQUARE ORDER" is a property of the SIDE, and a greedy scorer cannot hold
7930
+ // it — it places one label at a time against what is already on the paper,
7931
+ // so the second name takes the cheapest gap rather than its own place in
7932
+ // the row. Measured: `patterns/topology-a`'s aggregation node came out
7933
+ // `p1 p3 p2` over squares in the order p1 p2 p3. Every one of those labels
7934
+ // is legible and two of them are wrong, which is worse than a collision:
7935
+ // a collision announces itself and a swapped pair does not.
7936
+ //
7937
+ // So each (node, side) group is projected ONCE, here, before any placement
7938
+ // is scored. The projection is the smallest order-preserving spreading of
7939
+ // the ideal positions: walk the group in square order, push each name just
7940
+ // far enough to clear the previous one, then slide the whole run back so
7941
+ // it stays centred on where the squares actually are. A side with one port
7942
+ // is unmoved by construction — its slot IS its square's centreline — so
7943
+ // this pass costs the ordinary figure nothing.
7944
+ //
7945
+ // Separation is measured in TEXT, not in squares: half of each of the two
7946
+ // names plus the 3 px standoff. Two fittings 8 px apart on a 36 px device
7947
+ // (three links into one small node, which is `05-lacp`'s DUT) cannot give
7948
+ // their names 8 px and be read as two names; the names take the room the
7949
+ // TEXT needs and the reader maps name to square by ORDER, which is exactly
7950
+ // what the order-preserving projection guarantees.
7951
+ {
7952
+ const sides=new Map();
7953
+ for(const r of lblReq){
7954
+ if(!r.port) continue;
7955
+ const S=portSq(r.port,r.p);
7956
+ const k=(r.port.id===undefined?'?':r.port.id)+'|'+S.side;
7957
+ if(!sides.has(k)) sides.set(k,[]);
7958
+ const D=lblDims(r);
7959
+ sides.get(k).push({r, u0:S.vert?S.cx:S.cy, ext:S.vert?D.w:D.h});
7960
+ }
7961
+ for(const g of sides.values()){
7962
+ if(g.length<2) continue;
7963
+ // stable: equal crossings keep registration (edge) order
7964
+ g.forEach((e,i)=>{e.i=i;});
7965
+ g.sort((a,b)=>a.u0-b.u0||a.i-b.i);
7966
+ let u=g[0].u0, sum=0;
7967
+ g[0].u=u;
7968
+ for(let i=1;i<g.length;i++){
7969
+ const sep=(g[i-1].ext+g[i].ext)/2+PORT_SQ_GAP;
7970
+ u=Math.max(g[i].u0, u+sep);
7971
+ g[i].u=u;
7972
+ }
7973
+ for(const e of g) sum+=e.u-e.u0;
7974
+ const shift=sum/g.length; // re-centre on the squares
7975
+ for(const e of g) e.r.portU=e.u-shift;
7976
+ }
7977
+ }
7082
7978
  const placed=[];
7083
7979
  // A request may name a SECOND carrying segment (`alt`). Back edges do: the
7084
7980
  // stub leaving the source is the preferred carrier because it says which
@@ -7175,8 +8071,19 @@ function renderScene(doc,y0){
7175
8071
  // already there) and never merely because outside happens to be tidy.
7176
8072
  const cs=[];
7177
8073
  if(r.port) for(const c of portCands(r)) cs.push({c,si:0});
7178
- for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]) for(const ep of eps)
7179
- cs.push({c:cand(r,t,sides[si],cl,ep),si,out:!!r.port});
8074
+ // THE OWN SQUARE TRAVELS WITH THE REQUEST, NOT WITH THE FAMILY
8075
+ // A port name driven all the way out to the along-shaft
8076
+ // family still names its fitting and must still be told apart from the
8077
+ // next one, so those candidates carry the same square the border family
8078
+ // does — the forbidden band, the affinity filter and the standoff rule
8079
+ // below then apply to every position this label can take, instead of
8080
+ // stopping at the border.
8081
+ const RSQ=r.port?portSq(r.port,r.p):null;
8082
+ for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]) for(const ep of eps){
8083
+ const c=cand(r,t,sides[si],cl,ep);
8084
+ if(RSQ){ c.sq=RSQ.box; c.sqk=RSQ.key; }
8085
+ cs.push({c,si,out:!!r.port});
8086
+ }
7180
8087
  for(const CS of cs){
7181
8088
  const c=CS.c, si=CS.si, t=(c.t===undefined?tPref:c.t);
7182
8089
  c.car=[r.p,r.q]; // the carrier this candidate rides (item 59)
@@ -7185,13 +8092,82 @@ function renderScene(doc,y0){
7185
8092
  // one fact it exists to state, and no amount of collision relief buys
7186
8093
  // that back. Candidates that fail are dropped; `bestAny` keeps the
7187
8094
  // least-bad one so a figure with no legal position still draws.
8095
+ // A DRAWN ASSOCIATION REPLACES AN INFERRED ONE. Every
8096
+ // filter in this pass is a proxy for one question — can the reader
8097
+ // tell what this word belongs to — and each answers it by PROXIMITY,
8098
+ // because proximity is all an unadorned label has. A port name past
8099
+ // the standoff cap is not unadorned: it takes a leader (below), and a
8100
+ // line from the word to the fitting states the association outright.
8101
+ // So `lead` is computed first and the proximity proxies stand down
8102
+ // for it. The BAND does not: a name written across a foreign fitting
8103
+ // is a wrong statement no leader can correct.
8104
+ const lead=!!(c.sq&&boxSep(c.box,c.sq)>STANDOFF_CAP);
7188
8105
  let owns=true;
7189
- if(r.kind==='end'&&r.other){
8106
+ if(r.kind==='end'&&r.other&&!lead){
7190
8107
  const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
7191
8108
  owns=Math.hypot(ccx-r.p[0],ccy-r.p[1])<Math.hypot(ccx-r.other[0],ccy-r.other[1]);
7192
8109
  }
8110
+ // A FOREIGN SQUARE'S BAND IS FORBIDDEN SPACE, NOT A PRICE
8111
+ // Once a fitting is drawn, a port name no longer names
8112
+ // "this end of this link" — it names THAT SQUARE, and the only thing
8113
+ // that says which square is proximity. On the first squared render of
8114
+ // `patterns/topology-a` the aggregation node's three names came out
8115
+ // `p1 p3 p2` over squares in the order p1 p2 p3, because the
8116
+ // free-space terms priced the second name into the gap past its own
8117
+ // fitting: every one of those labels is legible and two of the three
8118
+ // are wrong. No collision relief buys that back, so this is a filter,
8119
+ // exactly as endpoint affinity above is, and it has two halves that
8120
+ // apply to DIFFERENT candidates:
8121
+ // • the BAND — the foreign square grown by the 3 px standoff — is
8122
+ // space NO port name may enter, wherever it was going to be
8123
+ // written. A name across a neighbour's fitting is the wrong
8124
+ // statement whether it got there from the border ring or from the
8125
+ // shaft, so this half is unconditional.
8126
+ // • the AFFINITY — nearer a foreign square's centre than its own —
8127
+ // applies only AT THE BORDER, where proximity is the only thing
8128
+ // saying which square a name belongs to. Out on the shaft the
8129
+ // name is associated by the LINE it rides (and, past the standoff
8130
+ // cap, by a drawn leader), and item 42's own-endpoint filter
8131
+ // already governs that ring. Applying it there was built and
8132
+ // measured: on `05-lacp`, three names whose squares sit 14.5 px
8133
+ // apart have their own shaft running through the middle of the
8134
+ // 7 px window the affinity test leaves them, so every clean
8135
+ // position was disqualified and the figure took the strikes
8136
+ // instead (lblcol 3 -> 5).
8137
+ if(c.sq){
8138
+ const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
8139
+ const d0=Math.hypot(ccx-(c.sq.x+c.sq.w/2), ccy-(c.sq.y+c.sq.h/2));
8140
+ const G=PORT_SQ_GAP;
8141
+ for(const b of sqBox){
8142
+ if(b.key===c.sqk) continue;
8143
+ if(c.box.x<b.x+b.w+G&&b.x-G<c.box.x+c.box.w&&
8144
+ c.box.y<b.y+b.h+G&&b.y-G<c.box.y+c.box.h){ owns=false; break; }
8145
+ if(c.atBorder&&
8146
+ Math.hypot(ccx-(b.x+b.w/2),ccy-(b.y+b.h/2))<d0-1e-9){ owns=false; break; }
8147
+ }
8148
+ }
7193
8149
  let s=0;
7194
8150
  for(const b of placed) s+=3*ovl(c.box,b);
8151
+ // A NAME WRITTEN ACROSS ITS OWN FITTING is the one arrangement the
8152
+ // notation cannot survive, and unlike a foreign square this one is
8153
+ // PRICED rather than forbidden: the offset family already starts the
8154
+ // name clear of its own square, so the only way back onto it is the
8155
+ // corner clamp on a device too small to hold the name anywhere else —
8156
+ // and on that device the least-bad answer still has to exist. Charged
8157
+ // at the `placed` weight, because a drawn square is drawn ink of the
8158
+ // same order, and measured on the LINE box, because a 7 px square and
8159
+ // a 12 px line box brushing at all is already too close to read as
8160
+ // two marks.
8161
+ if(c.sq) s+=3*ovl(c.box,c.sq);
8162
+ // THE LEADER'S OWN PRICE, AND THE BAND IT HAS TO SIT IN. A leader is
8163
+ // extra ink and a second thing to follow, so it must be dearer than
8164
+ // every adjacency that needs none — and it must be CHEAPER THAN A
8165
+ // STRIKETHROUGH, which costs 26 here, or the pass would keep choosing
8166
+ // a name lying across a line over the same name standing clear with
8167
+ // its association drawn. 15 is the middle of that band and it is the
8168
+ // whole of the tuning: below 26 by construction, above the 0-12 px
8169
+ // displacement prices that separate one clean position from another.
8170
+ if(lead) s+=15;
7195
8171
  // A PORT MARKER IS SUPPOSED TO BE INSIDE ITS OWN BOX (item 57), so
7196
8172
  // that one node is not charged for it. Every other box still is, at
7197
8173
  // the usual weights — a marker that has slid out of its own device
@@ -7333,31 +8309,64 @@ function renderScene(doc,y0){
7333
8309
  // the 16 px anchor and the affinity filter, both of item 42's.
7334
8310
  if(r.kind!=='end'&&segBoxGap(c.car[0],c.car[1],c.box)>STANDOFF_CAP){
7335
8311
  if(s<farS-1e-9){ farS=s; farBest=c; } continue; }
8312
+ // A PORT NAME OWES ITS SQUARE THE SAME STANDOFF, AND IT
8313
+ // PAYS FOR IT WITH A LEADER RATHER THAN WITH A FILTER. Item 59 makes
8314
+ // the cap a filter for mid-labels because their displacement is
8315
+ // unbounded — a mid-label can wander anywhere along a 900 px channel.
8316
+ // A port name's family is bounded by construction (the border rings
8317
+ // are at the border; the shaft ring is 16-24 px along its own edge),
8318
+ // and the tier prices above — PORT_HUG then PORT_OUT — already order
8319
+ // it. Making the cap a filter HERE was built and measured, and the
8320
+ // corpus refused it: on `05-lacp` it disqualified the whole shaft ring
8321
+ // for six names whose only near positions lie across their own shaft,
8322
+ // and the figure took six strikethroughs rather than six clean names
8323
+ // 20 px out (lblcol 5 -> 7). So the cap does not decide WHERE the name
8324
+ // goes; it decides whether the association is drawn, below.
7336
8325
  if(s<anyS-1e-9){ anyS=s; anyBest=c; }
7337
8326
  if(owns&&s<bestS-1e-9){ bestS=s; best=c; }
7338
8327
  }
7339
8328
  }
7340
8329
  if(!best) best=anyBest||farBest||offBest; // no legal position — least bad
7341
8330
  r0.win=best; // kept for item 55's recolour pass
7342
- lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,r0.halo);
7343
- // THE LEADER (item 59). A mid-label that had to be placed past the cap
7344
- // states its line explicitly: a hairline from the nearest point of its
7345
- // box to the nearest point of its own carrying segment, in the label's
7346
- // own colour. It is drawn UNDER nothing and over nothing it is 1 px,
7347
- // it starts at the box and it stops at the shaft so it adds no ink
7348
- // anywhere the reader is not already looking for the association.
7349
- if(r0.kind!=='end'&&best.car&&segBoxGap(best.car[0],best.car[1],best.box)>STANDOFF_CAP){
8331
+ // A PORT NAME CARRIES ITS SQUARE'S KEY. The name and the
8332
+ // fitting it names are one statement drawn as two marks, and nothing in
8333
+ // the finished SVG said they were a pair a reader (and the layout
8334
+ // gate, which now scores a foreign name written across a fitting) had
8335
+ // only proximity to go on, which is the very thing the notation exists
8336
+ // to stop relying on. The key is the square's snapped centre, so the two
8337
+ // elements carry the same string and the association is READ.
8338
+ lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,
8339
+ r0.halo+(best.sqk?' data-port-sq="'+best.sqk+'"':''));
8340
+ // THE LEADER (item 59), AND ITS SECOND CALLER. A label that
8341
+ // had to be placed past the cap states its referent explicitly: a
8342
+ // hairline from the nearest point of its box to that referent, in the
8343
+ // label's own colour. It is drawn UNDER nothing and over nothing — it is
8344
+ // 1 px, it starts at the box and it stops at the thing — so it adds no
8345
+ // ink anywhere the reader is not already looking for the association.
8346
+ //
8347
+ // TWO REFERENTS, ONE LEADER. A mid-label's is the nearest point of its
8348
+ // own carrying segment; a port name's is the CENTRE OF ITS OWN SQUARE,
8349
+ // because that is the mark it names. The drawing is otherwise identical
8350
+ // and is therefore drawn by one closure rather than copied — a second
8351
+ // leader mechanism is a second set of rules about when a leader appears,
8352
+ // and this figure only ever needs one.
8353
+ const leadTo=(fx,fy)=>{
7350
8354
  const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
7351
- const P0=best.car[0], Q0=best.car[1];
7352
- const vx=Q0[0]-P0[0], vy=Q0[1]-P0[1], L2=vx*vx+vy*vy;
7353
- const t=L2?Math.max(0,Math.min(1,((cx-P0[0])*vx+(cy-P0[1])*vy)/L2)):0;
7354
- const fx=P0[0]+t*vx, fy=P0[1]+t*vy;
7355
- // the box's own boundary point on the ray toward that foot
8355
+ // the box's own boundary point on the ray toward the referent
7356
8356
  const dx=fx-cx, dy=fy-cy, ax=Math.abs(dx)||1e-9, ay=Math.abs(dy)||1e-9;
7357
8357
  const k=Math.min((b.w/2+2)/ax,(b.h/2+2)/ay);
7358
8358
  r0.leadIdx=lblsvg.length;
7359
8359
  lblsvg.push('<line x1="'+(cx+dx*k)+'" y1="'+(cy+dy*k)+'" x2="'+fx+'" y2="'+fy+
7360
8360
  '" stroke="'+r0.col+'" stroke-width="1" opacity="0.6"/>');
8361
+ };
8362
+ if(r0.kind!=='end'&&best.car&&segBoxGap(best.car[0],best.car[1],best.box)>STANDOFF_CAP){
8363
+ const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
8364
+ const P0=best.car[0], Q0=best.car[1];
8365
+ const vx=Q0[0]-P0[0], vy=Q0[1]-P0[1], L2=vx*vx+vy*vy;
8366
+ const t=L2?Math.max(0,Math.min(1,((cx-P0[0])*vx+(cy-P0[1])*vy)/L2)):0;
8367
+ leadTo(P0[0]+t*vx, P0[1]+t*vy);
8368
+ } else if(best.sq&&boxSep(best.box,best.sq)>STANDOFF_CAP){
8369
+ leadTo(best.sq.x+best.sq.w/2, best.sq.y+best.sq.h/2);
7361
8370
  }
7362
8371
  placed.push(Object.assign({text:r0.text},best.box));
7363
8372
  W=Math.max(W, best.box.x+best.box.w+4);
@@ -7874,7 +8883,7 @@ function renderScene(doc,y0){
7874
8883
  used[pick]++;
7875
8884
  }
7876
8885
  if(tone.size>1) for(const [e2,col2] of tone){
7877
- const key='data-edge="'+e2.line+'"';
8886
+ const key='data-edge="'+edgeRef(e2)+'"';
7878
8887
  for(let i=0;i<esvg.length;i++)
7879
8888
  if(esvg[i].indexOf(key)>=0) esvg[i]=esvg[i].replace('stroke="#555"','stroke="'+col2+'"');
7880
8889
  for(const i of (arrowIdx.get(e2)||[]))
@@ -7915,7 +8924,8 @@ function renderScene(doc,y0){
7915
8924
  } else lblsvg.push(textEl(cx,cy-10,10,'middle',bcol,n.label,bhalo));
7916
8925
  continue;
7917
8926
  }
7918
- nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'" style="cursor:move">');
8927
+ nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'"'
8928
+ +(lassoMem.has(n.id)?' data-lasso="'+lassoMem.get(n.id).join(' ')+'"':'')+' style="cursor:move">');
7919
8929
  const fill=n.fill||'#fff', stroke=n.stroke||'#8a8880', txt=labelInk(fill,'#1d1d1b');
7920
8930
  const ndash=n.style==='dashed'?' stroke-dasharray="6 4"':(n.style==='dotted'?' stroke-dasharray="2 4"':'');
7921
8931
  if(n.shape==='diamond'){
@@ -7977,7 +8987,7 @@ function renderScene(doc,y0){
7977
8987
  // ONE MEMBER = NO ELLIPSE (item 53). The caption below is the whole
7978
8988
  // drawing, and an unlabelled one-member bundle draws nothing.
7979
8989
  if(!R.solo){
7980
- tsvg.push('<ellipse cx="'+rnd3(R.cx)+'" cy="'+rnd3(R.cy)+'" rx="'+rnd3(R.rL)+'" ry="'+rnd3(R.rA)+'" fill="transparent" stroke="'+(t.stroke||col)+'"'+dashOf(t.style,'6 4')+' stroke-width="1.6"'+rot+'/>');
8990
+ 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+'/>');
7981
8991
  W=Math.max(W,R.cx+R.hw); Hh=Math.max(Hh,R.cy+R.hh-y0-20);
7982
8992
  }
7983
8993
  const L=ringLbl.get(t);
@@ -8087,7 +9097,7 @@ function renderScene(doc,y0){
8087
9097
  ' paint-order="stroke" stroke="#fff" stroke-width="3"'));
8088
9098
  }
8089
9099
  const yEnd=y0+20+Hh+10;
8090
- return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
9100
+ return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+sqsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
8091
9101
  meta:{W:W, top:y0+20+chShift+tShift, Hh:Hh, left:bShift+lShift}, errs:gErrs};
8092
9102
  }
8093
9103
  // borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
@@ -8639,6 +9649,26 @@ function renderSequence(doc,y0){
8639
9649
  // would put a 17-digit tail in the artifact for no reader's benefit.
8640
9650
  function r2(v){ return Math.round(v*100)/100; }
8641
9651
 
9652
+ // `TYPED-BLOCK-TITLE-CANVAS` (backlog 66): A TYPED BLOCK'S TITLE JOINS ITS OWN CANVAS.
9653
+ //
9654
+ // Each of the four typed-block renderers (bitfield/table/timing/chart) sizes
9655
+ // its section from its DATA — cells, ruler, lanes, floor — and draws its own
9656
+ // caption over that canvas afterwards, at x=0, without ever measuring the
9657
+ // caption into the width it returns. A caption wider than the data therefore
9658
+ // ran past the right edge of the section's own `<svg>` box, and a section
9659
+ // canvas grows right and down only, so text at a negative coordinate is
9660
+ // CLIPPED, never merely misplaced (`LABEL-PLACEMENT-METRIC`, which fixed exactly this
9661
+ // for a `table` caption and a `chart` row/column label gutter but not for a
9662
+ // bitfield/timing title or a chart's own top caption — production field
9663
+ // report FR-4, decisions/registry.md).
9664
+ //
9665
+ // ONE MEASUREMENT, used by all four renderers below, so the calibration lives
9666
+ // in one place rather than four. Bold text at this size is measured ~8%
9667
+ // wider than the plain per-character estimate `cwMax(...)*CH` gives at
9668
+ // regular weight — verified against the raster, not assumed, the same
9669
+ // allowance `renderTable`'s caption fix already established.
9670
+ function typedBlockTitleW(label){ return cwMax(label)*CH*1.08+2; }
9671
+
8642
9672
  // ---- bitfield ----
8643
9673
  function renderBitfield(b,y0){
8644
9674
  const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
@@ -9069,7 +10099,12 @@ function renderBitfield(b,y0){
9069
10099
  }
9070
10100
  yb+=2;
9071
10101
  }
9072
- return {svg:svg.join(''), y:yb, w:wb,
10102
+ // THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE CAPTION IS INK (`TYPED-BLOCK-TITLE-CANVAS`).
10103
+ // `wb` is the DATA extent alone (cells, ruler, `present=` captions) — the
10104
+ // BOX below stays measured against it, because a region-scope
10105
+ // `threshold`/`band` is a statement about the data, not about the title
10106
+ // that names it. Only the returned canvas width grows for the caption.
10107
+ return {svg:svg.join(''), y:yb, w:Math.max(wb, typedBlockTitleW(b.label)),
9073
10108
  box:{x0:0, x1:wb, yA:y0+18, yB:yb}};
9074
10109
  }
9075
10110
 
@@ -9186,9 +10221,10 @@ function renderTable(t,y0){
9186
10221
  // grid is not the figure; the caption is not decoration.
9187
10222
  // Bold at 13 px is wider than `CH` (a regular-weight advance), so the caption
9188
10223
  // is measured with the same 8% allowance the raster needed — verified by
9189
- // rendering, not assumed.
9190
- const capW=cwMax(t.label)*CH*1.08+2;
9191
- return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,capW),
10224
+ // rendering, not assumed. `typedBlockTitleW` (`TYPED-BLOCK-TITLE-CANVAS`) is that
10225
+ // same measurement, now shared by all four typed-block renderers rather
10226
+ // than kept as this one's own private copy.
10227
+ return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,typedBlockTitleW(t.label)),
9192
10228
  box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
9193
10229
  }
9194
10230
 
@@ -9347,7 +10383,14 @@ function renderChart(b,y0,doc){
9347
10383
  svg.push(textEl(zr[0]+21, zr[1]-z*ZS+3.5, 9.5, 'start', '#6f6e69', String(z)));
9348
10384
  }
9349
10385
  const near=[PR(R,C,0)[0]+ox, PR(R,C,0)[1]];
9350
- const w=Math.max(near[0]+70, farRight+ox+4);
10386
+ // THE TOP CAPTION IS THE SECTION'S OWN INK TOO (`TYPED-BLOCK-TITLE-CANVAS`). The
10387
+ // row/column axis-label gutter (`ox`, above) was widened for exactly this
10388
+ // reason, but that pass measured the LEANED-OUT axis labels
10389
+ // and never the caption drawn at the section's fixed x=0 top-left corner —
10390
+ // a `chart` whose table name is long enough still ran the caption off the
10391
+ // right edge under its own data floor. The caption is never shifted (it is
10392
+ // already the section's leftmost, topmost ink); only the canvas grows.
10393
+ const w=Math.max(near[0]+70, farRight+ox+4, typedBlockTitleW(t.label+' — bar3d'));
9351
10394
  const hgt=Math.max(near[1]+24, farDown+6)-y0;
9352
10395
  return {svg:svg.join('')+lsvg.join(''), y:y0+hgt, w:w};
9353
10396
  }
@@ -9404,7 +10447,12 @@ function renderTiming(w,y0){
9404
10447
  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"/>');
9405
10448
  }
9406
10449
  const H=y+8+w.signals.length*(laneH+laneGap);
9407
- return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2,
10450
+ // THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE TITLE IS INK (`TYPED-BLOCK-TITLE-CANVAS`,
10451
+ // 0.5). `nameW+cycles*cycleW+2` is the DATA extent alone (the
10452
+ // signal-name gutter plus the lane grid); the BOX below stays measured
10453
+ // against it, unchanged, for the same reason a table's region box stays
10454
+ // measured against the grid and not the caption.
10455
+ return {svg:svg.join(''), y:H, w:Math.max(nameW+cycles*cycleW+2, typedBlockTitleW(w.label)),
9408
10456
  box:{x0:nameW, x1:nameW+cycles*cycleW, yA:y0+18, yB:H}};
9409
10457
  }
9410
10458
 
@@ -9482,14 +10530,27 @@ function __stackSectionSvgs(results) {
9482
10530
  // the default does NOT (embedded figures almost always sit under the
9483
10531
  // host document's caption — the majority case).
9484
10532
  // Multi-section sources are stacked vertically into a single SVG (MULTI-FIGURE-DOCUMENTS).
10533
+ //
10534
+ // TWO ERROR CHANNELS REACH ONE errors ARRAY. parse cannot see a
10535
+ // coordinate, so a document whose SOURCE is impeccable can still draw a false
10536
+ // statement — a group band enclosing a non-member, a pin covering a node
10537
+ // completely. The engine reports those from render (as .errs on its render
10538
+ // result), and core §8 requires a caller to treat a non-empty render
10539
+ // diagnostic list EXACTLY as it treats a parse error list. Until 0.4
10540
+ // this wrapper discarded that channel and returned errors: [] with an SVG of
10541
+ // the picture the engine had just said was wrong — the one copy of the engine
10542
+ // a require('figdown') user actually gets. Both channels now land here, and
10543
+ // either withholds the SVG.
9485
10544
  function render(text, opts) {
9486
10545
  var p = parse(text);
9487
10546
  if (p.errors.length) return { svg: null, errors: p.errors };
9488
- if (p.docs.length > 1) {
9489
- var rs = p.docs.map(function (d) { return __engine.render(d, opts); });
9490
- return { svg: __engine.stackSectionSvgs(rs), errors: [] };
9491
- }
9492
- return { svg: __engine.render(p.doc, opts).svg, errors: [] };
10547
+ if (!p.docs.length) return { svg: null, errors: [] };
10548
+ var rs = p.docs.map(function (d) { return __engine.render(d, opts); });
10549
+ var errs = [];
10550
+ for (var i = 0; i < rs.length; i++) errs = errs.concat(rs[i].errs || []);
10551
+ if (errs.length) return { svg: null, errors: errs };
10552
+ var svg = rs.length > 1 ? __engine.stackSectionSvgs(rs) : rs[0].svg;
10553
+ return { svg: svg, errors: [] };
9493
10554
  }
9494
10555
  // renderDoc(doc, opts) -> svg string, for an already-validated doc from parse().
9495
10556
  // For multi-section, pass parse().docs to renderDocs instead.
@@ -9502,10 +10563,13 @@ function renderDocs(docs, opts) {
9502
10563
  return __engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); }));
9503
10564
  }
9504
10565
  // artifact(text) -> { svg, errors } svg is the full self-carrying SVG:
9505
- // the render plus a <metadata id="figdown-source"> block embedding the
9506
- // source text, the SHA-256 OF THAT SOURCE, and the engine version that
9507
- // rendered it (same convention as tools/build-svg.js; spec core §7).
9508
- // svg is null when there are errors.
10566
+ // the render plus a <metadata id="figdown-source"> block
10567
+ // embedding the source text, the SHA-256 OF THAT SOURCE, and the engine
10568
+ // version that rendered it (same convention as tools/build-svg.js; spec §7).
10569
+ // svg is null when there are errors — parse-time OR geometry-time, on
10570
+ // tools/build-svg.js's contract (core §8): a non-empty render diagnostic list
10571
+ // refuses the artifact exactly as a parse error does, because writing it
10572
+ // anyway publishes the picture the engine has just said is wrong.
9509
10573
  function artifact(text, opts) {
9510
10574
  var src = String(text);
9511
10575
  var p = render(src, opts);