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