figdown 0.3.2 → 0.4.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/README.md +2 -0
- package/dist/figdown.js +1301 -54
- package/dist/figdown.mjs +1301 -54
- package/examples/evpn-fabric.svg +1 -1
- package/examples/showcase/arp-resolution.svg +3 -2
- package/examples/showcase/ethernet-frame.svg +1 -1
- package/examples/showcase/l2-forwarding-logic.svg +1 -1
- package/examples/showcase/tcp-handshake.svg +4 -3
- package/examples/showcase/tcp-header.svg +1 -1
- package/examples/showcase/tcp-state-machine.svg +1 -1
- package/guide/expressing.md +33 -6
- package/guide/layout.md +54 -10
- package/guide/showcase.md +51 -15
- package/integrations/mcp-server/README.md +11 -5
- package/integrations/mcp-server/server.js +19 -3
- package/package.json +2 -1
- package/skill/figdown/SKILL.md +20 -9
- package/skill/figdown/build-svg.js +16 -2
- package/skill/figdown/figdown.html +1491 -69
- package/skill/figdown/reference/experimental/flowchart.md +6 -4
- package/skill/figdown/reference/experimental/sequence.md +252 -0
- package/skill/figdown/reference/experimental/statechart.md +5 -3
- package/skill/figdown/reference/experimental/topology.md +6 -4
- package/skill/figdown/reference/reading.md +14 -9
- package/skill/figdown/reference/scene.md +8 -3
|
@@ -121,7 +121,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
|
|
|
121
121
|
// input to that promise, and under core §13 a 0.x renderer may differ from
|
|
122
122
|
// the next — which makes the recorded version the only thing that can
|
|
123
123
|
// explain a diff between two renderings of one source.
|
|
124
|
-
const FIGDOWN_VERSION = '0.
|
|
124
|
+
const FIGDOWN_VERSION = '0.4.0';
|
|
125
125
|
// `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
|
|
126
126
|
// counter does NOT reset (core §13.0.4 — `N` counts source states of the
|
|
127
127
|
// engine and only ever increases), so 0.1 is followed by
|
|
@@ -136,14 +136,34 @@ const FIGDOWN_VERSION = '0.3.2';
|
|
|
136
136
|
// fixes only. No new features. The language does not move." Shipping `note=`
|
|
137
137
|
// under `v0.2.z` would make `figdown 0.2` name two different languages: the one
|
|
138
138
|
// `v0.2.0` published and the one with `note=`. So the language number moves.
|
|
139
|
-
|
|
139
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `figdown 0.4` joins the set, and it joins it for the
|
|
140
|
+
// same reason `0.2` did — a GENRE token is language surface, and core §13.0
|
|
141
|
+
// makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
|
|
142
|
+
// no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
|
|
143
|
+
// the dispatch point lands first and the vocabulary follows it.
|
|
144
|
+
const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4'];
|
|
140
145
|
// Genres per declared language version. `Y` never removes (core §13.0), so
|
|
141
146
|
// each row is a superset of the one above it, and `figdown 0.1 <anything>`
|
|
142
147
|
// resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
|
|
143
148
|
const GENRES_BY_VERSION = {
|
|
144
149
|
'0.1': ['block','topology','flowchart','bitfield','table','timing'],
|
|
145
150
|
'0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
|
|
146
|
-
'0.3': ['block','topology','flowchart','bitfield','table','timing','statechart']
|
|
151
|
+
'0.3': ['block','topology','flowchart','bitfield','table','timing','statechart'],
|
|
152
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` is dispatchable from here on. Like
|
|
153
|
+
// `statechart` at `STATECHART-GENRE-SCOPE` it arrived with NO vocabulary of its own, and the
|
|
154
|
+
// consequence was stated here rather than left to be discovered: there was no
|
|
155
|
+
// `GENRE_KW.sequence` row, and the allowlist guard is written
|
|
156
|
+
// `GENRE_KW[doc.genre] && !GENRE_KW[doc.genre].has(kw)`, so a genre with no
|
|
157
|
+
// row is NOT narrowed — a `figdown 0.4 sequence` document could write any
|
|
158
|
+
// registered keyword and it parsed. The document that increment meant to
|
|
159
|
+
// land was the header ALONE.
|
|
160
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: CLOSED. `GENRE_KW.sequence` exists below, so the
|
|
161
|
+
// genre now constrains what it names — five keywords of its own, `class`,
|
|
162
|
+
// and the genre-free core — and `flow`/`rank`/`group` are line errors under
|
|
163
|
+
// it. The genre still has NO RENDERER: a valid `sequence` document parses to
|
|
164
|
+
// a model and draws an empty canvas, which is the state this increment means
|
|
165
|
+
// to land and is pinned by a fixture rather than left to be noticed.
|
|
166
|
+
'0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
|
|
147
167
|
};
|
|
148
168
|
// The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
|
|
149
169
|
// device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
|
|
@@ -451,6 +471,26 @@ const DIRECTIVE_OPTS={
|
|
|
451
471
|
// keyed by the surface word an author actually wrote.
|
|
452
472
|
flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
|
|
453
473
|
transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
|
|
474
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own rows. `message` is
|
|
475
|
+
// the fourth connector spelling and takes the connector set — `fill=` and
|
|
476
|
+
// the three retired label keys stay listed for the same reason they are
|
|
477
|
+
// listed on the other three, so their dedicated diagnostics fire instead of
|
|
478
|
+
// a bare `does not take` — plus `in=` (sense 1: the fragment or operand this
|
|
479
|
+
// message occurs inside) and `description=`. It does NOT gain a key of its
|
|
480
|
+
// own: `lost=` was proposed and refused (`UNDELIVERED-MESSAGE-MARKING`), and `OPT_KEYS` is unchanged
|
|
481
|
+
// by this whole increment.
|
|
482
|
+
message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description'],
|
|
483
|
+
// A lifeline is drawn as a head box over a dashed line, so it has an
|
|
484
|
+
// interior and takes `fill=`. `in=` is sense 1.
|
|
485
|
+
lifeline:['class','fill','stroke','style','in','note','description'],
|
|
486
|
+
// `type=` is MANDATORY on `fragment` and is checked in `parseSeqDirective`,
|
|
487
|
+
// not here: a missing key is not an inapplicable key. No `fill=` — a
|
|
488
|
+
// combined fragment is a FRAME drawn over the messages it contains, and
|
|
489
|
+
// painting its interior would hide them.
|
|
490
|
+
fragment:['type','class','stroke','style','in','note','description'],
|
|
491
|
+
// `in=` is MANDATORY on `operand` (an operand is a compartment OF a
|
|
492
|
+
// fragment) and is likewise checked in `parseSeqDirective`.
|
|
493
|
+
operand:['in','class','stroke','style','note','description'],
|
|
454
494
|
// `PAINT-ORDER-CONSTRUCT`: the `plane` row is GONE, not emptied — the keyword is
|
|
455
495
|
// withdrawn from the language, so it has no acceptor row at all, the shape
|
|
456
496
|
// `path`/`routing` left behind. `z-index=` goes with it: it
|
|
@@ -497,6 +537,31 @@ const DIRECTIVE_OPTS={
|
|
|
497
537
|
cell:['fill','stroke','class'], width:[],
|
|
498
538
|
signal:['data','fill','stroke'], gap:[]
|
|
499
539
|
};
|
|
540
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the FIRST genre-conditional option row, and it
|
|
541
|
+
// exists because `DIRECTIVE_OPTS` is keyed by the SURFACE WORD an author
|
|
542
|
+
// wrote, while `GENRE-VOCABULARY-OBLIGATION` makes a surface word a per-genre declaration. Every other
|
|
543
|
+
// shared spelling in the language names the same construct in every genre
|
|
544
|
+
// that has it — `state` under `statechart` IS `node` renamed (`GENRE-NODE-SPELLING`), so it
|
|
545
|
+
// takes `node`'s row exactly — but `state` under `sequence` is a DIFFERENT
|
|
546
|
+
// construct under the same spelling: a StateInvariant (UML 2.5.1 §17.12.25)
|
|
547
|
+
// that REFERENCES a lifeline rather than declaring an id. It has no shape and
|
|
548
|
+
// no extent of its own, so `shape=`, `width=` and `height=` name nothing on
|
|
549
|
+
// it; it can sit inside a fragment, so it takes `in=` (`SEQUENCE-CONTAINMENT-SCOPE`); and it takes
|
|
550
|
+
// `description=` like the rest of this genre's directives.
|
|
551
|
+
//
|
|
552
|
+
// The lookup is one table indexed genre-first, so a genre with no entry falls
|
|
553
|
+
// through to `DIRECTIVE_OPTS` untouched and the whole 0.1/0.2/0.3 surface is
|
|
554
|
+
// byte-identical. It is NOT a second registry: every key named here is
|
|
555
|
+
// already an `OPT_KEYS` member accepted by some directive, so nothing about
|
|
556
|
+
// the closed option namespace changes.
|
|
557
|
+
const GENRE_DIRECTIVE_OPTS={
|
|
558
|
+
sequence:{
|
|
559
|
+
state:['class','fill','stroke','style','in','note','description']
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
const directiveOpts=(kw,genre)=>
|
|
563
|
+
(genre && GENRE_DIRECTIVE_OPTS[genre] && GENRE_DIRECTIVE_OPTS[genre][kw])
|
|
564
|
+
|| DIRECTIVE_OPTS[kw];
|
|
500
565
|
const STYLES=['solid','dashed','dotted'];
|
|
501
566
|
// `RULE-POSITION-ENUMERATION`: every LIVE option key whose value grammar is an enum,
|
|
502
567
|
// read off spec/vocabulary-sources.tsv (`shape` column = `enum`, `status`
|
|
@@ -595,7 +660,7 @@ const RETIRED_OPT_KEYS={
|
|
|
595
660
|
// appears.
|
|
596
661
|
'z-index':'z-index= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): it was legal on `plane` and on nothing else, so it left with its only acceptor. There is no replacement spelling and no other directive to move it to. Delete the key: paint order is document order, and a later line paints on top (MIGRATIONS 0.3)',
|
|
597
662
|
z:'z= has been WITHDRAWN: it was renamed z-index=, and z-index= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`) — it was legal on `plane` and on nothing else. There is no replacement spelling. Delete the key: paint order is document order, a later line paints on top (MIGRATIONS 0.3)',
|
|
598
|
-
// `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until
|
|
663
|
+
// `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until 0.3 (`DRAWN-ANNOTATION-FORM`), and its
|
|
599
664
|
// row is gone because the key is LIVE again — SYNTAX-STYLE RULE 4.9
|
|
600
665
|
// obligation 3 forbids leaving the retirement message standing past the
|
|
601
666
|
// revival, on the ground that a message telling an author to write
|
|
@@ -663,7 +728,7 @@ const RETIRED_LAYER='layer has been WITHDRAWN: it was renamed plane, and plane w
|
|
|
663
728
|
// `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
|
|
664
729
|
const RETIRED_GUIDE='guide has been renamed: use threshold (in Illustrator, Inkscape, Figma and draw.io a "guide" is an author-only construction line that is NEVER rendered, while FigDown\'s is drawn output — an INVERTED name, which `UNSAFE-DEFAULT-ELIMINATION` rates worse than an unfamiliar one, and no counter-example was found where "guide" names rendered output. `guide` was also a FigDown coinage, and `SIZE-AND-DIRECTION-KEY-NAMING` makes coining a last resort; `threshold` comes whole from Grafana, whose "Show thresholds" render option offers "As lines", "As filled regions" and "As filled regions and lines" — FigDown\'s marker + region pair, split the same way — with IETF RED/AQM as the secondary source (RFC 2309: "Two RED parameters, minth (minimum threshold) and maxth (maximum threshold)"; RFC 7567: "an AQM algorithm configured with a threshold"). 78% of the measured corpus marks are thresholds; target/mean/reference marks: 0) (MIGRATIONS 0.1)';
|
|
665
730
|
// `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
|
|
666
|
-
const RETIRED_BOUNDARY='boundary has been renamed: use external (it declares an external I/O endpoint — the spec\'s own words — while
|
|
731
|
+
const RETIRED_BOUNDARY='boundary has been renamed: use external (it declares an external I/O endpoint — the spec\'s own words — while the Entity-Control-Boundary analysis pattern\'s «boundary» is an INTERNAL interface object, C4\'s System_Boundary is a dashed grouping container FigDown already spells `group`, and BPMN\'s Boundary Event is a third meaning) (MIGRATIONS 0.1)';
|
|
667
732
|
// `ROW-BREAK-NAMING`: the `bitfield` child keyword `wrap` became `break`.
|
|
668
733
|
const RETIRED_WRAP='wrap has been renamed: use break (in CSS and typography `wrap` is AUTOMATIC reflow — a mode — while this directive is an EXPLICIT row break, an event; CSS Fragmentation calls it "a forced break … explicitly indicated by the … author", HTML spells it `br`) (MIGRATIONS 0.1)';
|
|
669
734
|
// `PRESENCE-FLAG-SPELLING`: the 0.1 rename `optional` -> `conditional` (`PRESENCE-FLAG-SPELLING`)
|
|
@@ -1055,6 +1120,25 @@ const FLOWCHART_SUBJECT_KW=['external'];
|
|
|
1055
1120
|
// none of the six; `external` is additionally UML 2.5.1 §14's own
|
|
1056
1121
|
// `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
|
|
1057
1122
|
const STATECHART_SUBJECT_KW=[];
|
|
1123
|
+
// `sequence` (EXPERIMENTAL, 0.4, `SEQUENCE-SOURCE-STANDARD`-R182): THREE, and the array is
|
|
1124
|
+
// this genre's whole declaration of what a sequence figure is OF. `state` and
|
|
1125
|
+
// `fragment` and `operand` describe referents UML clause 17 defines —
|
|
1126
|
+
// `StateInvariant` (§17.12.25), `CombinedFragment` (§17.12.3) and
|
|
1127
|
+
// `InteractionOperand` (§17.12.14) — so they are subject vocabulary in exactly
|
|
1128
|
+
// `SUBJECT-VOCABULARY-SCOPE`'s sense, while `lifeline` and `message` are this genre's NODE and
|
|
1129
|
+
// CONNECTOR spellings and live in `GENRE_NODE_KW`/`GENRE_CONNECTOR_KW` with
|
|
1130
|
+
// the other genres' (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`). None of the six words the four scene genres
|
|
1131
|
+
// declare is here: `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, below), `external` `threshold`
|
|
1132
|
+
// `band` `bundle` have no measured need and no clause-17 referent, and
|
|
1133
|
+
// `flow`/`rank` are refused as a CONSEQUENCE of the genre's two axes being
|
|
1134
|
+
// declaration-ordered (draft §7) — they are simply absent from `GENRE_KW`.
|
|
1135
|
+
// `state` is SHARED with `statechart` as a spelling and is a SEPARATE
|
|
1136
|
+
// declaration with a different grammar: there slot 1 declares an id, here it
|
|
1137
|
+
// REFERENCES a lifeline (draft §29 Q5). Two genres agreeing on a spelling is
|
|
1138
|
+
// two declarations that agree, never one inherited — which is the whole of
|
|
1139
|
+
// `SUBJECT-VOCABULARY-SCOPE`, and is why this genre's `state` also takes its own option row
|
|
1140
|
+
// (GENRE_DIRECTIVE_OPTS).
|
|
1141
|
+
const SEQUENCE_SUBJECT_KW=['state','fragment','operand'];
|
|
1058
1142
|
// `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
|
|
1059
1143
|
// `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
|
|
1060
1144
|
// `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
|
|
@@ -1090,10 +1174,50 @@ const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
|
|
|
1090
1174
|
// symbol this genre cannot spell is a COVERAGE GAP in FigDown, not a state
|
|
1091
1175
|
// of the figure, and `node` is not its spelling — see
|
|
1092
1176
|
// the project’s working record for the coverage ledger.
|
|
1093
|
-
|
|
1094
|
-
const
|
|
1095
|
-
const
|
|
1096
|
-
const
|
|
1177
|
+
// sequence lifeline message (OMG UML 2.5.1 §17)
|
|
1178
|
+
const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'state',sequence:'lifeline'};
|
|
1179
|
+
const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition',sequence:'message'};
|
|
1180
|
+
const NODE_SPELLINGS=new Set(['node','state','lifeline']);
|
|
1181
|
+
const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition','message']);
|
|
1182
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: WHERE EACH GENRE'S NODE ROWS LIVE IN THE PARSED DOC.
|
|
1183
|
+
// `GENRE_NODE_KW` above says what the WORD is; this says which `doc`
|
|
1184
|
+
// collection the parser puts that word's rows in. The two are separate facts
|
|
1185
|
+
// and only one of them is `nodes`: `statechart` renames the word and keeps the
|
|
1186
|
+
// collection (a `state` is a scene node), while `sequence` renames BOTH — a
|
|
1187
|
+
// `lifeline` is not a scene node and lands in `doc.lifelines`. Any GUI test of
|
|
1188
|
+
// the form "is this id a thing this genre declares" has to ask through here.
|
|
1189
|
+
// Hand-writing `doc.nodes` is the defect it closes: the Fill/Delete/Raise/
|
|
1190
|
+
// Lower enablement asked `lastDoc.nodes` and so was permanently false under
|
|
1191
|
+
// `sequence`, greying out four buttons whose edits (`SEQUENCE-SOURCE-STANDARD`-R182) already worked.
|
|
1192
|
+
const GENRE_NODE_COLL={block:'nodes',topology:'nodes',flowchart:'nodes',
|
|
1193
|
+
statechart:'nodes',sequence:'lifelines'};
|
|
1194
|
+
const docNodes=(doc)=>(doc&&doc[GENRE_NODE_COLL[doc.genre]||'nodes'])||[];
|
|
1195
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that have their own
|
|
1196
|
+
// parser (`message` rides the connector scanner). The set is what dispatches
|
|
1197
|
+
// to `parseSeqDirective`, and it is scoped by `doc.genre` at the call site so
|
|
1198
|
+
// `state` still reaches `statechart`'s node parser under `statechart`.
|
|
1199
|
+
const SEQ_KW=new Set(['lifeline','state','fragment','operand']);
|
|
1200
|
+
// UML 2.5.1 `InteractionOperatorKind` (§17.12.15.3), taken WHOLE — twelve
|
|
1201
|
+
// values, every one the standard's own single lowercase spelling, so RULE 4.2
|
|
1202
|
+
// admits the abbreviations `alt` `opt` `par` `neg` `seq` unchanged. The clause
|
|
1203
|
+
// number matters: §17.6.2 was cited for this enum in an earlier draft and is
|
|
1204
|
+
// registered FALSE in spec/standards-claims.tsv (S024). FigDown makes the key
|
|
1205
|
+
// MANDATORY where UML gives the attribute a default of `seq`
|
|
1206
|
+
// (`interactionOperator : InteractionOperatorKind [1..1] = seq`, §17.12.3.5) —
|
|
1207
|
+
// a DECLARED divergence: a default would let a fragment assert nothing while
|
|
1208
|
+
// looking like it asserts something, which is the `numbering=` precedent.
|
|
1209
|
+
const SEQ_OPERATORS_FRAG=['alt','opt','loop','par','strict','seq','critical',
|
|
1210
|
+
'neg','assert','ignore','consider','break'];
|
|
1211
|
+
const SEQ_FRAG_CLAUSE='UML 2.5.1 §17.12.15.3';
|
|
1212
|
+
// Draft §8.2/§15.4: every UML Message has a sendEvent AND a receiveEvent, so
|
|
1213
|
+
// a direction-less message is not a thing this domain has. `--` is a LINE
|
|
1214
|
+
// ERROR under `sequence` and legal in every other genre — the operator set is
|
|
1215
|
+
// per genre for the same reason the keywords are.
|
|
1216
|
+
const SEQ_OPERATORS=new Set(['->','<-','<->']);
|
|
1217
|
+
// Every connector spelling in the language reaches the dedicated scanner —
|
|
1218
|
+
// the WRONG one for the genre must get the named diagnostic, not
|
|
1219
|
+
// `unrecognized line` (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`), so the dispatch is over the whole set.
|
|
1220
|
+
const CONN_LINE_RE=new RegExp('^('+[...CONNECTOR_SPELLINGS].join('|')+')(\\s|$)');
|
|
1097
1221
|
// `KEYWORD-RENAME-SCOPE`: the flowchart rename is GATED BY THE DECLARED LANGUAGE
|
|
1098
1222
|
// VERSION, because `GENRE-CONNECTOR-SPELLING` applied it to `figdown 0.1` and that BROKE documents
|
|
1099
1223
|
// legal at v0.1.8 — `figdown 0.1 flowchart` + `edge` stopped parsing, with
|
|
@@ -1107,7 +1231,12 @@ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition']);
|
|
|
1107
1231
|
// forbids; two spellings across VERSIONS is ordinary language evolution, and
|
|
1108
1232
|
// each version accepts exactly one. `statechart` needs no gate of its own —
|
|
1109
1233
|
// the GENRE requires 0.2 (GENRES_BY_VERSION), so `state`/`transition` cannot
|
|
1110
|
-
// be reached from a 0.1 document at all.
|
|
1234
|
+
// be reached from a 0.1 document at all. `sequence` inherits that argument
|
|
1235
|
+
// unchanged: its genre token requires `figdown 0.4`, so
|
|
1236
|
+
// `lifeline`/`message` are unreachable from any earlier document and there is
|
|
1237
|
+
// no earlier spelling for them to have replaced. A `CONNECTOR_MIN_VERSION`
|
|
1238
|
+
// row for `message` would therefore gate nothing and would make the engine
|
|
1239
|
+
// claim a rename that never happened.
|
|
1111
1240
|
const GENRE_CONNECTOR_KW_AT={
|
|
1112
1241
|
'0.1':{block:'edge',topology:'edge',flowchart:'edge'},
|
|
1113
1242
|
'0.2':GENRE_CONNECTOR_KW
|
|
@@ -1130,17 +1259,22 @@ const WORD_WHY={
|
|
|
1130
1259
|
flowline:'the connecting line in a flowchart is a FLOWLINE — the term the flowchart domain commonly uses for the symbol ISO 5807 §9.3.1 names "Line"',
|
|
1131
1260
|
transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
|
|
1132
1261
|
node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
|
|
1133
|
-
state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)'
|
|
1262
|
+
state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)',
|
|
1263
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: both are WHOLE borrows from the genre's source
|
|
1264
|
+
// standard, verified against the clause text rather than against a
|
|
1265
|
+
// secondary description (spec/standards-claims.tsv).
|
|
1266
|
+
lifeline:'a sequence figure has exactly ONE kind of participant column and it is a LIFELINE — the term UML 2.5.1 §17.3.3.1 defines and §17.3.4.1 draws',
|
|
1267
|
+
message:'the line between two participants in a sequence figure is a MESSAGE — the term UML 2.5.1 §17.4.4.1 uses for its notation'
|
|
1134
1268
|
};
|
|
1135
1269
|
// The named diagnostic `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING` owe: it says WHICH word this genre uses and
|
|
1136
1270
|
// WHY, and it names the migration, because every connector line in a
|
|
1137
1271
|
// reclassified document has to be rewritten (the cost `GENRE-CONNECTOR-SPELLING` accepted).
|
|
1138
1272
|
const WRONG_WORD=(surf,want,genre)=>
|
|
1139
1273
|
'"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
|
|
1140
|
-
'. Each
|
|
1274
|
+
'. Each genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`, sequence `lifeline` `message`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
|
|
1141
1275
|
// `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
|
|
1142
1276
|
// and `"threshold" is not allowed in genre topology` would send an author
|
|
1143
|
-
// looking for a typo. Each cell below was legal until
|
|
1277
|
+
// looking for a typo. Each cell below was legal until 0.3 and states
|
|
1144
1278
|
// WHY that genre no longer declares it — the ruling's own ground, per cell,
|
|
1145
1279
|
// because the grounds differ and a single sentence could not carry them.
|
|
1146
1280
|
// Every one of these withdrawals was FREE: `topology`, `flowchart` and
|
|
@@ -1175,6 +1309,33 @@ const WITHDRAWN_FROM_GENRE=(kw,genre)=>
|
|
|
1175
1309
|
GENRE_WITHDRAWN[genre][kw]+
|
|
1176
1310
|
' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`): a spelling accepted by several genres is several '+
|
|
1177
1311
|
'independent declarations, and this genre\'s was withdrawn without touching any other\'s.'+WITHDREW_AT;
|
|
1312
|
+
// `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: THE OTHER HALF OF `SCENE-KEYWORD-MEMBERSHIP` — a spelling a genre REFUSED
|
|
1313
|
+
// AT BIRTH. It is a SEPARATE table from `GENRE_WITHDRAWN` and not a sixth row
|
|
1314
|
+
// of it, because the two state different facts and only one of them is a
|
|
1315
|
+
// migration: a WITHDRAWAL took a word away from documents that legally used
|
|
1316
|
+
// it (so the message names the release and the rewrite tool), while a REFUSAL
|
|
1317
|
+
// means the genre never declared the word and no document can have been
|
|
1318
|
+
// written against it. Folding them would make the engine tell a `sequence`
|
|
1319
|
+
// author "it was WITHDRAWN from this genre" about a keyword this genre has
|
|
1320
|
+
// never had, and would date every refusal to a migration that did not happen.
|
|
1321
|
+
// What the two SHARE is the thing `SCENE-KEYWORD-MEMBERSHIP` was for: the author gets the GROUND,
|
|
1322
|
+
// and what to write instead, rather than a spellcheck.
|
|
1323
|
+
//
|
|
1324
|
+
// The refusal is FREE in the `EDGE-GEOMETRY-CONSTRUCTS` sense — `sequence` is EXPERIMENTAL and this
|
|
1325
|
+
// is the release that gives it a vocabulary at all, so no document loses a
|
|
1326
|
+
// line. Each cell states its own ruling's ground, because the grounds differ.
|
|
1327
|
+
const REFUSED_IN={
|
|
1328
|
+
sequence:{
|
|
1329
|
+
gap:'`sequence` does not declare `gap`, and the refusal is about the AXIS, not the spelling (`SEQUENCE-TIME-GAP`). UML 2.5.1 §17.3.3.1 makes the vertical axis NON-PROPORTIONAL — "The distance between two events on a time-line does not represent any literal measurement of time, only that non-zero time has passed" — so non-zero time has ALREADY passed between every adjacent pair of events in every sequence figure, by the source\'s own semantics. A `gap` line would therefore state a fact the reading rule gives everywhere, and what the author actually wants — draw more vertical space here — is a RENDERING request, which `PRESENTATION-AS-MEANING-CARRIER` keeps off the language\'s side of the line. `timing`\'s `gap` is a different construct: there the horizontal axis IS a tick count, so a break in it removes ticks that would otherwise be asserted. WRITE INSTEAD: put the elapsed time in the following message\'s label — `message c -> s "DHCPREQUEST (T1, 0.5x lease)"` — or in its `description=`. Reopens if this genre ever lands a construct that makes vertical position denote a quantity, because a discontinuity then has something to interrupt.',
|
|
1330
|
+
group:'`sequence` does not declare `group` (`SEQUENCE-PARTICIPANT-GROUPING`), on three grounds. ZERO measured need: the row was admitted as Mermaid `box` parity and the genre was then re-scoped to real-figure coverage without the row being re-tested. NOTHING TO BORROW: UML clause 17 has no lifeline-grouping construct — `PartDecomposition` (§17.7.3.2) decomposes ONE lifeline into a sub-interaction, and gates and the frame bound an interaction rather than a subset of its lifelines. And the GEOMETRY IS ALREADY TAKEN: `band` = membership is locked into the scene genres (the band contains every member and nothing else, core §2.6), while a `group` here would span min..max COLUMNS with no contiguity rule, so a band over two non-adjacent lifelines silently encloses a third that is not a member — one word carrying two different geometric promises, which `GENRE-VOCABULARY-OBLIGATION` forbids opening without evidence. WRITE INSTEAD: `class` naming what the participants have in common, plus `class=` on each `lifeline` — it earns a legend entry and asserts membership without asserting adjacency. Reopens on a count of real figures whose lifelines are drawn in labelled bands, and the re-proposal owes a contiguity check either way.'
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
const REFUSED_AT=' (refused; MIGRATIONS 0.4)';
|
|
1334
|
+
const REFUSED_IN_GENRE=(kw,genre)=>
|
|
1335
|
+
'"'+kw+'" is not allowed in genre '+genre+' — this genre REFUSED it, it is not a typo and not a withdrawal: '+
|
|
1336
|
+
REFUSED_IN[genre][kw]+
|
|
1337
|
+
' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`), so a spelling another genre declares is that genre\'s '+
|
|
1338
|
+
'declaration and never this one\'s.'+REFUSED_AT;
|
|
1178
1339
|
// `MEMBERSHIP-KEY-ACCEPTANCE`: THE OPTION-KEY HALF OF `SCENE-KEYWORD-MEMBERSHIP`. A per-genre withdrawal can
|
|
1179
1340
|
// strand an option KEY as easily as it strands a keyword: `in=` states
|
|
1180
1341
|
// membership and its ONLY value domain is the id of a containing `group`, so
|
|
@@ -1210,6 +1371,25 @@ const WITHDRAWN_OPT_FROM_GENRE=(key,genre)=>
|
|
|
1210
1371
|
' An option key is per genre for the same reason a keyword is (core §3, `GENRE-VOCABULARY-OBLIGATION`): the key is accepted '+
|
|
1211
1372
|
'by the directive AND by the genre, and this genre\'s acceptance was withdrawn without touching any other\'s.'+
|
|
1212
1373
|
WITHDREW_OPT_AT;
|
|
1374
|
+
// `UNDELIVERED-MESSAGE-MARKING`: the OPTION-KEY half of the refusal table, and the one
|
|
1375
|
+
// place in this engine where a diagnostic fires for a key that is NOT in
|
|
1376
|
+
// `OPT_KEYS`. That is deliberate and is the ruling's headline: `lost=` was
|
|
1377
|
+
// proposed and REFUSED, so registering the spelling to get a named message
|
|
1378
|
+
// would put the key in the language's closed option registry — the exact
|
|
1379
|
+
// thing the ruling declines — and a reader counting `OPT_KEYS` would find a
|
|
1380
|
+
// key no directive accepts. So the refusal rides the UNKNOWN-OPTION path
|
|
1381
|
+
// instead: `badOpts` and the connector scanner both consult this table before
|
|
1382
|
+
// they say `unknown option`, which costs one lookup and adds no surface.
|
|
1383
|
+
const REFUSED_OPT_IN={
|
|
1384
|
+
sequence:{
|
|
1385
|
+
lost:'`sequence` does not accept `lost=`; it was proposed and REFUSED (`UNDELIVERED-MESSAGE-MARKING`), and NO option key was added to the language for this genre. The model it wanted is not UML\'s: UML 2.5.1 §17.4.3.1\'s lost Message is one whose "destination ... is outside the scope of the description" — the recipient is NOT MODELLED — while the proposed key meant the recipient is modelled, named and drawn AND DELIVERY FAILED, which is ITU-T Z.120 §4.3\'s model ("a message is sent but not consumed") under UML\'s spelling. Carrying one standard\'s word with another standard\'s meaning is the cross-source mix RULE 4.1 calls a last resort, and it was undeclared. WRITE INSTEAD: declare the meaning once and reference it — `class dropped "Sent, never delivered"` then `message c -> s "DHCPREQUEST" class=dropped description="renewal unicast never reaches the issuing server"`. The class carries the fact and earns a legend entry; `description=` carries the per-message reason. The NEED stays on the record: reopens on a measured rate of readers taking a dropped message for a delivered one, at 22%, and any re-proposal must be grounded on Z.120 §4.3 rather than on UML §17.4.3.1.'
|
|
1386
|
+
}
|
|
1387
|
+
};
|
|
1388
|
+
const REFUSED_OPT_IN_GENRE=(key,genre)=>
|
|
1389
|
+
key+'= is not allowed in genre '+genre+' — this genre REFUSED the key, it is not a typo and not a withdrawal: '+
|
|
1390
|
+
REFUSED_OPT_IN[genre][key]+
|
|
1391
|
+
' The spelling is not in the language\'s option registry at all, so no other genre accepts it either.'+
|
|
1392
|
+
REFUSED_AT;
|
|
1213
1393
|
const GENRE_KW={
|
|
1214
1394
|
block:new Set(SCENE_HOST_KW.concat(BLOCK_SUBJECT_KW, ['node','edge'])),
|
|
1215
1395
|
topology:new Set(SCENE_HOST_KW.concat(TOPOLOGY_SUBJECT_KW, ['node','edge'])),
|
|
@@ -1228,7 +1408,28 @@ const GENRE_KW={
|
|
|
1228
1408
|
bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
|
|
1229
1409
|
// chart is experimental and attaches to a table id in the same document
|
|
1230
1410
|
table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
|
|
1231
|
-
timing:new Set(GENRE_FREE_KW.concat(['class','timing']))
|
|
1411
|
+
timing:new Set(GENRE_FREE_KW.concat(['class','timing'])),
|
|
1412
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` gets its row, and the row is what
|
|
1413
|
+
// closes the Batch-1 finding that the genre "states a reading and constrains
|
|
1414
|
+
// nothing". It is NOT built on `SCENE_HOST_KW`, and the three absences are
|
|
1415
|
+
// each a decision rather than an oversight:
|
|
1416
|
+
// - `flow` and `rank` are REFUSED. A sequence figure's two axes are BOTH
|
|
1417
|
+
// declaration-ordered — lifelines left-to-right in declaration order,
|
|
1418
|
+
// occurrences top-to-bottom in declaration order (draft §7) — so a key
|
|
1419
|
+
// that reverses or re-ranks a drawing would make the picture disagree
|
|
1420
|
+
// with the source, which `DECLARATION-ORDER-SEMANTICS` forbids. There is nothing for them to set.
|
|
1421
|
+
// - `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, `REFUSED_IN` above).
|
|
1422
|
+
// - the REGION openers `bitfield`/`table`/`timing`/`chart` are absent
|
|
1423
|
+
// because this genre has no scene to compose them into: `GENRE-COMPOSITION` composition
|
|
1424
|
+
// stacks a region OUTSIDE the scene, and a ladder has no outside yet.
|
|
1425
|
+
// They are absent, not refused — a `sequence` document that wanted a
|
|
1426
|
+
// register layout beside its exchange is a real want with no ruling, and
|
|
1427
|
+
// it stays an open question rather than a silent no.
|
|
1428
|
+
// What is left is the genre-free core, `class`, this genre's two spellings
|
|
1429
|
+
// and its three subject words — nine top-level keywords, and that is the
|
|
1430
|
+
// whole of what a `figdown 0.4 sequence` document may write.
|
|
1431
|
+
sequence:new Set(GENRE_FREE_KW.concat(['class'], SEQUENCE_SUBJECT_KW,
|
|
1432
|
+
['lifeline','message']))
|
|
1232
1433
|
};
|
|
1233
1434
|
const CHILD_KW=new Set(['field','break','cell','width','signal','gap']);
|
|
1234
1435
|
|
|
@@ -1252,6 +1453,59 @@ function splitFigdownSections(text){
|
|
|
1252
1453
|
return secs;
|
|
1253
1454
|
}
|
|
1254
1455
|
|
|
1456
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's DERIVED reading, in one
|
|
1457
|
+
// function so the parser's checks and (from a later increment) the renderer
|
|
1458
|
+
// cannot disagree about what a document says.
|
|
1459
|
+
//
|
|
1460
|
+
// Two things are derived and nothing else is:
|
|
1461
|
+
// ROWS — the figure's total order. `messages` and `states` are the two
|
|
1462
|
+
// collections that carry an occurrence; each element records the
|
|
1463
|
+
// source `line` it was written on, and sorting the union of the two
|
|
1464
|
+
// on that number IS the order (draft §31: declaration order, and
|
|
1465
|
+
// the order is TOTAL — a declared divergence from UML's partial
|
|
1466
|
+
// order, taken so a reader never has to compute one).
|
|
1467
|
+
// CHAIN — the containment tree from `in=`. A fragment or an operand may
|
|
1468
|
+
// name a fragment or an operand, so the chain alternates in
|
|
1469
|
+
// practice but the walk does not assume it.
|
|
1470
|
+
// EXTENT is the span of row slots a container owns, transitively: an occurrence
|
|
1471
|
+
// inside an operand is inside that operand's fragment too. `cycles` names any
|
|
1472
|
+
// container that reaches itself, so a caller can refuse to read a tree that is
|
|
1473
|
+
// not one instead of walking it to a guard.
|
|
1474
|
+
function seqModel(doc){
|
|
1475
|
+
const rows=[];
|
|
1476
|
+
for(const m of doc.messages||[]) rows.push({kind:'message',el:m,line:m.line});
|
|
1477
|
+
for(const s of doc.states||[]) rows.push({kind:'state', el:s,line:s.line});
|
|
1478
|
+
rows.sort((a,b)=>a.line-b.line);
|
|
1479
|
+
rows.forEach((r,i)=>{ r.slot=i; });
|
|
1480
|
+
const cont={};
|
|
1481
|
+
for(const f of doc.fragments||[]) cont[f.id]={kind:'fragment',el:f,parent:f['in']||null};
|
|
1482
|
+
for(const o of doc.operands||[]) cont[o.id]={kind:'operand', el:o,parent:o['in']||null};
|
|
1483
|
+
const cycles=[];
|
|
1484
|
+
const chain=(id)=>{
|
|
1485
|
+
const out=[], seen=new Set();
|
|
1486
|
+
let c=id;
|
|
1487
|
+
while(c && cont[c]){
|
|
1488
|
+
if(seen.has(c)) break;
|
|
1489
|
+
seen.add(c); out.push(c); c=cont[c].parent;
|
|
1490
|
+
}
|
|
1491
|
+
return out;
|
|
1492
|
+
};
|
|
1493
|
+
for(const id in cont){
|
|
1494
|
+
const seen=new Set(); let c=cont[id].parent;
|
|
1495
|
+
while(c && cont[c] && !seen.has(c)){ if(c===id){ cycles.push(id); break; } seen.add(c); c=cont[c].parent; }
|
|
1496
|
+
}
|
|
1497
|
+
const owned={}; for(const id in cont) owned[id]=[];
|
|
1498
|
+
for(const r of rows){
|
|
1499
|
+
const inId=r.el['in']||null;
|
|
1500
|
+
r.chain=inId?chain(inId):[];
|
|
1501
|
+
for(const id of r.chain) if(owned[id]) owned[id].push(r.slot);
|
|
1502
|
+
}
|
|
1503
|
+
const extent={};
|
|
1504
|
+
for(const id in cont)
|
|
1505
|
+
extent[id]=owned[id].length?{lo:Math.min(...owned[id]),hi:Math.max(...owned[id])}:null;
|
|
1506
|
+
return {rows,cont,owned,extent,chain,cycles};
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1255
1509
|
// parse(text) -> {doc, errs, docs}
|
|
1256
1510
|
// Single-section: docs=[doc] (backward-compatible doc/errs).
|
|
1257
1511
|
// Multi-section: one doc per figdown header; errs use full-file line numbers;
|
|
@@ -1272,6 +1526,11 @@ function parse(text){
|
|
|
1272
1526
|
const docs=[]; const errs=[];
|
|
1273
1527
|
for(const sec of secs){
|
|
1274
1528
|
const r=parseOne(sec.text);
|
|
1529
|
+
// A section's element `.line` values are section-local, and a GEOMETRY-time
|
|
1530
|
+
// error (one only `render` can raise) is built long after this loop has
|
|
1531
|
+
// finished re-basing the parse messages. Record the offset on the doc so
|
|
1532
|
+
// that error can quote the same full-file line the author is looking at.
|
|
1533
|
+
r.doc.lineOffset=sec.startLine-1;
|
|
1275
1534
|
for(const e of r.errs){
|
|
1276
1535
|
const m=/^Line (\d+): (.*)$/.exec(e);
|
|
1277
1536
|
if(m) errs.push('Line '+(+m[1]+sec.startLine-1)+': '+m[2]);
|
|
@@ -1292,7 +1551,27 @@ function parseOne(text){
|
|
|
1292
1551
|
// a distinction the model must keep.
|
|
1293
1552
|
const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
|
|
1294
1553
|
flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
|
|
1295
|
-
classes:[],boundaries:[]
|
|
1554
|
+
classes:[],boundaries:[],
|
|
1555
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` model. FIVE collections,
|
|
1556
|
+
// one per keyword, and they are separate arrays rather than
|
|
1557
|
+
// aliases of `nodes`/`edges` because a message is not an edge: an
|
|
1558
|
+
// edge is a relation between two nodes and has no position, while
|
|
1559
|
+
// a message is an OCCURRENCE with a place in a total order (draft
|
|
1560
|
+
// §31 — order is declaration order and it is TOTAL). Folding them
|
|
1561
|
+
// into `nodes`/`edges` would let the scene renderer reach them
|
|
1562
|
+
// and would put a time-ordered thing in a collection whose model
|
|
1563
|
+
// says order is not meaning.
|
|
1564
|
+
//
|
|
1565
|
+
// The ORDER of `messages`, `states` and the containment they
|
|
1566
|
+
// declare is recovered from the `line` each element carries: the
|
|
1567
|
+
// parser appends in source order, so each array is already
|
|
1568
|
+
// ordered and the union of `messages` and `states` sorted on
|
|
1569
|
+
// `line` is the figure's trace. Nothing else records time.
|
|
1570
|
+
//
|
|
1571
|
+
// These stay EMPTY in every non-`sequence` document, and the
|
|
1572
|
+
// canonical JSON binding omits an empty one (the `externals`
|
|
1573
|
+
// rule), so no existing golden moves a byte.
|
|
1574
|
+
lifelines:[],messages:[],states:[],fragments:[],operands:[]};
|
|
1296
1575
|
const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
|
|
1297
1576
|
bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
|
|
1298
1577
|
// §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
|
|
@@ -1415,17 +1694,40 @@ function parseOne(text){
|
|
|
1415
1694
|
if(idHere()){ err(n,ID_RULE); return; }
|
|
1416
1695
|
const tk2=tokenize(s.slice(i).trim());
|
|
1417
1696
|
if(tk2.error){ err(n,tk2.error); return; }
|
|
1418
|
-
const {pos:p2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
|
|
1697
|
+
const {pos:p2,posq:pq2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
|
|
1419
1698
|
if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
|
|
1420
|
-
|
|
1421
|
-
|
|
1699
|
+
// `UNDELIVERED-MESSAGE-MARKING`: the connector's copy of the genre REFUSAL check for
|
|
1700
|
+
// an option key the language does not register. It sits on the
|
|
1701
|
+
// unknown-option path because `lost=` is not in `OPT_KEYS` — see
|
|
1702
|
+
// `REFUSED_OPT_IN` — and it must be here as well as in `badOpts` because
|
|
1703
|
+
// `message` is scanned by this function and never reaches that one.
|
|
1704
|
+
if(u2.length){
|
|
1705
|
+
const ro=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
|
|
1706
|
+
if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
|
|
1707
|
+
else err(n,'unknown option "'+u2[0]+'="');
|
|
1708
|
+
return; }
|
|
1709
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `message` adds ONE trailing positional — the
|
|
1710
|
+
// quoted message label — to the connector grammar, because that is where
|
|
1711
|
+
// every sequence source in the corpus writes it and the `[mid]` form
|
|
1712
|
+
// reads as an annotation rather than as the message itself. Every other
|
|
1713
|
+
// connector spelling keeps the grammar unchanged, so the surplus-argument
|
|
1714
|
+
// error is still the right answer for them.
|
|
1715
|
+
let seqLabel=null;
|
|
1716
|
+
if(kw==='message'){
|
|
1717
|
+
if(p2.length>1){ err(n,'unexpected argument "'+p2[1]+'"'); return; }
|
|
1718
|
+
if(p2.length===1){
|
|
1719
|
+
if(!pq2[0]){ err(n,'message label must be quoted: message '+a+' '+op+' '+b+' "'+p2[0]+'" — '+Q_WHY); return; }
|
|
1720
|
+
if(mid!==null){ err(n,'message has two labels — the inline -['+mid+']-> mid-label and the trailing "'+p2[0]+'". Write one'); return; }
|
|
1721
|
+
seqLabel=p2[0];
|
|
1722
|
+
}
|
|
1723
|
+
} else if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
|
|
1422
1724
|
// 0.1: `edge` has its own scanner, so the language-wide retired
|
|
1423
1725
|
// keys need their own check here or `edge` would be the one directive
|
|
1424
1726
|
// that reports the generic message for a retired spelling.
|
|
1425
1727
|
for(const rk in RETIRED_OPT_KEYS)
|
|
1426
1728
|
if(o2[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); return; }
|
|
1427
1729
|
for(const k in o2)
|
|
1428
|
-
if(!
|
|
1730
|
+
if(!directiveOpts(kw,doc.genre).includes(k)){ err(n,kw+' does not take '+k+'='); return; }
|
|
1429
1731
|
for(const k of ['label','taillabel','headlabel'])
|
|
1430
1732
|
if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: '+kw+' A [tail] -[mid]-> [head] B (MIGRATIONS 0.1)'); return; }
|
|
1431
1733
|
if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR(kw)); return; }
|
|
@@ -1463,6 +1765,38 @@ function parseOne(text){
|
|
|
1463
1765
|
if(!pc.ok){ err(n,pc.err); return; }
|
|
1464
1766
|
ecls=pc.ids;
|
|
1465
1767
|
}
|
|
1768
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` fork. It is the SAME scanner with
|
|
1769
|
+
// a different reading, and the model it writes is a different collection:
|
|
1770
|
+
// a message is an OCCURRENCE with a place in the figure's total order
|
|
1771
|
+
// (draft §31), an edge is a relation with no position at all.
|
|
1772
|
+
if(kw==='message'){
|
|
1773
|
+
// Draft §8.2/§15.4. `--` parses everywhere else and is a line error
|
|
1774
|
+
// here, so the check is at the fork rather than in the operator scanner.
|
|
1775
|
+
if(!SEQ_OPERATORS.has(op)){
|
|
1776
|
+
err(n,'message needs a direction: -> <- <-> — a Message has a sending event occurrence AND a receiving event occurrence (UML 2.5.1 §17.4.3.1 defines the LOST case as the one where the receiving occurrence is absent), so a message with unstated direction is not a thing this genre has. "--" is a line error under sequence; for a sustained two-way exchange whose individual messages are not enumerated write <->');
|
|
1777
|
+
return; }
|
|
1778
|
+
if(o2['in']!==undefined){
|
|
1779
|
+
const e=idErr(o2['in'], optHasQ(oT2,'in'), null);
|
|
1780
|
+
if(e){ err(n,e); return; }
|
|
1781
|
+
}
|
|
1782
|
+
if(o2.description!==undefined && !optQ(oT2,'description')){
|
|
1783
|
+
err(n,'description= must be quoted: description="'+o2.description+'" — '+Q_WHY); return; }
|
|
1784
|
+
// ONE label field, fed by either spelling. The shared scanner also
|
|
1785
|
+
// accepts the inline `-[mid]->` form, and the check above makes writing
|
|
1786
|
+
// both a line error, so the model can never hold two — but the model
|
|
1787
|
+
// must not hold the same text under two keys either, so `mid` is NOT
|
|
1788
|
+
// projected beside `label` here the way it is on an edge. That the two
|
|
1789
|
+
// spellings both reach this field at all is an ALIAS in `IDENTITY-ASSERTION`'s sense
|
|
1790
|
+
// and is a FINDING for the genre document (Batch 5) rather than a
|
|
1791
|
+
// ruling taken here: the draft settles the trailing form and says
|
|
1792
|
+
// nothing about the brackets. `[tail]` and `[head]` are kept — they are
|
|
1793
|
+
// different positions, not a second spelling of the same one.
|
|
1794
|
+
doc.messages.push({a,b,op,tail,head,
|
|
1795
|
+
label:seqLabel!==null?seqLabel:mid,
|
|
1796
|
+
style:o2.style,cls:ecls,stroke:o2.stroke,note:o2.note,
|
|
1797
|
+
desc:o2.description,in:o2['in']||null,line:n});
|
|
1798
|
+
return;
|
|
1799
|
+
}
|
|
1466
1800
|
// §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
|
|
1467
1801
|
// and `fill=` name the same channel (`stroke=` wins when both are
|
|
1468
1802
|
// written); `text=` colours the [tail]/[mid]/[head] labels.
|
|
@@ -1471,6 +1805,94 @@ function parseOne(text){
|
|
|
1471
1805
|
plane:o2.plane||'base',line:n});
|
|
1472
1806
|
}
|
|
1473
1807
|
|
|
1808
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that are not the
|
|
1809
|
+
// connector. Reached only from the `doc.genre==='sequence'` dispatch, so
|
|
1810
|
+
// `state` under `statechart` never arrives here. Every option-VALUE check
|
|
1811
|
+
// (colours, `style=` enum, `class=` list, `in=` id spelling, `note=` and
|
|
1812
|
+
// `description=` quoting, `type=`'s bare-value rule) has already run in
|
|
1813
|
+
// `badOpts`; what is left is arity, mandatory arguments, and this genre's
|
|
1814
|
+
// own enum.
|
|
1815
|
+
const seqCls=(opts,optT)=>opts['class']!==undefined
|
|
1816
|
+
? parseClassList(opts['class'],optList(optT,'class')).ids : undefined;
|
|
1817
|
+
function parseSeqDirective(kw,n,pos,posq,opts,optT){
|
|
1818
|
+
if(kw==='lifeline'){
|
|
1819
|
+
// `lifeline <id> ["label"]` — the participant column. It DECLARES an id
|
|
1820
|
+
// and joins the document-wide id namespace, so a lifeline cannot share
|
|
1821
|
+
// a spelling with a fragment or an operand.
|
|
1822
|
+
const id=pos[1];
|
|
1823
|
+
const e=idErr(id,!!posq[1],'lifeline needs <id> ["label"]');
|
|
1824
|
+
if(e){ err(n,e); return; }
|
|
1825
|
+
if(dupId(id)||doc.lifelines.some(l=>l.id===id)){ err(n,'duplicate id "'+id+'"'); return; }
|
|
1826
|
+
if(BLK_LBL(n,'lifeline',id,pos,posq)) return;
|
|
1827
|
+
if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
|
|
1828
|
+
nodeIds.add(id);
|
|
1829
|
+
doc.lifelines.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in']||null,
|
|
1830
|
+
cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
|
|
1831
|
+
style:opts.style,note:opts.note,desc:opts.description,line:n});
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
if(kw==='state'){
|
|
1835
|
+
// `state <lifeline-id> "<state name>"` — a StateInvariant (UML 2.5.1
|
|
1836
|
+
// §17.12.25). Slot 1 REFERENCES a lifeline; it does NOT declare an id,
|
|
1837
|
+
// because nothing in this genre refers to a state occurrence. That is
|
|
1838
|
+
// the asymmetry with `statechart`'s `state`, where slot 1 declares
|
|
1839
|
+
// (draft §29, Q5), and it is why the two share a spelling and nothing
|
|
1840
|
+
// else. Slot 2 is MANDATORY: a state occurrence with no name asserts
|
|
1841
|
+
// nothing at all.
|
|
1842
|
+
const ref=pos[1];
|
|
1843
|
+
const e=idErr(ref,!!posq[1],'state needs <lifeline-id> "<state name>"');
|
|
1844
|
+
if(e){ err(n,e); return; }
|
|
1845
|
+
if(pos[2]===undefined){ err(n,'state needs a quoted state name: state '+ref+' "LISTEN" — under sequence a state occurrence names the lifeline it is ON and the state it is IN, and a state with no name asserts nothing'); return; }
|
|
1846
|
+
if(!posq[2]){ err(n,'state name must be quoted: state '+ref+' "'+pos[2]+'" — '+Q_WHY); return; }
|
|
1847
|
+
if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
|
|
1848
|
+
doc.states.push({ref,name:pos[2],in:opts['in']||null,
|
|
1849
|
+
cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
|
|
1850
|
+
style:opts.style,note:opts.note,desc:opts.description,line:n});
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
if(kw==='fragment'){
|
|
1854
|
+
// `fragment <id> ["label"] type=<operator>` — a CombinedFragment
|
|
1855
|
+
// (§17.12.3). `type=` is MANDATORY where UML defaults it to `seq`: a
|
|
1856
|
+
// fragment with no interaction operator draws a box that asserts
|
|
1857
|
+
// nothing, and a default would make the box look like an assertion.
|
|
1858
|
+
const id=pos[1];
|
|
1859
|
+
const e=idErr(id,!!posq[1],'fragment needs <id> ["label"] type=<operator>');
|
|
1860
|
+
if(e){ err(n,e); return; }
|
|
1861
|
+
if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
|
|
1862
|
+
err(n,'duplicate id "'+id+'"'); return; }
|
|
1863
|
+
if(BLK_LBL(n,'fragment',id,pos,posq)) return;
|
|
1864
|
+
if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
|
|
1865
|
+
if(opts.type===undefined){
|
|
1866
|
+
err(n,'fragment needs type=<operator> — a fragment with no interaction operator asserts nothing ('+
|
|
1867
|
+
SEQ_OPERATORS_FRAG.join('|')+', '+SEQ_FRAG_CLAUSE+'). UML defaults the attribute to seq and FigDown does not: a default would draw a frame that looks like an assertion and is not one'); return; }
|
|
1868
|
+
if(!SEQ_OPERATORS_FRAG.includes(opts.type)){
|
|
1869
|
+
err(n,'unknown interaction operator "'+opts.type+'" — write one of '+
|
|
1870
|
+
SEQ_OPERATORS_FRAG.join('|')+' ('+SEQ_FRAG_CLAUSE+' InteractionOperatorKind, taken whole)'); return; }
|
|
1871
|
+
doc.fragments.push({id,label:pos[2]!==undefined?pos[2]:null,type:opts.type,
|
|
1872
|
+
in:opts['in']||null,cls:seqCls(opts,optT),stroke:opts.stroke,
|
|
1873
|
+
style:opts.style,note:opts.note,desc:opts.description,line:n});
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
if(kw==='operand'){
|
|
1877
|
+
// `operand <id> ["guard"] in=<fragment-id>` — an InteractionOperand
|
|
1878
|
+
// (§17.12.14). `in=` is MANDATORY: an operand is a COMPARTMENT OF a
|
|
1879
|
+
// fragment and has no meaning apart from one.
|
|
1880
|
+
const id=pos[1];
|
|
1881
|
+
const e=idErr(id,!!posq[1],'operand needs <id> ["guard"] in=<fragment-id>');
|
|
1882
|
+
if(e){ err(n,e); return; }
|
|
1883
|
+
if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
|
|
1884
|
+
err(n,'duplicate id "'+id+'"'); return; }
|
|
1885
|
+
if(BLK_LBL(n,'operand',id,pos,posq)) return;
|
|
1886
|
+
if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
|
|
1887
|
+
if(opts['in']===undefined){
|
|
1888
|
+
err(n,'operand needs in=<fragment-id> — an operand is a compartment OF a fragment and has no meaning apart from one (UML 2.5.1 §17.12.14)'); return; }
|
|
1889
|
+
doc.operands.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in'],
|
|
1890
|
+
cls:seqCls(opts,optT),stroke:opts.stroke,style:opts.style,
|
|
1891
|
+
note:opts.note,desc:opts.description,line:n});
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1474
1896
|
for(let li=0; li<lines.length; li++){
|
|
1475
1897
|
const n=li+1;
|
|
1476
1898
|
let raw=lines[li];
|
|
@@ -1545,7 +1967,11 @@ function parseOne(text){
|
|
|
1545
1967
|
// the dispatch cannot be narrowed to the genre's own word — a `flowline`
|
|
1546
1968
|
// under `block` would then fall through to `unrecognized line`, which is
|
|
1547
1969
|
// exactly the answer these rulings owe an author better than.
|
|
1548
|
-
|
|
1970
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `message` is the FOURTH spelling scanned here,
|
|
1971
|
+
// and it is derived from `CONNECTOR_SPELLINGS` rather than spelled again,
|
|
1972
|
+
// so a fifth genre's connector reaches the named diagnostic by joining
|
|
1973
|
+
// that set and not by remembering to edit a regex.
|
|
1974
|
+
const mConn=CONN_LINE_RE.exec(raw.trim());
|
|
1549
1975
|
if(mConn){
|
|
1550
1976
|
const ckw=mConn[1];
|
|
1551
1977
|
if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 <genre>"'); }
|
|
@@ -1587,12 +2013,25 @@ function parseOne(text){
|
|
|
1587
2013
|
// Directives not in DIRECTIVE_OPTS (title's single quoted string, unknown
|
|
1588
2014
|
// keywords) are handled by their own paths.
|
|
1589
2015
|
const badOpts=(k)=>{
|
|
1590
|
-
|
|
2016
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the row is read GENRE-FIRST. `state` is one
|
|
2017
|
+
// spelling with two declarations and two option sets (`GENRE-VOCABULARY-OBLIGATION`), and
|
|
2018
|
+
// `GENRE_DIRECTIVE_OPTS` is the only place that fact is recorded.
|
|
2019
|
+
const allowed=directiveOpts(k,doc.genre);
|
|
1591
2020
|
if(!allowed) return false;
|
|
1592
2021
|
let bad=false;
|
|
1593
2022
|
// same-line repeated option key (last-wins was silent data loss)
|
|
1594
2023
|
if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
|
|
1595
|
-
|
|
2024
|
+
// `UNDELIVERED-MESSAGE-MARKING`: a genre REFUSAL of a key the language never
|
|
2025
|
+
// registered is answered here, on the unknown-option path, because that
|
|
2026
|
+
// is the only path it can reach: `lost=` is deliberately absent from
|
|
2027
|
+
// `OPT_KEYS` (the ruling adds no option key), so `splitOpts` reports it
|
|
2028
|
+
// as unknown. Checked BEFORE the generic message so the author gets the
|
|
2029
|
+
// ground and the replacement spelling instead of a spellcheck.
|
|
2030
|
+
const roOpt=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
|
|
2031
|
+
for(const u of unk){
|
|
2032
|
+
if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
|
|
2033
|
+
else err(n,'unknown option "'+u+'="');
|
|
2034
|
+
bad=true; }
|
|
1596
2035
|
// `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
|
|
1597
2036
|
// after `unknown option`, so a key the LANGUAGE does not have keeps its
|
|
1598
2037
|
// own answer, and before every value check, so a withdrawn key is never
|
|
@@ -2087,6 +2526,19 @@ function parseOne(text){
|
|
|
2087
2526
|
// Dynamic-profile reserved words keep their dedicated message (before `GENRE-KEYWORD-ALLOWLIST`).
|
|
2088
2527
|
if(kw==='page'||kw==='set'||kw==='pulse'){
|
|
2089
2528
|
err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); continue; }
|
|
2529
|
+
// `SEQUENCE-TIME-GAP`: a genre REFUSAL is consulted BEFORE the typed-block
|
|
2530
|
+
// child exemption below, because `gap` is BOTH — `timing`'s child keyword
|
|
2531
|
+
// and the one spelling `sequence` refused that is also a child word. Left
|
|
2532
|
+
// in the old order, `gap "T1 fires"` in a sequence document answered
|
|
2533
|
+
// `"gap" is a typed-block child — it needs a bitfield/table/timing block
|
|
2534
|
+
// above it`, which is TRUE OF THE LANGUAGE and says nothing about the
|
|
2535
|
+
// ruling the author has actually run into. This clause fires only for a
|
|
2536
|
+
// spelling that is a child keyword AND refused by this genre, so no other
|
|
2537
|
+
// genre's answer moves; every other refused or withdrawn word reaches its
|
|
2538
|
+
// own message through the ordinary chain below.
|
|
2539
|
+
if(sawHeader && doc.genre && CHILD_KW.has(kw) &&
|
|
2540
|
+
REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw]){
|
|
2541
|
+
err(n, REFUSED_IN_GENRE(kw, doc.genre)); continue; }
|
|
2090
2542
|
// `GENRE-KEYWORD-ALLOWLIST`: after closing a typed region, top-level keywords
|
|
2091
2543
|
// must be in the header genre allowlist. Child keywords still use the
|
|
2092
2544
|
// "needs a bitfield/table/timing above" path when they appear with no cur.
|
|
@@ -2104,6 +2556,11 @@ function parseOne(text){
|
|
|
2104
2556
|
// needs the ground, not a spellcheck.
|
|
2105
2557
|
else if(GENRE_WITHDRAWN[doc.genre] && GENRE_WITHDRAWN[doc.genre][kw])
|
|
2106
2558
|
err(n, WITHDRAWN_FROM_GENRE(kw, doc.genre));
|
|
2559
|
+
// `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: and one step further again. A word this genre
|
|
2560
|
+
// REFUSED is not an unknown word either, and it is not a withdrawal —
|
|
2561
|
+
// the author needs the ruling's ground and the spelling that works.
|
|
2562
|
+
else if(REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw])
|
|
2563
|
+
err(n, REFUSED_IN_GENRE(kw, doc.genre));
|
|
2107
2564
|
else
|
|
2108
2565
|
err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
|
|
2109
2566
|
continue;
|
|
@@ -2116,6 +2573,13 @@ function parseOne(text){
|
|
|
2116
2573
|
continue;
|
|
2117
2574
|
}
|
|
2118
2575
|
|
|
2576
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own directives.
|
|
2577
|
+
// Dispatched BEFORE the switch and scoped by `doc.genre`, which is what
|
|
2578
|
+
// keeps `state` reaching `statechart`'s node parser under `statechart` —
|
|
2579
|
+
// `GENRE-VOCABULARY-OBLIGATION` in the dispatcher, not only in the allowlist.
|
|
2580
|
+
if(doc.genre==='sequence' && SEQ_KW.has(kw)){
|
|
2581
|
+
parseSeqDirective(kw,n,pos,posq,opts,optT); continue; }
|
|
2582
|
+
|
|
2119
2583
|
switch(kw){
|
|
2120
2584
|
case 'title': {
|
|
2121
2585
|
if(sawTitle){ err(n,'duplicate title line'); break; }
|
|
@@ -2617,6 +3081,117 @@ function parseOne(text){
|
|
|
2617
3081
|
else if(groupIds.has(e.b)) errs.push('Line '+e.line+': edge endpoint "'+e.b+'" is a group — connect to a member node (group edges are not in v0.1)');
|
|
2618
3082
|
if(e.plane && !planeIds.has(e.plane)) errs.push('Line '+e.line+': unknown plane "'+e.plane+'"');
|
|
2619
3083
|
}
|
|
3084
|
+
// ── `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's semantic checks ────────
|
|
3085
|
+
// Everything here needs the WHOLE document, so none of it can live in
|
|
3086
|
+
// `parseSeqDirective`: forward references are legal (fixture 021 pins that
|
|
3087
|
+
// for the scene genres and the rule is language-wide), so an id can only be
|
|
3088
|
+
// resolved once every declaration has been read.
|
|
3089
|
+
if(doc.genre==='sequence'){
|
|
3090
|
+
const llIds=new Set(doc.lifelines.map(l=>l.id));
|
|
3091
|
+
const fragIds=new Set(doc.fragments.map(f=>f.id));
|
|
3092
|
+
const opIds=new Set(doc.operands.map(o=>o.id));
|
|
3093
|
+
// The `in=` OBJECT rule (`SEQUENCE-CONTAINMENT-SCOPE`), in one place because it is one rule: on
|
|
3094
|
+
// all five acceptors `in=` is sense 1 — *the element this one lives
|
|
3095
|
+
// inside* — and its value domain is a `fragment` or an `operand` id and
|
|
3096
|
+
// nothing else. The message names the domain AND the acceptor list,
|
|
3097
|
+
// because an author who wrote a lifeline id there has the relation right
|
|
3098
|
+
// and the object wrong, and needs to be told which.
|
|
3099
|
+
const IN_ACCEPTORS='message, operand, lifeline, state and fragment';
|
|
3100
|
+
const inErr=(line,val,what)=>{
|
|
3101
|
+
if(fragIds.has(val)||opIds.has(val)) return;
|
|
3102
|
+
errs.push('Line '+line+': unknown fragment or operand "'+val+'" — in= on a '+what+
|
|
3103
|
+
' names the fragment or operand this '+what+' occurs inside'+
|
|
3104
|
+
(llIds.has(val)?', and "'+val+'" is a LIFELINE. A message already names its lifelines through its two endpoints; a state names its lifeline in slot 1. in= is containment, not participation':'')+
|
|
3105
|
+
'. Under sequence in= is accepted on '+IN_ACCEPTORS+' — five acceptors, all sense 1 (UML 2.5.1 §17.12.13: a StateInvariant and a CombinedFragment are both InteractionFragments, so an InteractionOperand contains them both) — and its value is always a fragment or an operand id (draft §33.7)');
|
|
3106
|
+
};
|
|
3107
|
+
for(const l of doc.lifelines) if(l['in']) inErr(l.line,l['in'],'lifeline');
|
|
3108
|
+
for(const m of doc.messages){
|
|
3109
|
+
if(!llIds.has(m.a)) errs.push('Line '+m.line+': unknown lifeline "'+m.a+'"');
|
|
3110
|
+
if(!llIds.has(m.b)) errs.push('Line '+m.line+': unknown lifeline "'+m.b+'"');
|
|
3111
|
+
if(m['in']) inErr(m.line,m['in'],'message');
|
|
3112
|
+
}
|
|
3113
|
+
for(const st of doc.states){
|
|
3114
|
+
if(!llIds.has(st.ref)) errs.push('Line '+st.line+': unknown lifeline "'+st.ref+'" — state slot 1 REFERENCES a lifeline (it does not declare one: nothing in this genre refers to a state occurrence, so it takes no id of its own)');
|
|
3115
|
+
if(st['in']) inErr(st.line,st['in'],'state');
|
|
3116
|
+
}
|
|
3117
|
+
for(const f of doc.fragments) if(f['in']) inErr(f.line,f['in'],'fragment');
|
|
3118
|
+
// An operand's `in=` is MANDATORY and its object is narrower than the
|
|
3119
|
+
// general rule: a compartment belongs to a FRAGMENT, never to another
|
|
3120
|
+
// compartment, so an operand id there is a specific mistake with a
|
|
3121
|
+
// specific answer.
|
|
3122
|
+
for(const o of doc.operands)
|
|
3123
|
+
if(!fragIds.has(o['in']))
|
|
3124
|
+
errs.push('Line '+o.line+': unknown fragment "'+o['in']+'" — operand in= names a FRAGMENT'+
|
|
3125
|
+
(opIds.has(o['in'])?', not another operand: an operand is a compartment of a fragment, and a compartment has no compartments of its own (UML 2.5.1 §17.12.3: a CombinedFragment owns its operands)'
|
|
3126
|
+
:llIds.has(o['in'])?', not a lifeline: an operand is a compartment of a fragment, and a fragment spans lifelines rather than belonging to one'
|
|
3127
|
+
:''));
|
|
3128
|
+
// Two CONSECUTIVE `state` lines naming the same lifeline and the same
|
|
3129
|
+
// state name are a line error (draft §23.2). A state that has not changed
|
|
3130
|
+
// is never restated, so a genuine duplicate is always a mistake — and
|
|
3131
|
+
// because a transition is DERIVED from an adjacent pair, a reader
|
|
3132
|
+
// "tidying duplicates" could otherwise silently delete a fact.
|
|
3133
|
+
const lastState={};
|
|
3134
|
+
for(const st of doc.states.slice().sort((a,b)=>a.line-b.line)){
|
|
3135
|
+
if(lastState[st.ref]===st.name)
|
|
3136
|
+
errs.push('Line '+st.line+': lifeline "'+st.ref+'" is already in state "'+st.name+
|
|
3137
|
+
'" — a state that has not changed is never restated, and a transition is derived from the adjacent pair, so a restatement would assert a transition that did not happen (draft §23.2)');
|
|
3138
|
+
lastState[st.ref]=st.name;
|
|
3139
|
+
}
|
|
3140
|
+
const SM=seqModel(doc);
|
|
3141
|
+
// A containment CYCLE is checked before anything that walks the chain, or
|
|
3142
|
+
// the walk terminates on a guard and every downstream answer is arbitrary.
|
|
3143
|
+
for(const id of SM.cycles)
|
|
3144
|
+
errs.push('Line '+SM.cont[id].el.line+': '+SM.cont[id].kind+' "'+id+
|
|
3145
|
+
'" is inside itself — in= containment is a tree, and a cycle denotes nothing at all');
|
|
3146
|
+
if(!SM.cycles.length){
|
|
3147
|
+
// THE ONE-LEVEL NESTING CAP (`SEQUENCE-CONTAINMENT-SCOPE`). A fragment may sit inside an
|
|
3148
|
+
// operand of ONE enclosing fragment and no deeper. The cap is taken on
|
|
3149
|
+
// the v0.1 `group` precedent — "one level is the whole of v0.1's
|
|
3150
|
+
// containment" (core §2.2, and the diagnostic `group does not take
|
|
3151
|
+
// in=`) — and it is a SCOPE decision, not a principle: a second level
|
|
3152
|
+
// lands on measured need, the same evidence any other cell needs.
|
|
3153
|
+
for(const f of doc.fragments){
|
|
3154
|
+
const anc=SM.chain(f['in']).filter(id=>SM.cont[id].kind==='fragment');
|
|
3155
|
+
if(anc.length>1)
|
|
3156
|
+
errs.push('Line '+f.line+': fragment "'+f.id+'" nests '+anc.length+
|
|
3157
|
+
' levels deep (inside "'+anc[0]+'", inside "'+anc[anc.length-1]+'") — fragment nesting is capped at ONE level in v1: a fragment may sit in an operand of one enclosing fragment and no deeper. This is the v0.1 `group` precedent, where one level is the whole of the language\'s containment, and it is a scope decision rather than a principle — a second level lands on measured need (draft §33.7). Write the inner interaction as a sibling fragment, or state it in description=');
|
|
3158
|
+
}
|
|
3159
|
+
// CONTIGUITY (draft §28.1). A fragment's or operand's members must be a
|
|
3160
|
+
// CONTIGUOUS run in declaration order, and the reason is the MODEL and
|
|
3161
|
+
// not the drawing: an operand denotes the ORDERED RUN of the
|
|
3162
|
+
// occurrences it contains, so an occurrence that is not in it cannot
|
|
3163
|
+
// happen between two that are. Non-contiguous membership denotes
|
|
3164
|
+
// nothing (UML 2.5.1 §17.6).
|
|
3165
|
+
// One offending row is reported ONCE, against the DEEPEST container it
|
|
3166
|
+
// splits. A row inside an operand's span is inside that operand's
|
|
3167
|
+
// fragment too, so an un-deduplicated pass reports the same line twice
|
|
3168
|
+
// and the outer report's advice is wrong: writing `in=<fragment>` would
|
|
3169
|
+
// repair the fragment and leave the operand split. The deepest
|
|
3170
|
+
// container is the one whose `in=` actually fixes the document.
|
|
3171
|
+
const split=new Map(); // row slot -> container id
|
|
3172
|
+
for(const id in SM.cont){
|
|
3173
|
+
const e=SM.extent[id];
|
|
3174
|
+
const kind=SM.cont[id].kind, aKind=(kind==='operand'?'an ':'a ')+kind;
|
|
3175
|
+
if(!e){
|
|
3176
|
+
errs.push('Line '+SM.cont[id].el.line+': '+kind+' "'+id+
|
|
3177
|
+
'" has no members — '+aKind+"'s extent is the span of the lines carrying in="+id+
|
|
3178
|
+
', and a container with no extent asserts nothing');
|
|
3179
|
+
continue; }
|
|
3180
|
+
const own=new Set(SM.owned[id]);
|
|
3181
|
+
for(let s=e.lo;s<=e.hi;s++){
|
|
3182
|
+
if(own.has(s)) continue;
|
|
3183
|
+
const prev=split.get(s);
|
|
3184
|
+
if(prev===undefined || SM.chain(id).length>SM.chain(prev).length) split.set(s,id);
|
|
3185
|
+
break;
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
for(const [s,id] of [...split.entries()].sort((a,b)=>a[0]-b[0])){
|
|
3189
|
+
const r=SM.rows[s], e=SM.extent[id];
|
|
3190
|
+
errs.push('Line '+r.line+': this '+r.kind+' line splits '+SM.cont[id].kind+' "'+id+
|
|
3191
|
+
'" (lines '+SM.rows[e.lo].line+'–'+SM.rows[e.hi].line+') — members must be CONTIGUOUS in declaration order. An operand denotes the ordered run of the occurrences it contains, so an occurrence that is not in it cannot happen between two that are (UML 2.5.1 §17.6; draft §28.1). Write in='+id+' on it, or move it outside the run');
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
2620
3195
|
for(const r of doc.ranks) for(const id of r.ids)
|
|
2621
3196
|
if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
|
|
2622
3197
|
// `MARKER-TARGET-KINDS`: `in=` on `threshold`/`band` also resolves a REGION id —
|
|
@@ -2702,32 +3277,102 @@ function parseOne(text){
|
|
|
2702
3277
|
// `fill=` and no `stroke=`, used by an edge. Ignoring it would drop the
|
|
2703
3278
|
// edge's colour with nothing to warn on; honouring it would make `fill`
|
|
2704
3279
|
// mean "stroke" for that member. So it is a line error that names the
|
|
2705
|
-
// key to add. `edge`
|
|
3280
|
+
// key to add. `edge` was the only interior-less construct taking `class=`
|
|
3281
|
+
// until the `sequence` genre added three more (`message`, `fragment`,
|
|
3282
|
+
// `operand`), which is what 0.4 below is about.
|
|
2706
3283
|
//
|
|
2707
|
-
// 0.1 (`CLASS-PAINT-REQUIREMENT`)
|
|
2708
|
-
//
|
|
2709
|
-
//
|
|
2710
|
-
//
|
|
2711
|
-
//
|
|
2712
|
-
//
|
|
2713
|
-
//
|
|
2714
|
-
//
|
|
2715
|
-
//
|
|
2716
|
-
//
|
|
2717
|
-
//
|
|
2718
|
-
//
|
|
2719
|
-
//
|
|
2720
|
-
//
|
|
2721
|
-
//
|
|
2722
|
-
//
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
3284
|
+
// 0.1 (`CLASS-PAINT-REQUIREMENT`) added a SECOND half — a class that paints NEITHER
|
|
3285
|
+
// channel, joined by an edge, was a line error too — and 0.4 (`CLASS-CHANNEL-REACH`)
|
|
3286
|
+
// RETIRES that half. It is not deleted quietly: `CLASS-PAINT-REQUIREMENT`'s own release fixed
|
|
3287
|
+
// the harm it named. The stated defect was that such a class "shows
|
|
3288
|
+
// nothing in the legend", and the same release made the derived legend
|
|
3289
|
+
// draw the meaning with NO swatch (see the legend strip in `render`), so
|
|
3290
|
+
// the meaning does reach the reader. What survived was only "the member
|
|
3291
|
+
// takes its default paint" — which is exactly what 14 shipped `field`
|
|
3292
|
+
// members already get, legally, from meaning-only classes in
|
|
3293
|
+
// examples/gre.fd, quic.fd, srh.fd and showcase/tcp-header.fd. A rule that
|
|
3294
|
+
// cannot generalise past one collection was not a rule about channels. A
|
|
3295
|
+
// class that claims a meaning and declares no paint is therefore legal on
|
|
3296
|
+
// EVERY member (`CLASS-CHANNEL-REACH`, MIGRATIONS 0.4), which is also the form the
|
|
3297
|
+
// `sequence` genre is built on: `class` there carries what `group` (`SEQUENCE-PARTICIPANT-GROUPING`)
|
|
3298
|
+
// and `lost=` (`UNDELIVERED-MESSAGE-MARKING`) were refused in favour of, so a meaning with no paint
|
|
3299
|
+
// is that genre's designed idiom, not an oversight.
|
|
3300
|
+
//
|
|
3301
|
+
// `INTERIOR-LESS-ELEMENT-PAINT`'s half stands and now reaches EVERY collection that accepts
|
|
3302
|
+
// `class=` (`CLASS-CHANNEL-REACH`). Until this release the loop below ran over `doc.edges`
|
|
3303
|
+
// alone, so `class k "K" fill=#eee` plus `message c -> s "m" class=k` was
|
|
3304
|
+
// accepted, painted nothing, and put the class in the legend — a message
|
|
3305
|
+
// has its own collection because it has a position in time (`SEQUENCE-ORDER-MODEL`), and
|
|
3306
|
+
// the check never looked there.
|
|
3307
|
+
//
|
|
3308
|
+
// THE CHANNEL SETS ARE DERIVED FROM WHAT EACH RENDERER READS, not from
|
|
3309
|
+
// what the directive tables accept — a key the drawing never consults is
|
|
3310
|
+
// not a channel the member HAS. Read off the `chan()` call sites in
|
|
3311
|
+
// `renderSequence` and the `rsAll`/`dashOf` sites in `render`:
|
|
3312
|
+
// node, group, lifeline, state fill, stroke, style (box/pill: all three)
|
|
3313
|
+
// edge, message stroke, style (no interior)
|
|
3314
|
+
// fragment, operand stroke, style (frame/rule; a
|
|
3315
|
+
// fragment's interior would hide its own
|
|
3316
|
+
// members, so it has no `fill=` to set)
|
|
3317
|
+
// field, cell fill, stroke (`style=` left both
|
|
3318
|
+
// directives at `STYLE-KEY-SCOPE` and no `dashOf` reads
|
|
3319
|
+
// `f.style`/`mk.style`)
|
|
3320
|
+
// A member with all three channels can never fail this test; the rows are
|
|
3321
|
+
// listed anyway, because the table is the rule and a missing row would
|
|
3322
|
+
// read as "not considered".
|
|
3323
|
+
//
|
|
3324
|
+
// TWO CASES FIRE, and both are declared paint that cannot arrive:
|
|
3325
|
+
// (a) `fill=` with no `stroke=` on a class an INTERIOR-LESS member joins.
|
|
3326
|
+
// Not caught by (b), because `fill=` plus `style=` would pass it: on
|
|
3327
|
+
// a line `fill=` and `stroke=` NAME THE SAME CHANNEL (the same reason
|
|
3328
|
+
// `fill=` on an `edge` LINE is refused), so an author who wrote
|
|
3329
|
+
// `fill=` meant the line's colour and `style=` does not answer that.
|
|
3330
|
+
// (b) a class whose channels are ALL channels the member lacks — the
|
|
3331
|
+
// general shape, which reaches `style=`-only on a `field` or a `cell`.
|
|
3332
|
+
// Guarded on the class declaring at least one channel, so a
|
|
3333
|
+
// meaning-only class falls through it (`CLASS-CHANNEL-REACH`).
|
|
3334
|
+
// Both are per class, per channel — `INTERIOR-LESS-ELEMENT-PAINT`'s shape, for `INTERIOR-LESS-ELEMENT-PAINT`'s reason. A class
|
|
3335
|
+
// that also declares a channel the member HAS is fine and must stay fine:
|
|
3336
|
+
// `class hot "…" fill=#fee2e2 stroke=#dc2626` paints a node's box and an
|
|
3337
|
+
// edge's line from one meaning, and `class=hot,deprecated` splits one
|
|
3338
|
+
// meaning across two declarations (conformance case 308).
|
|
3339
|
+
const CLASS_CHANNELS={
|
|
3340
|
+
node: {has:['fill','stroke','style'], a:'a node'},
|
|
3341
|
+
group: {has:['fill','stroke','style'], a:'a group'},
|
|
3342
|
+
lifeline: {has:['fill','stroke','style'], a:'a lifeline'},
|
|
3343
|
+
state: {has:['fill','stroke','style'], a:'a state'},
|
|
3344
|
+
edge: {has:['stroke','style'], a:'an edge'},
|
|
3345
|
+
message: {has:['stroke','style'], a:'a message'},
|
|
3346
|
+
fragment: {has:['stroke','style'], a:'a fragment'},
|
|
3347
|
+
operand: {has:['stroke','style'], a:'an operand'},
|
|
3348
|
+
field: {has:['fill','stroke'], a:'a field'},
|
|
3349
|
+
cell: {has:['fill','stroke'], a:'a cell'},
|
|
3350
|
+
};
|
|
3351
|
+
const clsChan=(x,kind)=>{
|
|
3352
|
+
const K=CLASS_CHANNELS[kind];
|
|
3353
|
+
for(const cid of (x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]))){
|
|
3354
|
+
const c=doc.classes.find(y=>y.id===cid);
|
|
3355
|
+
if(!c) continue; // unknown id: its own error
|
|
3356
|
+
const decl=['fill','stroke','style'].filter(k=>c[k]!==undefined);
|
|
3357
|
+
if(!decl.length) continue; // meaning only — legal (`CLASS-CHANNEL-REACH`)
|
|
3358
|
+
if(!K.has.includes('fill')&&c.fill!==undefined&&c.stroke===undefined){
|
|
3359
|
+
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)');
|
|
3360
|
+
continue; }
|
|
3361
|
+
if(!decl.some(k=>K.has.includes(k)))
|
|
3362
|
+
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)');
|
|
3363
|
+
}
|
|
3364
|
+
};
|
|
3365
|
+
for(const x of doc.nodes) clsChan(x,'node');
|
|
3366
|
+
for(const x of doc.groups) clsChan(x,'group');
|
|
3367
|
+
for(const x of doc.edges) clsChan(x,'edge');
|
|
3368
|
+
for(const x of (doc.messages||[])) clsChan(x,'message');
|
|
3369
|
+
for(const x of (doc.lifelines||[])) clsChan(x,'lifeline');
|
|
3370
|
+
for(const x of (doc.states||[])) clsChan(x,'state');
|
|
3371
|
+
for(const x of (doc.fragments||[])) clsChan(x,'fragment');
|
|
3372
|
+
for(const x of (doc.operands||[])) clsChan(x,'operand');
|
|
3373
|
+
for(const b of doc.blocks){
|
|
3374
|
+
if(b.fields) for(const f of b.fields) clsChan(f,'field');
|
|
3375
|
+
if(b.marks) for(const mk of b.marks) clsChan(mk,'cell');
|
|
2731
3376
|
}
|
|
2732
3377
|
}
|
|
2733
3378
|
{ // class references must resolve (closed grammar)
|
|
@@ -3323,13 +3968,37 @@ function render(doc,ropts){
|
|
|
3323
3968
|
if(b.fields) for(const f of b.fields) rsAll(f);
|
|
3324
3969
|
if(b.marks) for(const mk of b.marks) rsAll(mk);
|
|
3325
3970
|
}
|
|
3971
|
+
// GEOMETRY-TIME DIAGNOSTICS. `parse` cannot see a coordinate, so a defect
|
|
3972
|
+
// that is only visible in the DRAWING (a group band enclosing a non-member
|
|
3973
|
+
// the author pinned there) has no channel to report through today. The scene
|
|
3974
|
+
// hands its diagnostics back here and `render` returns them beside the SVG;
|
|
3975
|
+
// a caller that writes an artifact must treat a non-empty list exactly as it
|
|
3976
|
+
// treats a parse error, because the alternative is writing a picture that
|
|
3977
|
+
// states something the source does not.
|
|
3978
|
+
let sceneErrs=[];
|
|
3326
3979
|
const parts=[]; let y=0, maxW=0;
|
|
3327
3980
|
if(doc.title && RO.title===true){ parts.push('<text x="0" y="16" font-size="15" font-weight="600">'+esc(doc.title)+'</text>'); y=30;
|
|
3328
3981
|
maxW=Math.max(maxW, cw(doc.title)*8.6); } // canvas must fit the title
|
|
3329
3982
|
let sceneMeta=null;
|
|
3330
|
-
|
|
3983
|
+
// THE LADDER. A sequence figure has NO SCENE: both of its axes
|
|
3984
|
+
// are declaration-ordered, so there is nothing for the scene layout to
|
|
3985
|
+
// place, and its elements live in their own five collections rather than in
|
|
3986
|
+
// `nodes`/`edges` — which is why the scene branch could never have drawn
|
|
3987
|
+
// one. The branch is an `else if` and not a second pass because the two
|
|
3988
|
+
// renderers are alternatives, never neighbours: `lifeline` and `node` cannot
|
|
3989
|
+
// both appear in one document (one genre, one node spelling).
|
|
3990
|
+
//
|
|
3991
|
+
// Everything AFTER this point is shared and unchanged — the region stack,
|
|
3992
|
+
// the derived `class` legend, the figure-level note, the canvas padding and
|
|
3993
|
+
// the title. A `class` on a `message` earns its legend entry from the same
|
|
3994
|
+
// code that derives a topology figure's.
|
|
3995
|
+
if(doc.genre==='sequence'&&(doc.lifelines||[]).length){
|
|
3996
|
+
const s=renderSequence(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
|
|
3997
|
+
}
|
|
3998
|
+
else if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
|
|
3331
3999
|
const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
|
|
3332
4000
|
sceneMeta=s.meta;
|
|
4001
|
+
if(s.errs&&s.errs.length) sceneErrs=sceneErrs.concat(s.errs);
|
|
3333
4002
|
}
|
|
3334
4003
|
// `MARKER-TARGET-KINDS`: a region-scope `threshold`/`band` is drawn HERE and not
|
|
3335
4004
|
// in `renderScene`, because a region is not in the scene. Typed blocks stack
|
|
@@ -3448,12 +4117,23 @@ function render(doc,ropts){
|
|
|
3448
4117
|
+'<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
|
|
3449
4118
|
+'<line x1="0" y1="0" x2="0" y2="6" stroke="#bbb" stroke-width="2"/></pattern></defs>'
|
|
3450
4119
|
+'<g transform="translate('+PADL+','+PADT+')">'+parts.join('')+'</g></svg>', w:W, h:H,
|
|
3451
|
-
sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}};
|
|
4120
|
+
sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}, errs:sceneErrs};
|
|
3452
4121
|
}
|
|
3453
4122
|
|
|
3454
4123
|
// ---- scene ----
|
|
3455
4124
|
function renderScene(doc,y0){
|
|
3456
4125
|
const nodes=doc.nodes.map(n=>({...n}));
|
|
4126
|
+
// Band padding around a group's members — the ONE place it is written. The
|
|
4127
|
+
// contiguity pass and the drawn rect must agree to the pixel: a pass that
|
|
4128
|
+
// separates against a slightly different rectangle from the one painted is a
|
|
4129
|
+
// pass that reports clean on a picture that is not.
|
|
4130
|
+
const BAND={l:14,r:14,t:26,b:12};
|
|
4131
|
+
// A geometry-time diagnostic carries a SOURCE line like every other error in
|
|
4132
|
+
// this engine, and in a multi-section document the line the author reads is
|
|
4133
|
+
// the FULL-FILE one. `parse` re-bases its own messages; a render error is
|
|
4134
|
+
// built here, after that pass, so the doc carries the offset itself (0 for a
|
|
4135
|
+
// single-section document, where the two numberings coincide).
|
|
4136
|
+
const srcLine=n=>(n===null||n===undefined)?null:n+(doc.lineOffset||0);
|
|
3457
4137
|
// §2.4 plane z = paint order, applied by every pass that stacks annotations
|
|
3458
4138
|
// (edges, bundle rings, threshold lines, zone bands). The sort is stable, so
|
|
3459
4139
|
// same-plane items keep document order — "a later line paints on top".
|
|
@@ -3673,7 +4353,45 @@ function renderScene(doc,y0){
|
|
|
3673
4353
|
for(let i=r0;i<r1;i++) mainGap[i]=Math.max(mainGap[i],need);
|
|
3674
4354
|
}
|
|
3675
4355
|
const center=n=>n.cross+cs(n)/2;
|
|
4356
|
+
// GROUP CONTIGUITY, ORDERING HALF. A group's band is the bounding box of its
|
|
4357
|
+
// members, so a non-member that the lane order happens to place BETWEEN two
|
|
4358
|
+
// of them is drawn inside the band — the picture then states a membership the
|
|
4359
|
+
// source never declared. The geometry pass further down can always evict the
|
|
4360
|
+
// intruder, but eviction leaves the members where they were and the band
|
|
4361
|
+
// keeps a hole where the intruder used to be; ordering them adjacent HERE,
|
|
4362
|
+
// before any coordinate exists, costs nothing and packs the group properly.
|
|
4363
|
+
//
|
|
4364
|
+
// Sorting by a key that is CONSTANT ACROSS A GROUP is what makes members
|
|
4365
|
+
// contiguous — equal keys land together — and the key is the group's MEAN
|
|
4366
|
+
// desired position, so the cluster still sits where the incoming order put
|
|
4367
|
+
// it. The sort is stable, so members keep the order they arrived in and a
|
|
4368
|
+
// lane with nothing interleaved is not reordered at all.
|
|
4369
|
+
//
|
|
4370
|
+
// A group with a PINNED member is left alone: the pin is the author's
|
|
4371
|
+
// coordinate, this pass cannot move it, and clustering the rest around a slot
|
|
4372
|
+
// the pin will overwrite only displaces free nodes for nothing. That figure
|
|
4373
|
+
// is the AUTHOR's half of the ruling and is reported, not redrawn.
|
|
4374
|
+
const clusterGroups=arr=>{ // arr of {n, d}; reordered in place
|
|
4375
|
+
if(!arr.some(x=>x.n.group)) return arr;
|
|
4376
|
+
const mean=new Map();
|
|
4377
|
+
for(const x of arr){ const g=x.n.group; if(!g) continue;
|
|
4378
|
+
if(!mean.has(g)) mean.set(g,{s:0,k:0,pin:false});
|
|
4379
|
+
const t=mean.get(g); t.s+=x.d; t.k++; if(pinned(x.n.id)) t.pin=true; }
|
|
4380
|
+
if(![...mean.values()].some(t=>!t.pin&&t.k>1)) return arr;
|
|
4381
|
+
const live=g=>g&&mean.has(g)&&!mean.get(g).pin;
|
|
4382
|
+
const key=x=>live(x.n.group)?mean.get(x.n.group).s/mean.get(x.n.group).k:x.d;
|
|
4383
|
+
const tag=x=>live(x.n.group)?x.n.group:'';
|
|
4384
|
+
arr.sort((p,q)=>key(p)-key(q)||(tag(p)<tag(q)?-1:tag(p)>tag(q)?1:0));
|
|
4385
|
+
return arr;
|
|
4386
|
+
};
|
|
3676
4387
|
ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
|
|
4388
|
+
// The seed is the ONLY ordering a single-rank figure ever gets: `sweep`
|
|
4389
|
+
// walks rank boundaries, so a scene with no edges never reaches `place`.
|
|
4390
|
+
// The six-line reproduction (`group g` + three nodes, the middle one not a
|
|
4391
|
+
// member) is exactly that figure, which is why the clustering runs here too
|
|
4392
|
+
// and not only in the sweep.
|
|
4393
|
+
const ord=clusterGroups(lane.map((n,k)=>({n,d:k}))).map(x=>x.n);
|
|
4394
|
+
lane.length=0; ord.forEach(n=>lane.push(n));
|
|
3677
4395
|
lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
|
|
3678
4396
|
// WHERE THE HOLD YIELDS, WHICH IS MOST OF THE RULE. Holding a chain node on
|
|
3679
4397
|
// its chain neighbour puts every OTHER neighbour of that node on one side of
|
|
@@ -3879,6 +4597,148 @@ function renderScene(doc,y0){
|
|
|
3879
4597
|
if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
|
|
3880
4598
|
else { n.x=p.fx; n.y=y0+20+p.fy; }
|
|
3881
4599
|
}
|
|
4600
|
+
// ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
|
|
4601
|
+
// A group's band is the BOUNDING BOX of its members (see gBox below), and
|
|
4602
|
+
// until this pass nothing checked that the box contained only members. A
|
|
4603
|
+
// non-member the layout happened to place between two members was therefore
|
|
4604
|
+
// drawn INSIDE the band, with no error and no warning: six legal lines
|
|
4605
|
+
// (`group g`, three nodes of which the middle one is not `in=g`, `layout`)
|
|
4606
|
+
// produced a picture that says the middle node is in the group. That is the
|
|
4607
|
+
// worst failure class this project has — a legal document that reads
|
|
4608
|
+
// confidently and wrongly — and it contradicts this genre's own rule that
|
|
4609
|
+
// membership is DECLARED and never inferred from rendered geometry.
|
|
4610
|
+
//
|
|
4611
|
+
// WHOEVER CHOSE THE POSITION BEARS THE RESPONSIBILITY. That single principle
|
|
4612
|
+
// splits the fix in two:
|
|
4613
|
+
//
|
|
4614
|
+
// the ENGINE chose it — auto-layout had freedom, so the engine MUST place
|
|
4615
|
+
// the members contiguously and the situation cannot arise. It is fixed
|
|
4616
|
+
// here, silently, at no cost to the author.
|
|
4617
|
+
// the AUTHOR chose it — a `pin` fixed the intruder (or fixed the members
|
|
4618
|
+
// whose extent IS the band) and the engine has no freedom left. It then
|
|
4619
|
+
// reports, naming the pin line, and the artifact is not written. The
|
|
4620
|
+
// engine never overrides the author's coordinate, and never draws a
|
|
4621
|
+
// statement the source did not make.
|
|
4622
|
+
//
|
|
4623
|
+
// It runs HERE — after pins are applied and before the group origins are
|
|
4624
|
+
// taken — because the defect exists in the final geometry and nowhere else.
|
|
4625
|
+
// The source looks fine; that is the whole point of the defect.
|
|
4626
|
+
const gErrs=[];
|
|
4627
|
+
{
|
|
4628
|
+
const SEP=16; // clearance left between a band and what is pushed out
|
|
4629
|
+
const MAXPASS=60; // resolution is monotone (always outward); this bounds
|
|
4630
|
+
// pathological alternation rather than expected work
|
|
4631
|
+
const real=nodes.filter(n=>!n.boundary);
|
|
4632
|
+
const memOf=id=>real.filter(n=>n.group===id);
|
|
4633
|
+
const cLo=n=>horiz?n.y:n.x, cSz=n=>horiz?n.h:n.w;
|
|
4634
|
+
const mv=(n,d)=>{ if(horiz) n.y+=d; else n.x+=d; };
|
|
4635
|
+
const groups=doc.groups.filter(g=>memOf(g.id).length);
|
|
4636
|
+
const minCross=()=>lay.length?Math.min(...lay.map(cLo)):0;
|
|
4637
|
+
const cross0=minCross(); // the envelope the layout had before this pass
|
|
4638
|
+
const bandOf=g=>{
|
|
4639
|
+
const m=memOf(g.id);
|
|
4640
|
+
const B={x0:Math.min(...m.map(n=>n.x))-BAND.l, x1:Math.max(...m.map(n=>n.x+n.w))+BAND.r,
|
|
4641
|
+
yA:Math.min(...m.map(n=>n.y))-BAND.t, yB:Math.max(...m.map(n=>n.y+n.h))+BAND.b};
|
|
4642
|
+
B.lo=horiz?B.yA:B.x0; B.hi=horiz?B.yB:B.x1;
|
|
4643
|
+
return B;
|
|
4644
|
+
};
|
|
4645
|
+
const inBand=(n,B)=>n.x<B.x1&&n.x+n.w>B.x0&&n.y<B.yB&&n.y+n.h>B.yA;
|
|
4646
|
+
const pinLine=id=>doc.pins[id]?doc.pins[id].line:null;
|
|
4647
|
+
// The MOVER is a whole group or a single free node — never half a group,
|
|
4648
|
+
// because moving one member of a group reshapes THAT group's band and the
|
|
4649
|
+
// next pass would only find the same class of defect one group along.
|
|
4650
|
+
const unitOf=n=>n.group?memOf(n.group):[n];
|
|
4651
|
+
const canMove=u=>u.every(n=>!pinned(n.id))
|
|
4652
|
+
&& !(u[0].group&&doc.pins[u[0].group]&&doc.pins[u[0].group].fx!==null);
|
|
4653
|
+
const said=new Set();
|
|
4654
|
+
const collect=()=>{
|
|
4655
|
+
const out=[];
|
|
4656
|
+
for(const g of groups){
|
|
4657
|
+
const B=bandOf(g);
|
|
4658
|
+
for(const n of real){
|
|
4659
|
+
if(n.group===g.id||said.has(g.id+' '+n.id)) continue;
|
|
4660
|
+
if(inBand(n,B)) out.push({g,n});
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
return out;
|
|
4664
|
+
};
|
|
4665
|
+
let left=[];
|
|
4666
|
+
// EVERY conflict gets attention on every pass, and the band is recomputed
|
|
4667
|
+
// immediately before each resolution. Taking only the first conflict each
|
|
4668
|
+
// pass was tried and is wrong: one pair that alternates starves every other
|
|
4669
|
+
// pair for the whole iteration budget, and the run then reports as
|
|
4670
|
+
// "unresolvable" figures the pass had never once looked at.
|
|
4671
|
+
for(let pass=0;pass<MAXPASS;pass++){
|
|
4672
|
+
left=collect();
|
|
4673
|
+
if(!left.length) break;
|
|
4674
|
+
for(const c of left){
|
|
4675
|
+
const B=bandOf(c.g);
|
|
4676
|
+
if(!inBand(c.n,B)) continue; // an earlier resolution cleared it
|
|
4677
|
+
const gMem=memOf(c.g.id);
|
|
4678
|
+
// Who yields: the intruder if the engine placed it, otherwise the group
|
|
4679
|
+
// if the engine placed THAT, otherwise nobody and the author is told.
|
|
4680
|
+
let unit=unitOf(c.n), obst=B;
|
|
4681
|
+
if(!canMove(unit)){
|
|
4682
|
+
if(canMove(gMem)){ unit=gMem;
|
|
4683
|
+
obst={lo:cLo(c.n), hi:cLo(c.n)+cSz(c.n)}; }
|
|
4684
|
+
else {
|
|
4685
|
+
// No freedom anywhere: report, name the line that took it away, and
|
|
4686
|
+
// stop considering this pair so the loop still terminates.
|
|
4687
|
+
const who=pinned(c.n.id)?c.n.id
|
|
4688
|
+
:(c.n.group&&doc.pins[c.n.group]&&doc.pins[c.n.group].fx!==null?c.n.group
|
|
4689
|
+
:(gMem.find(m=>pinned(m.id))||{id:c.g.id}).id);
|
|
4690
|
+
const ln=srcLine(pinLine(who));
|
|
4691
|
+
gErrs.push('Line '+(ln!==null?ln:srcLine(c.g.line))+': pin puts "'+c.n.id
|
|
4692
|
+
+'" inside the band of group "'+c.g.id+'" — a band is the bounding box of the '
|
|
4693
|
+
+'group\'s members, so this draws "'+c.n.id+'" as one of them. Move the pin clear '
|
|
4694
|
+
+'of the group\'s extent, or say what the drawing says with in='+c.g.id+'.');
|
|
4695
|
+
said.add(c.g.id+' '+c.n.id); continue;
|
|
4696
|
+
}
|
|
4697
|
+
}
|
|
4698
|
+
const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
|
|
4699
|
+
const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
|
|
4700
|
+
// NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
|
|
4701
|
+
// whichever way is shorter — sends the unit past the layout's own
|
|
4702
|
+
// starting edge often enough to matter (`reference/topology` put L1 at
|
|
4703
|
+
// x=-90 and the viewBox clipped it away). Growing the canvas the other
|
|
4704
|
+
// way is not available either: the only uniform-shift machinery this
|
|
4705
|
+
// renderer has moves PINNED nodes with everything else, and a pinned
|
|
4706
|
+
// node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
|
|
4707
|
+
// stability violation this engine has already paid for once. So the
|
|
4708
|
+
// constraint is applied HERE, to the choice: the negative direction is
|
|
4709
|
+
// taken only when the unit still lands inside the envelope the layout
|
|
4710
|
+
// had before this pass ran. Nothing outside the mover ever moves.
|
|
4711
|
+
const dNegOK=uLo+dNeg>=cross0;
|
|
4712
|
+
const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
|
|
4713
|
+
const ranks=new Set(unit.map(n=>n.rank));
|
|
4714
|
+
const keep=new Set(unit.concat(unit===gMem?[]:gMem));
|
|
4715
|
+
// Everything the mover would be pushed ONTO travels with it: same rank,
|
|
4716
|
+
// same side, clear of the obstacle. Relative order and spacing inside a
|
|
4717
|
+
// lane are preserved, so the fix cannot manufacture an overlap.
|
|
4718
|
+
// A node that BELONGS to a group never travels this way — a group moves
|
|
4719
|
+
// whole or not at all, and dragging half of one along would reshape its
|
|
4720
|
+
// band, which is the same defect one group further on.
|
|
4721
|
+
for(const m of lay){
|
|
4722
|
+
if(keep.has(m)||!ranks.has(m.rank)) continue;
|
|
4723
|
+
if(!m.virtual&&m.group) continue;
|
|
4724
|
+
const mLo=cLo(m), mHi=mLo+cSz(m);
|
|
4725
|
+
if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
|
|
4726
|
+
}
|
|
4727
|
+
for(const n of unit) mv(n,d);
|
|
4728
|
+
}
|
|
4729
|
+
}
|
|
4730
|
+
left=collect();
|
|
4731
|
+
// The invariant is CHECKED, not assumed: anything the pass could not place
|
|
4732
|
+
// is named. A figure that reaches this line with a hit is a defect in this
|
|
4733
|
+
// pass, and saying so beats drawing the false statement quietly.
|
|
4734
|
+
for(const c of left){
|
|
4735
|
+
if(said.has(c.g.id+' '+c.n.id)) continue;
|
|
4736
|
+
gErrs.push('Line '+srcLine(c.g.line)+': group "'+c.g.id+'" would enclose non-member "'
|
|
4737
|
+
+c.n.id+'" and the layout pass could not separate them; the figure is not drawn rather '
|
|
4738
|
+
+'than drawn wrongly. Give "'+c.n.id+'" a pin outside the group, or add it with in='
|
|
4739
|
+
+c.g.id+'.');
|
|
4740
|
+
}
|
|
4741
|
+
}
|
|
3882
4742
|
// Pass 3: an unpinned group has no anchor of its own; its display origin
|
|
3883
4743
|
// (drag anchor / data-gx,gy) is the top-left of its members' FINAL positions,
|
|
3884
4744
|
// so it reflects any pinned members and matches the group box drawn below.
|
|
@@ -4055,8 +4915,8 @@ function renderScene(doc,y0){
|
|
|
4055
4915
|
const mem=nodes.filter(n=>n.group===g.id);
|
|
4056
4916
|
if(!mem.length) continue;
|
|
4057
4917
|
const o=gOrigin[g.id];
|
|
4058
|
-
const x0=Math.min(...mem.map(n=>n.x))-
|
|
4059
|
-
const yA=Math.min(...mem.map(n=>n.y))-
|
|
4918
|
+
const x0=Math.min(...mem.map(n=>n.x))-BAND.l, x1=Math.max(...mem.map(n=>n.x+n.w))+BAND.r;
|
|
4919
|
+
const yA=Math.min(...mem.map(n=>n.y))-BAND.t, yB=Math.max(...mem.map(n=>n.y+n.h))+BAND.b;
|
|
4060
4920
|
gBox[g.id]={x0,x1,yA,yB};
|
|
4061
4921
|
const gdash=g.style==='dashed'?' stroke-dasharray="6 4"':(g.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
4062
4922
|
gsvg.push('<g data-group="'+g.id+'" data-gx="'+o.x+'" data-gy="'+o.y+'" style="cursor:move">'
|
|
@@ -5086,7 +5946,7 @@ function renderScene(doc,y0){
|
|
|
5086
5946
|
}
|
|
5087
5947
|
const yEnd=y0+20+Hh+10;
|
|
5088
5948
|
return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
|
|
5089
|
-
meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}};
|
|
5949
|
+
meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}, errs:gErrs};
|
|
5090
5950
|
}
|
|
5091
5951
|
// borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
|
|
5092
5952
|
// It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
|
|
@@ -5250,6 +6110,393 @@ function edgeRuns(v, p, n, span, ownAt){
|
|
|
5250
6110
|
return out;
|
|
5251
6111
|
}
|
|
5252
6112
|
|
|
6113
|
+
// ---- ladder (the `sequence` genre) ----
|
|
6114
|
+
//
|
|
6115
|
+
// THE ORDERING RULE, stated once, because everything below depends on it:
|
|
6116
|
+
//
|
|
6117
|
+
// The TIME axis is the declaration order of the `message` and `state` lines
|
|
6118
|
+
// taken JOINTLY. Line m above line n asserts that m occurs before n.
|
|
6119
|
+
// `lifeline` declaration order is the COLUMN axis, left to right. Both axes
|
|
6120
|
+
// are declaration-ordered, and that is why this genre has no `flow` and no
|
|
6121
|
+
// `rank`: a key that reordered the drawing would make the picture disagree
|
|
6122
|
+
// with the text (`SEQUENCE-SOURCE-STANDARD`-R182). `fragment` and `operand` lines are
|
|
6123
|
+
// DECLARATIONS and carry no time position of their own; a container's drawn
|
|
6124
|
+
// extent is the span of its members' positions. Implementation: every
|
|
6125
|
+
// element carries its source line number, so the row order is recovered by
|
|
6126
|
+
// ONE sort on that number in `seqModel` — the model never stores an ordinal.
|
|
6127
|
+
//
|
|
6128
|
+
// Everything else in this function is a DRAWING CONVENTION the engine owns
|
|
6129
|
+
// under `DOMAIN-CONVENTION-DIRECTIVES` and is marked CHOSEN where it is not obvious. The author names
|
|
6130
|
+
// MEANING (who talks to whom, in what order, inside which fragment); the
|
|
6131
|
+
// engine decides every coordinate, and there is no key that moves one.
|
|
6132
|
+
//
|
|
6133
|
+
// The layout is SIX DETERMINISTIC PASSES and no fixed-point iteration:
|
|
6134
|
+
// 1. container column spans (which columns each fragment/operand covers)
|
|
6135
|
+
// 2. the column axis (centre-to-centre distances, widened to fit)
|
|
6136
|
+
// 3. the time axis (one slot per row, plus container headroom)
|
|
6137
|
+
// 4. container box geometry (from the tops/bottoms the cursor recorded)
|
|
6138
|
+
// 5. paint (background, mid, ink — three ordered layers)
|
|
6139
|
+
// 6. the canvas extent (widest of columns, boxes and overhanging ink)
|
|
6140
|
+
//
|
|
6141
|
+
// NOT DRAWN: activation bars. UML's ExecutionSpecification is a separate
|
|
6142
|
+
// referent with a separate spelling, and the genre has no keyword for it — so
|
|
6143
|
+
// the renderer must not invent one out of message adjacency, which would put
|
|
6144
|
+
// an assertion in the picture that the source does not make.
|
|
6145
|
+
function renderSequence(doc,y0){
|
|
6146
|
+
const M=seqModel(doc);
|
|
6147
|
+
const lls=doc.lifelines;
|
|
6148
|
+
if(!lls.length) return {svg:'',y:y0,w:0};
|
|
6149
|
+
const col={}; lls.forEach((l,i)=>{ col[l.id]=i; });
|
|
6150
|
+
// `OMITTED-LABEL-RECORDING`/`EMPTY-LABEL-STATE` display fallback, applied here rather than in `render`: the model
|
|
6151
|
+
// records an omitted label as absent (null) and the RENDERER substitutes the
|
|
6152
|
+
// id, so `lifeline c` draws "c". An explicitly empty label draws nothing.
|
|
6153
|
+
const lblOf=(x)=>(x.label===null||x.label===undefined)?x.id:x.label;
|
|
6154
|
+
// class cascade — the same rule `render` applies to nodes and edges, applied
|
|
6155
|
+
// to this genre's elements (a `class` on a `message` is what `lost=` was
|
|
6156
|
+
// refused in favour of, `UNDELIVERED-MESSAGE-MARKING`). Read-only: the element is never patched.
|
|
6157
|
+
const C={}; for(const c of doc.classes||[]) C[c.id]=c;
|
|
6158
|
+
const clsIds=(x)=>x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
|
|
6159
|
+
const chan=(x,k)=>{ if(x[k]!==undefined) return x[k];
|
|
6160
|
+
let v; for(const id of clsIds(x)) if(C[id]&&C[id][k]!==undefined) v=C[id][k]; return v; };
|
|
6161
|
+
// `seqModel` reports the containment chain; DEPTH is a view of it and lives
|
|
6162
|
+
// here because only the drawing needs it (nesting inset, paint order).
|
|
6163
|
+
const depth=(id)=>M.chain(id).length-1;
|
|
6164
|
+
|
|
6165
|
+
// ── geometry constants (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`) ────────────────────────────────────
|
|
6166
|
+
const HEAD_H=32, HEAD_PADX=13, HEAD_MINW=76;
|
|
6167
|
+
const ROW_H=34; // base row pitch. See the F5 note below.
|
|
6168
|
+
const SELF_W=40, SELF_EXTRA=26, STATE_H=22;
|
|
6169
|
+
const LBL_FS=11, LBL_LIFT=8; // label sits LBL_LIFT px above its own arrow
|
|
6170
|
+
const FRAG_TOP=26, FRAG_BOT=12, OPERAND_TOP=20, FRAG_PADX=22, FRAG_INSET=9;
|
|
6171
|
+
const ENC_PAD=6; // clearance a container's frame keeps off its members
|
|
6172
|
+
// F5 (spec/core.md §14.3) is a CONSTRAINT ON ROW_H, not an afterthought.
|
|
6173
|
+
// A message label's centre sits LBL_LIFT + fs*0.55 ≈ 14 px above its own
|
|
6174
|
+
// arrow, so its margin against the arrow one row away is ROW_H - 2*14.
|
|
6175
|
+
// F5 requires that to exceed M = 4 px, i.e. ROW_H > 32. ROW_H = 34 gives a
|
|
6176
|
+
// computed margin of 6 px at the worst case (two consecutive messages over
|
|
6177
|
+
// the same span) and much more in practice. This is the whole reason a
|
|
6178
|
+
// ladder is F5-cheap: the geometry separates labels by CONSTRUCTION, so the
|
|
6179
|
+
// margin is a property of the row pitch and not of any per-figure search.
|
|
6180
|
+
|
|
6181
|
+
const headW=lls.map(l=>Math.max(HEAD_MINW, cwMax(lblOf(l))*CH+2*HEAD_PADX));
|
|
6182
|
+
const lblPx=(s)=>s?cwMax(s)*(6.5*LBL_FS/11)+8:0;
|
|
6183
|
+
// A state pill's width is needed in TWO passes — the container-enclosure
|
|
6184
|
+
// pass and the paint pass — so it is written once. A pill wider than its
|
|
6185
|
+
// container's padding is exactly the case that made the enclosure pass
|
|
6186
|
+
// necessary (see PASS 4).
|
|
6187
|
+
const statePillW=(el)=>Math.max(46, cwMax(el.name)*6.6+18);
|
|
6188
|
+
|
|
6189
|
+
// ── PASS 1 — container column spans (needed BEFORE the column axis,
|
|
6190
|
+
// because a fragment's operator tab and label have to fit inside its
|
|
6191
|
+
// own box) ────────────────────────────────────────────────────────────
|
|
6192
|
+
const cspan={};
|
|
6193
|
+
for(const id in M.cont){
|
|
6194
|
+
const slots=M.owned[id];
|
|
6195
|
+
let cmin=Infinity,cmax=-Infinity;
|
|
6196
|
+
for(const sl of slots){ const r=M.rows[sl];
|
|
6197
|
+
if(r.kind==='message'){ const a=col[r.el.a],b=col[r.el.b];
|
|
6198
|
+
if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);}
|
|
6199
|
+
if(b!==undefined){cmin=Math.min(cmin,b);cmax=Math.max(cmax,b);} }
|
|
6200
|
+
else { const a=col[r.el.ref];
|
|
6201
|
+
if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);} } }
|
|
6202
|
+
cspan[id]=isFinite(cmin)?{cmin,cmax}:null;
|
|
6203
|
+
}
|
|
6204
|
+
// a fragment must be at least as wide as the operands it holds
|
|
6205
|
+
for(const id in M.cont){
|
|
6206
|
+
if(M.cont[id].kind!=='operand') continue;
|
|
6207
|
+
const p=M.cont[id].parent;
|
|
6208
|
+
if(p&&cspan[p]&&cspan[id]){ cspan[p].cmin=Math.min(cspan[p].cmin,cspan[id].cmin);
|
|
6209
|
+
cspan[p].cmax=Math.max(cspan[p].cmax,cspan[id].cmax); }
|
|
6210
|
+
}
|
|
6211
|
+
const tabW=(id)=>cwMax(M.cont[id].el.type||'')*6.6+16;
|
|
6212
|
+
const capW=(id)=>{ const c=M.cont[id];
|
|
6213
|
+
const lab=(c.el.label===null||c.el.label===undefined)?'':('['+c.el.label+']');
|
|
6214
|
+
return (c.kind==='fragment'?tabW(id)+14:8)+cwMax(lab)*6.6+10; };
|
|
6215
|
+
|
|
6216
|
+
// ── PASS 2 — the column axis: centre-to-centre distances ────────────────
|
|
6217
|
+
const nc=lls.length, cd=[];
|
|
6218
|
+
for(let k=0;k+1<nc;k++) cd.push(headW[k]/2+headW[k+1]/2+26);
|
|
6219
|
+
let rightPad=0;
|
|
6220
|
+
const widen=(i,j,need)=>{ // need = required span i..j
|
|
6221
|
+
if(j<=i) return;
|
|
6222
|
+
let have=0; for(let k=i;k<j;k++) have+=cd[k];
|
|
6223
|
+
if(have>=need) return;
|
|
6224
|
+
const add=(need-have)/(j-i); for(let k=i;k<j;k++) cd[k]+=add;
|
|
6225
|
+
};
|
|
6226
|
+
for(const r of M.rows){
|
|
6227
|
+
if(r.kind!=='message') continue;
|
|
6228
|
+
const ci=col[r.el.a], cj=col[r.el.b];
|
|
6229
|
+
if(ci===undefined||cj===undefined) continue;
|
|
6230
|
+
const w=lblPx(r.el.label)+26;
|
|
6231
|
+
if(ci===cj){ // self-message
|
|
6232
|
+
const need=SELF_W+lblPx(r.el.label)+16;
|
|
6233
|
+
if(ci+1<nc) widen(ci,ci+1,need); else rightPad=Math.max(rightPad,need);
|
|
6234
|
+
} else widen(Math.min(ci,cj),Math.max(ci,cj),w);
|
|
6235
|
+
}
|
|
6236
|
+
// the fragment caption is INSIDE the box, so it constrains the columns the
|
|
6237
|
+
// box spans — a caption that overflows its own box names nothing.
|
|
6238
|
+
for(const id in M.cont){
|
|
6239
|
+
const cs=cspan[id]; if(!cs) continue;
|
|
6240
|
+
const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
|
|
6241
|
+
if(cs.cmin===cs.cmax) rightPad=Math.max(rightPad,capW(id)-pad-headW[cs.cmax]/2);
|
|
6242
|
+
else widen(cs.cmin,cs.cmax,capW(id)-2*pad);
|
|
6243
|
+
}
|
|
6244
|
+
// The LEFT margin is structural too. Column 0's head box normally sets it,
|
|
6245
|
+
// but two things drawn on that column are wider than it: a `state` pill (as
|
|
6246
|
+
// wide as its state name) and a container frame whose left edge sits a
|
|
6247
|
+
// padding outside the column. Whichever overhangs furthest pushes the whole
|
|
6248
|
+
// axis right, so nothing is ever drawn at a negative x — the canvas has no
|
|
6249
|
+
// room there and the ink would simply be clipped away.
|
|
6250
|
+
let leftPad=0;
|
|
6251
|
+
for(const r of M.rows)
|
|
6252
|
+
if(r.kind==='state'&&col[r.el.ref]===0)
|
|
6253
|
+
leftPad=Math.max(leftPad, statePillW(r.el)/2+ENC_PAD-headW[0]/2);
|
|
6254
|
+
for(const id in M.cont){
|
|
6255
|
+
const cs=cspan[id]; if(!cs||cs.cmin!==0) continue;
|
|
6256
|
+
const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
|
|
6257
|
+
leftPad=Math.max(leftPad, pad+ENC_PAD+6-headW[0]/2);
|
|
6258
|
+
}
|
|
6259
|
+
const x=[]; x[0]=headW[0]/2+Math.max(0,leftPad);
|
|
6260
|
+
for(let k=1;k<nc;k++) x[k]=x[k-1]+cd[k-1];
|
|
6261
|
+
|
|
6262
|
+
// ── PASS 3 — the time axis: one slot per row, plus the space containers
|
|
6263
|
+
// need for their frames ────────────────────────────────────────────────
|
|
6264
|
+
const opensAt={}, closesAt={};
|
|
6265
|
+
for(const id in M.cont){ const e=M.extent[id]; if(!e) continue;
|
|
6266
|
+
(opensAt[e.lo]=opensAt[e.lo]||[]).push(id);
|
|
6267
|
+
(closesAt[e.hi]=closesAt[e.hi]||[]).push(id); }
|
|
6268
|
+
const sortDeep=(a)=>a.slice().sort((p,q)=>depth(p)-depth(q));
|
|
6269
|
+
const yTop=y0;
|
|
6270
|
+
let y=yTop+HEAD_H+22;
|
|
6271
|
+
// Box tops and bottoms are recorded AS THE CURSOR PASSES THEM, so an outer
|
|
6272
|
+
// fragment and the operand that opens with it get DIFFERENT tops and their
|
|
6273
|
+
// captions cannot land on each other. (Deriving both from the member row
|
|
6274
|
+
// overlapped them — visible in the rendered pixels, not in any metric.)
|
|
6275
|
+
const boxTop={}, boxBot={};
|
|
6276
|
+
for(const r of M.rows){
|
|
6277
|
+
for(const id of sortDeep(opensAt[r.slot]||[])){
|
|
6278
|
+
// a top-level fragment gets clear air above it, or two consecutive
|
|
6279
|
+
// fragments share a border and read as one box.
|
|
6280
|
+
if(depth(id)===0&&M.cont[id].kind==='fragment') y+=8;
|
|
6281
|
+
boxTop[id]=y; y+=(M.cont[id].kind==='fragment'?FRAG_TOP:OPERAND_TOP); }
|
|
6282
|
+
r.y0=y;
|
|
6283
|
+
let h=ROW_H;
|
|
6284
|
+
if(r.kind==='message'&&r.el.a===r.el.b) h+=SELF_EXTRA;
|
|
6285
|
+
// A label is drawn ABOVE its own arrow, so every extra line of it is
|
|
6286
|
+
// extra row pitch — otherwise line 2 lands ON the arrow.
|
|
6287
|
+
if(r.kind==='message') h+=Math.max(0,String(r.el.label||'').split('\n').length-1)*LBL_FS*1.3;
|
|
6288
|
+
// NOTE what is NOT here: `description=` reserves no row height, because it
|
|
6289
|
+
// puts NO INK on the page (core §10, §12.7 — "description= addresses the
|
|
6290
|
+
// machine and draws nothing, note= addresses the human and always draws").
|
|
6291
|
+
// The ladder honours that division: a description becomes an SVG <title>
|
|
6292
|
+
// on the element it names and nothing else. (The prototype this was ported
|
|
6293
|
+
// from drew it as grey prose under the arrow, which is the one thing the
|
|
6294
|
+
// key is defined not to do.)
|
|
6295
|
+
r.yMid=y+h/2;
|
|
6296
|
+
y+=h;
|
|
6297
|
+
r.y1=y;
|
|
6298
|
+
for(const id of sortDeep(closesAt[r.slot]||[]).reverse()){
|
|
6299
|
+
if(M.cont[id].kind==='fragment') y+=FRAG_BOT;
|
|
6300
|
+
boxBot[id]=y; }
|
|
6301
|
+
// The NEXT row's label is drawn ABOVE its own arrow, so a row that follows
|
|
6302
|
+
// a closing frame starts its label ~3 px under that frame's border and the
|
|
6303
|
+
// two read as one mark. A frame that closes therefore buys clear air below
|
|
6304
|
+
// it, on the same ground as the clear air a top-level fragment buys above.
|
|
6305
|
+
if((closesAt[r.slot]||[]).length && r.slot+1<M.rows.length) y+=10;
|
|
6306
|
+
}
|
|
6307
|
+
const bottom=y+10;
|
|
6308
|
+
|
|
6309
|
+
// ── PASS 4 — containers: box geometry ───────────────────────────────────
|
|
6310
|
+
const fbox={};
|
|
6311
|
+
for(const id in M.cont){
|
|
6312
|
+
const e=M.extent[id]; if(!e) continue;
|
|
6313
|
+
const cs=cspan[id]||{cmin:0,cmax:nc-1};
|
|
6314
|
+
const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
|
|
6315
|
+
fbox[id]={x0:x[cs.cmin]-pad, x1:Math.max(x[cs.cmax]+pad, x[cs.cmin]-pad+capW(id)),
|
|
6316
|
+
y0:boxTop[id], y1:boxBot[id],
|
|
6317
|
+
cmin:cs.cmin,cmax:cs.cmax,d,kind:M.cont[id].kind};
|
|
6318
|
+
}
|
|
6319
|
+
// A container's frame must ENCLOSE THE INK OF ITS MEMBERS, and the column
|
|
6320
|
+
// span alone does not guarantee that: a member's drawing can be wider than
|
|
6321
|
+
// the column it sits on. A `state` pill is centred on its lifeline and is as
|
|
6322
|
+
// wide as its state name, so a long name overhangs the fixed padding and the
|
|
6323
|
+
// pill pokes out through the frame that is supposed to contain it — visible
|
|
6324
|
+
// in the rendered pixels of the reference figure ("RENEWING" inside `loop`)
|
|
6325
|
+
// and invisible to every metric. The frame is therefore grown to the drawn
|
|
6326
|
+
// extent of what it owns. `owned` is TRANSITIVE, so an inner operand's
|
|
6327
|
+
// members widen the enclosing fragment too.
|
|
6328
|
+
for(const id in fbox){
|
|
6329
|
+
for(const sl of M.owned[id]){
|
|
6330
|
+
const r=M.rows[sl];
|
|
6331
|
+
let lo,hi;
|
|
6332
|
+
if(r.kind==='state'){ const ci=col[r.el.ref]; if(ci===undefined) continue;
|
|
6333
|
+
const w=statePillW(r.el); lo=x[ci]-w/2; hi=x[ci]+w/2; }
|
|
6334
|
+
else { const a=col[r.el.a], b=col[r.el.b]; if(a===undefined||b===undefined) continue;
|
|
6335
|
+
lo=Math.min(x[a],x[b]);
|
|
6336
|
+
hi=(a===b)?x[a]+SELF_W+8+lblPx(r.el.label):Math.max(x[a],x[b]); }
|
|
6337
|
+
fbox[id].x0=Math.min(fbox[id].x0,lo-ENC_PAD);
|
|
6338
|
+
fbox[id].x1=Math.max(fbox[id].x1,hi+ENC_PAD);
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
6341
|
+
// a fragment must contain its operands' boxes
|
|
6342
|
+
for(const id in fbox){
|
|
6343
|
+
if(fbox[id].kind!=='operand') continue;
|
|
6344
|
+
const p=M.cont[id].parent;
|
|
6345
|
+
if(p&&fbox[p]){ fbox[p].y1=Math.max(fbox[p].y1,fbox[id].y1);
|
|
6346
|
+
fbox[p].x0=Math.min(fbox[p].x0,fbox[id].x0-6);
|
|
6347
|
+
fbox[p].x1=Math.max(fbox[p].x1,fbox[id].x1+6); }
|
|
6348
|
+
}
|
|
6349
|
+
// An operand is a COMPARTMENT OF its fragment, so it is exactly as wide as
|
|
6350
|
+
// the fragment: its separator rule DIVIDES the frame and must reach both
|
|
6351
|
+
// borders, and its guard is read against the frame's left edge. Derived from
|
|
6352
|
+
// the parent LAST, after the parent has finished growing, so the divider can
|
|
6353
|
+
// never be shorter than the box it divides (UML 2.5.1 §17.12.3/§17.12.14).
|
|
6354
|
+
for(const id in fbox){
|
|
6355
|
+
if(fbox[id].kind!=='operand') continue;
|
|
6356
|
+
const p=M.cont[id].parent;
|
|
6357
|
+
if(p&&fbox[p]){ fbox[id].x0=fbox[p].x0; fbox[id].x1=fbox[p].x1; }
|
|
6358
|
+
}
|
|
6359
|
+
|
|
6360
|
+
// ── PASS 5 — paint ──────────────────────────────────────────────────────
|
|
6361
|
+
const bg=[], mid=[], ink=[];
|
|
6362
|
+
const HALO=' paint-order="stroke" stroke="#fff" stroke-width="3"';
|
|
6363
|
+
const arrowTri=(tip,from,c)=>{
|
|
6364
|
+
const dx=tip[0]-from[0], dy=tip[1]-from[1], L=Math.hypot(dx,dy)||1;
|
|
6365
|
+
const ux=dx/L, uy=dy/L, arm=10.08, hw=5.6;
|
|
6366
|
+
const bx=tip[0]-ux*arm, by=tip[1]-uy*arm;
|
|
6367
|
+
ink.push('<path d="M'+r2(tip[0])+' '+r2(tip[1])+' L'+r2(bx-uy*hw)+' '+r2(by+ux*hw)
|
|
6368
|
+
+' L'+r2(bx+uy*hw)+' '+r2(by-ux*hw)+' z" fill="'+c+'" stroke="none"/>');
|
|
6369
|
+
};
|
|
6370
|
+
const inkExtent=[];
|
|
6371
|
+
// `description=` → an SVG <title> and nothing else (core §10). `DESCRIPTION-KEY-SPELLING`'s rule
|
|
6372
|
+
// applies: a <title> names its PARENT, so it is never a loose sibling in the
|
|
6373
|
+
// figure's single <g> — where every description in the figure would name the
|
|
6374
|
+
// same element and a conforming UA would show one arbitrary tooltip for the
|
|
6375
|
+
// whole picture. Here each one wraps its own shape in a one-element <g>,
|
|
6376
|
+
// which keeps the shape SELF-CLOSING so the reference linter's edge and node
|
|
6377
|
+
// readers still find it.
|
|
6378
|
+
const titleEl=(s)=>(s===undefined||s===null)?'':'<title>'+esc(s)+'</title>';
|
|
6379
|
+
const withTitle=(s,shape)=>s===undefined||s===null?shape:'<g>'+titleEl(s)+shape+'</g>';
|
|
6380
|
+
|
|
6381
|
+
// lifelines: head box + descending dashed line.
|
|
6382
|
+
// The head is emitted as a `data-node` group — it IS the participant, and
|
|
6383
|
+
// the reference linter's node reader finds it there.
|
|
6384
|
+
lls.forEach((l,i)=>{
|
|
6385
|
+
const w=headW[i], hx=x[i]-w/2, lab=lblOf(l);
|
|
6386
|
+
const f=chan(l,'fill')||'#eef2ff', st=chan(l,'stroke')||'#4f46e5';
|
|
6387
|
+
bg.push('<line x1="'+r2(x[i])+'" y1="'+r2(yTop+HEAD_H+8)+'" x2="'+r2(x[i])+'" y2="'+r2(bottom)
|
|
6388
|
+
+'" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4"/>');
|
|
6389
|
+
bg.push('<g data-node="'+esc(l.id)+'" data-x="'+r2(hx)+'" data-y="'+r2(yTop+8)+'">'
|
|
6390
|
+
+titleEl(l.desc)
|
|
6391
|
+
+'<rect x="'+r2(hx)+'" y="'+r2(yTop+8)+'" width="'+r2(w)+'" height="'+HEAD_H
|
|
6392
|
+
+'" rx="4" fill="'+f+'" stroke="'+st+'"'+dashOf(chan(l,'style'),'')+'/>'
|
|
6393
|
+
+textEl(x[i], yTop+8+HEAD_H/2+4.5, 13, 'middle', labelInk(f,'#1d1d1b'), lab, '')
|
|
6394
|
+
+'</g>');
|
|
6395
|
+
});
|
|
6396
|
+
|
|
6397
|
+
// fragment / operand boxes, outermost first so nesting paints correctly
|
|
6398
|
+
const boxIds=Object.keys(fbox).sort((a,b)=>fbox[a].d-fbox[b].d);
|
|
6399
|
+
for(const id of boxIds){
|
|
6400
|
+
const B=fbox[id], c=M.cont[id];
|
|
6401
|
+
// both containers take `stroke=` and `class=`; the DEFAULT differs,
|
|
6402
|
+
// because a fragment's frame is a border and an operand's rule is a
|
|
6403
|
+
// divider inside one (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`).
|
|
6404
|
+
const st=chan(c.el,'stroke')||(c.kind==='fragment'?'#64748b':'#94a3b8');
|
|
6405
|
+
if(c.kind==='fragment'){
|
|
6406
|
+
mid.push(withTitle(c.el.desc,
|
|
6407
|
+
'<rect x="'+r2(B.x0)+'" y="'+r2(B.y0)+'" width="'+r2(B.x1-B.x0)+'" height="'+r2(B.y1-B.y0)
|
|
6408
|
+
+'" fill="none" stroke="'+st+'" stroke-width="1"'+dashOf(chan(c.el,'style'),'')+'/>'));
|
|
6409
|
+
// the operator tab — UML's pentagon in the top-left corner (§17.12.3;
|
|
6410
|
+
// the operator vocabulary itself is §17.12.15.3's InteractionOperatorKind)
|
|
6411
|
+
const tw0=cwMax(c.el.type)*6.6+16, th=15;
|
|
6412
|
+
mid.push('<path d="M'+r2(B.x0)+' '+r2(B.y0)+' h'+r2(tw0)+' l6,'+r2(th-6)+' v'+r2(6)
|
|
6413
|
+
+' h'+r2(-tw0-6)+' z" fill="#f8fafc" stroke="'+st+'" stroke-width="1"/>');
|
|
6414
|
+
mid.push(textEl(B.x0+7, B.y0+11, 10.5, 'start', '#334155', c.el.type, ''));
|
|
6415
|
+
if(c.el.label!==null&&c.el.label!==undefined)
|
|
6416
|
+
mid.push(textEl(B.x0+tw0+14, B.y0+11, 10.5, 'start', '#475569', '['+c.el.label+']', HALO));
|
|
6417
|
+
} else {
|
|
6418
|
+
// an operand compartment: a dashed rule above it (except the first) and
|
|
6419
|
+
// its guard at the left. UML draws the guard in square brackets.
|
|
6420
|
+
const p=M.cont[id].parent, sibs=doc.operands.filter(o=>o['in']===p);
|
|
6421
|
+
const first=sibs.length&&sibs[0].id===id;
|
|
6422
|
+
const rule=first?''
|
|
6423
|
+
:'<line x1="'+r2(B.x0)+'" y1="'+r2(B.y0+4)+'" x2="'+r2(B.x1)+'" y2="'+r2(B.y0+4)
|
|
6424
|
+
+'" stroke="'+st+'" stroke-width="1" stroke-dasharray="5 4"/>';
|
|
6425
|
+
const guard=(c.el.label!==null&&c.el.label!==undefined)
|
|
6426
|
+
? textEl(B.x0+8, B.y0+13, 10.5, 'start', '#475569', '['+c.el.label+']', HALO) : '';
|
|
6427
|
+
// An operand has no box of its own, so its <title> names the group
|
|
6428
|
+
// holding the two marks it DOES draw — the separator rule and the guard.
|
|
6429
|
+
if(rule||guard) mid.push(withTitle(c.el.desc, rule+guard));
|
|
6430
|
+
}
|
|
6431
|
+
}
|
|
6432
|
+
|
|
6433
|
+
// rows
|
|
6434
|
+
for(const r of M.rows){
|
|
6435
|
+
if(r.kind==='state'){
|
|
6436
|
+
// a state occurrence is a pill CENTRED ON ITS OWN COLUMN — the lifeline
|
|
6437
|
+
// it names in slot 1 (UML 2.5.1 §17.12.25's StateInvariant).
|
|
6438
|
+
const ci=col[r.el.ref]; if(ci===undefined) continue;
|
|
6439
|
+
const f=chan(r.el,'fill')||'#fff7ed', st=chan(r.el,'stroke')||'#c2410c';
|
|
6440
|
+
const w=statePillW(r.el);
|
|
6441
|
+
mid.push(withTitle(r.el.desc,
|
|
6442
|
+
'<rect x="'+r2(x[ci]-w/2)+'" y="'+r2(r.yMid-STATE_H/2)+'" width="'+r2(w)+'" height="'+STATE_H
|
|
6443
|
+
+'" rx="9" fill="'+f+'" stroke="'+st+'" stroke-width="1"'+dashOf(chan(r.el,'style'),'')+'/>'));
|
|
6444
|
+
ink.push(textEl(x[ci], r.yMid+4, LBL_FS, 'middle', labelInk(f,'#7c2d12'), r.el.name, ''));
|
|
6445
|
+
inkExtent.push(x[ci]+w/2);
|
|
6446
|
+
continue;
|
|
6447
|
+
}
|
|
6448
|
+
// message
|
|
6449
|
+
const e=r.el, ci=col[e.a], cj=col[e.b];
|
|
6450
|
+
if(ci===undefined||cj===undefined) continue;
|
|
6451
|
+
const st=chan(e,'stroke')||'#334155';
|
|
6452
|
+
const dash=dashOf(chan(e,'style'),'');
|
|
6453
|
+
if(ci===cj){ // self-message
|
|
6454
|
+
// a rectangular loop off the column and back to it. The shaft is one
|
|
6455
|
+
// `path` at the same stroke-width as a straight message, so the axis
|
|
6456
|
+
// readers see one edge and not three.
|
|
6457
|
+
const sx=x[ci], top=r.yMid-11, bot=r.yMid+11, ex=sx+SELF_W;
|
|
6458
|
+
mid.push(withTitle(e.desc,
|
|
6459
|
+
'<path d="M'+r2(sx)+' '+r2(top)+' L'+r2(ex)+' '+r2(top)+' L'+r2(ex)+' '+r2(bot)
|
|
6460
|
+
+' L'+r2(sx+11)+' '+r2(bot)+'" fill="none" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
|
|
6461
|
+
arrowTri([sx+2,bot],[sx+12,bot],st);
|
|
6462
|
+
if(e.label){ ink.push(textEl(ex+8, r.yMid+4, LBL_FS, 'start', '#1d1d1b', e.label, HALO));
|
|
6463
|
+
inkExtent.push(ex+8+lblPx(e.label)); }
|
|
6464
|
+
continue;
|
|
6465
|
+
}
|
|
6466
|
+
// A message between NON-ADJACENT columns crosses the lifelines between
|
|
6467
|
+
// them: the shaft is drawn straight from source centre to target centre
|
|
6468
|
+
// and the dashed columns it passes are left intact. This is UML's drawing
|
|
6469
|
+
// and it is also the honest one — a jog around an intervening lifeline
|
|
6470
|
+
// would suggest the message went somewhere it did not.
|
|
6471
|
+
const fwd=(e.op==='<-')?false:true; // '->' and '<->' read a→b
|
|
6472
|
+
let sx=fwd?x[ci]:x[cj], tx0=fwd?x[cj]:x[ci];
|
|
6473
|
+
const dir=Math.sign(tx0-sx)||1;
|
|
6474
|
+
sx+=dir*1.5;
|
|
6475
|
+
const ex=tx0-dir*1.5;
|
|
6476
|
+
mid.push(withTitle(e.desc,
|
|
6477
|
+
'<line x1="'+r2(sx)+'" y1="'+r2(r.yMid)+'" x2="'+r2(ex)+'" y2="'+r2(r.yMid)
|
|
6478
|
+
+'" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
|
|
6479
|
+
arrowTri([ex,r.yMid],[ex-dir*10,r.yMid],st);
|
|
6480
|
+
// `<->` is ONE shaft with TWO heads: the model says one occurrence, so
|
|
6481
|
+
// the drawing must not show two lines and invite a reader to count two.
|
|
6482
|
+
if(e.op==='<->') arrowTri([sx,r.yMid],[sx+dir*10,r.yMid],st);
|
|
6483
|
+
if(e.label){
|
|
6484
|
+
const nl=String(e.label).split('\n').length;
|
|
6485
|
+
ink.push(textEl((sx+ex)/2, r.yMid-LBL_LIFT-(nl-1)*LBL_FS*1.3/2, LBL_FS, 'middle', '#1d1d1b', e.label, HALO));
|
|
6486
|
+
}
|
|
6487
|
+
}
|
|
6488
|
+
|
|
6489
|
+
// ── PASS 6 — the canvas extent ──────────────────────────────────────────
|
|
6490
|
+
const W=Math.max(x[nc-1]+headW[nc-1]/2, ...Object.keys(fbox).map(k=>fbox[k].x1),
|
|
6491
|
+
...inkExtent)+rightPad+4;
|
|
6492
|
+
return {svg:bg.join('')+mid.join('')+ink.join(''), y:bottom, w:W,
|
|
6493
|
+
box:{x0:0,x1:W,yA:yTop,yB:bottom}};
|
|
6494
|
+
}
|
|
6495
|
+
// coordinates are emitted at 2 decimal places: the ladder's arithmetic divides
|
|
6496
|
+
// (`widen` spreads a shortfall over a run of columns), and an unrounded double
|
|
6497
|
+
// would put a 17-digit tail in the artifact for no reader's benefit.
|
|
6498
|
+
function r2(v){ return Math.round(v*100)/100; }
|
|
6499
|
+
|
|
5253
6500
|
// ---- bitfield ----
|
|
5254
6501
|
function renderBitfield(b,y0){
|
|
5255
6502
|
const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
|
|
@@ -6324,12 +7571,28 @@ let lastSVG='', lastMeta=null, lastPad={x:0,y:0}, lastDoc=null;
|
|
|
6324
7571
|
// SPELLINGS, not per-genre legality (the parser owns legality), so the
|
|
6325
7572
|
// per-genre withdrawals of `SCENE-KEYWORD-MEMBERSHIP` change nothing here — `threshold`, `band` and
|
|
6326
7573
|
// `bundle` are still content lines in the genres that still declare them.
|
|
6327
|
-
|
|
7574
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `lifeline` `message` `fragment` `operand` join the
|
|
7575
|
+
// list, because under `sequence` they ARE the content zone — the genre has no
|
|
7576
|
+
// `node`/`edge` spelling at all. Omitting them did not produce a line error;
|
|
7577
|
+
// it produced a WRONG FIGURE, which under this genre is worse. `lastContentLineIdx`
|
|
7578
|
+
// would find nothing but the `title` line, so every GUI insert landed at the TOP
|
|
7579
|
+
// of the document: a new lifeline became column 1 instead of the rightmost
|
|
7580
|
+
// column, and a new message became the FIRST occurrence in time instead of the
|
|
7581
|
+
// last. Both axes of a ladder are declaration order (draft §7), so "where the
|
|
7582
|
+
// line goes" is meaning here, not formatting.
|
|
7583
|
+
const CONTENT_KW=/^(figdown|title|node|state|lifeline|message|fragment|operand|group|external|edge|flowline|transition|flow|rank|threshold|band|bundle|class)\b/;
|
|
6328
7584
|
// `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: `state` is a node-declaring keyword under
|
|
6329
7585
|
// `statechart`, so an id it declares must count as USED here or the GUI
|
|
6330
7586
|
// hands out a colliding id. The list is spellings, not per-genre legality —
|
|
6331
7587
|
// the parser owns legality.
|
|
6332
|
-
|
|
7588
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `lifeline` `fragment` `operand` are the three
|
|
7589
|
+
// id-DECLARING keywords of `sequence` (its `state` REFERENCES a lifeline in
|
|
7590
|
+
// slot 1 and declares nothing, so it must stay out of this list under either
|
|
7591
|
+
// genre — it is already here for `statechart`, where slot 1 does declare, and
|
|
7592
|
+
// the shared spelling is exactly `SUBJECT-VOCABULARY-SCOPE`'s two-declarations-that-agree). Without
|
|
7593
|
+
// them `nextFreeId` handed out `n1` in a document that already had
|
|
7594
|
+
// `lifeline n1`, and the GUI authored `duplicate id "n1"`.
|
|
7595
|
+
const ID_DECL_KW=/^(node|state|lifeline|fragment|operand|group|external|bundle|class|bitfield|table|timing)\s+(\S+)/;
|
|
6333
7596
|
function lastContentLineIdx(lines){
|
|
6334
7597
|
let last=-1;
|
|
6335
7598
|
const layoutIdx=lines.findIndex(l=>l.trim()==='layout');
|
|
@@ -6391,7 +7654,44 @@ function guiVersion(lines){
|
|
|
6391
7654
|
// `flowline` into a `figdown 0.1 flowchart` document would make the editor
|
|
6392
7655
|
// author a line error.
|
|
6393
7656
|
const guiConnKw=lines=>connectorKwAt(guiGenre(lines), guiVersion(lines))||'edge';
|
|
6394
|
-
|
|
7657
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: "a line that DECLARES a node" is a per-DOCUMENT question,
|
|
7658
|
+
// not a per-language one, because two genres may SHARE a spelling for two
|
|
7659
|
+
// different grammars. `state` is the case: under `statechart` it declares a
|
|
7660
|
+
// node, under `sequence` it declares an OCCURRENCE that references a lifeline.
|
|
7661
|
+
// The union pattern (`NODE_KW_ALT`, right for "any node-ish line, any genre")
|
|
7662
|
+
// therefore mis-targets inside a ladder — measured, both directions:
|
|
7663
|
+
// * `state c "BOUND"` written above `lifeline c` captured Fill and Rename,
|
|
7664
|
+
// which then painted / relabelled the OCCURRENCE and left the head alone;
|
|
7665
|
+
// * Raise/Lower took a `state` line for the neighbouring node line and swapped
|
|
7666
|
+
// a lifeline past it, silently moving that occurrence's slot in the time
|
|
7667
|
+
// axis — a button that moves a column moving a ROW instead.
|
|
7668
|
+
// So the pattern is built from the DOCUMENT'S OWN node word, the same registry
|
|
7669
|
+
// read `guiNodeKw` already makes for what to write.
|
|
7670
|
+
const nodeLineReAt=lines=>new RegExp('^\\s*'+guiNodeKw(lines)+'\\s+');
|
|
7671
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: ONE alternation, built from the engine's own
|
|
7672
|
+
// `NODE_SPELLINGS`, for every GUI pattern that has to find "the line that
|
|
7673
|
+
// declares this node". Three of them were spelling `node|state` by hand and so
|
|
7674
|
+
// went blind the moment a genre added a fourth word: under `sequence` a
|
|
7675
|
+
// `lifeline` could not be renamed, recoloured, raised, lowered or deleted,
|
|
7676
|
+
// because none of the three could find its line. A per-genre word list written
|
|
7677
|
+
// out by hand is the defect; this is the single source.
|
|
7678
|
+
const NODE_KW_ALT=[...NODE_SPELLINGS].join('|');
|
|
7679
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: DOES THIS GENRE HAVE A COORDINATE FOR A PIN TO SET?
|
|
7680
|
+
// `pin` is genre-free vocabulary (`LAYOUT-ZONE-NAMESPACE`, `LAYOUT_KW`), so it PARSES under every
|
|
7681
|
+
// genre — but it only MEANS anything where a scene lays the figure out. A
|
|
7682
|
+
// ladder's two axes are both declaration-ordered (draft §7), which is the same
|
|
7683
|
+
// fact that keeps `flow` out of `GENRE_KW.sequence`: a genre with no direction
|
|
7684
|
+
// key to turn has no coordinate for a pin to write either. So the test reads
|
|
7685
|
+
// the keyword table rather than naming genres, and the next genre answers it
|
|
7686
|
+
// by declaring its own vocabulary.
|
|
7687
|
+
//
|
|
7688
|
+
// This is a GUI-AFFORDANCE test and nothing else. It does not refuse the
|
|
7689
|
+
// directive — a `pin` under `sequence` is legal, ignored, and the author's to
|
|
7690
|
+
// keep or delete. What it refuses is OFFERING the gesture: a drag that writes
|
|
7691
|
+
// a line the renderer never reads springs the head back and leaves dead
|
|
7692
|
+
// layout in the document, which is a GUI action that is not the text edit it
|
|
7693
|
+
// claims to be (`EDITOR-REQUIREMENT`).
|
|
7694
|
+
const genrePositionsByPin=(genre)=>!!(GENRE_KW[genre]&&GENRE_KW[genre].has('flow'));
|
|
6395
7695
|
function ensureFlowDirective(lines){
|
|
6396
7696
|
if(lines.some(l=>/^flow\b/.test(l.trim()))) return lines;
|
|
6397
7697
|
let genre='block';
|
|
@@ -6403,7 +7703,16 @@ function ensureFlowDirective(lines){
|
|
|
6403
7703
|
if(m){ genre=m[1]; break; }
|
|
6404
7704
|
}
|
|
6405
7705
|
// pure typed genres have no scene layout axis
|
|
6406
|
-
|
|
7706
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` joins them, and for the opposite
|
|
7707
|
+
// reason — it HAS two axes and the genre already fixes both. Columns run in
|
|
7708
|
+
// `lifeline` declaration order and time runs down the page in the order the
|
|
7709
|
+
// occurrence lines are written (draft §7), so `flow` is not a keyword of
|
|
7710
|
+
// this genre at all: writing `flow right` here is a LINE ERROR ("flow" is
|
|
7711
|
+
// not allowed in genre sequence), and the very first GUI insert into a
|
|
7712
|
+
// sequence document was authoring it. Even if it parsed it would be wrong —
|
|
7713
|
+
// a direction key could reverse an axis the genre fixes, and the drawing
|
|
7714
|
+
// would then disagree with the text (`DECLARATION-ORDER-SEMANTICS`).
|
|
7715
|
+
if(genre==='bitfield'||genre==='table'||genre==='timing'||genre==='sequence') return lines;
|
|
6407
7716
|
const dir=genre==='flowchart'?'down':'right';
|
|
6408
7717
|
let insertAt=lines.findIndex(l=>/^figdown\b/.test(l.trim()));
|
|
6409
7718
|
if(insertAt<0){
|
|
@@ -6604,7 +7913,7 @@ function setNodeLabel(id,label){
|
|
|
6604
7913
|
const lines=$('src').value.split('\n');
|
|
6605
7914
|
const idx=nodeLineIdx(lines,id); if(idx<0) return;
|
|
6606
7915
|
const {code,comment}=splitLineComment(lines[idx]);
|
|
6607
|
-
const re=new RegExp('^(\\s*
|
|
7916
|
+
const re=new RegExp('^(\\s*(?:'+NODE_KW_ALT+')\\s+'+id+')(?:\\s+("(?:\\\\.|[^"\\\\])*"|\\S+))?(.*)$');
|
|
6608
7917
|
const m=re.exec(code);
|
|
6609
7918
|
if(!m) return;
|
|
6610
7919
|
lines[idx]=joinCodeComment(m[1]+' "'+escapeFdString(label)+'"'+(m[3]||''), comment);
|
|
@@ -6738,7 +8047,17 @@ function installDirectEdit(){
|
|
|
6738
8047
|
ev.preventDefault();
|
|
6739
8048
|
const id=g.dataset.node, x0=+g.dataset.x, y0=+g.dataset.y, start=toModel(ev);
|
|
6740
8049
|
let dx=0,dy=0,moved=false;
|
|
6741
|
-
|
|
8050
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: THE DRAG IS NOT OFFERED WHERE A PIN SETS NOTHING.
|
|
8051
|
+
// The pointer handler stays — a lifeline head must still be CLICKABLE, to
|
|
8052
|
+
// select it and to arm a link — but under a declaration-ordered genre the
|
|
8053
|
+
// pointer never becomes a drag: no ghost translate (so nothing to spring
|
|
8054
|
+
// back) and `moved` stays false, so the `up` below takes the click branch
|
|
8055
|
+
// and no pin is ever materialized. Before this, dragging a head wrote a
|
|
8056
|
+
// legal `pin` line the ladder does not read; the head snapped back and
|
|
8057
|
+
// the document kept the dead line.
|
|
8058
|
+
const pinnable=genrePositionsByPin(lastDoc&&lastDoc.genre);
|
|
8059
|
+
const mv=e=>{ if(!pinnable) return;
|
|
8060
|
+
const p=toModel(e); dx=p.x-start.x; dy=p.y-start.y;
|
|
6742
8061
|
if(Math.abs(dx)+Math.abs(dy)>2) moved=true;
|
|
6743
8062
|
g.setAttribute('transform','translate('+dx+','+dy+')'); };
|
|
6744
8063
|
const up=()=>{ window.removeEventListener('pointermove',mv); window.removeEventListener('pointerup',up);
|
|
@@ -6770,7 +8089,11 @@ function installDirectEdit(){
|
|
|
6770
8089
|
g.addEventListener('dblclick',ev=>{
|
|
6771
8090
|
ev.preventDefault();
|
|
6772
8091
|
const id=g.dataset.node;
|
|
6773
|
-
|
|
8092
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the CURRENT label is looked up through the genre's own
|
|
8093
|
+
// collection — under `sequence` it is `doc.lifelines`, so asking
|
|
8094
|
+
// `lastDoc.nodes` offered an empty prompt for a lifeline that has a
|
|
8095
|
+
// label, and accepting it silently blanked the head.
|
|
8096
|
+
const nd=docNodes(lastDoc).find(n=>n.id===id);
|
|
6774
8097
|
const nv=prompt('Label for node "'+id+'":', (nd&&nd.label)||'');
|
|
6775
8098
|
if(nv!==null && nv!==''){ setNodeLabel(id,nv);
|
|
6776
8099
|
$('status').textContent='double-click → rewrote node '+id+' label (GUI action = text edit)'; }
|
|
@@ -6778,7 +8101,11 @@ function installDirectEdit(){
|
|
|
6778
8101
|
});
|
|
6779
8102
|
}
|
|
6780
8103
|
let selectedId=null, linkArm=null;
|
|
6781
|
-
|
|
8104
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the DOCUMENT'S node word, not the union — see
|
|
8105
|
+
// `nodeLineReAt`. Every caller here is answering "which line declares the
|
|
8106
|
+
// element the user clicked", and under `sequence` the union answered with a
|
|
8107
|
+
// `state` occurrence line whenever one was written above the declaration.
|
|
8108
|
+
function nodeLineIdx(lines,id){ return lines.findIndex(l=>new RegExp('^\\s*'+guiNodeKw(lines)+'\\s+'+id+'\\b').test(l)); }
|
|
6782
8109
|
function setNodeOption(id,key,val){
|
|
6783
8110
|
// Rewrite/append the option on the CODE side of any trailing comment (P0).
|
|
6784
8111
|
const lines=$('src').value.split('\n');
|
|
@@ -6790,6 +8117,7 @@ function setNodeOption(id,key,val){
|
|
|
6790
8117
|
$('src').value=lines.join('\n'); refresh();
|
|
6791
8118
|
}
|
|
6792
8119
|
function deleteNode(id){
|
|
8120
|
+
const before=$('src').value.split('\n');
|
|
6793
8121
|
// edges / paths / thresholds / bands via the parsed doc (line numbers);
|
|
6794
8122
|
// rank and bundle member lists are rewritten so the document still
|
|
6795
8123
|
// parses after the delete (P0: orphaned refs were left behind).
|
|
@@ -6800,11 +8128,21 @@ function deleteNode(id){
|
|
|
6800
8128
|
for(const p of d.paths||[]) if(p.a===id||p.b===id) drop.add(p.line);
|
|
6801
8129
|
for(const g of d.thresholds||[]) if(g.target===id) drop.add(g.line);
|
|
6802
8130
|
for(const b of d.bands||[]) if(b.target===id) drop.add(b.line);
|
|
8131
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` CASCADES, exactly as `statechart`
|
|
8132
|
+
// does — deleting a `state` there drops every `transition` touching it,
|
|
8133
|
+
// because those live in `doc.edges` and the loop above already sees them.
|
|
8134
|
+
// A sequence figure's connectors do NOT: a `message` is its own model row
|
|
8135
|
+
// (`doc.messages`), and so is a `state` occurrence (`doc.states`, which
|
|
8136
|
+
// REFERENCES a lifeline in `ref`). Neither was reachable from any loop
|
|
8137
|
+
// here, so deleting a lifeline used to leave its messages behind pointing
|
|
8138
|
+
// at an id the document no longer declares.
|
|
8139
|
+
for(const m of d.messages||[]) if(m.a===id||m.b===id) drop.add(m.line);
|
|
8140
|
+
for(const s of d.states||[]) if(s.ref===id) drop.add(s.line);
|
|
6803
8141
|
}
|
|
6804
8142
|
const lines=$('src').value.split('\n').map((l,li)=>{
|
|
6805
8143
|
if(drop.has(li+1)) return null;
|
|
6806
8144
|
const t=l.trim();
|
|
6807
|
-
if(new RegExp('^(
|
|
8145
|
+
if(new RegExp('^('+NODE_KW_ALT+'|pin)\\s+'+id+'\\b').test(t)) return null;
|
|
6808
8146
|
// 0.1: `rank` is ONE comma-delimited token. This rewrite used to
|
|
6809
8147
|
// both READ and WRITE the retired space form, so deleting a node from a
|
|
6810
8148
|
// ranked scene produced a document the engine now refuses.
|
|
@@ -6830,25 +8168,70 @@ function deleteNode(id){
|
|
|
6830
8168
|
return l;
|
|
6831
8169
|
}).filter(l=>l!==null);
|
|
6832
8170
|
selectedId=null;
|
|
6833
|
-
$('src').value=lines.join('\n'); refresh();
|
|
8171
|
+
$('src').value=dropEmptyContainers(lines, before).join('\n'); refresh();
|
|
8172
|
+
}
|
|
8173
|
+
// A `fragment` or an `operand` has no extent of its own — its extent IS the
|
|
8174
|
+
// span of the lines carrying `in=<id>`, and the engine refuses a container
|
|
8175
|
+
// with none ("a container with no extent asserts nothing"). So when a cascade
|
|
8176
|
+
// takes the last member with it, the declaration has to go too, or the delete
|
|
8177
|
+
// leaves a document that no longer parses. This is the SAME rule the `rank`
|
|
8178
|
+
// and `bundle` rewrites above already follow — a member list emptied by a
|
|
8179
|
+
// delete drops its line — and it repeats because an operand is itself a
|
|
8180
|
+
// member of its fragment, so removing one can empty the other.
|
|
8181
|
+
// Genre-safe without a genre read: no other genre spells these two words.
|
|
8182
|
+
// `before` is the source AS IT WAS, and it is what keeps this a cascade rather
|
|
8183
|
+
// than a cleaner: a container that was already empty before the delete is the
|
|
8184
|
+
// author's own error to see and fix, not something a delete may quietly remove.
|
|
8185
|
+
function dropEmptyContainers(lines, before){
|
|
8186
|
+
const hadMember=cid=>{
|
|
8187
|
+
const re=new RegExp('\\bin='+cid+'(?![\\w-])');
|
|
8188
|
+
return before.some(l=>re.test(l));
|
|
8189
|
+
};
|
|
8190
|
+
for(let changed=true; changed; ){
|
|
8191
|
+
changed=false;
|
|
8192
|
+
for(let i=0;i<lines.length;i++){
|
|
8193
|
+
const m=/^(fragment|operand)\s+(\S+)/.exec(lines[i].trim());
|
|
8194
|
+
if(!m || !hadMember(m[2])) continue;
|
|
8195
|
+
const re=new RegExp('\\bin='+m[2]+'(?![\\w-])');
|
|
8196
|
+
if(lines.some((l,j)=>j!==i&&re.test(l))) continue;
|
|
8197
|
+
lines.splice(i,1); i--; changed=true;
|
|
8198
|
+
}
|
|
8199
|
+
}
|
|
8200
|
+
return lines;
|
|
6834
8201
|
}
|
|
6835
8202
|
function moveNodeLine(id,dir){ // dir +1 = later line = painted on top
|
|
6836
8203
|
const lines=$('src').value.split('\n');
|
|
6837
8204
|
const idx=nodeLineIdx(lines,id); if(idx<0) return;
|
|
6838
|
-
const isNode=l=>
|
|
8205
|
+
const isNode=l=>nodeLineReAt(lines).test(l);
|
|
8206
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: what the move MEANS is the genre's, not the button's.
|
|
8207
|
+
// Where a scene lays the figure out, line order is paint order. Where both
|
|
8208
|
+
// axes are declaration-ordered (draft §7), moving a `lifeline` line moves its
|
|
8209
|
+
// COLUMN — the same keystroke, a different fact, and the status line may not
|
|
8210
|
+
// report the scene's one for a ladder.
|
|
8211
|
+
const scene=genrePositionsByPin(lastDoc&&lastDoc.genre);
|
|
6839
8212
|
let j=idx+dir;
|
|
6840
8213
|
while(j>=0&&j<lines.length&&!isNode(lines[j])) j+=dir;
|
|
6841
|
-
if(j<0||j>=lines.length) { $('status').textContent='already at the '+
|
|
8214
|
+
if(j<0||j>=lines.length) { $('status').textContent='already at the '+
|
|
8215
|
+
(scene?(dir>0?'top':'bottom')+' of paint order':(dir>0?'last':'first')+' column'); return; }
|
|
6842
8216
|
[lines[idx],lines[j]]=[lines[j],lines[idx]];
|
|
6843
8217
|
$('src').value=lines.join('\n'); refresh();
|
|
6844
|
-
$('status').textContent=(dir>0?'raised':'lowered')+' '+id+' — its line moved '+(dir>0?'down':'up')+
|
|
8218
|
+
$('status').textContent=(dir>0?'raised':'lowered')+' '+id+' — its line moved '+(dir>0?'down':'up')+
|
|
8219
|
+
' in the text ('+(scene?'line order = paint order':'declaration order = column order')+')';
|
|
6845
8220
|
}
|
|
6846
8221
|
function select(id){
|
|
6847
8222
|
selectedId=id;
|
|
6848
|
-
|
|
8223
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the enablement test asks the GENRE'S collection, not
|
|
8224
|
+
// `doc.nodes`. Fill / Delete / Raise / Lower all act on the node LINE, and
|
|
8225
|
+
// the patterns that find it have known every genre's spelling since
|
|
8226
|
+
// 0.4, so every one of them already worked on a `lifeline` — this
|
|
8227
|
+
// enablement test was the only thing between the
|
|
8228
|
+
// buttons and the edits, and it was permanently false under `sequence`
|
|
8229
|
+
// because a lifeline is in `doc.lifelines`. Same shape as `guiNodeKw`:
|
|
8230
|
+
// resolve through the engine's registry rather than branching on the genre.
|
|
8231
|
+
const has=id!==null && docNodes(lastDoc).some(n=>n.id===id);
|
|
6849
8232
|
['fillpick','delnode','raise','lower'].forEach(b=>$(b).disabled=!has);
|
|
6850
8233
|
if(has){
|
|
6851
|
-
const nd=lastDoc.
|
|
8234
|
+
const nd=docNodes(lastDoc).find(n=>n.id===id);
|
|
6852
8235
|
if(nd.fill&&/^#([0-9a-fA-F]{6})$/.test(nd.fill)) $('fillpick').value=nd.fill;
|
|
6853
8236
|
highlightSelection();
|
|
6854
8237
|
}
|
|
@@ -6869,6 +8252,11 @@ function drawResizeHandle(){
|
|
|
6869
8252
|
const svg=$('out').querySelector('svg'); if(!svg) return;
|
|
6870
8253
|
svg.querySelectorAll('.rsz').forEach(x=>x.remove());
|
|
6871
8254
|
if(!selectedId) return;
|
|
8255
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the corner handle writes `pin <id> width= height=`, so
|
|
8256
|
+
// it is the SECOND gesture that would author a line the ladder does not
|
|
8257
|
+
// read — and it only became reachable now that a lifeline can be selected.
|
|
8258
|
+
// Same test as the drag: where a pin sets nothing, the handle is not drawn.
|
|
8259
|
+
if(!genrePositionsByPin(lastDoc&&lastDoc.genre)) return;
|
|
6872
8260
|
const g=svg.querySelector('[data-node="'+selectedId+'"]'); if(!g) return;
|
|
6873
8261
|
const sh=g.querySelector('rect,polygon,ellipse'); if(!sh) return;
|
|
6874
8262
|
const b=sh.getBBox();
|
|
@@ -6933,6 +8321,17 @@ function setCellColor(tid,r,c,color){
|
|
|
6933
8321
|
let groupArm=false;
|
|
6934
8322
|
function createGroup(ids){
|
|
6935
8323
|
const lines=$('src').value.split('\n');
|
|
8324
|
+
// `SEQUENCE-PARTICIPANT-GROUPING`: `group` is REFUSED under `sequence`, so the marquee
|
|
8325
|
+
// cannot write one — it would author the genre's own refusal diagnostic. The
|
|
8326
|
+
// GUI says what the engine's diagnostic says to write instead, rather than
|
|
8327
|
+
// producing a broken document and letting the parser explain it afterwards.
|
|
8328
|
+
// Read off `GENRE_KW`, so a genre that simply has no `group` is covered too.
|
|
8329
|
+
const g=guiGenre(lines);
|
|
8330
|
+
if(GENRE_KW[g] && !GENRE_KW[g].has('group')){
|
|
8331
|
+
$('status').textContent='genre '+g+' has no "group" — write a `class` naming what these '+
|
|
8332
|
+
ids.length+' element(s) have in common and put class= on each of them';
|
|
8333
|
+
return;
|
|
8334
|
+
}
|
|
6936
8335
|
const gid=nextFreeId(lines,'g');
|
|
6937
8336
|
const gnum=gid.slice(1);
|
|
6938
8337
|
let firstIdx=lines.length;
|
|
@@ -6988,10 +8387,26 @@ function refresh(){
|
|
|
6988
8387
|
let r;
|
|
6989
8388
|
if(docs && docs.length>1){
|
|
6990
8389
|
const parts=docs.map(d=>render(d,{title:true}));
|
|
6991
|
-
r={svg:stackSectionSvgs(parts), sceneMeta:parts[0].sceneMeta, pad:parts[0].pad
|
|
8390
|
+
r={svg:stackSectionSvgs(parts), sceneMeta:parts[0].sceneMeta, pad:parts[0].pad,
|
|
8391
|
+
errs:parts.reduce((a,p)=>a.concat(p.errs||[]),[])};
|
|
6992
8392
|
} else {
|
|
6993
8393
|
r=render(doc,{title:true});
|
|
6994
8394
|
}
|
|
8395
|
+
// GEOMETRY-TIME ERRORS get the parse channel, because the author cannot tell
|
|
8396
|
+
// the two apart and must not have to: a group band that encloses a node the
|
|
8397
|
+
// source never put in the group is a picture that states something the
|
|
8398
|
+
// document does not, and drawing it anyway is the defect. Same list, same
|
|
8399
|
+
// clickable line numbers, same refusal to render.
|
|
8400
|
+
if(r.errs&&r.errs.length){
|
|
8401
|
+
eEl.innerHTML=r.errs.map(x=>{
|
|
8402
|
+
const m=/^Line (\d+):/.exec(x);
|
|
8403
|
+
return '<div class="errline"'+(m?' data-line="'+m[1]+'"':'')+'>'+esc(x)+'</div>';
|
|
8404
|
+
}).join('');
|
|
8405
|
+
ok.textContent=''; out.innerHTML=''; lastSVG='';
|
|
8406
|
+
$('svgsrc').textContent=''; $('svgimg').removeAttribute('src');
|
|
8407
|
+
$('status').textContent=r.errs.length+' error(s) — nothing rendered (determinism over convenience)';
|
|
8408
|
+
return;
|
|
8409
|
+
}
|
|
6995
8410
|
lastSVG=r.svg; lastMeta=r.sceneMeta; lastPad=r.pad; lastDoc=doc;
|
|
6996
8411
|
out.innerHTML=r.svg;
|
|
6997
8412
|
installDirectEdit();
|
|
@@ -7043,7 +8458,14 @@ function insertNodeLine(){
|
|
|
7043
8458
|
const nnum=nid.slice(1);
|
|
7044
8459
|
const last=lastContentLineIdx(lines);
|
|
7045
8460
|
const kk=$('newkind').value;
|
|
7046
|
-
|
|
8461
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the kind picker only gets to speak if the genre's
|
|
8462
|
+
// node directive TAKES `shape=`. A `lifeline` does not — a participant column
|
|
8463
|
+
// has one drawn form and the option names nothing on it — so `+ Node` with
|
|
8464
|
+
// the picker off `box` was writing `lifeline n1 "Node 1" shape=rounded`,
|
|
8465
|
+
// i.e. `lifeline does not take shape=`. The option table is the authority,
|
|
8466
|
+
// the same way `guiNodeKw` reads the genre table rather than guessing.
|
|
8467
|
+
const shaped=(directiveOpts(guiNodeKw(lines), guiGenre(lines))||[]).includes('shape');
|
|
8468
|
+
lines.splice(last+1,0,guiNodeKw(lines)+' '+nid+' "Node '+nnum+'"'+(kk!=='box'&&shaped?' shape='+kk:''));
|
|
7047
8469
|
$('src').value=lines.join('\n'); refresh();
|
|
7048
8470
|
return nid;
|
|
7049
8471
|
}
|