figdown 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/dist/README.md +85 -0
- package/dist/figdown.js +1781 -407
- package/dist/figdown.mjs +1781 -407
- package/examples/evpn-fabric.svg +1 -1
- package/examples/showcase/arp-resolution.svg +1 -1
- package/examples/showcase/ethernet-frame.svg +1 -1
- package/examples/showcase/l2-forwarding-logic.svg +1 -1
- package/examples/showcase/tcp-handshake.svg +1 -1
- package/examples/showcase/tcp-header.svg +1 -1
- package/examples/showcase/tcp-state-machine.svg +1 -1
- package/integrations/mcp-server/server.js +50 -4
- package/package.json +13 -2
- package/skill/README.md +130 -0
- package/skill/figdown/SKILL.md +20 -0
- package/skill/figdown/build-svg.js +17 -4
- package/skill/figdown/figdown.html +4539 -898
- package/skill/figdown/reference/experimental/flowchart.md +23 -0
- package/skill/figdown/reference/experimental/sequence.md +28 -0
- package/skill/figdown/reference/experimental/statechart.md +29 -0
- package/skill/figdown/reference/experimental/topology.md +42 -0
- package/skill/figdown/reference/scene.md +23 -0
- package/skill/figdown/reference/transcribe.md +4 -1
package/dist/figdown.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// figdown.js — FigDown embeddable library (0.
|
|
1
|
+
// figdown.js — FigDown embeddable library (0.5.1)
|
|
2
2
|
// GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
|
|
3
3
|
// Regenerate with: node tools/make-lib.js
|
|
4
4
|
(function (root, factory) {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
}
|
|
11
11
|
}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
12
12
|
'use strict';
|
|
13
|
-
var VERSION = "0.
|
|
13
|
+
var VERSION = "0.5.1";
|
|
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.
|
|
27
|
+
const FIGDOWN_VERSION = '0.5.1';
|
|
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, raised downstream. 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
|
-
|
|
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".
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 , "
|
|
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+$/;
|
|
@@ -1192,6 +1298,35 @@ const WORD_WHY={
|
|
|
1192
1298
|
const WRONG_WORD=(surf,want,genre)=>
|
|
1193
1299
|
'"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
|
|
1194
1300
|
'. Each genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`, sequence `lifeline` `message`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
|
|
1301
|
+
// `LANGUAGE-EXTENSION-POLICY`/`RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): the `x-` prefix is RESERVED against FigDown's own
|
|
1302
|
+
// vocabulary and is NOT an extension namespace. Until this entry it was the
|
|
1303
|
+
// one reservation core §10 wrote down by name and NOTHING enforced: `x-note`
|
|
1304
|
+
// earned `"x-note" is not allowed in genre block` — the same words a typo
|
|
1305
|
+
// gets — while `page`, `;` and `step` each got a diagnostic naming their
|
|
1306
|
+
// reservation. `SEMICOLON-STATUS`'s own title says a reservation that is not enforced is not
|
|
1307
|
+
// a reservation, so this is what makes `x-` one.
|
|
1308
|
+
//
|
|
1309
|
+
// The message says three things, and the THIRD is the whole value: `LANGUAGE-EXTENSION-POLICY`
|
|
1310
|
+
// RETIRED the promise of a future extension mechanism, so an author who reads
|
|
1311
|
+
// core §10's pre-0.5 wording is being told the opposite of the rule. It must
|
|
1312
|
+
// say the prefix will not open, not merely that it is taken.
|
|
1313
|
+
//
|
|
1314
|
+
// IT MUST NOT REACH IDENTIFIERS. The reservation is about the language's own
|
|
1315
|
+
// vocabulary; an id is the author's. `node x-foo "A"` and `class x-bar
|
|
1316
|
+
// "meaning"` parsed and rendered before this entry and still do, which is why
|
|
1317
|
+
// both call sites below are in KEYWORD position and OPTION-KEY position and
|
|
1318
|
+
// nowhere else.
|
|
1319
|
+
const X_RESERVED_WHY =
|
|
1320
|
+
': the "x-" prefix is reserved against FigDown\'s OWN vocabulary — no standard '+
|
|
1321
|
+
'keyword or option key may begin "x-" — and it is NOT an extension namespace. '+
|
|
1322
|
+
'The language is CLOSED, so this does not become legal by waiting (core §10, `LANGUAGE-EXTENSION-POLICY`). '+
|
|
1323
|
+
'Record the fact in a publication manifest instead, spelled x-<owner>-<key> '+
|
|
1324
|
+
'(spec/figdown-manifest.md §2.1); propose the construct through .github/CONTRIBUTING.md §2; '+
|
|
1325
|
+
'or fork with your own header token. Identifiers are untouched: node x-foo "A" and '+
|
|
1326
|
+
'class x-bar "meaning" both parse';
|
|
1327
|
+
const X_RESERVED_KW = kw => '"'+kw+'" is RESERVED, not an extension keyword'+X_RESERVED_WHY;
|
|
1328
|
+
const X_RESERVED_OPT = k => 'option "'+k+'=" is RESERVED, not an extension option key'+X_RESERVED_WHY;
|
|
1329
|
+
const isXReserved = s => typeof s==='string' && s.slice(0,2)==='x-';
|
|
1195
1330
|
// `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
|
|
1196
1331
|
// and `"threshold" is not allowed in genre topology` would send an author
|
|
1197
1332
|
// looking for a typo. Each cell below was legal until 0.3 and states
|
|
@@ -1426,6 +1561,74 @@ function seqModel(doc){
|
|
|
1426
1561
|
return {rows,cont,owned,extent,chain,cycles};
|
|
1427
1562
|
}
|
|
1428
1563
|
|
|
1564
|
+
// `XML-CHARACTER-LEGALITY` (core §15.5) — XML-ILLEGAL CHARACTERS ARE A LINE ERROR.
|
|
1565
|
+
//
|
|
1566
|
+
// Every artifact is an XML document (core §7 embeds the source in one), and
|
|
1567
|
+
// XML 1.0 forbids a fixed set of code points OUTRIGHT: they cannot appear
|
|
1568
|
+
// literally, and they cannot be written as a character reference either, so
|
|
1569
|
+
// there is no escaping that rescues them. Until this rule the engine passed
|
|
1570
|
+
// them through: a label containing a raw U+0000 parsed clean, rendered clean,
|
|
1571
|
+
// and produced an `.svg` that NO conforming XML reader will open. Nothing
|
|
1572
|
+
// caught it — not the parser, which had no opinion, and not any gate, because
|
|
1573
|
+
// every document in the tree was clean.
|
|
1574
|
+
//
|
|
1575
|
+
// Refusing is the §8 principle, not a new one: a document with errors renders
|
|
1576
|
+
// NOTHING, because a drawing the engine cannot stand behind is worse than no
|
|
1577
|
+
// drawing. An artifact that is not well-formed is exactly that case — the
|
|
1578
|
+
// figure is unreadable by the machine reader the whole format exists to serve
|
|
1579
|
+
// — so it costs a line error, at parse time, before anything is drawn.
|
|
1580
|
+
// Determinism over convenience.
|
|
1581
|
+
//
|
|
1582
|
+
// THE SET, and why it is stated as ranges rather than "control characters":
|
|
1583
|
+
// U+0000–U+0008, U+000B, U+000C, U+000E–U+001F C0 controls, EXCEPT
|
|
1584
|
+
// U+0009 tab, U+000A LF and
|
|
1585
|
+
// U+000D CR, which are legal
|
|
1586
|
+
// and stay legal
|
|
1587
|
+
// U+FFFE, U+FFFF permanently unassigned
|
|
1588
|
+
// an UNPAIRED surrogate (U+D800–U+DFFF) not a character at all; a
|
|
1589
|
+
// correctly paired astral
|
|
1590
|
+
// character is legal and is
|
|
1591
|
+
// NOT flagged
|
|
1592
|
+
// U+007F DEL and the C1 range are legal XML 1.0 characters and are NOT
|
|
1593
|
+
// refused — this rule enforces XML's list, not a taste for printability.
|
|
1594
|
+
const XML_ILLEGAL = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/;
|
|
1595
|
+
function xmlIllegalErrors(text){
|
|
1596
|
+
const out=[];
|
|
1597
|
+
const lines=String(text).split('\n');
|
|
1598
|
+
for(let li=0; li<lines.length; li++){
|
|
1599
|
+
const line=lines[li];
|
|
1600
|
+
const seen=new Set();
|
|
1601
|
+
for(let i=0;i<line.length;i++){
|
|
1602
|
+
const c=line.charCodeAt(i);
|
|
1603
|
+
let bad=false;
|
|
1604
|
+
if(XML_ILLEGAL.test(line[i])) bad=true;
|
|
1605
|
+
else if(c>=0xD800&&c<=0xDBFF){ // high surrogate: needs a low one
|
|
1606
|
+
const n=line.charCodeAt(i+1);
|
|
1607
|
+
if(!(n>=0xDC00&&n<=0xDFFF)) bad=true; else i++;
|
|
1608
|
+
}
|
|
1609
|
+
else if(c>=0xDC00&&c<=0xDFFF) bad=true; // lone low surrogate
|
|
1610
|
+
if(!bad) continue;
|
|
1611
|
+
// One error per DISTINCT character per line. A 1 MB label of NULs is one
|
|
1612
|
+
// defect and one fix, and 1 000 000 identical lines would bury every
|
|
1613
|
+
// other diagnostic in the pass — the same reason §8.1 caps duplication.
|
|
1614
|
+
const cp='U+'+c.toString(16).toUpperCase().padStart(4,'0');
|
|
1615
|
+
if(seen.has(cp)) continue;
|
|
1616
|
+
seen.add(cp);
|
|
1617
|
+
// The tail names the RULE the character broke, and there are two rules,
|
|
1618
|
+
// so there are two tails. One generic sentence would be wrong for half
|
|
1619
|
+
// the set — "only tab, newline and carriage return are allowed" says
|
|
1620
|
+
// nothing true about U+FFFF — and a diagnostic that misdescribes the
|
|
1621
|
+
// rule sends the author to the wrong fix.
|
|
1622
|
+
const tail = c<0x20
|
|
1623
|
+
? ' (below U+0020 only tab, newline and carriage return are allowed)'
|
|
1624
|
+
: ' (U+FFFE, U+FFFF and unpaired surrogates are not characters — no escape writes one)';
|
|
1625
|
+
out.push('Line '+(li+1)+': illegal character '+cp+
|
|
1626
|
+
' — XML forbids it, so the artifact could not be well-formed'+tail);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
return out;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1429
1632
|
// parse(text) -> {doc, errs, docs}
|
|
1430
1633
|
// Single-section: docs=[doc] (backward-compatible doc/errs).
|
|
1431
1634
|
// Multi-section: one doc per figdown header; errs use full-file line numbers;
|
|
@@ -1438,12 +1641,20 @@ function parse(text){
|
|
|
1438
1641
|
// makes the normative rule ("a BOM at the very start of the document is
|
|
1439
1642
|
// ignored") implementable from the spec alone. Behaviour is unchanged.
|
|
1440
1643
|
text=String(text).replace(/^\uFEFF/,'');
|
|
1644
|
+
// `XML-CHARACTER-LEGALITY`: the XML-illegal-character sweep runs HERE \u2014 over the whole document,
|
|
1645
|
+
// before it is split into sections \u2014 for two reasons. Its line numbers are
|
|
1646
|
+
// already full-file, which is what every other message is re-based to below;
|
|
1647
|
+
// and the rule is about the SOURCE, not about any directive, so it must see
|
|
1648
|
+
// comments and blank lines too. Its findings JOIN the pass rather than
|
|
1649
|
+
// replacing it (\u00A78: all errors report in one pass), so an author fixing a
|
|
1650
|
+
// NUL still sees the typo on the next line in the same run.
|
|
1651
|
+
const xmlErrs=xmlIllegalErrors(text);
|
|
1441
1652
|
const secs=splitFigdownSections(text);
|
|
1442
1653
|
if(!secs){
|
|
1443
1654
|
const r=parseOne(String(text));
|
|
1444
|
-
return {doc:r.doc, errs:r.errs, docs:[r.doc]};
|
|
1655
|
+
return {doc:r.doc, errs:xmlErrs.concat(r.errs), docs:[r.doc]};
|
|
1445
1656
|
}
|
|
1446
|
-
const docs=[]; const errs=
|
|
1657
|
+
const docs=[]; const errs=xmlErrs.slice();
|
|
1447
1658
|
for(const sec of secs){
|
|
1448
1659
|
const r=parseOne(sec.text);
|
|
1449
1660
|
// A section's element `.line` values are section-local, and a GEOMETRY-time
|
|
@@ -1461,6 +1672,197 @@ function parse(text){
|
|
|
1461
1672
|
return {doc:docs[0], errs, docs};
|
|
1462
1673
|
}
|
|
1463
1674
|
|
|
1675
|
+
// ── THE CONNECTOR SCANNER, WITH A RETURN VALUE ────────────────
|
|
1676
|
+
// `EDGE-LABEL-PLACEMENT`/`REVERSE-ARROW-OPERATOR`'s connector grammar — `<kw> <id> [tail] <op> [head] <id>` — used to
|
|
1677
|
+
// live entirely INSIDE `parseOne`, as `parseEdgeLine`'s local scanner, and it
|
|
1678
|
+
// reported only through `err()` and a push into `doc.edges`/`doc.messages`.
|
|
1679
|
+
// That put it out of reach of the editor's GUI half: the code in the UI
|
|
1680
|
+
// section needs the SOURCE OFFSETS of each part of a connector line to edit
|
|
1681
|
+
// one label, one operator or one endpoint in place, leaving every other byte of
|
|
1682
|
+
// line alone (the editor's edge-editing requirement). A GUI cannot answer that
|
|
1683
|
+
// with a pattern of its own — a second grammar is a second answer to
|
|
1684
|
+
// `edge a -[hop #1]-> b`, to `edge a--b`, to `["]"]`, to `-[x]->` and to every
|
|
1685
|
+
// other case this scanner already settles, and the two answers would drift the
|
|
1686
|
+
// first time either side moved. That is the drift class `NODE_KW_ALT` was
|
|
1687
|
+
// created to close for node lines and it is the same class here.
|
|
1688
|
+
//
|
|
1689
|
+
// So the tokenizer moved OUT and grew a return value; NOTHING about the
|
|
1690
|
+
// grammar changed. There is still exactly ONE connector grammar, now with two
|
|
1691
|
+
// callers: `parseEdgeLine`, which adds the option pass, every semantic check
|
|
1692
|
+
// and the model write, and `scanConnectorLine`, which adds nothing and writes
|
|
1693
|
+
// nothing. Every syntax diagnostic is produced HERE and merely relayed by the
|
|
1694
|
+
// parser, so the two callers cannot disagree about what is legal.
|
|
1695
|
+
//
|
|
1696
|
+
// Offsets are UTF-16 indices into the string passed in — the parser passes the
|
|
1697
|
+
// line already trimmed, exactly as it did before.
|
|
1698
|
+
function scanConnector(s,kw){
|
|
1699
|
+
kw=kw||'edge';
|
|
1700
|
+
let i=kw.length; // past the connector keyword
|
|
1701
|
+
const bad=(m)=>({ok:false,error:m});
|
|
1702
|
+
const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
|
|
1703
|
+
// `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
|
|
1704
|
+
// followed by a second one, because `--` is the link operator. This is
|
|
1705
|
+
// what lets `edge a--b` mean the same thing as `bundle t1 a--b`; before
|
|
1706
|
+
// the ban the greedy scanner ate `a--b` as one id and then reported
|
|
1707
|
+
// "edge needs an operator", so the same token read oppositely two lines
|
|
1708
|
+
// apart (SYNTAX-STYLE §6.3).
|
|
1709
|
+
const readId=()=>{ const m=/^[A-Za-z_](?:[A-Za-z0-9_]|-(?!-))*/.exec(s.slice(i));
|
|
1710
|
+
if(!m) return null; i+=m[0].length; return m[0]; };
|
|
1711
|
+
// `QUOTED-IDS`: an endpoint is an id position. A quoted token or a spelling that
|
|
1712
|
+
// is not an id gets the ID RULE — not "edge needs <id> …", which named
|
|
1713
|
+
// the wrong thing when the operator was plainly there.
|
|
1714
|
+
const idHere=()=>i<s.length&&!/[\s[\-<>]/.test(s[i]);
|
|
1715
|
+
const readLbl=()=>{ // called at '['
|
|
1716
|
+
i++;
|
|
1717
|
+
if(s[i]==='"'){ // ["..."] — quoted content
|
|
1718
|
+
i++; let v='';
|
|
1719
|
+
while(i<s.length&&s[i]!=='"'){
|
|
1720
|
+
if(s[i]==='\\'){ const e=s[i+1];
|
|
1721
|
+
if(e==='n'){ v+='\n'; i+=2; continue; }
|
|
1722
|
+
if(e==='"'){ v+='"'; i+=2; continue; }
|
|
1723
|
+
if(e==='\\'){ v+='\\'; i+=2; continue; }
|
|
1724
|
+
return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'}; }
|
|
1725
|
+
v+=s[i]; i++;
|
|
1726
|
+
}
|
|
1727
|
+
if(i>=s.length) return {error:'unterminated string in [label]'};
|
|
1728
|
+
i++;
|
|
1729
|
+
if(s[i]!==']') return {error:'expected ] after quoted label'};
|
|
1730
|
+
i++;
|
|
1731
|
+
if(!v) return {error:'empty [label]'};
|
|
1732
|
+
return {v};
|
|
1733
|
+
}
|
|
1734
|
+
let depth=1,v='';
|
|
1735
|
+
while(i<s.length){
|
|
1736
|
+
const c=s[i];
|
|
1737
|
+
if(c==='[') depth++;
|
|
1738
|
+
else if(c===']'){ depth--; if(!depth){ i++;
|
|
1739
|
+
v=v.trim();
|
|
1740
|
+
if(!v) return {error:'empty [label]'};
|
|
1741
|
+
return {v}; } }
|
|
1742
|
+
v+=c; i++;
|
|
1743
|
+
}
|
|
1744
|
+
return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
|
|
1745
|
+
};
|
|
1746
|
+
// Spans are recorded in SOURCE ORDER and are half-open [start,end). A label
|
|
1747
|
+
// span covers its BRACKETS as well as its text, because the edit a GUI makes
|
|
1748
|
+
// to a written label is a replacement of the whole `[...]` (deleting one
|
|
1749
|
+
// means deleting its brackets too); `mid` is the one span nested inside
|
|
1750
|
+
// another, since `-[x]->` is one operator with a label in the middle of it.
|
|
1751
|
+
// An ABSENT label gets no span at all, which is how a caller tells
|
|
1752
|
+
// `edge a -> b` from `edge a [t] -> b` — the value is `null` in both the
|
|
1753
|
+
// field and the span, and an empty `[]` is a line error, never a third state.
|
|
1754
|
+
const sp={};
|
|
1755
|
+
ws();
|
|
1756
|
+
if(s[i]==='"') return bad(ID_RULE);
|
|
1757
|
+
const aAt=i;
|
|
1758
|
+
const a=readId();
|
|
1759
|
+
if(!a) return bad(idHere()?ID_RULE:kw+' needs <id> ->|<-|--|<-> <id>');
|
|
1760
|
+
if(idHere()) return bad(ID_RULE);
|
|
1761
|
+
sp.a={start:aAt,end:i};
|
|
1762
|
+
ws(); let tail=null;
|
|
1763
|
+
if(s[i]==='['){ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
|
|
1764
|
+
tail=r.v; sp.tail={start:at,end:i}; }
|
|
1765
|
+
ws();
|
|
1766
|
+
const opAt=i;
|
|
1767
|
+
let lh=null;
|
|
1768
|
+
if(s.startsWith('<-',i)){ lh='<-'; i+=2; }
|
|
1769
|
+
else if(s[i]==='-'){ lh='-'; i++; }
|
|
1770
|
+
else return bad(kw+' needs an operator: -> <- -- <-> (a [mid] label splits it: -[x]->)');
|
|
1771
|
+
let mid=null, op=null;
|
|
1772
|
+
if(s[i]==='['){
|
|
1773
|
+
const at=i; const r=readLbl(); if(r.error) return bad(r.error);
|
|
1774
|
+
mid=r.v; sp.mid={start:at,end:i};
|
|
1775
|
+
if(s.startsWith('->',i)){ op=lh==='<-'?'<->':'->'; i+=2; }
|
|
1776
|
+
else if(s[i]==='-'){ op=lh==='<-'?'<-':'--'; i++; }
|
|
1777
|
+
else return bad('expected - or -> to close the operator after [label]');
|
|
1778
|
+
} else if(lh==='<-'){
|
|
1779
|
+
if(s[i]==='>'){ op='<->'; i++; } else op='<-';
|
|
1780
|
+
} else {
|
|
1781
|
+
if(s[i]==='-'){ op='--'; i++; }
|
|
1782
|
+
else if(s[i]==='>'){ op='->'; i++; }
|
|
1783
|
+
else return bad(kw+' needs an operator: -> <- -- <->');
|
|
1784
|
+
}
|
|
1785
|
+
sp.connector={start:opAt,end:i};
|
|
1786
|
+
ws(); let head=null;
|
|
1787
|
+
if(s[i]==='['){ const at=i; const r=readLbl(); if(r.error) return bad(r.error);
|
|
1788
|
+
head=r.v; sp.head={start:at,end:i}; }
|
|
1789
|
+
ws();
|
|
1790
|
+
if(s[i]==='"') return bad(ID_RULE);
|
|
1791
|
+
const bAt=i;
|
|
1792
|
+
const b=readId();
|
|
1793
|
+
if(!b) return bad(idHere()?ID_RULE:kw+' needs a target id after the operator');
|
|
1794
|
+
if(idHere()) return bad(ID_RULE);
|
|
1795
|
+
sp.b={start:bAt,end:i};
|
|
1796
|
+
// Everything after endpoint `b` is the OPTION REGION, and it starts at `b`'s
|
|
1797
|
+
// last byte, not at the next non-space: an option edit that began anywhere
|
|
1798
|
+
// earlier could reach back into `[head]`, which is the one thing the caller
|
|
1799
|
+
// asked this span to make impossible. The parser tokenizes exactly this
|
|
1800
|
+
// slice, and `message`'s trailing quoted label lives in it too.
|
|
1801
|
+
sp.options={start:i,end:s.length};
|
|
1802
|
+
return {ok:true,keyword:kw,a,b,op,tail,mid,head,spans:sp};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// The connector word this genre spells at this version, or the diagnostic that
|
|
1806
|
+
// says so. `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`'s answer lives here rather than at the dispatch below
|
|
1807
|
+
// because the parser and `scanConnectorLine` must give the same one: a GUI
|
|
1808
|
+
// that offered to edit a `flowline` line in a `block` document would be
|
|
1809
|
+
// offering to edit a line error.
|
|
1810
|
+
const connectorWordError=(surf,genre,version)=>{
|
|
1811
|
+
const want=connectorKwAt(genre,version);
|
|
1812
|
+
if(!want) return '"'+surf+'" is not allowed in genre '+genre;
|
|
1813
|
+
if(surf===want) return null;
|
|
1814
|
+
const need=CONNECTOR_MIN_VERSION[surf];
|
|
1815
|
+
// The author wrote a word this genre really does use — just not in
|
|
1816
|
+
// the version they declared. That is a different mistake from the
|
|
1817
|
+
// wrong domain's word, and it has a different one-step fix.
|
|
1818
|
+
if(need && version && need>version && GENRE_CONNECTOR_KW[genre]===surf)
|
|
1819
|
+
return WRONG_VERSION_WORD(surf,want,genre,need,version);
|
|
1820
|
+
return WRONG_WORD(surf,want,genre);
|
|
1821
|
+
};
|
|
1822
|
+
|
|
1823
|
+
// scanConnectorLine(code, genre, version) — the connector grammar, READ-ONLY,
|
|
1824
|
+
// for a caller that has one line and wants to know what is where in it.
|
|
1825
|
+
//
|
|
1826
|
+
// {ok:true, keyword, a, b, op, tail, mid, head,
|
|
1827
|
+
// spans:{a, tail?, connector, mid?, head?, b, options}}
|
|
1828
|
+
// {ok:false, error}
|
|
1829
|
+
//
|
|
1830
|
+
// `code` is a CODE-ONLY line: the caller has already cut any trailing comment
|
|
1831
|
+
// (`findComment`), because a `#` inside `[a hop #1]` is not a comment and only
|
|
1832
|
+
// the caller knows where its line came from. Leading and trailing whitespace
|
|
1833
|
+
// are tolerated and the offsets account for them, so a span can be spliced
|
|
1834
|
+
// straight back into the string that was passed in.
|
|
1835
|
+
//
|
|
1836
|
+
// It mutates nothing, appends to no error array, and never throws for input:
|
|
1837
|
+
// a line that is not a connector at all — or is one the genre does not use, or
|
|
1838
|
+
// is malformed — comes back as `{ok:false,error}` carrying the same sentence
|
|
1839
|
+
// the parser would have reported for it.
|
|
1840
|
+
function scanConnectorLine(code, genre, version){
|
|
1841
|
+
if(typeof code!=='string') return {ok:false,error:'connector line must be a string'};
|
|
1842
|
+
const s=code.trim();
|
|
1843
|
+
const off=code.length-code.replace(/^\s+/,'').length;
|
|
1844
|
+
const m=CONN_LINE_RE.exec(s);
|
|
1845
|
+
if(!m) return {ok:false,error:'not a connector line — it does not begin with '+
|
|
1846
|
+
[...CONNECTOR_SPELLINGS].join(', ')};
|
|
1847
|
+
const surf=m[1];
|
|
1848
|
+
// The genre gate is OPTIONAL: with no genre named, any of the four spellings
|
|
1849
|
+
// scans, which is what a caller inspecting a fragment out of context needs.
|
|
1850
|
+
if(genre && GENRE_KW[genre]){
|
|
1851
|
+
const e=connectorWordError(surf,genre,version);
|
|
1852
|
+
if(e) return {ok:false,error:e};
|
|
1853
|
+
}
|
|
1854
|
+
const r=scanConnector(s,surf);
|
|
1855
|
+
if(!r.ok) return r;
|
|
1856
|
+
for(const k in r.spans){
|
|
1857
|
+
r.spans[k]={start:r.spans[k].start+off,end:r.spans[k].end+off};
|
|
1858
|
+
}
|
|
1859
|
+
// The option region runs to the END of the line the caller passed, trailing
|
|
1860
|
+
// whitespace included, so replacing that span replaces every option and
|
|
1861
|
+
// nothing else.
|
|
1862
|
+
r.spans.options.end=code.length;
|
|
1863
|
+
return r;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1464
1866
|
function parseOne(text){
|
|
1465
1867
|
const errs=[];
|
|
1466
1868
|
// `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
|
|
@@ -1493,13 +1895,21 @@ function parseOne(text){
|
|
|
1493
1895
|
// rule), so no existing golden moves a byte.
|
|
1494
1896
|
lifelines:[],messages:[],states:[],fragments:[],operands:[]};
|
|
1495
1897
|
const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
|
|
1496
|
-
bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set()
|
|
1898
|
+
bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set(),
|
|
1899
|
+
// `CONNECTOR-IDENTITY-KEY`: the connector ids written with `id=`. They are a
|
|
1900
|
+
// SET beside the others only for bookkeeping — for UNIQUENESS they are
|
|
1901
|
+
// part of the one shared namespace `dupId` tests, because a `bundle`
|
|
1902
|
+
// member that is a bare token must resolve to exactly one kind of
|
|
1903
|
+
// thing. A separate namespace would have made `bundle t1 "T" x` mean
|
|
1904
|
+
// one thing when `x` is a node and another when it is a connector, and
|
|
1905
|
+
// both at once when it is both.
|
|
1906
|
+
edgeIds=new Set();
|
|
1497
1907
|
// §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
|
|
1498
1908
|
// typed blocks (bitfield/table/timing) share ONE namespace, so a bare id in
|
|
1499
1909
|
// `edge`/`pin`/`chart` can never be ambiguous. `plane`, `class` and
|
|
1500
1910
|
// `bundle` keep their own namespaces: each is referenced through a dedicated
|
|
1501
1911
|
// option or keyword, never as a bare id.
|
|
1502
|
-
const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id);
|
|
1912
|
+
const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id)||edgeIds.has(id);
|
|
1503
1913
|
let cur=null; // current typed block (bitfield/table/timing)
|
|
1504
1914
|
// `REPEATED-DIRECTIVE-HANDLING`: `title`, `flow`, `layout` and a per-id `pin` are SINGLE-VALUED — a
|
|
1505
1915
|
// repetition is a line error on the second occurrence, never a silent
|
|
@@ -1533,86 +1943,19 @@ function parseOne(text){
|
|
|
1533
1943
|
// `edge`, `flowline` or `transition`. One scanner, three words: every
|
|
1534
1944
|
// message names the word on the line, and nothing downstream of here knows
|
|
1535
1945
|
// the difference (the model records a connector, not a spelling).
|
|
1946
|
+
//
|
|
1947
|
+
// 0.4: the scanner itself is `scanConnector`, at top level, so the
|
|
1948
|
+
// editor's GUI half can read the same grammar through `scanConnectorLine`.
|
|
1949
|
+
// What is left here is everything a SCAN cannot do: the option pass, the
|
|
1950
|
+
// semantic checks, and the model write. Every syntax diagnostic below the
|
|
1951
|
+
// call is relayed verbatim from the scanner, so this function reports
|
|
1952
|
+
// exactly what it reported when the scanner was local to it.
|
|
1536
1953
|
function parseEdgeLine(s,n,kw){
|
|
1537
1954
|
kw=kw||'edge';
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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());
|
|
1955
|
+
const sc=scanConnector(s,kw);
|
|
1956
|
+
if(!sc.ok){ err(n,sc.error); return; }
|
|
1957
|
+
const {a,b,op,tail,mid,head}=sc;
|
|
1958
|
+
const tk2=tokenize(s.slice(sc.spans.options.start).trim());
|
|
1616
1959
|
if(tk2.error){ err(n,tk2.error); return; }
|
|
1617
1960
|
const {pos:p2,posq:pq2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
|
|
1618
1961
|
if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
|
|
@@ -1623,7 +1966,12 @@ function parseOne(text){
|
|
|
1623
1966
|
// `message` is scanned by this function and never reaches that one.
|
|
1624
1967
|
if(u2.length){
|
|
1625
1968
|
const ro=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
|
|
1626
|
-
|
|
1969
|
+
// `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 2a of 2 — the reserved `x-` prefix in
|
|
1970
|
+
// OPTION-KEY position, on the connector path. `message` is scanned by
|
|
1971
|
+
// this function and never reaches `badOpts`, which is why the check has
|
|
1972
|
+
// to exist in both places (the same reason `UNDELIVERED-MESSAGE-MARKING`'s genre refusal does).
|
|
1973
|
+
if(isXReserved(u2[0])) err(n,X_RESERVED_OPT(u2[0]));
|
|
1974
|
+
else if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
|
|
1627
1975
|
else err(n,'unknown option "'+u2[0]+'="');
|
|
1628
1976
|
return; }
|
|
1629
1977
|
// `SEQUENCE-GENRE-VOCABULARY`: `message` adds ONE trailing positional — the
|
|
@@ -1675,6 +2023,26 @@ function parseOne(text){
|
|
|
1675
2023
|
if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); return; }
|
|
1676
2024
|
if(!optQ(oT2,'note')){ err(n,'note= must be quoted: note="'+o2.note+'" — '+Q_WHY); return; }
|
|
1677
2025
|
}
|
|
2026
|
+
// `CONNECTOR-IDENTITY-KEY`: `id=` — the connector's optional handle. Three checks
|
|
2027
|
+
// in the order an author's next move is decided by:
|
|
2028
|
+
// 1. the VERSION GATE, first, so a 0.4 document is told the one thing it
|
|
2029
|
+
// can do about the line rather than being told its id is malformed;
|
|
2030
|
+
// 2. the ID LEXIS, `QUOTED-IDS`'s one wording for every id position, so a
|
|
2031
|
+
// connector id is spelled exactly as a node id is (bare, no `--`);
|
|
2032
|
+
// 3. UNIQUENESS. A connector id joins the SECTION's existing
|
|
2033
|
+
// node/group/external/region namespace, which is what makes a bare
|
|
2034
|
+
// single-token `bundle` member decidable — a member either resolves to
|
|
2035
|
+
// one connector or names something that is not one, and never both. So
|
|
2036
|
+
// the diagnostic is `duplicate id`, the one §8.1 already has, and not a
|
|
2037
|
+
// new one: this is the same namespace, not a parallel one.
|
|
2038
|
+
let eid;
|
|
2039
|
+
if(o2.id!==undefined){
|
|
2040
|
+
if(belowOptVersion('id',doc.version)){ err(n,ID_VERSION(doc.version)); return; }
|
|
2041
|
+
const e=idErr(o2.id, optHasQ(oT2,'id'), 'id= needs an id: '+kw+' '+a+' '+op+' '+b+' id=<id>');
|
|
2042
|
+
if(e){ err(n,e); return; }
|
|
2043
|
+
if(dupId(o2.id)){ err(n,'duplicate id "'+o2.id+'"'); return; }
|
|
2044
|
+
eid=o2.id;
|
|
2045
|
+
}
|
|
1678
2046
|
// `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
|
|
1679
2047
|
// Checked before the value, exactly as `badOpts` does it.
|
|
1680
2048
|
if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
|
|
@@ -1711,7 +2079,8 @@ function parseOne(text){
|
|
|
1711
2079
|
// ruling taken here: the draft settles the trailing form and says
|
|
1712
2080
|
// nothing about the brackets. `[tail]` and `[head]` are kept — they are
|
|
1713
2081
|
// different positions, not a second spelling of the same one.
|
|
1714
|
-
|
|
2082
|
+
if(eid!==undefined) edgeIds.add(eid);
|
|
2083
|
+
doc.messages.push({id:eid,a,b,op,tail,head,
|
|
1715
2084
|
label:seqLabel!==null?seqLabel:mid,
|
|
1716
2085
|
style:o2.style,cls:ecls,stroke:o2.stroke,note:o2.note,
|
|
1717
2086
|
desc:o2.description,in:o2['in']||null,line:n});
|
|
@@ -1720,7 +2089,8 @@ function parseOne(text){
|
|
|
1720
2089
|
// §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
|
|
1721
2090
|
// and `fill=` name the same channel (`stroke=` wins when both are
|
|
1722
2091
|
// written); `text=` colours the [tail]/[mid]/[head] labels.
|
|
1723
|
-
|
|
2092
|
+
if(eid!==undefined) edgeIds.add(eid);
|
|
2093
|
+
doc.edges.push({id:eid,a,b,op,tail,mid,head,style:o2.style,cls:ecls,
|
|
1724
2094
|
stroke:o2.stroke,note:o2.note,
|
|
1725
2095
|
plane:o2.plane||'base',line:n});
|
|
1726
2096
|
}
|
|
@@ -1905,20 +2275,13 @@ function parseOne(text){
|
|
|
1905
2275
|
// `KEYWORD-RENAME-SCOPE`: the word this genre uses is read AT THE DECLARED VERSION, not
|
|
1906
2276
|
// at the newest one. A genre with no connector at all (bitfield, table,
|
|
1907
2277
|
// timing) still gets the allowlist message.
|
|
2278
|
+
// 0.4: the three answers this gate can give are spelled once, in
|
|
2279
|
+
// `connectorWordError`, because `scanConnectorLine` has to give the same
|
|
2280
|
+
// three to the GUI. The `sawHeader` guard stays here: it is about where
|
|
2281
|
+
// the parser is in the document, which no scanner of one line can know.
|
|
1908
2282
|
if(sawHeader && doc.genre && GENRE_KW[doc.genre]){
|
|
1909
|
-
const
|
|
1910
|
-
if(
|
|
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; }
|
|
2283
|
+
const e=connectorWordError(ckw, doc.genre, doc.version);
|
|
2284
|
+
if(e){ err(n,e); continue; }
|
|
1922
2285
|
}
|
|
1923
2286
|
parseEdgeLine(raw.trim(),n,ckw);
|
|
1924
2287
|
continue;
|
|
@@ -1949,7 +2312,12 @@ function parseOne(text){
|
|
|
1949
2312
|
// ground and the replacement spelling instead of a spellcheck.
|
|
1950
2313
|
const roOpt=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
|
|
1951
2314
|
for(const u of unk){
|
|
1952
|
-
|
|
2315
|
+
// `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 2b of 2 — the reserved `x-` prefix in
|
|
2316
|
+
// OPTION-KEY position, on the ordinary directive path. Checked before
|
|
2317
|
+
// the genre refusal and before the generic message, because a key the
|
|
2318
|
+
// language will never register is a stronger fact than either.
|
|
2319
|
+
if(isXReserved(u)) err(n,X_RESERVED_OPT(u));
|
|
2320
|
+
else if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
|
|
1953
2321
|
else err(n,'unknown option "'+u+'="');
|
|
1954
2322
|
bad=true; }
|
|
1955
2323
|
// `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
|
|
@@ -2131,6 +2499,18 @@ function parseOne(text){
|
|
|
2131
2499
|
} else { err(n,'first line must be "figdown 0.1 <genre>"'); }
|
|
2132
2500
|
} else if(kw==='figdown'){ err(n,'duplicate version header'); continue; }
|
|
2133
2501
|
|
|
2502
|
+
// `RESERVED-PREFIX-ENFORCEMENT` (2026-08-23): CALL SITE 1 of 2 — the reserved `x-` prefix in
|
|
2503
|
+
// KEYWORD position. It sits here, above every other keyword answer, for
|
|
2504
|
+
// two reasons: no registered keyword begins `x-`, so it can pre-empt
|
|
2505
|
+
// nothing legitimate; and every downstream answer this would otherwise
|
|
2506
|
+
// reach (`not allowed in genre <g>`, `not valid inside <block>`,
|
|
2507
|
+
// `unrecognized line`) is a message about a typo, which is what §8.4 of
|
|
2508
|
+
// decisions/registry.md measured and what this replaces.
|
|
2509
|
+
// It sits BELOW the first-line header check deliberately: "first line
|
|
2510
|
+
// must be figdown …" is the more important thing to tell an author, and
|
|
2511
|
+
// an `x-` first line reports both, exactly as it reported two before.
|
|
2512
|
+
if(isXReserved(kw)){ err(n,X_RESERVED_KW(kw)); continue; }
|
|
2513
|
+
|
|
2134
2514
|
// Retired spelling: `colw` → `width`. Keyword naming
|
|
2135
2515
|
// discipline — one lowercase word, borrowed standard terminology; `colw`
|
|
2136
2516
|
// was the only invented abbreviation in the registry. Reported wherever
|
|
@@ -2404,7 +2784,7 @@ function parseOne(text){
|
|
|
2404
2784
|
if(/[2-9]/.test(lane)){ err(n,RETIRED_LANE_DIGIT); continue; }
|
|
2405
2785
|
if(!/^[01pnx=.]+$/.test(lane)){ err(n,'lane may contain only 0 1 p n x = .'); continue; }
|
|
2406
2786
|
// / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
|
|
2407
|
-
// members (a
|
|
2787
|
+
// members (a,,b), or a count that does not match the lane's `=`
|
|
2408
2788
|
// cells are all line errors — never silent drop or shift.
|
|
2409
2789
|
// Spelled `labels=` until 0.1 (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
|
|
2410
2790
|
// `data`, "an array of signal labels" naming every value cell, and
|
|
@@ -2726,26 +3106,53 @@ function parseOne(text){
|
|
|
2726
3106
|
// the half-converted line the old tolerance could not report.
|
|
2727
3107
|
if(restT.length>1){
|
|
2728
3108
|
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
|
-
|
|
3109
|
+
// `CONNECTOR-IDENTITY-KEY`: a member is an endpoint PAIR or a connector ID,
|
|
3110
|
+
// in the same comma list, told apart by LEXIS and not by a flag: `--`
|
|
3111
|
+
// cannot occur inside an id (`LINK-OPERATOR-IN-IDS`), so a member either contains it and
|
|
3112
|
+
// is a pair or does not and is an id. RULE 5 is satisfied because each
|
|
3113
|
+
// form reaches input the other cannot — the pair form reaches ANONYMOUS
|
|
3114
|
+
// connectors, which have no id to name; the id form reaches PARALLEL
|
|
3115
|
+
// connectors, which no pair can address — so neither is a spelling
|
|
3116
|
+
// variant of the other. Mixed lists need no rule of their own: every
|
|
3117
|
+
// member resolves independently.
|
|
3118
|
+
//
|
|
3119
|
+
// `members` is the AUTHORED list in source order and is what the model
|
|
3120
|
+
// projects. `pairs` stays what it always was — the endpoint pairs the
|
|
3121
|
+
// ring geometry reads — and an id member appends its resolved pair to
|
|
3122
|
+
// it in the semantic pass below, once the connector it names exists.
|
|
3123
|
+
const pairs=[], members=[]; let badp=null;
|
|
2730
3124
|
outerB:
|
|
2731
3125
|
for(const t of restT){
|
|
2732
3126
|
for(const s of splitList(t,0)){
|
|
2733
3127
|
const mem=s.v.trim(); if(!mem) continue;
|
|
2734
3128
|
if(s.h){ badp=ID_RULE; break outerB; }
|
|
3129
|
+
if(!mem.includes('--')){
|
|
3130
|
+
// A bare token. Under figdown 0.4 and below there is no id form,
|
|
3131
|
+
// so the answer is the one it always was: this is a malformed
|
|
3132
|
+
// pair. Naming the id form to a document that cannot write it
|
|
3133
|
+
// would send the author to a key their header does not have.
|
|
3134
|
+
if(belowOptVersion('id',doc.version)){
|
|
3135
|
+
badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
|
|
3136
|
+
if(!isId(mem)){
|
|
3137
|
+
badp='bad member "'+mem+'" (expected A--B or a connector id)'; break outerB; }
|
|
3138
|
+
members.push({id:mem});
|
|
3139
|
+
continue;
|
|
3140
|
+
}
|
|
2735
3141
|
const parts=mem.split('--');
|
|
2736
3142
|
if(parts.length!==2||!isId(parts[0])||!isId(parts[1])){
|
|
2737
3143
|
badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
|
|
2738
3144
|
pairs.push([parts[0],parts[1]]);
|
|
3145
|
+
members.push({a:parts[0],b:parts[1]});
|
|
2739
3146
|
}
|
|
2740
3147
|
}
|
|
2741
3148
|
if(badp){ err(n,badp); break; }
|
|
2742
|
-
if(!
|
|
3149
|
+
if(!members.length){ err(n,'bundle needs at least one member link A--B'); break; }
|
|
2743
3150
|
bundleIds.add(id);
|
|
2744
3151
|
// §5 on the derived ring: `fill=` stays the ring colour (stroke +
|
|
2745
3152
|
// label) as before, `stroke=`/`text=` split it, `style=` picks the
|
|
2746
3153
|
// dash (the conventional default is dashed), `plane=` orders this
|
|
2747
3154
|
// ring against the other rings.
|
|
2748
|
-
doc.trunks.push({id,label:tlabel,pairs,stroke:opts.stroke,
|
|
3155
|
+
doc.trunks.push({id,label:tlabel,pairs,members,stroke:opts.stroke,
|
|
2749
3156
|
style:opts.style,plane:opts.plane,line:n});
|
|
2750
3157
|
break;
|
|
2751
3158
|
}
|
|
@@ -3189,13 +3596,85 @@ function parseOne(text){
|
|
|
3189
3596
|
for(const f of doc.bands)
|
|
3190
3597
|
if(!groupIds.has(f.target)&&!nodeIds.has(f.target)&&!regionTarget(f.target))
|
|
3191
3598
|
errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3599
|
+
// `MEMBER-LIST-DUPLICATION`: a bundle member repeated in ONE member list is a
|
|
3600
|
+
// line error — the identity-model-proposal's probe 5, a live defect
|
|
3601
|
+
// independent of edge identity. `bundle t1 "T" a--b,a--b` used to parse
|
|
3602
|
+
// silently into a two-member bundle over one link. The duplicate test is
|
|
3603
|
+
// on the RESOLVED edge (the same *Edge* object each pair member resolves
|
|
3604
|
+
// to), not on the spelling, so `a--b,b--a` is caught too — two spellings
|
|
3605
|
+
// of an undirected pair that resolve to one edge are one member named
|
|
3606
|
+
// twice, not two. Stating the rule this way, rather than as "the same
|
|
3607
|
+
// string written twice", is deliberate: it is the form that survives the
|
|
3608
|
+
// future id-form member (§4.1 of the proposal) without a rewrite, because
|
|
3609
|
+
// an id and a pair spelling can resolve to the same edge too. `seen` is
|
|
3610
|
+
// per BUNDLE — each trunk's member list is checked against itself, not
|
|
3611
|
+
// against any other bundle's.
|
|
3612
|
+
//
|
|
3613
|
+
// `CONNECTOR-IDENTITY-KEY`: the loop walks the AUTHORED member list, so a member is
|
|
3614
|
+
// resolved by the form it was written in and the diagnostic names what the
|
|
3615
|
+
// author wrote. The duplicate test does not change and did not need to: it
|
|
3616
|
+
// was already bound to the resolved *Edge* object, which is exactly why an id
|
|
3617
|
+
// member and a pair member naming ONE connector are caught by it without a
|
|
3618
|
+
// second rule — the whole reason `MEMBER-LIST-DUPLICATION` was written that way.
|
|
3619
|
+
for(const t of doc.trunks){
|
|
3620
|
+
const seen=new Set();
|
|
3621
|
+
for(const m of (t.members||t.pairs.map(p=>({a:p[0],b:p[1]})))){
|
|
3622
|
+
// The written spelling, for every message this loop can produce.
|
|
3623
|
+
const spelled=m.id!==undefined?m.id:m.a+'--'+m.b;
|
|
3624
|
+
let hit=null;
|
|
3625
|
+
if(m.id!==undefined){
|
|
3626
|
+
// The id form. It resolves against the connectors of THIS SECTION and
|
|
3627
|
+
// nothing else: ids are unique per section (core §1) and a
|
|
3628
|
+
// cross-section reference is a line error by design, so an id from a
|
|
3629
|
+
// neighbouring section arrives here as an id this section never
|
|
3630
|
+
// declared — which is what the second message says, in the words that
|
|
3631
|
+
// are true for it.
|
|
3632
|
+
const named=doc.edges.filter(e=>e.id===m.id);
|
|
3633
|
+
// What the id DOES name, when it does not name a connector. The kind is
|
|
3634
|
+
// in the message because it is the whole of the author's next move: a
|
|
3635
|
+
// node id in a member list is a `bundle b "B" a--b` written short, and
|
|
3636
|
+
// a class id there is a different mistake entirely.
|
|
3637
|
+
const kind=nodeIds.has(m.id)&&!edgeIds.has(m.id)?'a node':
|
|
3638
|
+
groupIds.has(m.id)?'a group':
|
|
3639
|
+
boundaryIds.has(m.id)?'an external endpoint':
|
|
3640
|
+
blockIds.has(m.id)?'a region':
|
|
3641
|
+
classIds.has(m.id)?'a class':
|
|
3642
|
+
bundleIds.has(m.id)?'a bundle':null;
|
|
3643
|
+
if(named.length===1) hit=named[0];
|
|
3644
|
+
else if(kind){
|
|
3645
|
+
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');
|
|
3646
|
+
continue;
|
|
3647
|
+
} else {
|
|
3648
|
+
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=)');
|
|
3649
|
+
continue;
|
|
3650
|
+
}
|
|
3651
|
+
// The ring reads endpoint pairs, so a resolved id member contributes
|
|
3652
|
+
// the pair its connector runs between. Nothing about the drawing is
|
|
3653
|
+
// authored here (`DOMAIN-CONVENTION-DIRECTIVES`) — which links the ring encloses is.
|
|
3654
|
+
t.pairs.push([hit.a,hit.b]);
|
|
3655
|
+
} else {
|
|
3656
|
+
const a=m.a, b=m.b;
|
|
3657
|
+
if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
|
|
3658
|
+
const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a));
|
|
3659
|
+
if(matches.length===0){
|
|
3660
|
+
errs.push('Line '+t.line+': no edge between "'+a+'" and "'+b+'" for bundle member'); continue; }
|
|
3661
|
+
if(matches.length>1){
|
|
3662
|
+
// `CONNECTOR-IDENTITY-KEY` REPLACES probe 6's message. The old one cited `v0.1` in a 0.4
|
|
3663
|
+
// engine, stated a SCOPE EXCLUSION where the truth is "say which one
|
|
3664
|
+
// you mean", and named no remedy. Parallel links are the defining
|
|
3665
|
+
// case of a LAG, so a bundle that cannot name one member of three is
|
|
3666
|
+
// a construct whose definition and whose grammar disagree; now there
|
|
3667
|
+
// is a way out and the message is the place the author meets it.
|
|
3668
|
+
errs.push('Line '+t.line+': "'+a+'--'+b+'" matches '+matches.length+
|
|
3669
|
+
' 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)');
|
|
3670
|
+
continue; }
|
|
3671
|
+
hit=matches[0];
|
|
3672
|
+
}
|
|
3673
|
+
if(seen.has(hit))
|
|
3674
|
+
errs.push('Line '+t.line+': duplicate bundle member "'+spelled+'" — each member is stated once');
|
|
3675
|
+
else
|
|
3676
|
+
seen.add(hit);
|
|
3677
|
+
}
|
|
3199
3678
|
}
|
|
3200
3679
|
// `ELEMENT-GEOMETRY-DIRECTIVE`: `pin` has a SPLIT DOMAIN, and both halves are checked
|
|
3201
3680
|
// here because both need the finished id sets.
|
|
@@ -3260,7 +3739,7 @@ function parseOne(text){
|
|
|
3260
3739
|
// is that genre's designed idiom, not an oversight.
|
|
3261
3740
|
//
|
|
3262
3741
|
// `INTERIOR-LESS-ELEMENT-PAINT`'s half stands and now reaches EVERY collection that accepts
|
|
3263
|
-
// `class=` (`CLASS-CHANNEL-REACH`). Until
|
|
3742
|
+
// `class=` (`CLASS-CHANNEL-REACH`). Until 0.4 the loop below ran over `doc.edges`
|
|
3264
3743
|
// alone, so `class k "K" fill=#eee` plus `message c -> s "m" class=k` was
|
|
3265
3744
|
// accepted, painted nothing, and put the class in the legend — a message
|
|
3266
3745
|
// has its own collection because it has a position in time (`SEQUENCE-ORDER-MODEL`), and
|
|
@@ -3311,6 +3790,7 @@ function parseOne(text){
|
|
|
3311
3790
|
};
|
|
3312
3791
|
const clsChan=(x,kind)=>{
|
|
3313
3792
|
const K=CLASS_CHANNELS[kind];
|
|
3793
|
+
const claimed={}; // channel -> class id that already set it on THIS element (`CLASS-CHANNEL-COLLISION`)
|
|
3314
3794
|
for(const cid of (x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]))){
|
|
3315
3795
|
const c=doc.classes.find(y=>y.id===cid);
|
|
3316
3796
|
if(!c) continue; // unknown id: its own error
|
|
@@ -3319,8 +3799,29 @@ function parseOne(text){
|
|
|
3319
3799
|
if(!K.has.includes('fill')&&c.fill!==undefined&&c.stroke===undefined){
|
|
3320
3800
|
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
3801
|
continue; }
|
|
3322
|
-
if(!decl.some(k=>K.has.includes(k)))
|
|
3802
|
+
if(!decl.some(k=>K.has.includes(k))){
|
|
3323
3803
|
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)');
|
|
3804
|
+
continue; }
|
|
3805
|
+
// `CLASS-CHANNEL-COLLISION`: two CARRIED CLASSES binding the SAME paint
|
|
3806
|
+
// channel on one element is a line error — §8's own rule ("a repeated
|
|
3807
|
+
// option key on ONE line is a line error, never last-wins") one level
|
|
3808
|
+
// up, closing the hole a class boundary opened in it. The check is
|
|
3809
|
+
// per element, per channel the member actually has (K.has): the
|
|
3810
|
+
// first carried class to set a channel CLAIMS it, and a later class
|
|
3811
|
+
// repeating a claimed channel is refused by name. Element-direct
|
|
3812
|
+
// `fill=`/`stroke=`/`style=` is untouched — it already overrides
|
|
3813
|
+
// every class (rigidity, `LAYOUT-STABILITY`) and is not a second class SOURCE.
|
|
3814
|
+
// `class=hot,deprecated` in conformance case 308 is the shape this
|
|
3815
|
+
// must keep legal: each channel comes from exactly one class.
|
|
3816
|
+
for(const k of K.has){
|
|
3817
|
+
if(c[k]===undefined) continue;
|
|
3818
|
+
if(claimed[k]!==undefined){
|
|
3819
|
+
const other=K.has.filter(o=>o!==k).map(o=>o+'=').join('/');
|
|
3820
|
+
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');
|
|
3821
|
+
} else {
|
|
3822
|
+
claimed[k]=cid;
|
|
3823
|
+
}
|
|
3824
|
+
}
|
|
3324
3825
|
}
|
|
3325
3826
|
};
|
|
3326
3827
|
for(const x of doc.nodes) clsChan(x,'node');
|
|
@@ -3480,6 +3981,163 @@ function stackSectionSvgs(results){
|
|
|
3480
3981
|
+chunks.join('')+'</svg>';
|
|
3481
3982
|
}
|
|
3482
3983
|
|
|
3984
|
+
// ---- the accessibility profile's emission (spec/figdown-a11y.md) ----------
|
|
3985
|
+
// `ACCESSIBILITY-PROFILE`/`ACCESSIBLE-DESCRIPTION-SOURCES`/`ACCESSIBLE-TEXT-EMISSION` (decisions/registry.md).
|
|
3986
|
+
// The publication profile wants three things in a published artifact: a role,
|
|
3987
|
+
// a NON-VISUAL name, and a description whose review state a machine can read.
|
|
3988
|
+
// None of it is emitted by default and none of it is a language construct —
|
|
3989
|
+
// the `with-a11y` RENDER OPTION (core §7, `ACCESSIBLE-TEXT-EMISSION` route 1) is the only way in,
|
|
3990
|
+
// exactly as `with-title` (`TITLE-RENDER-DEFAULT`) is the only way to draw the title as ink.
|
|
3991
|
+
//
|
|
3992
|
+
// WHY THIS SITS OUTSIDE `render` AND NOT INSIDE IT. Two reasons, and the
|
|
3993
|
+
// second is the load-bearing one:
|
|
3994
|
+
// 1. Core §7/`RENDERING-DETERMINISM` make an artifact a pure function of (source, recorded
|
|
3995
|
+
// options) and promise byte-identical output for one engine version.
|
|
3996
|
+
// Code that never runs cannot move a byte, and `render` is left exactly
|
|
3997
|
+
// as it was so that every existing artifact and golden is untouched.
|
|
3998
|
+
// 2. `ACCESSIBILITY-PROFILE` rules ONE artifact, ONE root, ONE name — the FIRST section's
|
|
3999
|
+
// `title`. A multi-section artifact's root is built by
|
|
4000
|
+
// `stackSectionSvgs` from N per-section renders, so a `<title>` emitted
|
|
4001
|
+
// inside `render` would land inside a `<g>` (naming the group, per `DESCRIPTION-KEY-SPELLING`
|
|
4002
|
+
// and SVG 1.1 §5.4) and there would be N of them. The name belongs to
|
|
4003
|
+
// the finished root, which only the artifact layer holds.
|
|
4004
|
+
// So the artifact layer (tools/build-svg.js, dist/figdown.js's `artifact()`)
|
|
4005
|
+
// calls `a11yApply(svg, docs[0])` on the finished root and records
|
|
4006
|
+
// `with-a11y` in `data-render-options`.
|
|
4007
|
+
const A11Y_ROLE = 'graphics-document';
|
|
4008
|
+
|
|
4009
|
+
// `ACCESSIBILITY-PROFILE` item 3: the accessible name is the document's `title` string,
|
|
4010
|
+
// UNMODIFIED — not truncated, not prefixed, never invented. `title` is
|
|
4011
|
+
// optional in the grammar and stays optional (`ACCESSIBILITY-PROFILE` is explicit that the
|
|
4012
|
+
// untitled figure still parses and still renders): a document with no
|
|
4013
|
+
// `title` line gets NO root `<title>`, and whether such a figure may be
|
|
4014
|
+
// PUBLISHED is the profile's question and the verifier's, never the
|
|
4015
|
+
// engine's. The test is ABSENCE, not truthiness (`EMPTY-LABEL-STATE`): `title ""` is a
|
|
4016
|
+
// written empty name and is emitted as one.
|
|
4017
|
+
function a11yTitleOf(doc){
|
|
4018
|
+
return (doc && doc.title!==null && doc.title!==undefined) ? String(doc.title) : null;
|
|
4019
|
+
}
|
|
4020
|
+
|
|
4021
|
+
// ---- the derived description (`ACCESSIBLE-DESCRIPTION-SOURCES` item 7) --------------------------------
|
|
4022
|
+
// A `derived` description is a deterministic projection of the MODEL into
|
|
4023
|
+
// prose — "exactly as good as the model, and no better" — and `ACCESSIBLE-DESCRIPTION-SOURCES`'s
|
|
4024
|
+
// operative half is the NEGATIVE rule: it MUST NOT state what the model does
|
|
4025
|
+
// not assert. No causality, no "this shows how X works", no count the model
|
|
4026
|
+
// cannot produce. Every sentence below is an inventory statement over a
|
|
4027
|
+
// collection the model actually carries.
|
|
4028
|
+
//
|
|
4029
|
+
// COVERED GENRES ARE EXACTLY THE THREE `ACCESSIBLE-DESCRIPTION-SOURCES` RULED CONTENT RULES FOR:
|
|
4030
|
+
// `topology`, `bitfield`, `sequence`. Every other genre the engine supports
|
|
4031
|
+
// (`block`, `flowchart`, `statechart`, `table`, `timing`, and the
|
|
4032
|
+
// experimental `chart` region) gets NO derived description — the state stays
|
|
4033
|
+
// `absent` — because inventing content rules for them here would be design,
|
|
4034
|
+
// not implementation. That boundary is stated, not silent: spec/figdown-a11y.md
|
|
4035
|
+
// §4.4 records which genres are covered, which are not, and why.
|
|
4036
|
+
const A11Y_DERIVED_GENRES = ['topology','bitfield','sequence'];
|
|
4037
|
+
function a11yQ(s){ return '"'+String(s)+'"'; }
|
|
4038
|
+
function a11yCount(n,word){ return n+' '+word+(n===1?'':'s'); }
|
|
4039
|
+
// id, plus the label the model carries WHEN it carries one. The renderer's
|
|
4040
|
+
// id-for-absent-label fallback (`OMITTED-LABEL-RECORDING`) is a DISPLAY rule and deliberately does
|
|
4041
|
+
// not run here: the model records absence, and a derived description that
|
|
4042
|
+
// silently promoted an id to a name would assert a name nobody wrote.
|
|
4043
|
+
function a11yNamed(x){
|
|
4044
|
+
return (x.label===null||x.label===undefined) ? String(x.id) : String(x.id)+' '+a11yQ(x.label);
|
|
4045
|
+
}
|
|
4046
|
+
function a11yDeriveTopology(doc){
|
|
4047
|
+
const s=[];
|
|
4048
|
+
s.push('Topology figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
|
|
4049
|
+
s.push(a11yCount(doc.nodes.length,'node')+', '+a11yCount(doc.edges.length,'connector')
|
|
4050
|
+
+', '+a11yCount(doc.groups.length,'group')+'.');
|
|
4051
|
+
s.push('Flow direction: '+String(doc.flow)+'.');
|
|
4052
|
+
if(doc.nodes.length) s.push('Nodes: '+doc.nodes.map(a11yNamed).join(', ')+'.');
|
|
4053
|
+
for(const g of doc.groups){
|
|
4054
|
+
const mem=doc.nodes.filter(n=>n.group===g.id).map(n=>String(n.id));
|
|
4055
|
+
s.push('Group '+a11yNamed(g)+(mem.length?' contains: '+mem.join(', ')+'.':' has no declared members.'));
|
|
4056
|
+
}
|
|
4057
|
+
// A class's MEANING is `label` on the engine object (the canonical
|
|
4058
|
+
// projection renames it `meaning`). `ACCESSIBLE-DESCRIPTION-SOURCES` says "each declared class and its
|
|
4059
|
+
// stated meaning (which the legend already prints)", so the legend's own
|
|
4060
|
+
// filter is inherited rather than re-invented: `class x ""` is an attribute
|
|
4061
|
+
// grouping that states no meaning, it prints no legend entry, and it makes
|
|
4062
|
+
// no sentence here either.
|
|
4063
|
+
for(const c of (doc.classes||[])) if(c.label!=='')
|
|
4064
|
+
s.push('Class '+String(c.id)+' means '+a11yQ(c.label)+'.');
|
|
4065
|
+
return s.join(' ');
|
|
4066
|
+
}
|
|
4067
|
+
function a11yDeriveBitfield(doc){
|
|
4068
|
+
const s=[];
|
|
4069
|
+
s.push('Bitfield figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
|
|
4070
|
+
const blocks=(doc.blocks||[]).filter(b=>b.type==='bitfield');
|
|
4071
|
+
if(!blocks.length) return null;
|
|
4072
|
+
for(const b of blocks){
|
|
4073
|
+
s.push('Bitfield '+a11yNamed(b)+': word '+a11yCount(b.word,'bit')
|
|
4074
|
+
+', numbering '+String(b.numbering)+'.');
|
|
4075
|
+
// `wrap` entries are ROW BREAKS (projected as `break`), a division of the
|
|
4076
|
+
// drawing and not a field; `ACCESSIBLE-DESCRIPTION-SOURCES`'s rule is "fields in order with widths".
|
|
4077
|
+
// A field's width is `w` on the engine object: a bit count, or the string
|
|
4078
|
+
// `*` for the remainder of the row.
|
|
4079
|
+
const fields=(b.fields||[]).filter(f=>!f.wrap);
|
|
4080
|
+
if(!fields.length){ s.push('No fields declared.'); continue; }
|
|
4081
|
+
s.push('Fields in order: '+fields.map(f=>
|
|
4082
|
+
a11yQ(f.name)+' '+(String(f.w)==='*'?'width *':a11yCount(f.w,'bit'))).join(', ')+'.');
|
|
4083
|
+
}
|
|
4084
|
+
return s.join(' ');
|
|
4085
|
+
}
|
|
4086
|
+
function a11yDeriveSequence(doc){
|
|
4087
|
+
const s=[];
|
|
4088
|
+
s.push('Sequence figure'+(a11yTitleOf(doc)!==null?' '+a11yQ(doc.title):'')+'.');
|
|
4089
|
+
const lls=doc.lifelines||[], msgs=doc.messages||[], frags=doc.fragments||[];
|
|
4090
|
+
s.push('Lifelines in declaration order: '+(lls.length?lls.map(a11yNamed).join(', '):'none')+'.');
|
|
4091
|
+
// The time axis is declaration order and it is TOTAL (`SEQUENCE-GENRE-VOCABULARY`, draft §31),
|
|
4092
|
+
// so "in their stated total order" is `line` ascending — the model's own
|
|
4093
|
+
// ordering key, not a re-reading of the picture.
|
|
4094
|
+
const ordered=msgs.slice().sort((p,q)=>p.line-q.line);
|
|
4095
|
+
s.push('Messages in stated order: '+(ordered.length?ordered.map((m,i)=>
|
|
4096
|
+
(i+1)+'. '+String(m.a)+' '+String(m.op)+' '+String(m.b)
|
|
4097
|
+
+(m.label===null||m.label===undefined?'':' '+a11yQ(m.label))).join('; '):'none')+'.');
|
|
4098
|
+
if(frags.length) s.push('Fragments: '+frags.map(f=>
|
|
4099
|
+
a11yNamed(f)+' (type '+String(f.type)+')').join(', ')+'.');
|
|
4100
|
+
return s.join(' ');
|
|
4101
|
+
}
|
|
4102
|
+
function a11yDerivedDesc(doc){
|
|
4103
|
+
if(!doc||A11Y_DERIVED_GENRES.indexOf(doc.genre)<0) return null;
|
|
4104
|
+
if(doc.genre==='topology') return a11yDeriveTopology(doc);
|
|
4105
|
+
if(doc.genre==='bitfield') return a11yDeriveBitfield(doc);
|
|
4106
|
+
if(doc.genre==='sequence') return a11yDeriveSequence(doc);
|
|
4107
|
+
return null; // state `absent` — an uncovered genre, spec/figdown-a11y.md §4.4
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
// Apply the profile to a FINISHED root `<svg>`: the role on the root, the
|
|
4111
|
+
// non-visual `<title>` as its FIRST child (SVG 1.1 §5.4 / `DESCRIPTION-KEY-SPELLING` — a `<title>`
|
|
4112
|
+
// names its parent and belongs first), then the `<desc>` carrying the derived
|
|
4113
|
+
// description with its state in a `data-*` attribute (`ACCESSIBLE-DESCRIPTION-SOURCES` item 6, way 1; the
|
|
4114
|
+
// manifest carries the evidence, way 3). Idempotent: a root that already
|
|
4115
|
+
// declares a role is returned unchanged.
|
|
4116
|
+
function a11yApply(svg, doc){
|
|
4117
|
+
const s=String(svg);
|
|
4118
|
+
const m=/^<svg\b[^>]*>/.exec(s);
|
|
4119
|
+
if(!m) return s;
|
|
4120
|
+
if(/\brole="/.test(m[0])) return s;
|
|
4121
|
+
const parts=[];
|
|
4122
|
+
const t=a11yTitleOf(doc);
|
|
4123
|
+
if(t!==null) parts.push('<title>'+esc(t)+'</title>');
|
|
4124
|
+
const d=a11yDerivedDesc(doc);
|
|
4125
|
+
if(d!==null&&d!=='') parts.push('<desc data-desc-state="derived">'+esc(d)+'</desc>');
|
|
4126
|
+
return m[0].replace(/>$/,' role="'+A11Y_ROLE+'">')+parts.join('')+s.slice(m[0].length);
|
|
4127
|
+
}
|
|
4128
|
+
|
|
4129
|
+
// The `data-render-options` value for a render (core §7). ONE spelling, in
|
|
4130
|
+
// ONE place, so the artifact layers cannot drift: options in declaration
|
|
4131
|
+
// order, space-separated — the SVG/HTML idiom for a token list. A default
|
|
4132
|
+
// render writes no attribute at all, which is why the empty string is
|
|
4133
|
+
// returned rather than an empty attribute.
|
|
4134
|
+
function renderOptionsAttr(opts){
|
|
4135
|
+
const names=[];
|
|
4136
|
+
if(opts&&opts.title===true) names.push('with-title');
|
|
4137
|
+
if(opts&&opts.a11y===true) names.push('with-a11y');
|
|
4138
|
+
return names.length?' data-render-options="'+names.join(' ')+'"':'';
|
|
4139
|
+
}
|
|
4140
|
+
|
|
3483
4141
|
// `SEMICOLON-STATUS`: `;` is RESERVED for a future statement separator, and
|
|
3484
4142
|
// RULE 1.3 says a reserved mark MUST NOT be given any other meaning. Until
|
|
3485
4143
|
// this release it was an ordinary character: `node a ;` parsed and `;`
|
|
@@ -3556,7 +4214,7 @@ function findComment(s){
|
|
|
3556
4214
|
// ============================================================
|
|
3557
4215
|
const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
|
|
3558
4216
|
|
|
3559
|
-
// ---- cw(): script-aware advance width --------------------------------
|
|
4217
|
+
// ---- cw(): script-aware advance width (`TEXT-ADVANCE-MEASUREMENT`) --------------------------------
|
|
3560
4218
|
// Every px-per-character constant in this file — CH here, and its siblings at
|
|
3561
4219
|
// the other font sizes (8.6 title, 6.6 legend, 6.5 edge label, 6.3/6.2 bitfield
|
|
3562
4220
|
// caption) — is calibrated on LATIN, where one character advances ~0.554 em.
|
|
@@ -3738,8 +4396,9 @@ function noteSvg(x,y,box,carrier){
|
|
|
3738
4396
|
// about "a group" and no more — so the association the source states was
|
|
3739
4397
|
// lost on the way to the page even when the placement was perfect. The pair
|
|
3740
4398
|
// (kind, ref) is unambiguous: `ref` is the element's identity in this very
|
|
3741
|
-
// drawing — a `data-node` id, a `data-group` id, or
|
|
3742
|
-
//
|
|
4399
|
+
// drawing — a `data-node` id, a `data-group` id, or whatever `data-edge`
|
|
4400
|
+
// keys a connector by, which from `CONNECTOR-IDENTITY-KEY` is the connector's
|
|
4401
|
+
// AUTHORED id where it has one and its source line where it does not.
|
|
3743
4402
|
// `title` is the one kind with no ref and needs none: it names the FIGURE,
|
|
3744
4403
|
// and there is exactly one. The attribute changes no pixel.
|
|
3745
4404
|
const ref=carrier&&carrier.ref!==undefined&&carrier.ref!==null?' data-note-for="'+esc(String(carrier.ref))+'"':'';
|
|
@@ -4126,15 +4785,80 @@ function render(doc,ropts){
|
|
|
4126
4785
|
// Retiring `color=` removes the first case; omitting the swatch removes
|
|
4127
4786
|
// the second. Every channel a class can still declare (`fill`, `stroke`,
|
|
4128
4787
|
// `style`) is drawn, so "declared but not shown" is now unreachable.
|
|
4788
|
+
// 0.5 (`LEGEND-SWATCH-SHAPE`): the swatch depicts THE CHANNEL THE CLASS ACTUALLY
|
|
4789
|
+
// PAINTS. `CLASS-PAINT-REQUIREMENT` ruled WHETHER a swatch is drawn and never WHAT SHAPE it
|
|
4790
|
+
// takes, so every painting class got a `<rect>` — and a class that
|
|
4791
|
+
// declares only `stroke=` and is carried only by CONNECTORS then appeared
|
|
4792
|
+
// as a white box with a coloured outline while the drawing showed a
|
|
4793
|
+
// coloured LINE. The legend's visual vocabulary did not match the
|
|
4794
|
+
// figure's, and with `style=dashed` the dash landed on a rectangle's
|
|
4795
|
+
// perimeter instead of along a run. Measured on 2026-08-26: 36 of this
|
|
4796
|
+
// repository's 76 legend-bearing class-uses are connector-only (33 of them
|
|
4797
|
+
// stroke-without-fill) and 10 figures are entirely connector-only; in the
|
|
4798
|
+
// production corpus behind the field reports, 160 of 1350 legend-bearing
|
|
4799
|
+
// class-uses are connector-only and 159 of those are stroke-without-fill.
|
|
4800
|
+
//
|
|
4801
|
+
// WHICH KINDS ARE CONNECTORS is read off the model, not guessed and not
|
|
4802
|
+
// stored: the four connector spellings (`edge` `flowline` `transition`
|
|
4803
|
+
// `message`, core §10) are exactly `doc.edges` and `doc.messages` in the
|
|
4804
|
+
// model — every other collection that carries `class=` is a box, a frame
|
|
4805
|
+
// or a cell, i.e. something with an area. This adds no model field, no
|
|
4806
|
+
// option key and no spelling; it is a byte-moving RENDER change.
|
|
4807
|
+
const clsUse={};
|
|
4808
|
+
{ const use=(x,conn)=>{
|
|
4809
|
+
const ids=(x.cls===undefined||x.cls===null)?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
|
|
4810
|
+
for(const id of ids){ const u=clsUse[id]||(clsUse[id]={conn:false,box:false});
|
|
4811
|
+
if(conn) u.conn=true; else u.box=true; } };
|
|
4812
|
+
for(const x of doc.nodes) use(x,false);
|
|
4813
|
+
for(const x of doc.groups) use(x,false);
|
|
4814
|
+
for(const x of doc.edges) use(x,true);
|
|
4815
|
+
for(const x of (doc.messages||[])) use(x,true);
|
|
4816
|
+
for(const x of (doc.lifelines||[])) use(x,false);
|
|
4817
|
+
for(const x of (doc.states||[])) use(x,false);
|
|
4818
|
+
for(const x of (doc.fragments||[])) use(x,false);
|
|
4819
|
+
for(const x of (doc.operands||[])) use(x,false);
|
|
4820
|
+
for(const b of doc.blocks){ use(b,false);
|
|
4821
|
+
if(b.fields) for(const f of b.fields) use(f,false);
|
|
4822
|
+
if(b.marks) for(const mk of b.marks) use(mk,false); } }
|
|
4129
4823
|
for(const c of legendCls){
|
|
4130
4824
|
const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
|
|
4131
|
-
|
|
4132
|
-
|
|
4825
|
+
// The samples this class earns, in the ruled order: LINE then BOX.
|
|
4826
|
+
// · carried only by connectors → a line sample (`LEGEND-SWATCH-SHAPE` clause 1)
|
|
4827
|
+
// · carried only by boxes → the `<rect>` `CLASS-PAINT-REQUIREMENT` already drew (2)
|
|
4828
|
+
// · carried by both → both samples, side by side (3): the
|
|
4829
|
+
// legend exists to explain, and the extra width is cheaper than
|
|
4830
|
+
// making a reader guess which half of the figure an entry is about.
|
|
4831
|
+
// · declares `fill=` → a box sample WHATEVER it is carried
|
|
4832
|
+
// by (4), because a fill needs an area to be visible. A box on a
|
|
4833
|
+
// connector-only class is then the class declaring a channel its
|
|
4834
|
+
// elements cannot show — `CLASS-CHANNEL-COLLISION`'s territory, reported there, not
|
|
4835
|
+
// re-ruled here (and `gate:legend`'s unreachable-channel check
|
|
4836
|
+
// already refuses that shape in this repository's corpus).
|
|
4837
|
+
// · declares no paint → no sample at all (5, `CLASS-PAINT-REQUIREMENT` unchanged).
|
|
4838
|
+
// · declared but never carried → the shape comes from the DECLARED
|
|
4839
|
+
// channels alone: `fill` → box, stroke-only → line. Nothing else is
|
|
4840
|
+
// known about it, and its own paint is the only evidence there is.
|
|
4841
|
+
const u=clsUse[c.id];
|
|
4842
|
+
const samples=[];
|
|
4133
4843
|
if(paints){
|
|
4134
|
-
|
|
4135
|
-
|
|
4844
|
+
if(u ? u.conn : c.fill===undefined) samples.push('line');
|
|
4845
|
+
if(u ? (u.box||c.fill!==undefined) : c.fill!==undefined) samples.push('box');
|
|
4846
|
+
}
|
|
4847
|
+
const tw=cw(c.label)*6.6+9+21*samples.length;
|
|
4848
|
+
if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
|
|
4849
|
+
const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
4850
|
+
let sx=lx;
|
|
4851
|
+
for(const s of samples){
|
|
4852
|
+
// Both samples take the SAME 16 px advance, so a mixed entry is two
|
|
4853
|
+
// swatches and not a wider one. The line is drawn at the rect's own
|
|
4854
|
+
// vertical centre (3 + 11/2) and at 1.6 — the stroke-width every
|
|
4855
|
+
// router in this engine gives a drawn connector — so the sample is
|
|
4856
|
+
// the same ink the figure lays down, at the same weight.
|
|
4857
|
+
if(s==='line') es.push('<line x1="'+sx+'" y1="'+(ly+8.5)+'" x2="'+(sx+16)+'" y2="'+(ly+8.5)+'" stroke="'+(c.stroke||'#555')+'" stroke-width="1.6"'+dash+'/>');
|
|
4858
|
+
else es.push('<rect x="'+sx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.fill||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
|
|
4859
|
+
sx+=21;
|
|
4136
4860
|
}
|
|
4137
|
-
es.push('<text x="'+(lx+
|
|
4861
|
+
es.push('<text x="'+(lx+21*samples.length)+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
|
|
4138
4862
|
lx+=tw+14; maxW=Math.max(maxW,lx);
|
|
4139
4863
|
}
|
|
4140
4864
|
parts.push(es.join(''));
|
|
@@ -4868,6 +5592,190 @@ function renderScene(doc,y0){
|
|
|
4868
5592
|
}
|
|
4869
5593
|
}
|
|
4870
5594
|
}
|
|
5595
|
+
// ── THE RING GEOMETRY IS DEFINED HERE BECAUSE TWO PASSES NEED IT ───────
|
|
5596
|
+
// (item 69.) `ringOf` used to sit with the ring DRAWING, a
|
|
5597
|
+
// thousand lines below, because the ring is derived from final coordinates
|
|
5598
|
+
// and nothing before the draw had a use for it. The lasso-containment rule
|
|
5599
|
+
// gives it a second caller ABOVE: the separation pass immediately below has
|
|
5600
|
+
// to know where each bundle's lasso will fall so it can keep a non-member
|
|
5601
|
+
// out of it, exactly as it already keeps one out of a group band. The
|
|
5602
|
+
// definition moved; not one character of the derivation changed, and the
|
|
5603
|
+
// ring is still COMPUTED (`rings`, below the label pass) at the geometry it
|
|
5604
|
+
// is drawn from, so what the separation pass sees is a PROJECTION and what
|
|
5605
|
+
// the truth check sees is the ring itself.
|
|
5606
|
+
// ── A BUNDLE RING IS ORIENTED BY ITS MEMBERS (item 43) ───────────────────
|
|
5607
|
+
// Until now the ring was derived from the member links' MIDPOINTS and
|
|
5608
|
+
// nothing else: `rx = max(46, x-spread + 38)`, `ry = max(26, y-spread + 22)`.
|
|
5609
|
+
// Two facts about that formula are the defect. It is DIRECTION-BLIND — the
|
|
5610
|
+
// axes are the canvas's, never the links' — and its floors are large enough
|
|
5611
|
+
// that a bundle of two short legs draws a near-circle whatever the legs do.
|
|
5612
|
+
// Measured on `patterns/topology-b`: rx 46 / ry 42.7, aspect
|
|
5613
|
+
// 1.07, sitting on two legs that fan VERTICALLY, with the caption lying
|
|
5614
|
+
// across the waist and on top of the `p3` endpoint label.
|
|
5615
|
+
//
|
|
5616
|
+
// The drawing convention for a link bundle is a loop THROUGH which the
|
|
5617
|
+
// links run: narrow along the links, long across them. So the ring is now
|
|
5618
|
+
// derived from the members' own frame.
|
|
5619
|
+
//
|
|
5620
|
+
// 1. DIRECTION. The mean UNDIRECTED direction of the members, by the
|
|
5621
|
+
// doubled-angle circular mean — doubling is what makes `a--b` and
|
|
5622
|
+
// `b--a` the same direction, so a bundle does not change shape when an
|
|
5623
|
+
// author writes a member the other way round (conformance 394).
|
|
5624
|
+
// 2. THE BAND. The ring sits at the MIDPOINT OF THE MEMBERS' SHARED RUN
|
|
5625
|
+
// along that direction — the stretch of the corridor every member is
|
|
5626
|
+
// actually in. That is the placement rule because it is the only band
|
|
5627
|
+
// at which "the spread of the members" is a fact about all of them: a
|
|
5628
|
+
// fanning set (the reference topology's ECMP pair, which leaves two
|
|
5629
|
+
// different spines and arrives at one leaf) has a different spread at
|
|
5630
|
+
// every station, and the shared run is the interval over which the
|
|
5631
|
+
// question is even well posed. Members with no shared run at all fall
|
|
5632
|
+
// back to the mean of their midpoints, which is the old centre.
|
|
5633
|
+
// 3. THE AXES. MINOR along the mean direction, sized to clear the strokes;
|
|
5634
|
+
// MAJOR across it, the members' spread at the band plus padding. A ring
|
|
5635
|
+
// needs a long axis to read as a ring, and it takes it from the spread
|
|
5636
|
+
// when the members fan (`rA >= rL`) and from its own RUN when they do
|
|
5637
|
+
// not — a single-member bundle (`reference/topology`'s multi-home link)
|
|
5638
|
+
// has zero spread, and the shape that hugs one link is an ellipse lying
|
|
5639
|
+
// ALONG it, never a circle straddling it.
|
|
5640
|
+
// 4. NO NODE CONTACT. The ring is shrunk along its minor axis until it
|
|
5641
|
+
// clears every node box, and if it cannot it reverts to the pre-0.4
|
|
5642
|
+
// geometry rather than inventing a shape (no corpus figure does).
|
|
5643
|
+
//
|
|
5644
|
+
// The caption is NOT placed here — it is registered with the label pass
|
|
5645
|
+
// below, so it can see the endpoint labels and the edges it has to avoid.
|
|
5646
|
+
const rnd3=v=>Math.round(v*1000)/1000; // ring coordinates, printed short
|
|
5647
|
+
const RING_ALONG=15, // semi-minor: the ring's body along the links
|
|
5648
|
+
RING_ACROSS=20, // clearance beyond the outermost member
|
|
5649
|
+
RING_LONG=34, // the long axis a ring needs to read as a ring
|
|
5650
|
+
RING_FAN=2, // below this spread the members are one line
|
|
5651
|
+
RING_FLOOR=7, // how thin the minor axis may be squeezed
|
|
5652
|
+
RING_SOLO=4; // a ONE-member bundle: the caption's stand-off
|
|
5653
|
+
// ── A ONE-MEMBER BUNDLE IS ITS CAPTION (item 53) ─────────────────────────
|
|
5654
|
+
// `bundle mh "multi-home" l1--l2` is legal and means something real (a
|
|
5655
|
+
// one-link LAG, an Ethernet Segment with a single member), so the
|
|
5656
|
+
// declaration is not an error and is not dropped. But a ring exists to
|
|
5657
|
+
// UNITE lines, and around ONE line it unites nothing: it is ink that adds
|
|
5658
|
+
// no fact, and on the reference topology it also bought the seam a 104 px
|
|
5659
|
+
// corridor demand (2*RX_MIN + clearance) for a shape nobody had to see.
|
|
5660
|
+
//
|
|
5661
|
+
// So a single-member bundle DRAWS NO ELLIPSE. What it draws is its
|
|
5662
|
+
// CAPTION, bundle-styled (the trunk's own stroke, the ring caption's type)
|
|
5663
|
+
// and placed BY THE LINK — which is exactly the statement the construct
|
|
5664
|
+
// makes: this link is the bundle, and here is its name. The frame below is
|
|
5665
|
+
// therefore kept as a PLACEMENT frame and not as a drawing: the caption
|
|
5666
|
+
// pass already sites a caption around a ring's rim, so a ring squeezed to
|
|
5667
|
+
// `RING_SOLO` across gives the same pass a candidate family hugging the
|
|
5668
|
+
// link. Nothing else in the pass changes.
|
|
5669
|
+
//
|
|
5670
|
+
// AN EMPTY CAPTION ON A ONE-MEMBER BUNDLE THEREFORE DRAWS NOTHING AT ALL.
|
|
5671
|
+
// That is a real consequence and it is not hidden: the reference figure
|
|
5672
|
+
// keeps `bundle unlabelled "" s2--l2` — it is the form demonstrator for
|
|
5673
|
+
// the empty label — and states in a comment that this form is now
|
|
5674
|
+
// MODEL-ONLY. The model still carries the trunk, `read` still reports it,
|
|
5675
|
+
// and the drawing says nothing because there is nothing a ring around one
|
|
5676
|
+
// unnamed line could say.
|
|
5677
|
+
// Does the ring's disc meet an axis-aligned rect? Both are mapped into the
|
|
5678
|
+
// frame where the ring is the unit circle; the rect becomes a convex quad,
|
|
5679
|
+
// and the test is "is the quad within 1 of the origin".
|
|
5680
|
+
const ringHitsRect=(R,RL,RA,b)=>{
|
|
5681
|
+
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=>{
|
|
5682
|
+
const dx=p[0]-R.cx, dy=p[1]-R.cy;
|
|
5683
|
+
return [(R.ux*dx+R.uy*dy)/RL, (R.vx*dx+R.vy*dy)/RA];
|
|
5684
|
+
});
|
|
5685
|
+
let inside=false;
|
|
5686
|
+
for(let i=0,j=3;i<4;j=i++)
|
|
5687
|
+
if((q[i][1]>0)!==(q[j][1]>0) &&
|
|
5688
|
+
0 < (q[j][0]-q[i][0])*(0-q[i][1])/(q[j][1]-q[i][1])+q[i][0]) inside=!inside;
|
|
5689
|
+
if(inside) return true;
|
|
5690
|
+
for(let i=0,j=3;i<4;j=i++){
|
|
5691
|
+
const vx=q[j][0]-q[i][0], vy=q[j][1]-q[i][1], L2=vx*vx+vy*vy;
|
|
5692
|
+
const t=L2?Math.max(0,Math.min(1,-(q[i][0]*vx+q[i][1]*vy)/L2)):0;
|
|
5693
|
+
if(Math.hypot(q[i][0]+t*vx, q[i][1]+t*vy)<1) return true;
|
|
5694
|
+
}
|
|
5695
|
+
return false;
|
|
5696
|
+
};
|
|
5697
|
+
const ringOf=t=>{
|
|
5698
|
+
const segs=[];
|
|
5699
|
+
for(const [a,b] of t.pairs){
|
|
5700
|
+
const A=byId[a], B=byId[b]; if(!A||!B) continue;
|
|
5701
|
+
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);
|
|
5702
|
+
if(Math.hypot(q[0]-p[0],q[1]-p[1])>1e-9) segs.push([p,q]);
|
|
5703
|
+
}
|
|
5704
|
+
if(!segs.length) return null;
|
|
5705
|
+
// (1) mean undirected direction
|
|
5706
|
+
let c2=0,s2=0;
|
|
5707
|
+
for(const [p,q] of segs){
|
|
5708
|
+
const L=Math.hypot(q[0]-p[0],q[1]-p[1]);
|
|
5709
|
+
const c=(q[0]-p[0])/L, s=(q[1]-p[1])/L;
|
|
5710
|
+
c2+=c*c-s*s; s2+=2*c*s;
|
|
5711
|
+
}
|
|
5712
|
+
const th=Math.hypot(c2,s2)<1e-9
|
|
5713
|
+
? Math.atan2(segs[0][1][1]-segs[0][0][1], segs[0][1][0]-segs[0][0][0])
|
|
5714
|
+
: 0.5*Math.atan2(s2,c2);
|
|
5715
|
+
const ux=Math.cos(th), uy=Math.sin(th), vx=-uy, vy=ux;
|
|
5716
|
+
// (2) the band: the midpoint of the shared run
|
|
5717
|
+
const iv=segs.map(([p,q])=>{
|
|
5718
|
+
const a=ux*p[0]+uy*p[1], b=ux*q[0]+uy*q[1];
|
|
5719
|
+
return a<=b?[a,b,p,q]:[b,a,q,p];
|
|
5720
|
+
});
|
|
5721
|
+
const lo=Math.max(...iv.map(z=>z[0])), hi=Math.min(...iv.map(z=>z[1]));
|
|
5722
|
+
const s=lo<hi ? (lo+hi)/2 : iv.reduce((x,z)=>x+(z[0]+z[1])/2,0)/iv.length;
|
|
5723
|
+
const P=iv.map(z=>{
|
|
5724
|
+
const f=Math.min(1,Math.max(0,(s-z[0])/((z[1]-z[0])||1)));
|
|
5725
|
+
return [z[2][0]+(z[3][0]-z[2][0])*f, z[2][1]+(z[3][1]-z[2][1])*f];
|
|
5726
|
+
});
|
|
5727
|
+
const cx=P.reduce((a,p)=>a+p[0],0)/P.length, cy=P.reduce((a,p)=>a+p[1],0)/P.length;
|
|
5728
|
+
const spread=Math.max(...P.map(p=>Math.abs(vx*(p[0]-cx)+vy*(p[1]-cy))));
|
|
5729
|
+
const runHalf=Math.min(...iv.map(z=>Math.min(s-z[0],z[1]-s)));
|
|
5730
|
+
// (3) axes. Two shapes, and which one is drawn is decided by whether the
|
|
5731
|
+
// members FAN at all. A set that fans is CROSSED by the ring: thin along
|
|
5732
|
+
// the links, long enough across them to take the whole spread with room
|
|
5733
|
+
// to spare, so each member passes through a part of the rim where the
|
|
5734
|
+
// ring is still visibly open. A set that does not fan — one link, or
|
|
5735
|
+
// members lying on top of each other — has no spread to take, and the
|
|
5736
|
+
// shape that says "this link is the bundle" is an ellipse lying ALONG it.
|
|
5737
|
+
let rA, rL;
|
|
5738
|
+
if(spread<RING_FAN){ rL=Math.max(RING_ALONG,Math.min(RING_LONG,Math.max(0,runHalf))); rA=RING_ACROSS; }
|
|
5739
|
+
else { rL=RING_ALONG; rA=Math.max(spread+RING_ACROSS,RING_LONG); }
|
|
5740
|
+
const R={cx,cy,ux,uy,vx,vy,th};
|
|
5741
|
+
// (0) ONE MEMBER (item 53): no ellipse is drawn, so this is a caption
|
|
5742
|
+
// frame and not a shape. It is squeezed across to `RING_SOLO` so the
|
|
5743
|
+
// caption pass's rim stations sit beside the link rather than a ring's
|
|
5744
|
+
// radius away from it, and the node-clearance step below is skipped —
|
|
5745
|
+
// there is no ink to keep out of a box.
|
|
5746
|
+
if(segs.length===1){
|
|
5747
|
+
R.solo=true; R.rL=rL; R.rA=RING_SOLO;
|
|
5748
|
+
R.hw=Math.hypot(rL*ux, RING_SOLO*vx); R.hh=Math.hypot(rL*uy, RING_SOLO*vy);
|
|
5749
|
+
return R;
|
|
5750
|
+
}
|
|
5751
|
+
// (4) out of every node box
|
|
5752
|
+
const boxes=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
|
|
5753
|
+
if(boxes.some(b=>ringHitsRect(R,rL,rA,b))){
|
|
5754
|
+
let ok=false;
|
|
5755
|
+
for(let k=1;k<=24&&!ok;k++){
|
|
5756
|
+
const RL=rL-(rL-RING_FLOOR)*k/24;
|
|
5757
|
+
if(!boxes.some(b=>ringHitsRect(R,RL,rA,b))){ rL=RL; ok=true; }
|
|
5758
|
+
}
|
|
5759
|
+
// No orientation of this ring clears the drawing. Rather than publish a
|
|
5760
|
+
// ring lying over a node, revert to the pre-0.4 derivation, which is at
|
|
5761
|
+
// least the shape every earlier artifact recorded. No corpus figure
|
|
5762
|
+
// takes this branch; it exists so that a hostile geometry degrades to
|
|
5763
|
+
// the old defect instead of a new one.
|
|
5764
|
+
if(!ok){
|
|
5765
|
+
const M=segs.map(([p,q])=>[(p[0]+q[0])/2,(p[1]+q[1])/2]);
|
|
5766
|
+
const mx=M.reduce((a,m)=>a+m[0],0)/M.length, my=M.reduce((a,m)=>a+m[1],0)/M.length;
|
|
5767
|
+
const lx=Math.max(46,Math.max(...M.map(m=>Math.abs(m[0]-mx)))+38);
|
|
5768
|
+
const ly=Math.max(26,Math.max(...M.map(m=>Math.abs(m[1]-my)))+22);
|
|
5769
|
+
return {cx:mx, cy:my, ux:1, uy:0, vx:0, vy:1, th:0, legacy:true,
|
|
5770
|
+
rL:lx, rA:ly, hw:lx, hh:ly};
|
|
5771
|
+
}
|
|
5772
|
+
}
|
|
5773
|
+
R.rL=rL; R.rA=rA;
|
|
5774
|
+
// the axis-aligned box the caption is placed beside and the canvas grows to
|
|
5775
|
+
R.hw=Math.hypot(rL*ux, rA*vx); R.hh=Math.hypot(rL*uy, rA*vy);
|
|
5776
|
+
return R;
|
|
5777
|
+
};
|
|
5778
|
+
|
|
4871
5779
|
// ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
|
|
4872
5780
|
// A group's band is the BOUNDING BOX of its members (see gBox below), and
|
|
4873
5781
|
// until this pass nothing checked that the box contained only members. A
|
|
@@ -4922,6 +5830,44 @@ function renderScene(doc,y0){
|
|
|
4922
5830
|
const canMove=u=>u.every(n=>!pinned(n.id))
|
|
4923
5831
|
&& !(u[0].group&&doc.pins[u[0].group]&&doc.pins[u[0].group].fx!==null);
|
|
4924
5832
|
const said=new Set();
|
|
5833
|
+
// ── THE SAME REGION RULE FOR A BUNDLE'S LASSO (item 69) ────
|
|
5834
|
+
// A band and a lasso are the same statement drawn two ways: each is a
|
|
5835
|
+
// shape DERIVED from a declared membership, so whatever the shape contains
|
|
5836
|
+
// reads as a member. This pass already keeps a non-member out of a band;
|
|
5837
|
+
// the lasso is added to the same convergence rather than given a pass of
|
|
5838
|
+
// its own, because the two constraints interact — a node pushed out of a
|
|
5839
|
+
// lasso can land in a band, and one pushed out of a band can land in a
|
|
5840
|
+
// lasso — and only a shared loop can settle both.
|
|
5841
|
+
//
|
|
5842
|
+
// CONTAINMENT IS THE WHOLE BOX, NOT ITS CENTRE, which is the band's own
|
|
5843
|
+
// discipline (`inBand` tests the box's extent against the band's, never a
|
|
5844
|
+
// point). The two differ in STRICTNESS, and the difference is forced by
|
|
5845
|
+
// the shapes: a band is FILLED, so a box that merely overlaps it is
|
|
5846
|
+
// already sitting on painted group territory; a lasso is a RING with no
|
|
5847
|
+
// interior (§8.4), and `ringOf` step (4) has already shrunk it clear of
|
|
5848
|
+
// every node box, so a box can only be wholly inside or wholly outside.
|
|
5849
|
+
// Full containment is therefore not a weaker rule here, it is the only
|
|
5850
|
+
// reachable one — and it is the same rule the complete-cover check uses
|
|
5851
|
+
// one construct over (`inside`).
|
|
5852
|
+
//
|
|
5853
|
+
// A ONE-MEMBER BUNDLE DRAWS NO ELLIPSE (item 53) and therefore makes no
|
|
5854
|
+
// containment claim: there is no shape, so there is nothing to be inside,
|
|
5855
|
+
// and `lassoOf` returns null for it. Its caption is a label like any other.
|
|
5856
|
+
// PORT SQUARES need no separate test: a square straddles the border of the
|
|
5857
|
+
// node it belongs to, so a node clear of the ring carries its fittings out
|
|
5858
|
+
// with it. EDGES are not tested at all — a lasso exists to be crossed by
|
|
5859
|
+
// lines, and every member link runs through it by construction.
|
|
5860
|
+
const trunks=(doc.trunks||[]).filter(t=>t.pairs&&t.pairs.length);
|
|
5861
|
+
const lMem=t=>{ const s=new Set(); for(const [a,b] of t.pairs){ s.add(a); s.add(b); } return s; };
|
|
5862
|
+
const lassoOf=t=>{ const R=ringOf(t); return (R&&!R.solo)?R:null; };
|
|
5863
|
+
const inLasso=(n,R)=>{
|
|
5864
|
+
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]]){
|
|
5865
|
+
const dx=p[0]-R.cx, dy=p[1]-R.cy;
|
|
5866
|
+
if(Math.hypot((R.ux*dx+R.uy*dy)/R.rL,(R.vx*dx+R.vy*dy)/R.rA)>1) return false;
|
|
5867
|
+
}
|
|
5868
|
+
return true;
|
|
5869
|
+
};
|
|
5870
|
+
const lKey=(t,n)=>'bundle '+t.id+' '+n.id;
|
|
4925
5871
|
const collect=()=>{
|
|
4926
5872
|
const out=[];
|
|
4927
5873
|
for(const g of groups){
|
|
@@ -4931,8 +5877,50 @@ function renderScene(doc,y0){
|
|
|
4931
5877
|
if(inBand(n,B)) out.push({g,n});
|
|
4932
5878
|
}
|
|
4933
5879
|
}
|
|
5880
|
+
for(const t of trunks){
|
|
5881
|
+
const R=lassoOf(t); if(!R) continue;
|
|
5882
|
+
const mem=lMem(t);
|
|
5883
|
+
for(const n of real){
|
|
5884
|
+
if(mem.has(n.id)||said.has(lKey(t,n))) continue;
|
|
5885
|
+
if(inLasso(n,R)) out.push({t,n,mem});
|
|
5886
|
+
}
|
|
5887
|
+
}
|
|
4934
5888
|
return out;
|
|
4935
5889
|
};
|
|
5890
|
+
// The MOVE, shared by both regions. `unit` travels; `obst` is the cross-axis
|
|
5891
|
+
// interval it must end up clear of; `keep` is everything that must NOT be
|
|
5892
|
+
// dragged along with it.
|
|
5893
|
+
const push=(unit,obst,keep)=>{
|
|
5894
|
+
const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
|
|
5895
|
+
const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
|
|
5896
|
+
// NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
|
|
5897
|
+
// whichever way is shorter — sends the unit past the layout's own
|
|
5898
|
+
// starting edge often enough to matter (`reference/topology` put L1 at
|
|
5899
|
+
// x=-90 and the viewBox clipped it away). Growing the canvas the other
|
|
5900
|
+
// way is not available either: the only uniform-shift machinery this
|
|
5901
|
+
// renderer has moves PINNED nodes with everything else, and a pinned
|
|
5902
|
+
// node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
|
|
5903
|
+
// stability violation this engine has already paid for once. So the
|
|
5904
|
+
// constraint is applied HERE, to the choice: the negative direction is
|
|
5905
|
+
// taken only when the unit still lands inside the envelope the layout
|
|
5906
|
+
// had before this pass ran. Nothing outside the mover ever moves.
|
|
5907
|
+
const dNegOK=uLo+dNeg>=cross0;
|
|
5908
|
+
const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
|
|
5909
|
+
const ranks=new Set(unit.map(n=>n.rank));
|
|
5910
|
+
// Everything the mover would be pushed ONTO travels with it: same rank,
|
|
5911
|
+
// same side, clear of the obstacle. Relative order and spacing inside a
|
|
5912
|
+
// lane are preserved, so the fix cannot manufacture an overlap.
|
|
5913
|
+
// A node that BELONGS to a group never travels this way — a group moves
|
|
5914
|
+
// whole or not at all, and dragging half of one along would reshape its
|
|
5915
|
+
// band, which is the same defect one group further on.
|
|
5916
|
+
for(const m of lay){
|
|
5917
|
+
if(keep.has(m)||!ranks.has(m.rank)) continue;
|
|
5918
|
+
if(!m.virtual&&m.group) continue;
|
|
5919
|
+
const mLo=cLo(m), mHi=mLo+cSz(m);
|
|
5920
|
+
if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
|
|
5921
|
+
}
|
|
5922
|
+
for(const n of unit) mv(n,d);
|
|
5923
|
+
};
|
|
4936
5924
|
let left=[];
|
|
4937
5925
|
// EVERY conflict gets attention on every pass, and the band is recomputed
|
|
4938
5926
|
// immediately before each resolution. Taking only the first conflict each
|
|
@@ -4943,6 +5931,49 @@ function renderScene(doc,y0){
|
|
|
4943
5931
|
left=collect();
|
|
4944
5932
|
if(!left.length) break;
|
|
4945
5933
|
for(const c of left){
|
|
5934
|
+
if(c.t){
|
|
5935
|
+
// A LASSO CONFLICT. Only the intruder ever yields. The band's second
|
|
5936
|
+
// option — move the GROUP instead — has no honest analogue here: a
|
|
5937
|
+
// bundle's members are links between devices that other links also
|
|
5938
|
+
// touch, so "move the members" relocates half the figure and reshapes
|
|
5939
|
+
// the very ring it is trying to fix. When the intruder cannot move,
|
|
5940
|
+
// an author coordinate is what fixed it, and the rule below (at the
|
|
5941
|
+
// final geometry) says so rather than the engine overriding a pin.
|
|
5942
|
+
const R=lassoOf(c.t);
|
|
5943
|
+
if(!R||!inLasso(c.n,R)) continue; // an earlier resolution cleared it
|
|
5944
|
+
const unit=unitOf(c.n);
|
|
5945
|
+
if(!canMove(unit)){ said.add(lKey(c.t,c.n)); continue; }
|
|
5946
|
+
// Clear of the ring's AXIS-ALIGNED extent, not of the ellipse: a box
|
|
5947
|
+
// outside the bounding box is outside the ring for certain, whatever
|
|
5948
|
+
// the ring's rotation, and the pass stays monotone (always outward).
|
|
5949
|
+
const obst=horiz?{lo:R.cy-R.hh, hi:R.cy+R.hh}:{lo:R.cx-R.hw, hi:R.cx+R.hw};
|
|
5950
|
+
// AND IT MOVES NOTHING BUT THE INTRUDER. The band's push takes what
|
|
5951
|
+
// it would land on along with it; a lasso's cannot, and the reason is
|
|
5952
|
+
// the shape. A band's extent stops at its members' boxes, so a mover
|
|
5953
|
+
// pushed clear of it lands in free space. A lasso's extent, when the
|
|
5954
|
+
// ring falls back to the pre-0.4 derivation — which is what a bundle
|
|
5955
|
+
// whose links pass THROUGH intermediate devices always does, since
|
|
5956
|
+
// the ring cannot clear their boxes — spans the whole fan, so "clear
|
|
5957
|
+
// of the ring" is exactly where the outermost members already are.
|
|
5958
|
+
// Dragging them along was measured: it walks the top member off the
|
|
5959
|
+
// canvas (y=-26 on the three-tap stress figure) and the ring grows
|
|
5960
|
+
// with them, so the intruder is enclosed again on the next pass. So
|
|
5961
|
+
// the move is taken ONLY into room that is already free, and when
|
|
5962
|
+
// there is none the pass declines and the rule below refuses the
|
|
5963
|
+
// figure. A separation that has to damage the drawing to succeed is
|
|
5964
|
+
// not a separation; not drawn beats drawn wrongly, here as well.
|
|
5965
|
+
const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
|
|
5966
|
+
const ranks=new Set(unit.map(n=>n.rank)), inUnit=new Set(unit);
|
|
5967
|
+
const others=lay.filter(m=>!inUnit.has(m)&&ranks.has(m.rank));
|
|
5968
|
+
const roomFor=d=>uLo+d>=cross0 &&
|
|
5969
|
+
others.every(m=>{ const mLo=cLo(m), mHi=mLo+cSz(m);
|
|
5970
|
+
return mHi<=uLo+d-SEP||mLo>=uHi+d+SEP; });
|
|
5971
|
+
const go=[(obst.lo-SEP)-uHi,(obst.hi+SEP)-uLo]
|
|
5972
|
+
.filter(roomFor).sort((p,q)=>Math.abs(p)-Math.abs(q));
|
|
5973
|
+
if(!go.length){ said.add(lKey(c.t,c.n)); continue; }
|
|
5974
|
+
for(const n of unit) mv(n,go[0]);
|
|
5975
|
+
continue;
|
|
5976
|
+
}
|
|
4946
5977
|
const B=bandOf(c.g);
|
|
4947
5978
|
if(!inBand(c.n,B)) continue; // an earlier resolution cleared it
|
|
4948
5979
|
const gMem=memOf(c.g.id);
|
|
@@ -4966,36 +5997,7 @@ function renderScene(doc,y0){
|
|
|
4966
5997
|
said.add(c.g.id+' '+c.n.id); continue;
|
|
4967
5998
|
}
|
|
4968
5999
|
}
|
|
4969
|
-
|
|
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);
|
|
6000
|
+
push(unit,obst,new Set(unit.concat(unit===gMem?[]:gMem)));
|
|
4999
6001
|
}
|
|
5000
6002
|
}
|
|
5001
6003
|
left=collect();
|
|
@@ -5003,6 +6005,13 @@ function renderScene(doc,y0){
|
|
|
5003
6005
|
// is named. A figure that reaches this line with a hit is a defect in this
|
|
5004
6006
|
// pass, and saying so beats drawing the false statement quietly.
|
|
5005
6007
|
for(const c of left){
|
|
6008
|
+
// A LASSO residue is NOT reported here. This pass sees a PROJECTION of
|
|
6009
|
+
// the ring, taken before the boundary anchors are seated and before the
|
|
6010
|
+
// uniform canvas shifts; the ring the reader sees is the one computed at
|
|
6011
|
+
// the final geometry, and that is where its truth is judged (item 69,
|
|
6012
|
+
// below the label pass). Reporting twice from two geometries is how a
|
|
6013
|
+
// figure gets refused for a ring that was never drawn.
|
|
6014
|
+
if(c.t) continue;
|
|
5006
6015
|
if(said.has(c.g.id+' '+c.n.id)) continue;
|
|
5007
6016
|
gErrs.push('Line '+srcLine(c.g.line)+': group "'+c.g.id+'" would enclose non-member "'
|
|
5008
6017
|
+c.n.id+'" and the layout pass could not separate them; the figure is not drawn rather '
|
|
@@ -5778,7 +6787,18 @@ function renderScene(doc,y0){
|
|
|
5778
6787
|
// node box, which is already an obstacle.
|
|
5779
6788
|
//
|
|
5780
6789
|
// An external's label is NOT an obstacle to its OWN edge — that edge must
|
|
5781
|
-
// reach the anchor the label names.
|
|
6790
|
+
// reach the anchor the label names. A GROUP's name is the same rule one
|
|
6791
|
+
// construct over, and 0.4 shipped only half of it (`GROUP-BOUNDARY-OBSTACLE`): a group's
|
|
6792
|
+
// name strip runs the full width of the band's TOP, so it lies across every
|
|
6793
|
+
// approach an outside node has to a member inside. Made an obstacle to all
|
|
6794
|
+
// comers, it turned every boundary-crossing edge into a detour that left the
|
|
6795
|
+
// corridor, ran down the band's outer edge and entered the member from the
|
|
6796
|
+
// side — the drawing then said the line arrives at the CONTAINER when the
|
|
6797
|
+
// source says it arrives at the MEMBER. `EDGE-BEND-RETENTION` had already ruled the group
|
|
6798
|
+
// BOX is an obstacle only to an edge that "neither starts nor ends inside";
|
|
6799
|
+
// the name is part of the same band and takes the same exemption. A foreign
|
|
6800
|
+
// shaft — neither endpoint in the group — is still detoured, which is the
|
|
6801
|
+
// whole of what 0.4 was right about.
|
|
5782
6802
|
const extLbl=n=>{
|
|
5783
6803
|
const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
|
|
5784
6804
|
const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
|
|
@@ -5793,197 +6813,116 @@ function renderScene(doc,y0){
|
|
|
5793
6813
|
for(const k in gBox){ const g=doc.groups.find(z=>z.id===k);
|
|
5794
6814
|
if(!g||!g.label) continue;
|
|
5795
6815
|
const B=gBox[k];
|
|
5796
|
-
nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1});
|
|
6816
|
+
nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1, id:k});
|
|
5797
6817
|
}
|
|
5798
|
-
//
|
|
5799
|
-
|
|
6818
|
+
// Which group an endpoint id belongs to, for the name-strip exemption below.
|
|
6819
|
+
const grpOfId=new Map();
|
|
6820
|
+
for(const n of nodes) if(n.group) grpOfId.set(n.id,n.group);
|
|
6821
|
+
// The obstacle list a given edge must respect: every band name except the
|
|
6822
|
+
// ones this edge terminates AT or INSIDE, plus every external label except
|
|
6823
|
+
// the ones this edge itself terminates at.
|
|
5800
6824
|
// A label's ink is its glyphs plus the clearance that keeps a line from
|
|
5801
6825
|
// READING as struck through it. 4 px on every side — the same number the
|
|
5802
6826
|
// legibility floor uses for a label's association margin — so a shaft that
|
|
5803
6827
|
// grazes a name at 3 px is detoured rather than tolerated.
|
|
5804
6828
|
const LBL_PAD=4;
|
|
5805
6829
|
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});
|
|
6830
|
+
const ownsName=(o,e)=>o.id===e.a||o.id===e.b||
|
|
6831
|
+
o.id===grpOfId.get(e.a)||o.id===grpOfId.get(e.b);
|
|
5806
6832
|
const lblObs=e=>extObs.filter(o=>o.id!==e.a&&o.id!==e.b).map(padded)
|
|
5807
|
-
.concat(nameObs.map(padded));
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
//
|
|
5811
|
-
//
|
|
5812
|
-
//
|
|
5813
|
-
//
|
|
5814
|
-
//
|
|
5815
|
-
//
|
|
5816
|
-
//
|
|
5817
|
-
//
|
|
5818
|
-
//
|
|
5819
|
-
//
|
|
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.
|
|
6833
|
+
.concat(nameObs.filter(o=>!ownsName(o,e)).map(padded));
|
|
6834
|
+
const rings=new Map();
|
|
6835
|
+
for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
|
|
6836
|
+
// ── A LASSO THAT ENCLOSES A NON-MEMBER IS A FALSE DRAWING (item 69) ──────
|
|
6837
|
+
// The group-band rule, one construct over, and the reason it is owed is a
|
|
6838
|
+
// measurement: a three-link aggregation drawn from the UNH-IOL LACP test
|
|
6839
|
+
// suite (Test Setup 2, fig 06 of that validation exercise) put a lasso at
|
|
6840
|
+
// cx 592 cy 455, rx 231 ry 162 around six member links — and fully inside
|
|
6841
|
+
// it, at normalised radius 0.70, sat two test stations that are not in the
|
|
6842
|
+
// aggregation at all. The figure told the reader they were. The engine said
|
|
6843
|
+
// nothing and `layout-lint` scored it 0, while the IDENTICAL claim written
|
|
6844
|
+
// as a `group` band was refused four times by the pass above. Backlog 43 and
|
|
6845
|
+
// 53 were about lasso SHAPE; this is lasso TRUTH.
|
|
5862
6846
|
//
|
|
5863
|
-
//
|
|
5864
|
-
//
|
|
5865
|
-
//
|
|
5866
|
-
//
|
|
5867
|
-
//
|
|
5868
|
-
//
|
|
5869
|
-
//
|
|
5870
|
-
//
|
|
6847
|
+
// WHOEVER CHOSE THE POSITION BEARS THE RESPONSIBILITY — the band's principle,
|
|
6848
|
+
// inherited whole, and its boundary inherited with it. The complete-cover
|
|
6849
|
+
// check (backlog 47b) fires only when at least one of the two
|
|
6850
|
+
// boxes is at a coordinate the author never wrote; this rule is that same
|
|
6851
|
+
// question asked the other way round. Where NO author coordinate is involved
|
|
6852
|
+
// the engine had the freedom and MUST use it, so the separation pass above
|
|
6853
|
+
// has already moved the intruder out and nothing reaches here. What reaches
|
|
6854
|
+
// here is a figure whose geometry an author fixed: the intruder is pinned,
|
|
6855
|
+
// or a member endpoint is, or the separation pass ran out of room. Then the
|
|
6856
|
+
// engine reports and the artifact is not written, because it will not draw a
|
|
6857
|
+
// membership the source did not declare.
|
|
5871
6858
|
//
|
|
5872
|
-
//
|
|
5873
|
-
//
|
|
5874
|
-
//
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
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};
|
|
6859
|
+
// The test is at the FINAL geometry — the same ring, from the same `ringOf`,
|
|
6860
|
+
// that is drawn a thousand lines below — because the ring the reader reads is
|
|
6861
|
+
// the only one whose truth is at stake.
|
|
6862
|
+
{
|
|
6863
|
+
const real=nodes.filter(n=>!n.boundary&&n.w>0&&n.h>0);
|
|
6864
|
+
for(const t of doc.trunks||[]){
|
|
6865
|
+
const R=rings.get(t);
|
|
6866
|
+
if(!R||R.solo) continue; // one member draws no ellipse (item 53)
|
|
6867
|
+
const mem=new Set(); for(const [a,b] of t.pairs){ mem.add(a); mem.add(b); }
|
|
6868
|
+
const memPinned=[...mem].some(id=>pinned(id));
|
|
6869
|
+
for(const n of real){
|
|
6870
|
+
if(mem.has(n.id)) continue;
|
|
6871
|
+
let inside=true;
|
|
6872
|
+
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]]){
|
|
6873
|
+
const dx=p[0]-R.cx, dy=p[1]-R.cy;
|
|
6874
|
+
if(Math.hypot((R.ux*dx+R.uy*dy)/R.rL,(R.vx*dx+R.vy*dy)/R.rA)>1){ inside=false; break; }
|
|
6875
|
+
}
|
|
6876
|
+
if(!inside) continue;
|
|
6877
|
+
const ln=srcLine(t.line);
|
|
6878
|
+
// Two voices, chosen by who chose the coordinate — the band's own
|
|
6879
|
+
// split. A pin is named as a pin so the author knows which line to
|
|
6880
|
+
// edit; a figure the engine could not separate says so, and admits it.
|
|
6881
|
+
gErrs.push(pinned(n.id)||memPinned
|
|
6882
|
+
? 'Line '+ln+': the lasso for bundle "'+t.id+'" would enclose non-member "'+n.id
|
|
6883
|
+
+'" — a lasso is the drawn extent of the bundle\'s members, so this draws "'+n.id
|
|
6884
|
+
+'" as one of them. Move the pin clear of the bundle\'s extent, or change what "'
|
|
6885
|
+
+t.id+'" collects so the lasso is its members\' own. The figure is not drawn '
|
|
6886
|
+
+'rather than drawn wrongly.'
|
|
6887
|
+
: 'Line '+ln+': the lasso for bundle "'+t.id+'" would enclose non-member "'+n.id
|
|
6888
|
+
+'" and the layout pass could not separate them; the figure is not drawn rather '
|
|
6889
|
+
+'than drawn wrongly. Give "'+n.id+'" a pin outside the bundle\'s extent, or add '
|
|
6890
|
+
+'its link to "'+t.id+'".');
|
|
5973
6891
|
}
|
|
5974
6892
|
}
|
|
5975
|
-
|
|
5976
|
-
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
|
|
5980
|
-
|
|
5981
|
-
|
|
6893
|
+
}
|
|
6894
|
+
// WHOSE EXTENT THE LASSO IS, WRITTEN INTO THE DRAWING (item 69).
|
|
6895
|
+
// `data-lasso` follows the `data-port-sq` pattern exactly: ONE
|
|
6896
|
+
// attribute name, carried by the INK and by the thing the ink is about, with
|
|
6897
|
+
// the same string on both, so a reader — `tools/layout-lint.js` first — reads
|
|
6898
|
+
// the association instead of inferring it. The ellipse carries the bundle's
|
|
6899
|
+
// id; every node the ellipse was derived from carries the ids of the lassos
|
|
6900
|
+
// it is a member of (a node may be in more than one, space-separated). The
|
|
6901
|
+
// geometry itself is NOT duplicated into the attribute: `cx/cy/rx/ry` and the
|
|
6902
|
+
// `rotate()` are already the drawn truth, and a second copy could disagree
|
|
6903
|
+
// with the first. Only bundles that DRAW an ellipse appear — a one-member
|
|
6904
|
+
// bundle draws none (item 53) and claims no extent, so it stamps nothing.
|
|
6905
|
+
const lassoMem=new Map();
|
|
6906
|
+
for(const [t,R] of rings){
|
|
6907
|
+
if(R.solo) continue;
|
|
6908
|
+
for(const [a,b] of t.pairs) for(const id of [a,b]){
|
|
6909
|
+
if(!lassoMem.has(id)) lassoMem.set(id,[]);
|
|
6910
|
+
if(!lassoMem.get(id).includes(t.id)) lassoMem.get(id).push(t.id);
|
|
6911
|
+
}
|
|
6912
|
+
}
|
|
5982
6913
|
// filled by the label pass below, consumed by the ring drawing further down
|
|
5983
6914
|
const ringLbl=new Map(), ringLeads=[];
|
|
5984
6915
|
// edges (sorted by plane z, then doc order)
|
|
5985
6916
|
const edges=zsort(doc.edges);
|
|
5986
6917
|
const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
|
|
6918
|
+
// THE PORT SQUARES GET THEIR OWN LAYER (item 57b), between the
|
|
6919
|
+
// node bodies and the labels. Over the nodes, because half of each square is
|
|
6920
|
+
// meant to be seen INSIDE the box and the whole point of the notation is
|
|
6921
|
+
// that the fitting sits ON the boundary; over the edges, because the
|
|
6922
|
+
// connector ATTACHES to the square rather than passing through it; under
|
|
6923
|
+
// everything in `lblsvg`, because a port name — and an arrowhead, which
|
|
6924
|
+
// lives in that layer too — must never be occluded by the fitting it names.
|
|
6925
|
+
const sqsvg=[];
|
|
5987
6926
|
// ── deferred edge-label placement ───────────────────────────────────────
|
|
5988
6927
|
// An edge label is not written where it is emitted. Each emission reserves
|
|
5989
6928
|
// its slot in lblsvg (so the paint order is unchanged) and registers the
|
|
@@ -6040,8 +6979,9 @@ function renderScene(doc,y0){
|
|
|
6040
6979
|
// box and repeating one label three times is what this removes.
|
|
6041
6980
|
//
|
|
6042
6981
|
// 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`
|
|
6044
|
-
// one
|
|
6982
|
+
// target outline — shape-check asserts exactly that, and `data-edge` keys
|
|
6983
|
+
// each one (its id, or its source line when anonymous; `CONNECTOR-IDENTITY-KEY`) — so the shared
|
|
6984
|
+
// trunk is stroked once per member. That
|
|
6045
6985
|
// coincidence is the convention and not a defect, and the members say so:
|
|
6046
6986
|
// every bus path carries `data-bus="<target>"`, which is what lets a reader
|
|
6047
6987
|
// (and layout-lint) tell a deliberate trunk from two edges hidden under each
|
|
@@ -6401,7 +7341,7 @@ function renderScene(doc,y0){
|
|
|
6401
7341
|
// pattern to a period boundary at the path's end.
|
|
6402
7342
|
const per=e.style==='dashed'?10:(e.style==='dotted'?6:0);
|
|
6403
7343
|
const doff=per?' stroke-dashoffset="'+(((per-(runLen(pts)%per))%per).toFixed(2))+'"':'';
|
|
6404
|
-
esvg.push('<path data-edge="'+e
|
|
7344
|
+
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
7345
|
noteSegs(e,pts);
|
|
6406
7346
|
for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
|
|
6407
7347
|
if(bus.dots) for(const d of bus.dots)
|
|
@@ -6434,7 +7374,26 @@ function renderScene(doc,y0){
|
|
|
6434
7374
|
if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
|
|
6435
7375
|
continue;
|
|
6436
7376
|
}
|
|
6437
|
-
|
|
7377
|
+
// A SELF-TRANSITION IS EXEMPT FROM THE PINNED-ENDPOINT EXCLUSION
|
|
7378
|
+
// (item 64). The `!pinned` guard belongs to the CHANNEL
|
|
7379
|
+
// route below it: a channel back edge is planned in `chPlan`, which is
|
|
7380
|
+
// built over auto-layout lanes and has nothing to say about a box the
|
|
7381
|
+
// author placed, so a pinned back edge correctly falls through to the
|
|
7382
|
+
// straight route. A SELF-loop has no such dependency — its geometry is a
|
|
7383
|
+
// function of ONE box and a free side, both of which a pinned box has —
|
|
7384
|
+
// and sweeping it into the same exclusion made `pin` silently delete the
|
|
7385
|
+
// drawing: `borderPoint(A, A's own centre)` twice is the same point, so
|
|
7386
|
+
// the straight route emitted a zero-length `<line>` at the state's centre
|
|
7387
|
+
// with the trigger label printed across the state's name. Measured on
|
|
7388
|
+
// 0.4 over a pinned three-state triangle and a pinned two-state
|
|
7389
|
+
// pair: every self-transition length 0.0. The rule the exemption keeps is
|
|
7390
|
+
// one line — a self-transition draws the same way whether its state's
|
|
7391
|
+
// coordinate came from a pin or from the layout pass — so the side
|
|
7392
|
+
// selection below is REUSED as-is rather than duplicated for pins; a
|
|
7393
|
+
// second selection rule would be a second convention, and `DOMAIN-CONVENTION-DIRECTIVES` gives the
|
|
7394
|
+
// engine one. (`chBack` is always true here: `isBack` takes every
|
|
7395
|
+
// self-edge, and the `recip` pair test skips `e.a===e.b`.)
|
|
7396
|
+
if(chBack(e)&&(A===B||(!pinned(e.a)&&!pinned(e.b)))){
|
|
6438
7397
|
// ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
|
|
6439
7398
|
// Edge labels are DEFERRED: every label is registered against its
|
|
6440
7399
|
// FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
|
|
@@ -6491,7 +7450,7 @@ function renderScene(doc,y0){
|
|
|
6491
7450
|
for(const sd of ['r','l','b','t']){ const pp=mkLoop(sd); if(!loopHit(pp)){ sp=pp; break; } }
|
|
6492
7451
|
if(!sp) sp=mkLoop('r');
|
|
6493
7452
|
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
|
|
7453
|
+
esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
6495
7454
|
noteSegs(e,sp);
|
|
6496
7455
|
// A self-loop's outer run is 16 px long, so sliding the label ALONG it
|
|
6497
7456
|
// buys ~15 px and no escape at all from a line crossing it — and a
|
|
@@ -6631,7 +7590,7 @@ function renderScene(doc,y0){
|
|
|
6631
7590
|
// it is what lets a reader (and `layout-lint`'s coincident term) tell a
|
|
6632
7591
|
// deliberate shared trunk from two lines hidden under each other. Written
|
|
6633
7592
|
// LAST, after stroke-width, so no existing reader breaks.
|
|
6634
|
-
esvg.push('<path data-edge="'+e
|
|
7593
|
+
esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash
|
|
6635
7594
|
+(P.bus?' data-bus="'+esc(P.bus)+'"':'')+'/>');
|
|
6636
7595
|
noteSegs(e,pts);
|
|
6637
7596
|
if(P.busTail){
|
|
@@ -6670,7 +7629,7 @@ function renderScene(doc,y0){
|
|
|
6670
7629
|
const lrPts=longRoute(e);
|
|
6671
7630
|
if(lrPts){
|
|
6672
7631
|
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
|
|
7632
|
+
esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(lrPts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
6674
7633
|
noteSegs(e,lrPts);
|
|
6675
7634
|
if(e.mid){
|
|
6676
7635
|
// WHICH LEG CARRIES THE LABEL, and it is the OPPOSITE of the channel
|
|
@@ -6799,7 +7758,7 @@ function renderScene(doc,y0){
|
|
|
6799
7758
|
}
|
|
6800
7759
|
}
|
|
6801
7760
|
}
|
|
6802
|
-
esvg.push('<path data-edge="'+e
|
|
7761
|
+
esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
6803
7762
|
noteSegs(e,pts);
|
|
6804
7763
|
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
7764
|
if(e.tail) seg(p0,pts[1],e.tail,10,p1,runLen(pts));
|
|
@@ -6833,7 +7792,7 @@ function renderScene(doc,y0){
|
|
|
6833
7792
|
}
|
|
6834
7793
|
}
|
|
6835
7794
|
if(route){
|
|
6836
|
-
esvg.push('<path data-edge="'+e
|
|
7795
|
+
esvg.push('<path data-edge="'+edgeRef(e)+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
6837
7796
|
noteSegs(e,route);
|
|
6838
7797
|
if(e.mid){ // the longest segment carries the mid label
|
|
6839
7798
|
let bi=0,bl=-1;
|
|
@@ -6850,7 +7809,7 @@ function renderScene(doc,y0){
|
|
|
6850
7809
|
for(const pP of route){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+4-y0-20); }
|
|
6851
7810
|
continue;
|
|
6852
7811
|
}
|
|
6853
|
-
esvg.push('<line data-edge="'+e
|
|
7812
|
+
esvg.push('<line data-edge="'+edgeRef(e)+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
6854
7813
|
noteSegs(e,[[x1,yy1],[x2,yy2]]);
|
|
6855
7814
|
if(e.mid)
|
|
6856
7815
|
reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
|
|
@@ -7018,6 +7977,68 @@ function renderScene(doc,y0){
|
|
|
7018
7977
|
// how near a corner a marker may be written; the offsets are one marker
|
|
7019
7978
|
// width apart, which is exactly the pitch two ports need to be two.
|
|
7020
7979
|
const PORT_IN=2, PORT_END=4, PORT_HUG=25, PORT_OUT=60;
|
|
7980
|
+
// ── THE STRADDLING PORT SQUARE (item 57b) ─────────────────
|
|
7981
|
+
// OMG UML 2.5.1 §11.4's composite-structure port notation: a small square
|
|
7982
|
+
// drawn ON the classifier's boundary, half inside and half outside, with
|
|
7983
|
+
// the connector attaching to it and the port name labelling it. Here the
|
|
7984
|
+
// classifier is the device box, the connector is the link, and the name is
|
|
7985
|
+
// the interface. Item 57 put the NAME at the crossing; this puts a MARK
|
|
7986
|
+
// there, so the crossing is stated by the drawing rather than inferred
|
|
7987
|
+
// from where a word happens to sit.
|
|
7988
|
+
//
|
|
7989
|
+
// `PORT_SQ` is 7 px — the CAP-HEIGHT OF THE PORT NAME ITSELF (the endpoint
|
|
7990
|
+
// font is 10-11 px, so its capitals are about 7), which is what makes the
|
|
7991
|
+
// fitting read as the name's own mark rather than a second little box: it
|
|
7992
|
+
// is a fifth of an ordinary 36 px topology node's height and it never
|
|
7993
|
+
// competes with the device outline. The size was MEASURED, not chosen:
|
|
7994
|
+
// swept 7/8/9/10 px against layout-lint over this corpus, and 7 is the
|
|
7995
|
+
// only value that regresses no figure — `reference/topology` scores 6 at
|
|
7996
|
+
// 9 px, 4 at 8 px and 2 at 7 px against the pre-square 4, because every
|
|
7997
|
+
// extra pixel of fitting is a pixel of along-border room the name no
|
|
7998
|
+
// longer has on a small device.
|
|
7999
|
+
//
|
|
8000
|
+
// It is stroked in the OWNING NODE's own stroke at the node's own 1 px
|
|
8001
|
+
// weight and filled with the node's own fill, so it belongs to the device
|
|
8002
|
+
// that owns the port and to nothing else. `PORT_SQ_GAP` is the engine's
|
|
8003
|
+
// own 3 px label standoff, reused rather than reinvented; it is also the
|
|
8004
|
+
// width of the FORBIDDEN BAND around a foreign square (see `portCands`).
|
|
8005
|
+
const PORT_SQ=7, PORT_SQ_GAP=3;
|
|
8006
|
+
// Where the square goes: the crossing point snapped onto the nearest side
|
|
8007
|
+
// of the owner's box, clamped so a square can never hang off a corner.
|
|
8008
|
+
// The side comes back with it — the placement family needs it.
|
|
8009
|
+
const portSq=(N,p)=>{
|
|
8010
|
+
const dl=Math.abs(p[0]-N.x), dr=Math.abs(p[0]-(N.x+N.w)),
|
|
8011
|
+
dt=Math.abs(p[1]-N.y), db=Math.abs(p[1]-(N.y+N.h));
|
|
8012
|
+
const m=Math.min(dl,dr,dt,db);
|
|
8013
|
+
const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
|
|
8014
|
+
const vert=(side==='top'||side==='bottom');
|
|
8015
|
+
const h2=PORT_SQ/2;
|
|
8016
|
+
let cx,cy;
|
|
8017
|
+
// A CURVED OR ANGLED OUTLINE IS STILL THE OUTLINE. Snapping to the
|
|
8018
|
+
// bounding box is right for the rectangle family and WRONG for
|
|
8019
|
+
// everything else: `showcase/srl-evpn-irb`'s EVPN-VXLAN cloud is an
|
|
8020
|
+
// ellipse 620 x 110, and its `irb0.24` port snapped to the box bottom
|
|
8021
|
+
// came out 8 px BELOW the drawn curve — a fitting floating in space
|
|
8022
|
+
// beside the thing it is fitted to. The edge already ends on the true
|
|
8023
|
+
// outline (`borderPoint` put it there), so for a non-rectangular shape
|
|
8024
|
+
// the crossing point IS the answer and the square is simply centred on
|
|
8025
|
+
// it, axis-aligned. It straddles a slanted or curved border at a slight
|
|
8026
|
+
// angle to it, which is how every hand-drawn figure does it too.
|
|
8027
|
+
const boxy=!N.shape||N.shape==='box'||N.shape==='rounded'||N.shape==='cylinder';
|
|
8028
|
+
if(!boxy){ cx=Math.max(N.x+h2,Math.min(N.x+N.w-h2,p[0]));
|
|
8029
|
+
cy=Math.max(N.y+h2,Math.min(N.y+N.h-h2,p[1])); }
|
|
8030
|
+
else if(vert){ cx=Math.max(N.x+h2,Math.min(N.x+N.w-h2,p[0]));
|
|
8031
|
+
cy=side==='top'?N.y:N.y+N.h; }
|
|
8032
|
+
else { cy=Math.max(N.y+h2,Math.min(N.y+N.h-h2,p[1]));
|
|
8033
|
+
cx=side==='left'?N.x:N.x+N.w; }
|
|
8034
|
+
return {cx,cy,side,vert,key:cx.toFixed(2)+','+cy.toFixed(2),
|
|
8035
|
+
box:{x:cx-h2,y:cy-h2,w:PORT_SQ,h:PORT_SQ}};
|
|
8036
|
+
};
|
|
8037
|
+
// gap between two boxes, 0 when they meet — the same quantity `segBoxGap`
|
|
8038
|
+
// measures between a segment and a box, and it is what the standoff rule
|
|
8039
|
+
// below is written in.
|
|
8040
|
+
const boxSep=(a,b)=>Math.hypot(Math.max(b.x-(a.x+a.w),0,a.x-(b.x+b.w)),
|
|
8041
|
+
Math.max(b.y-(a.y+a.h),0,a.y-(b.y+b.h)));
|
|
7021
8042
|
// The node's own drawn label, computed by the SAME formulas the node pass
|
|
7022
8043
|
// emits it with (shrink-to-fit included), so the obstacle and the drawing
|
|
7023
8044
|
// cannot disagree about where a name is.
|
|
@@ -7034,12 +8055,19 @@ function renderScene(doc,y0){
|
|
|
7034
8055
|
// the glyph band inside that, and the two questions are different (see the
|
|
7035
8056
|
// own-name term in the scorer).
|
|
7036
8057
|
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)});
|
|
8058
|
+
// A label request's own text box, in the one place both the slot pass and
|
|
8059
|
+
// the candidate family can read it — they must agree about how wide a name
|
|
8060
|
+
// is or the fan below spaces names by a width nobody draws.
|
|
8061
|
+
const lblDims=r=>{
|
|
8062
|
+
const lines=String(r.text).split('\n'), nL=lines.length;
|
|
8063
|
+
const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
|
|
8064
|
+
const lh=r.fs*1.3;
|
|
8065
|
+
return {w, h:(nL-1)*lh+r.fs*1.1, up:(nL-1)*lh/2+r.fs*0.85};
|
|
8066
|
+
};
|
|
7037
8067
|
const portCands=r=>{
|
|
7038
8068
|
const N=r.port, P=r.p, out=[];
|
|
7039
8069
|
if(!N) return out;
|
|
7040
|
-
const
|
|
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;
|
|
8070
|
+
const D=lblDims(r), w=D.w, h=D.h, up=D.up;
|
|
7043
8071
|
// Which border did the link cross? The nearest side of the box.
|
|
7044
8072
|
const dl=Math.abs(P[0]-N.x), dr=Math.abs(P[0]-(N.x+N.w)),
|
|
7045
8073
|
dt=Math.abs(P[1]-N.y), db=Math.abs(P[1]-(N.y+N.h));
|
|
@@ -7047,13 +8075,28 @@ function renderScene(doc,y0){
|
|
|
7047
8075
|
const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
|
|
7048
8076
|
const vert=(side==='top'||side==='bottom');
|
|
7049
8077
|
// ROOM. A marker that cannot be written between the two corners of the
|
|
7050
|
-
// side it crosses has no
|
|
7051
|
-
//
|
|
8078
|
+
// side it crosses has no INSIDE form at all — but it still has an
|
|
8079
|
+
// outside one, hugging the border it could not get into, and that is a
|
|
8080
|
+
// far better answer than the along-the-shaft family it used to fall
|
|
8081
|
+
// through to (an unclamped `roomIn` return sent every name on a narrow
|
|
8082
|
+
// device out into the seam between its links, which is the defect the
|
|
8083
|
+
// port convention exists to end). So the shortage is now a filter on
|
|
8084
|
+
// the INSIDE ring only.
|
|
7052
8085
|
const span=vert?N.w:N.h, need=(vert?w:h)+2*PORT_END;
|
|
7053
|
-
|
|
8086
|
+
const roomIn=span>=need;
|
|
7054
8087
|
const lo=(vert?N.x:N.y)+PORT_END, hi=(vert?N.x+N.w:N.y+N.h)-PORT_END;
|
|
7055
8088
|
const pitch=(vert?w:h)+4;
|
|
7056
|
-
|
|
8089
|
+
// THE NAME IS ANCHORED TO ITS OWN SQUARE, not to the raw
|
|
8090
|
+
// crossing: `u0` is the square's centreline along the border, so the
|
|
8091
|
+
// corner clamp inside `portSq` can never leave a name off the mark it
|
|
8092
|
+
// names. `base` is the smallest along-border offset at which the two
|
|
8093
|
+
// marks are two — half the square, the 3 px standoff, half the text —
|
|
8094
|
+
// and `deep` is the same clearance taken PERPENDICULARLY, past the
|
|
8095
|
+
// square's far face.
|
|
8096
|
+
const SQ=portSq(N,P);
|
|
8097
|
+
const u0=vert?SQ.cx:SQ.cy;
|
|
8098
|
+
const base=PORT_SQ/2+PORT_SQ_GAP+(vert?w:h)/2;
|
|
8099
|
+
const deep=PORT_SQ/2+PORT_SQ_GAP;
|
|
7057
8100
|
// TWO RINGS OF CANDIDATES, AND BOTH ARE AT THE CROSSING.
|
|
7058
8101
|
// in = inside the border, the convention proper;
|
|
7059
8102
|
// out = the mirror image, just OUTSIDE the same border.
|
|
@@ -7066,19 +8109,162 @@ function renderScene(doc,y0){
|
|
|
7066
8109
|
// could not get inside keeps it on its own port, which is the fact it
|
|
7067
8110
|
// exists to state. It is surcharged, so it is taken only after inside
|
|
7068
8111
|
// has failed.
|
|
7069
|
-
|
|
7070
|
-
|
|
8112
|
+
//
|
|
8113
|
+
// WITH A SQUARE AT THE CROSSING THE ANCHOR CHANGES, which is the
|
|
8114
|
+
// placement half of the maintainer's ruling ("A/B 皆有埠名文字定位不夠
|
|
8115
|
+
// 理想" — in both styles the port name is not sited well enough):
|
|
8116
|
+
//
|
|
8117
|
+
// 1. THE NAME IS ANCHORED TO ITS OWN SQUARE, on the square's
|
|
8118
|
+
// centreline along the border. When the port is alone on that side
|
|
8119
|
+
// the anchor IS `u0` and the name sits squarely on its fitting's
|
|
8120
|
+
// line — which is the whole reading the notation buys.
|
|
8121
|
+
// 2. WHEN THE SIDE CARRIES SEVERAL PORTS the anchor is the SLOT the
|
|
8122
|
+
// side pass assigned (`r.portU`): the same ideal, projected so that
|
|
8123
|
+
// consecutive names clear each other IN SQUARE ORDER. Order is the
|
|
8124
|
+
// property that matters and it is why the slot is computed for the
|
|
8125
|
+
// whole side at once instead of being negotiated one label at a
|
|
8126
|
+
// time by a greedy scorer — a greedy pass produced `p1 p3 p2` over
|
|
8127
|
+
// squares in the order p1 p2 p3 on `patterns/topology-a`, and every
|
|
8128
|
+
// one of those three labels is legible while two of them are wrong.
|
|
8129
|
+
//
|
|
8130
|
+
// The perpendicular offset follows from the anchor: a name still over
|
|
8131
|
+
// its own square's along-border extent must clear the square's face
|
|
8132
|
+
// (`deep`), and one that has stepped clear of it along the border only
|
|
8133
|
+
// needs the ordinary 2 px (`PORT_IN`).
|
|
8134
|
+
//
|
|
8135
|
+
// The ladder (±one text pitch at a time, from the slot) stays behind
|
|
8136
|
+
// that as relief for a name whose slot is occupied by something else —
|
|
8137
|
+
// the node's own name, a neighbour's label. Each of these exists inside
|
|
8138
|
+
// the border and outside it. INSIDE IS THE CONVENTION PROPER; the
|
|
8139
|
+
// outside ring is the mirror image, hugging the same border, and it is
|
|
8140
|
+
// surcharged (`hug`, PORT_HUG) so it is taken only after inside has
|
|
8141
|
+
// failed — but it is far cheaper than the ordinary along-the-shaft
|
|
8142
|
+
// family (PORT_OUT), which is the ruling's second clause: a crowded
|
|
8143
|
+
// interior sends the name to the OUTSIDE FLANK of its own port before it
|
|
8144
|
+
// ever sends it out onto the shaft.
|
|
8145
|
+
const uS=(r.portU===undefined?u0:r.portU);
|
|
8146
|
+
const dpFor=u=>(Math.abs(u-u0)<base-1e-9?deep:PORT_IN);
|
|
8147
|
+
const fam=[];
|
|
8148
|
+
for(const inside of [true,false]) fam.push({inside,u:uS,dp:dpFor(uS),ex:0});
|
|
8149
|
+
// OUTSIDE, THE NAME MAY ALSO STAND OFF THE BORDER. Inside, the far wall
|
|
8150
|
+
// of the box ends the argument; outside there is open paper, and one
|
|
8151
|
+
// step of it is often the whole difference. `05-lacp`'s DUT is 49.6 px
|
|
8152
|
+
// wide, carries three ports on its left face and their names are 41.4 px
|
|
8153
|
+
// — every name has to hug the outside, and every shaft converging on
|
|
8154
|
+
// those three fittings runs through the hug ring on its way in. Two
|
|
8155
|
+
// 8 px steps outward (priced like any other displacement, and past the
|
|
8156
|
+
// second one the standoff rule below draws the association as a leader)
|
|
8157
|
+
// let the fan open out to where the shafts have separated.
|
|
8158
|
+
for(const d of [8,16]) fam.push({inside:false,u:uS,dp:dpFor(uS)+d,ex:d});
|
|
8159
|
+
for(const inside of [true,false]) for(const sg of [1,-1]) for(const k of [0,1,2])
|
|
8160
|
+
fam.push({inside,u:uS+sg*(base+k*pitch),dp:PORT_IN,ex:base+k*pitch});
|
|
8161
|
+
for(const F of fam){
|
|
8162
|
+
const inside=F.inside, IN=F.dp;
|
|
8163
|
+
if(inside&&!roomIn) continue;
|
|
8164
|
+
// THE CORNERS BIND INSIDE AND DO NOT BIND OUTSIDE. Inside, a name is
|
|
8165
|
+
// written between the two corners of the face it belongs to or it is
|
|
8166
|
+
// written on another face; outside there is no face to run out of, so
|
|
8167
|
+
// clamping the outside ring to the node's own extent was simply
|
|
8168
|
+
// collapsing a fanned side back into a stack. A node 36 px tall with
|
|
8169
|
+
// three links arriving 8 px apart has no inside answer at all, and
|
|
8170
|
+
// its outside answer is the fan — which needs to reach past the
|
|
8171
|
+
// corner to exist.
|
|
8172
|
+
const half=(vert?w:h)/2;
|
|
8173
|
+
const u=inside?Math.max(lo+half, Math.min(hi-half, F.u)):F.u;
|
|
7071
8174
|
let bx,by;
|
|
7072
|
-
if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-
|
|
7073
|
-
else if(side==='top'){ bx=u-w/2; by=inside?N.y+
|
|
7074
|
-
else if(side==='left'){ bx=inside?N.x+
|
|
7075
|
-
else { bx=inside?N.x+N.w-
|
|
8175
|
+
if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-IN-h:N.y+N.h+IN; }
|
|
8176
|
+
else if(side==='top'){ bx=u-w/2; by=inside?N.y+IN:N.y-IN-h; }
|
|
8177
|
+
else if(side==='left'){ bx=inside?N.x+IN:N.x-IN-w; by=u-h/2; }
|
|
8178
|
+
else { bx=inside?N.x+N.w-IN-w:N.x+N.w+IN; by=u-h/2; }
|
|
7076
8179
|
out.push({x:bx+w/2, y:by+up, anchor:'middle', t:undefined, side,
|
|
7077
|
-
|
|
8180
|
+
sq:SQ.box, sqk:SQ.key, atBorder:true,
|
|
8181
|
+
ex:F.ex, inPort:inside, hug:!inside,
|
|
7078
8182
|
box:{x:bx,y:by,w,h}});
|
|
7079
8183
|
}
|
|
7080
8184
|
return out;
|
|
7081
8185
|
};
|
|
8186
|
+
// ── DRAW EVERY PORT SQUARE BEFORE ANY NAME IS PLACED ──────────────────
|
|
8187
|
+
// The squares are a function of geometry alone (owner box + crossing
|
|
8188
|
+
// point), so they are all known before the greedy placement pass starts.
|
|
8189
|
+
// Computing them here rather than inside that loop buys the property that
|
|
8190
|
+
// matters: EVERY name is scored against EVERY square, including squares
|
|
8191
|
+
// belonging to names that have not been placed yet. A name written across
|
|
8192
|
+
// a later port's fitting would say the wrong thing about which link it
|
|
8193
|
+
// belongs to, and a one-pass greedy loop that emitted squares as it went
|
|
8194
|
+
// could only ever avoid the ones already drawn.
|
|
8195
|
+
//
|
|
8196
|
+
// Two crossings can coincide (co-located links on one border, a bundle's
|
|
8197
|
+
// members leaving through one face): the square is keyed on its snapped
|
|
8198
|
+
// centre so one fitting is drawn once, not stroked n times. That key is
|
|
8199
|
+
// also the square's IDENTITY in the drawing — it is written on the rect
|
|
8200
|
+
// as `data-port-sq` and on the name that belongs to it, so a reader (and
|
|
8201
|
+
// `tools/layout-lint.js`) can tell a name's own fitting from a foreign one
|
|
8202
|
+
// by reading rather than by guessing at distances.
|
|
8203
|
+
const sqBox=[], sqSeen=new Set();
|
|
8204
|
+
for(const r of lblReq){
|
|
8205
|
+
if(!r.port) continue;
|
|
8206
|
+
const S=portSq(r.port,r.p);
|
|
8207
|
+
if(sqSeen.has(S.key)) continue;
|
|
8208
|
+
sqSeen.add(S.key);
|
|
8209
|
+
sqBox.push(Object.assign({key:S.key},S.box));
|
|
8210
|
+
const N=r.port;
|
|
8211
|
+
sqsvg.push('<rect x="'+S.box.x.toFixed(2)+'" y="'+S.box.y.toFixed(2)+
|
|
8212
|
+
'" width="'+PORT_SQ+'" height="'+PORT_SQ+'" fill="'+(N.fill||'#fff')+
|
|
8213
|
+
'" stroke="'+(N.stroke||'#8a8880')+'" data-port-sq="'+S.key+'"/>');
|
|
8214
|
+
W=Math.max(W, S.box.x+S.box.w+4);
|
|
8215
|
+
Hh=Math.max(Hh, S.box.y+S.box.h+4-y0-20);
|
|
8216
|
+
}
|
|
8217
|
+
// ── ONE SIDE'S NAMES ARE ORDERED TOGETHER, NOT ONE AT A TIME ──────────
|
|
8218
|
+
// "Same side, several ports: the names serialize along the border in
|
|
8219
|
+
// SQUARE ORDER" is a property of the SIDE, and a greedy scorer cannot hold
|
|
8220
|
+
// it — it places one label at a time against what is already on the paper,
|
|
8221
|
+
// so the second name takes the cheapest gap rather than its own place in
|
|
8222
|
+
// the row. Measured: `patterns/topology-a`'s aggregation node came out
|
|
8223
|
+
// `p1 p3 p2` over squares in the order p1 p2 p3. Every one of those labels
|
|
8224
|
+
// is legible and two of them are wrong, which is worse than a collision:
|
|
8225
|
+
// a collision announces itself and a swapped pair does not.
|
|
8226
|
+
//
|
|
8227
|
+
// So each (node, side) group is projected ONCE, here, before any placement
|
|
8228
|
+
// is scored. The projection is the smallest order-preserving spreading of
|
|
8229
|
+
// the ideal positions: walk the group in square order, push each name just
|
|
8230
|
+
// far enough to clear the previous one, then slide the whole run back so
|
|
8231
|
+
// it stays centred on where the squares actually are. A side with one port
|
|
8232
|
+
// is unmoved by construction — its slot IS its square's centreline — so
|
|
8233
|
+
// this pass costs the ordinary figure nothing.
|
|
8234
|
+
//
|
|
8235
|
+
// Separation is measured in TEXT, not in squares: half of each of the two
|
|
8236
|
+
// names plus the 3 px standoff. Two fittings 8 px apart on a 36 px device
|
|
8237
|
+
// (three links into one small node, which is `05-lacp`'s DUT) cannot give
|
|
8238
|
+
// their names 8 px and be read as two names; the names take the room the
|
|
8239
|
+
// TEXT needs and the reader maps name to square by ORDER, which is exactly
|
|
8240
|
+
// what the order-preserving projection guarantees.
|
|
8241
|
+
{
|
|
8242
|
+
const sides=new Map();
|
|
8243
|
+
for(const r of lblReq){
|
|
8244
|
+
if(!r.port) continue;
|
|
8245
|
+
const S=portSq(r.port,r.p);
|
|
8246
|
+
const k=(r.port.id===undefined?'?':r.port.id)+'|'+S.side;
|
|
8247
|
+
if(!sides.has(k)) sides.set(k,[]);
|
|
8248
|
+
const D=lblDims(r);
|
|
8249
|
+
sides.get(k).push({r, u0:S.vert?S.cx:S.cy, ext:S.vert?D.w:D.h});
|
|
8250
|
+
}
|
|
8251
|
+
for(const g of sides.values()){
|
|
8252
|
+
if(g.length<2) continue;
|
|
8253
|
+
// stable: equal crossings keep registration (edge) order
|
|
8254
|
+
g.forEach((e,i)=>{e.i=i;});
|
|
8255
|
+
g.sort((a,b)=>a.u0-b.u0||a.i-b.i);
|
|
8256
|
+
let u=g[0].u0, sum=0;
|
|
8257
|
+
g[0].u=u;
|
|
8258
|
+
for(let i=1;i<g.length;i++){
|
|
8259
|
+
const sep=(g[i-1].ext+g[i].ext)/2+PORT_SQ_GAP;
|
|
8260
|
+
u=Math.max(g[i].u0, u+sep);
|
|
8261
|
+
g[i].u=u;
|
|
8262
|
+
}
|
|
8263
|
+
for(const e of g) sum+=e.u-e.u0;
|
|
8264
|
+
const shift=sum/g.length; // re-centre on the squares
|
|
8265
|
+
for(const e of g) e.r.portU=e.u-shift;
|
|
8266
|
+
}
|
|
8267
|
+
}
|
|
7082
8268
|
const placed=[];
|
|
7083
8269
|
// A request may name a SECOND carrying segment (`alt`). Back edges do: the
|
|
7084
8270
|
// stub leaving the source is the preferred carrier because it says which
|
|
@@ -7175,8 +8361,19 @@ function renderScene(doc,y0){
|
|
|
7175
8361
|
// already there) and never merely because outside happens to be tidy.
|
|
7176
8362
|
const cs=[];
|
|
7177
8363
|
if(r.port) for(const c of portCands(r)) cs.push({c,si:0});
|
|
7178
|
-
|
|
7179
|
-
|
|
8364
|
+
// THE OWN SQUARE TRAVELS WITH THE REQUEST, NOT WITH THE FAMILY
|
|
8365
|
+
// A port name driven all the way out to the along-shaft
|
|
8366
|
+
// family still names its fitting and must still be told apart from the
|
|
8367
|
+
// next one, so those candidates carry the same square the border family
|
|
8368
|
+
// does — the forbidden band, the affinity filter and the standoff rule
|
|
8369
|
+
// below then apply to every position this label can take, instead of
|
|
8370
|
+
// stopping at the border.
|
|
8371
|
+
const RSQ=r.port?portSq(r.port,r.p):null;
|
|
8372
|
+
for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]) for(const ep of eps){
|
|
8373
|
+
const c=cand(r,t,sides[si],cl,ep);
|
|
8374
|
+
if(RSQ){ c.sq=RSQ.box; c.sqk=RSQ.key; }
|
|
8375
|
+
cs.push({c,si,out:!!r.port});
|
|
8376
|
+
}
|
|
7180
8377
|
for(const CS of cs){
|
|
7181
8378
|
const c=CS.c, si=CS.si, t=(c.t===undefined?tPref:c.t);
|
|
7182
8379
|
c.car=[r.p,r.q]; // the carrier this candidate rides (item 59)
|
|
@@ -7185,13 +8382,82 @@ function renderScene(doc,y0){
|
|
|
7185
8382
|
// one fact it exists to state, and no amount of collision relief buys
|
|
7186
8383
|
// that back. Candidates that fail are dropped; `bestAny` keeps the
|
|
7187
8384
|
// least-bad one so a figure with no legal position still draws.
|
|
8385
|
+
// A DRAWN ASSOCIATION REPLACES AN INFERRED ONE. Every
|
|
8386
|
+
// filter in this pass is a proxy for one question — can the reader
|
|
8387
|
+
// tell what this word belongs to — and each answers it by PROXIMITY,
|
|
8388
|
+
// because proximity is all an unadorned label has. A port name past
|
|
8389
|
+
// the standoff cap is not unadorned: it takes a leader (below), and a
|
|
8390
|
+
// line from the word to the fitting states the association outright.
|
|
8391
|
+
// So `lead` is computed first and the proximity proxies stand down
|
|
8392
|
+
// for it. The BAND does not: a name written across a foreign fitting
|
|
8393
|
+
// is a wrong statement no leader can correct.
|
|
8394
|
+
const lead=!!(c.sq&&boxSep(c.box,c.sq)>STANDOFF_CAP);
|
|
7188
8395
|
let owns=true;
|
|
7189
|
-
if(r.kind==='end'&&r.other){
|
|
8396
|
+
if(r.kind==='end'&&r.other&&!lead){
|
|
7190
8397
|
const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
|
|
7191
8398
|
owns=Math.hypot(ccx-r.p[0],ccy-r.p[1])<Math.hypot(ccx-r.other[0],ccy-r.other[1]);
|
|
7192
8399
|
}
|
|
8400
|
+
// A FOREIGN SQUARE'S BAND IS FORBIDDEN SPACE, NOT A PRICE
|
|
8401
|
+
// Once a fitting is drawn, a port name no longer names
|
|
8402
|
+
// "this end of this link" — it names THAT SQUARE, and the only thing
|
|
8403
|
+
// that says which square is proximity. On the first squared render of
|
|
8404
|
+
// `patterns/topology-a` the aggregation node's three names came out
|
|
8405
|
+
// `p1 p3 p2` over squares in the order p1 p2 p3, because the
|
|
8406
|
+
// free-space terms priced the second name into the gap past its own
|
|
8407
|
+
// fitting: every one of those labels is legible and two of the three
|
|
8408
|
+
// are wrong. No collision relief buys that back, so this is a filter,
|
|
8409
|
+
// exactly as endpoint affinity above is, and it has two halves that
|
|
8410
|
+
// apply to DIFFERENT candidates:
|
|
8411
|
+
// • the BAND — the foreign square grown by the 3 px standoff — is
|
|
8412
|
+
// space NO port name may enter, wherever it was going to be
|
|
8413
|
+
// written. A name across a neighbour's fitting is the wrong
|
|
8414
|
+
// statement whether it got there from the border ring or from the
|
|
8415
|
+
// shaft, so this half is unconditional.
|
|
8416
|
+
// • the AFFINITY — nearer a foreign square's centre than its own —
|
|
8417
|
+
// applies only AT THE BORDER, where proximity is the only thing
|
|
8418
|
+
// saying which square a name belongs to. Out on the shaft the
|
|
8419
|
+
// name is associated by the LINE it rides (and, past the standoff
|
|
8420
|
+
// cap, by a drawn leader), and item 42's own-endpoint filter
|
|
8421
|
+
// already governs that ring. Applying it there was built and
|
|
8422
|
+
// measured: on `05-lacp`, three names whose squares sit 14.5 px
|
|
8423
|
+
// apart have their own shaft running through the middle of the
|
|
8424
|
+
// 7 px window the affinity test leaves them, so every clean
|
|
8425
|
+
// position was disqualified and the figure took the strikes
|
|
8426
|
+
// instead (lblcol 3 -> 5).
|
|
8427
|
+
if(c.sq){
|
|
8428
|
+
const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
|
|
8429
|
+
const d0=Math.hypot(ccx-(c.sq.x+c.sq.w/2), ccy-(c.sq.y+c.sq.h/2));
|
|
8430
|
+
const G=PORT_SQ_GAP;
|
|
8431
|
+
for(const b of sqBox){
|
|
8432
|
+
if(b.key===c.sqk) continue;
|
|
8433
|
+
if(c.box.x<b.x+b.w+G&&b.x-G<c.box.x+c.box.w&&
|
|
8434
|
+
c.box.y<b.y+b.h+G&&b.y-G<c.box.y+c.box.h){ owns=false; break; }
|
|
8435
|
+
if(c.atBorder&&
|
|
8436
|
+
Math.hypot(ccx-(b.x+b.w/2),ccy-(b.y+b.h/2))<d0-1e-9){ owns=false; break; }
|
|
8437
|
+
}
|
|
8438
|
+
}
|
|
7193
8439
|
let s=0;
|
|
7194
8440
|
for(const b of placed) s+=3*ovl(c.box,b);
|
|
8441
|
+
// A NAME WRITTEN ACROSS ITS OWN FITTING is the one arrangement the
|
|
8442
|
+
// notation cannot survive, and unlike a foreign square this one is
|
|
8443
|
+
// PRICED rather than forbidden: the offset family already starts the
|
|
8444
|
+
// name clear of its own square, so the only way back onto it is the
|
|
8445
|
+
// corner clamp on a device too small to hold the name anywhere else —
|
|
8446
|
+
// and on that device the least-bad answer still has to exist. Charged
|
|
8447
|
+
// at the `placed` weight, because a drawn square is drawn ink of the
|
|
8448
|
+
// same order, and measured on the LINE box, because a 7 px square and
|
|
8449
|
+
// a 12 px line box brushing at all is already too close to read as
|
|
8450
|
+
// two marks.
|
|
8451
|
+
if(c.sq) s+=3*ovl(c.box,c.sq);
|
|
8452
|
+
// THE LEADER'S OWN PRICE, AND THE BAND IT HAS TO SIT IN. A leader is
|
|
8453
|
+
// extra ink and a second thing to follow, so it must be dearer than
|
|
8454
|
+
// every adjacency that needs none — and it must be CHEAPER THAN A
|
|
8455
|
+
// STRIKETHROUGH, which costs 26 here, or the pass would keep choosing
|
|
8456
|
+
// a name lying across a line over the same name standing clear with
|
|
8457
|
+
// its association drawn. 15 is the middle of that band and it is the
|
|
8458
|
+
// whole of the tuning: below 26 by construction, above the 0-12 px
|
|
8459
|
+
// displacement prices that separate one clean position from another.
|
|
8460
|
+
if(lead) s+=15;
|
|
7195
8461
|
// A PORT MARKER IS SUPPOSED TO BE INSIDE ITS OWN BOX (item 57), so
|
|
7196
8462
|
// that one node is not charged for it. Every other box still is, at
|
|
7197
8463
|
// the usual weights — a marker that has slid out of its own device
|
|
@@ -7333,31 +8599,64 @@ function renderScene(doc,y0){
|
|
|
7333
8599
|
// the 16 px anchor and the affinity filter, both of item 42's.
|
|
7334
8600
|
if(r.kind!=='end'&&segBoxGap(c.car[0],c.car[1],c.box)>STANDOFF_CAP){
|
|
7335
8601
|
if(s<farS-1e-9){ farS=s; farBest=c; } continue; }
|
|
8602
|
+
// A PORT NAME OWES ITS SQUARE THE SAME STANDOFF, AND IT
|
|
8603
|
+
// PAYS FOR IT WITH A LEADER RATHER THAN WITH A FILTER. Item 59 makes
|
|
8604
|
+
// the cap a filter for mid-labels because their displacement is
|
|
8605
|
+
// unbounded — a mid-label can wander anywhere along a 900 px channel.
|
|
8606
|
+
// A port name's family is bounded by construction (the border rings
|
|
8607
|
+
// are at the border; the shaft ring is 16-24 px along its own edge),
|
|
8608
|
+
// and the tier prices above — PORT_HUG then PORT_OUT — already order
|
|
8609
|
+
// it. Making the cap a filter HERE was built and measured, and the
|
|
8610
|
+
// corpus refused it: on `05-lacp` it disqualified the whole shaft ring
|
|
8611
|
+
// for six names whose only near positions lie across their own shaft,
|
|
8612
|
+
// and the figure took six strikethroughs rather than six clean names
|
|
8613
|
+
// 20 px out (lblcol 5 -> 7). So the cap does not decide WHERE the name
|
|
8614
|
+
// goes; it decides whether the association is drawn, below.
|
|
7336
8615
|
if(s<anyS-1e-9){ anyS=s; anyBest=c; }
|
|
7337
8616
|
if(owns&&s<bestS-1e-9){ bestS=s; best=c; }
|
|
7338
8617
|
}
|
|
7339
8618
|
}
|
|
7340
8619
|
if(!best) best=anyBest||farBest||offBest; // no legal position — least bad
|
|
7341
8620
|
r0.win=best; // kept for item 55's recolour pass
|
|
7342
|
-
|
|
7343
|
-
//
|
|
7344
|
-
//
|
|
7345
|
-
//
|
|
7346
|
-
//
|
|
7347
|
-
//
|
|
7348
|
-
//
|
|
7349
|
-
|
|
8621
|
+
// A PORT NAME CARRIES ITS SQUARE'S KEY. The name and the
|
|
8622
|
+
// fitting it names are one statement drawn as two marks, and nothing in
|
|
8623
|
+
// the finished SVG said they were a pair — a reader (and the layout
|
|
8624
|
+
// gate, which now scores a foreign name written across a fitting) had
|
|
8625
|
+
// only proximity to go on, which is the very thing the notation exists
|
|
8626
|
+
// to stop relying on. The key is the square's snapped centre, so the two
|
|
8627
|
+
// elements carry the same string and the association is READ.
|
|
8628
|
+
lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,
|
|
8629
|
+
r0.halo+(best.sqk?' data-port-sq="'+best.sqk+'"':''));
|
|
8630
|
+
// THE LEADER (item 59), AND ITS SECOND CALLER. A label that
|
|
8631
|
+
// had to be placed past the cap states its referent explicitly: a
|
|
8632
|
+
// hairline from the nearest point of its box to that referent, in the
|
|
8633
|
+
// label's own colour. It is drawn UNDER nothing and over nothing — it is
|
|
8634
|
+
// 1 px, it starts at the box and it stops at the thing — so it adds no
|
|
8635
|
+
// ink anywhere the reader is not already looking for the association.
|
|
8636
|
+
//
|
|
8637
|
+
// TWO REFERENTS, ONE LEADER. A mid-label's is the nearest point of its
|
|
8638
|
+
// own carrying segment; a port name's is the CENTRE OF ITS OWN SQUARE,
|
|
8639
|
+
// because that is the mark it names. The drawing is otherwise identical
|
|
8640
|
+
// and is therefore drawn by one closure rather than copied — a second
|
|
8641
|
+
// leader mechanism is a second set of rules about when a leader appears,
|
|
8642
|
+
// and this figure only ever needs one.
|
|
8643
|
+
const leadTo=(fx,fy)=>{
|
|
7350
8644
|
const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
|
|
7351
|
-
|
|
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
|
|
8645
|
+
// the box's own boundary point on the ray toward the referent
|
|
7356
8646
|
const dx=fx-cx, dy=fy-cy, ax=Math.abs(dx)||1e-9, ay=Math.abs(dy)||1e-9;
|
|
7357
8647
|
const k=Math.min((b.w/2+2)/ax,(b.h/2+2)/ay);
|
|
7358
8648
|
r0.leadIdx=lblsvg.length;
|
|
7359
8649
|
lblsvg.push('<line x1="'+(cx+dx*k)+'" y1="'+(cy+dy*k)+'" x2="'+fx+'" y2="'+fy+
|
|
7360
8650
|
'" stroke="'+r0.col+'" stroke-width="1" opacity="0.6"/>');
|
|
8651
|
+
};
|
|
8652
|
+
if(r0.kind!=='end'&&best.car&&segBoxGap(best.car[0],best.car[1],best.box)>STANDOFF_CAP){
|
|
8653
|
+
const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
|
|
8654
|
+
const P0=best.car[0], Q0=best.car[1];
|
|
8655
|
+
const vx=Q0[0]-P0[0], vy=Q0[1]-P0[1], L2=vx*vx+vy*vy;
|
|
8656
|
+
const t=L2?Math.max(0,Math.min(1,((cx-P0[0])*vx+(cy-P0[1])*vy)/L2)):0;
|
|
8657
|
+
leadTo(P0[0]+t*vx, P0[1]+t*vy);
|
|
8658
|
+
} else if(best.sq&&boxSep(best.box,best.sq)>STANDOFF_CAP){
|
|
8659
|
+
leadTo(best.sq.x+best.sq.w/2, best.sq.y+best.sq.h/2);
|
|
7361
8660
|
}
|
|
7362
8661
|
placed.push(Object.assign({text:r0.text},best.box));
|
|
7363
8662
|
W=Math.max(W, best.box.x+best.box.w+4);
|
|
@@ -7874,7 +9173,7 @@ function renderScene(doc,y0){
|
|
|
7874
9173
|
used[pick]++;
|
|
7875
9174
|
}
|
|
7876
9175
|
if(tone.size>1) for(const [e2,col2] of tone){
|
|
7877
|
-
const key='data-edge="'+e2
|
|
9176
|
+
const key='data-edge="'+edgeRef(e2)+'"';
|
|
7878
9177
|
for(let i=0;i<esvg.length;i++)
|
|
7879
9178
|
if(esvg[i].indexOf(key)>=0) esvg[i]=esvg[i].replace('stroke="#555"','stroke="'+col2+'"');
|
|
7880
9179
|
for(const i of (arrowIdx.get(e2)||[]))
|
|
@@ -7915,7 +9214,8 @@ function renderScene(doc,y0){
|
|
|
7915
9214
|
} else lblsvg.push(textEl(cx,cy-10,10,'middle',bcol,n.label,bhalo));
|
|
7916
9215
|
continue;
|
|
7917
9216
|
}
|
|
7918
|
-
nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'"
|
|
9217
|
+
nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'"'
|
|
9218
|
+
+(lassoMem.has(n.id)?' data-lasso="'+lassoMem.get(n.id).join(' ')+'"':'')+' style="cursor:move">');
|
|
7919
9219
|
const fill=n.fill||'#fff', stroke=n.stroke||'#8a8880', txt=labelInk(fill,'#1d1d1b');
|
|
7920
9220
|
const ndash=n.style==='dashed'?' stroke-dasharray="6 4"':(n.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
7921
9221
|
if(n.shape==='diamond'){
|
|
@@ -7977,7 +9277,7 @@ function renderScene(doc,y0){
|
|
|
7977
9277
|
// ONE MEMBER = NO ELLIPSE (item 53). The caption below is the whole
|
|
7978
9278
|
// drawing, and an unlabelled one-member bundle draws nothing.
|
|
7979
9279
|
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+'/>');
|
|
9280
|
+
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
9281
|
W=Math.max(W,R.cx+R.hw); Hh=Math.max(Hh,R.cy+R.hh-y0-20);
|
|
7982
9282
|
}
|
|
7983
9283
|
const L=ringLbl.get(t);
|
|
@@ -8087,7 +9387,7 @@ function renderScene(doc,y0){
|
|
|
8087
9387
|
' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
8088
9388
|
}
|
|
8089
9389
|
const yEnd=y0+20+Hh+10;
|
|
8090
|
-
return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
|
|
9390
|
+
return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+sqsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
|
|
8091
9391
|
meta:{W:W, top:y0+20+chShift+tShift, Hh:Hh, left:bShift+lShift}, errs:gErrs};
|
|
8092
9392
|
}
|
|
8093
9393
|
// borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
|
|
@@ -8639,6 +9939,26 @@ function renderSequence(doc,y0){
|
|
|
8639
9939
|
// would put a 17-digit tail in the artifact for no reader's benefit.
|
|
8640
9940
|
function r2(v){ return Math.round(v*100)/100; }
|
|
8641
9941
|
|
|
9942
|
+
// `TYPED-BLOCK-TITLE-CANVAS` (backlog 66): A TYPED BLOCK'S TITLE JOINS ITS OWN CANVAS.
|
|
9943
|
+
//
|
|
9944
|
+
// Each of the four typed-block renderers (bitfield/table/timing/chart) sizes
|
|
9945
|
+
// its section from its DATA — cells, ruler, lanes, floor — and draws its own
|
|
9946
|
+
// caption over that canvas afterwards, at x=0, without ever measuring the
|
|
9947
|
+
// caption into the width it returns. A caption wider than the data therefore
|
|
9948
|
+
// ran past the right edge of the section's own `<svg>` box, and a section
|
|
9949
|
+
// canvas grows right and down only, so text at a negative coordinate is
|
|
9950
|
+
// CLIPPED, never merely misplaced (`LABEL-PLACEMENT-METRIC`, which fixed exactly this
|
|
9951
|
+
// for a `table` caption and a `chart` row/column label gutter but not for a
|
|
9952
|
+
// bitfield/timing title or a chart's own top caption — reported from
|
|
9953
|
+
// downstream production authoring).
|
|
9954
|
+
//
|
|
9955
|
+
// ONE MEASUREMENT, used by all four renderers below, so the calibration lives
|
|
9956
|
+
// in one place rather than four. Bold text at this size is measured ~8%
|
|
9957
|
+
// wider than the plain per-character estimate `cwMax(...)*CH` gives at
|
|
9958
|
+
// regular weight — verified against the raster, not assumed, the same
|
|
9959
|
+
// allowance `renderTable`'s caption fix already established.
|
|
9960
|
+
function typedBlockTitleW(label){ return cwMax(label)*CH*1.08+2; }
|
|
9961
|
+
|
|
8642
9962
|
// ---- bitfield ----
|
|
8643
9963
|
function renderBitfield(b,y0){
|
|
8644
9964
|
const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
|
|
@@ -9069,7 +10389,12 @@ function renderBitfield(b,y0){
|
|
|
9069
10389
|
}
|
|
9070
10390
|
yb+=2;
|
|
9071
10391
|
}
|
|
9072
|
-
|
|
10392
|
+
// THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE CAPTION IS INK (`TYPED-BLOCK-TITLE-CANVAS`).
|
|
10393
|
+
// `wb` is the DATA extent alone (cells, ruler, `present=` captions) — the
|
|
10394
|
+
// BOX below stays measured against it, because a region-scope
|
|
10395
|
+
// `threshold`/`band` is a statement about the data, not about the title
|
|
10396
|
+
// that names it. Only the returned canvas width grows for the caption.
|
|
10397
|
+
return {svg:svg.join(''), y:yb, w:Math.max(wb, typedBlockTitleW(b.label)),
|
|
9073
10398
|
box:{x0:0, x1:wb, yA:y0+18, yB:yb}};
|
|
9074
10399
|
}
|
|
9075
10400
|
|
|
@@ -9132,9 +10457,14 @@ function renderTable(t,y0){
|
|
|
9132
10457
|
const mk=markOf(r,c);
|
|
9133
10458
|
// block fill= is the default DATA-cell fill (header tint is structural)
|
|
9134
10459
|
const fill=(mk&&mk.fill)||(cell.hdr?'#eeede6':(hlRow(r)?'#fef3c7':(t.fill||'#fff')));
|
|
9135
|
-
// addressable cells carry table-id:row:col
|
|
9136
|
-
|
|
9137
|
-
|
|
10460
|
+
// addressable cells carry table-id:row:col for EVERY tier (backlog 71):
|
|
10461
|
+
// data rows are 1..n, the bottom header tier keeps its historical `0`
|
|
10462
|
+
// spelling so existing consumers of that channel see no change, and
|
|
10463
|
+
// every tier above it is `hN` (1-indexed from the top) — the same
|
|
10464
|
+
// spelling the editor's own internal row-token model already used, so
|
|
10465
|
+
// the editor can read this channel with no translation table.
|
|
10466
|
+
const addrR = r>=H ? String(r-H+1) : (r===H-1 ? '0' : 'h'+(r+1));
|
|
10467
|
+
const addr = ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
|
|
9138
10468
|
// A merged cell owns every grid square it spans, so its internal
|
|
9139
10469
|
// boundaries have the same owner on both sides and are never drawn.
|
|
9140
10470
|
const rec={id:'c'+r+'_'+c, c:(mk&&mk.stroke)||null, d:false};
|
|
@@ -9186,9 +10516,10 @@ function renderTable(t,y0){
|
|
|
9186
10516
|
// grid is not the figure; the caption is not decoration.
|
|
9187
10517
|
// Bold at 13 px is wider than `CH` (a regular-weight advance), so the caption
|
|
9188
10518
|
// is measured with the same 8% allowance the raster needed — verified by
|
|
9189
|
-
// rendering, not assumed.
|
|
9190
|
-
|
|
9191
|
-
|
|
10519
|
+
// rendering, not assumed. `typedBlockTitleW` (`TYPED-BLOCK-TITLE-CANVAS`) is that
|
|
10520
|
+
// same measurement, now shared by all four typed-block renderers rather
|
|
10521
|
+
// than kept as this one's own private copy.
|
|
10522
|
+
return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,typedBlockTitleW(t.label)),
|
|
9192
10523
|
box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
|
|
9193
10524
|
}
|
|
9194
10525
|
|
|
@@ -9347,7 +10678,14 @@ function renderChart(b,y0,doc){
|
|
|
9347
10678
|
svg.push(textEl(zr[0]+21, zr[1]-z*ZS+3.5, 9.5, 'start', '#6f6e69', String(z)));
|
|
9348
10679
|
}
|
|
9349
10680
|
const near=[PR(R,C,0)[0]+ox, PR(R,C,0)[1]];
|
|
9350
|
-
|
|
10681
|
+
// THE TOP CAPTION IS THE SECTION'S OWN INK TOO (`TYPED-BLOCK-TITLE-CANVAS`). The
|
|
10682
|
+
// row/column axis-label gutter (`ox`, above) was widened for exactly this
|
|
10683
|
+
// reason, but that pass measured the LEANED-OUT axis labels
|
|
10684
|
+
// and never the caption drawn at the section's fixed x=0 top-left corner —
|
|
10685
|
+
// a `chart` whose table name is long enough still ran the caption off the
|
|
10686
|
+
// right edge under its own data floor. The caption is never shifted (it is
|
|
10687
|
+
// already the section's leftmost, topmost ink); only the canvas grows.
|
|
10688
|
+
const w=Math.max(near[0]+70, farRight+ox+4, typedBlockTitleW(t.label+' — bar3d'));
|
|
9351
10689
|
const hgt=Math.max(near[1]+24, farDown+6)-y0;
|
|
9352
10690
|
return {svg:svg.join('')+lsvg.join(''), y:y0+hgt, w:w};
|
|
9353
10691
|
}
|
|
@@ -9404,12 +10742,18 @@ function renderTiming(w,y0){
|
|
|
9404
10742
|
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
10743
|
}
|
|
9406
10744
|
const H=y+8+w.signals.length*(laneH+laneGap);
|
|
9407
|
-
|
|
10745
|
+
// THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE TITLE IS INK (`TYPED-BLOCK-TITLE-CANVAS`,
|
|
10746
|
+
// 0.5). `nameW+cycles*cycleW+2` is the DATA extent alone (the
|
|
10747
|
+
// signal-name gutter plus the lane grid); the BOX below stays measured
|
|
10748
|
+
// against it, unchanged, for the same reason a table's region box stays
|
|
10749
|
+
// measured against the grid and not the caption.
|
|
10750
|
+
return {svg:svg.join(''), y:H, w:Math.max(nameW+cycles*cycleW+2, typedBlockTitleW(w.label)),
|
|
9408
10751
|
box:{x0:nameW, x1:nameW+cycles*cycleW, yA:y0+18, yB:H}};
|
|
9409
10752
|
}
|
|
9410
10753
|
|
|
9411
10754
|
// ============================================================
|
|
9412
|
-
return { parse: parse, render: render, stackSectionSvgs: stackSectionSvgs
|
|
10755
|
+
return { parse: parse, render: render, stackSectionSvgs: stackSectionSvgs,
|
|
10756
|
+
a11yApply: a11yApply, renderOptionsAttr: renderOptionsAttr };
|
|
9413
10757
|
})();
|
|
9414
10758
|
|
|
9415
10759
|
// ---- minimal synchronous SHA-256 (FIPS 180-4), hex output ----
|
|
@@ -9480,32 +10824,62 @@ function __stackSectionSvgs(results) {
|
|
|
9480
10824
|
// (determinism over convenience: no partial renders of invalid input).
|
|
9481
10825
|
// opts (presentation, renderer tier): { title: true } draws the title;
|
|
9482
10826
|
// the default does NOT (embedded figures almost always sit under the
|
|
9483
|
-
// host document's caption — the majority case).
|
|
10827
|
+
// host document's caption — the majority case). { a11y: true } adds the
|
|
10828
|
+
// accessibility profile's emission (spec/figdown-a11y.md; ACCESSIBILITY-PROFILE):
|
|
10829
|
+
// role="graphics-document" on the root, the non-visual <title> as its first
|
|
10830
|
+
// child, and a state-flagged derived <desc>. The two are ORTHOGONAL — one
|
|
10831
|
+
// decides ink, the other the accessible name — and both default to off.
|
|
9484
10832
|
// Multi-section sources are stacked vertically into a single SVG (MULTI-FIGURE-DOCUMENTS).
|
|
10833
|
+
//
|
|
10834
|
+
// TWO ERROR CHANNELS REACH ONE errors ARRAY. parse cannot see a
|
|
10835
|
+
// coordinate, so a document whose SOURCE is impeccable can still draw a false
|
|
10836
|
+
// statement — a group band enclosing a non-member, a pin covering a node
|
|
10837
|
+
// completely. The engine reports those from render (as .errs on its render
|
|
10838
|
+
// result), and core §8 requires a caller to treat a non-empty render
|
|
10839
|
+
// diagnostic list EXACTLY as it treats a parse error list. Until 0.4
|
|
10840
|
+
// this wrapper discarded that channel and returned errors: [] with an SVG of
|
|
10841
|
+
// the picture the engine had just said was wrong — the one copy of the engine
|
|
10842
|
+
// a require('figdown') user actually gets. Both channels now land here, and
|
|
10843
|
+
// either withholds the SVG.
|
|
9485
10844
|
function render(text, opts) {
|
|
9486
10845
|
var p = parse(text);
|
|
9487
10846
|
if (p.errors.length) return { svg: null, errors: p.errors };
|
|
9488
|
-
if (p.docs.length
|
|
9489
|
-
|
|
9490
|
-
|
|
9491
|
-
|
|
9492
|
-
return { svg:
|
|
10847
|
+
if (!p.docs.length) return { svg: null, errors: [] };
|
|
10848
|
+
var rs = p.docs.map(function (d) { return __engine.render(d, opts); });
|
|
10849
|
+
var errs = [];
|
|
10850
|
+
for (var i = 0; i < rs.length; i++) errs = errs.concat(rs[i].errs || []);
|
|
10851
|
+
if (errs.length) return { svg: null, errors: errs };
|
|
10852
|
+
var svg = rs.length > 1 ? __engine.stackSectionSvgs(rs) : rs[0].svg;
|
|
10853
|
+
return { svg: __a11y(svg, p.docs[0], opts), errors: [] };
|
|
10854
|
+
}
|
|
10855
|
+
// The accessibility profile's emission (spec/figdown-a11y.md; ACCESSIBILITY-PROFILE).
|
|
10856
|
+
// Applied to the FINISHED root, never inside a per-section render: ACCESSIBILITY-PROFILE gives
|
|
10857
|
+
// an artifact ONE root, ONE name, and the name is the FIRST section's title.
|
|
10858
|
+
// A caller that does not ask gets the byte-identical default (core §7 / RENDERING-DETERMINISM).
|
|
10859
|
+
function __a11y(svg, doc, opts) {
|
|
10860
|
+
if (!svg || !(opts && opts.a11y === true)) return svg;
|
|
10861
|
+
if (typeof __engine.a11yApply !== 'function') return svg;
|
|
10862
|
+
return __engine.a11yApply(svg, doc);
|
|
9493
10863
|
}
|
|
9494
10864
|
// renderDoc(doc, opts) -> svg string, for an already-validated doc from parse().
|
|
9495
10865
|
// For multi-section, pass parse().docs to renderDocs instead.
|
|
9496
10866
|
function renderDoc(doc, opts) {
|
|
9497
|
-
return __engine.render(doc, opts).svg;
|
|
10867
|
+
return __a11y(__engine.render(doc, opts).svg, doc, opts);
|
|
9498
10868
|
}
|
|
9499
10869
|
function renderDocs(docs, opts) {
|
|
9500
10870
|
if (!docs || !docs.length) return '';
|
|
9501
|
-
if (docs.length === 1) return __engine.render(docs[0], opts).svg;
|
|
9502
|
-
return __engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); }))
|
|
10871
|
+
if (docs.length === 1) return __a11y(__engine.render(docs[0], opts).svg, docs[0], opts);
|
|
10872
|
+
return __a11y(__engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); })),
|
|
10873
|
+
docs[0], opts);
|
|
9503
10874
|
}
|
|
9504
10875
|
// artifact(text) -> { svg, errors } svg is the full self-carrying SVG:
|
|
9505
|
-
// the render plus a <metadata id="figdown-source"> block
|
|
9506
|
-
// source text, the SHA-256 OF THAT SOURCE, and the engine
|
|
9507
|
-
// rendered it (same convention as tools/build-svg.js; spec
|
|
9508
|
-
// svg is null when there are errors
|
|
10876
|
+
// the render plus a <metadata id="figdown-source"> block
|
|
10877
|
+
// embedding the source text, the SHA-256 OF THAT SOURCE, and the engine
|
|
10878
|
+
// version that rendered it (same convention as tools/build-svg.js; spec §7).
|
|
10879
|
+
// svg is null when there are errors — parse-time OR geometry-time, on
|
|
10880
|
+
// tools/build-svg.js's contract (core §8): a non-empty render diagnostic list
|
|
10881
|
+
// refuses the artifact exactly as a parse error does, because writing it
|
|
10882
|
+
// anyway publishes the picture the engine has just said is wrong.
|
|
9509
10883
|
function artifact(text, opts) {
|
|
9510
10884
|
var src = String(text);
|
|
9511
10885
|
var p = render(src, opts);
|
|
@@ -9513,7 +10887,7 @@ function artifact(text, opts) {
|
|
|
9513
10887
|
// The artifact records the SHA-256 OF THE SOURCE, the ENGINE VERSION that
|
|
9514
10888
|
// rendered it, and any non-default render option (core §7) — together they
|
|
9515
10889
|
// keep third-party rebuilds bit-identical and give a diff somewhere to point
|
|
9516
|
-
var optAttr = (opts
|
|
10890
|
+
var optAttr = __engine.renderOptionsAttr(opts);
|
|
9517
10891
|
var meta = '<metadata id="figdown-source" data-sha256="' + __sha256hex(src) + '"'
|
|
9518
10892
|
+ ' data-engine-version="' + VERSION + '"' + optAttr + '><![CDATA[\n'
|
|
9519
10893
|
+ src.replace(/]]>/g, ']]]]><![CDATA[>') + '\n]]></metadata>';
|