figdown 0.3.1 → 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
package/dist/figdown.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// figdown.js — FigDown embeddable library (0.
|
|
1
|
+
// figdown.js — FigDown embeddable library (0.4.0)
|
|
2
2
|
// GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
|
|
3
3
|
// Regenerate with: node tools/make-lib.js
|
|
4
4
|
(function (root, factory) {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
}
|
|
11
11
|
}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
|
12
12
|
'use strict';
|
|
13
|
-
var VERSION = "0.
|
|
13
|
+
var VERSION = "0.4.0";
|
|
14
14
|
|
|
15
15
|
// ---- engine (extracted verbatim from editor/figdown.html) ----
|
|
16
16
|
var __engine = (function () {
|
|
@@ -24,7 +24,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
|
|
|
24
24
|
// input to that promise, and under core §13 a 0.x renderer may differ from
|
|
25
25
|
// the next — which makes the recorded version the only thing that can
|
|
26
26
|
// explain a diff between two renderings of one source.
|
|
27
|
-
const FIGDOWN_VERSION = '0.
|
|
27
|
+
const FIGDOWN_VERSION = '0.4.0';
|
|
28
28
|
// `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
|
|
29
29
|
// counter does NOT reset (core §13.0.4 — `N` counts source states of the
|
|
30
30
|
// engine and only ever increases), so 0.1 is followed by
|
|
@@ -39,14 +39,34 @@ const FIGDOWN_VERSION = '0.3.1';
|
|
|
39
39
|
// fixes only. No new features. The language does not move." Shipping `note=`
|
|
40
40
|
// under `v0.2.z` would make `figdown 0.2` name two different languages: the one
|
|
41
41
|
// `v0.2.0` published and the one with `note=`. So the language number moves.
|
|
42
|
-
|
|
42
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `figdown 0.4` joins the set, and it joins it for the
|
|
43
|
+
// same reason `0.2` did — a GENRE token is language surface, and core §13.0
|
|
44
|
+
// makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
|
|
45
|
+
// no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
|
|
46
|
+
// the dispatch point lands first and the vocabulary follows it.
|
|
47
|
+
const LANG_VERSIONS = ['0.1', '0.2', '0.3', '0.4'];
|
|
43
48
|
// Genres per declared language version. `Y` never removes (core §13.0), so
|
|
44
49
|
// each row is a superset of the one above it, and `figdown 0.1 <anything>`
|
|
45
50
|
// resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
|
|
46
51
|
const GENRES_BY_VERSION = {
|
|
47
52
|
'0.1': ['block','topology','flowchart','bitfield','table','timing'],
|
|
48
53
|
'0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
|
|
49
|
-
'0.3': ['block','topology','flowchart','bitfield','table','timing','statechart']
|
|
54
|
+
'0.3': ['block','topology','flowchart','bitfield','table','timing','statechart'],
|
|
55
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` is dispatchable from here on. Like
|
|
56
|
+
// `statechart` at `STATECHART-GENRE-SCOPE` it arrived with NO vocabulary of its own, and the
|
|
57
|
+
// consequence was stated here rather than left to be discovered: there was no
|
|
58
|
+
// `GENRE_KW.sequence` row, and the allowlist guard is written
|
|
59
|
+
// `GENRE_KW[doc.genre] && !GENRE_KW[doc.genre].has(kw)`, so a genre with no
|
|
60
|
+
// row is NOT narrowed — a `figdown 0.4 sequence` document could write any
|
|
61
|
+
// registered keyword and it parsed. The document that increment meant to
|
|
62
|
+
// land was the header ALONE.
|
|
63
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: CLOSED. `GENRE_KW.sequence` exists below, so the
|
|
64
|
+
// genre now constrains what it names — five keywords of its own, `class`,
|
|
65
|
+
// and the genre-free core — and `flow`/`rank`/`group` are line errors under
|
|
66
|
+
// it. The genre still has NO RENDERER: a valid `sequence` document parses to
|
|
67
|
+
// a model and draws an empty canvas, which is the state this increment means
|
|
68
|
+
// to land and is pinned by a fixture rather than left to be noticed.
|
|
69
|
+
'0.4': ['block','topology','flowchart','bitfield','table','timing','statechart','sequence']
|
|
50
70
|
};
|
|
51
71
|
// The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
|
|
52
72
|
// device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
|
|
@@ -354,6 +374,26 @@ const DIRECTIVE_OPTS={
|
|
|
354
374
|
// keyed by the surface word an author actually wrote.
|
|
355
375
|
flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
|
|
356
376
|
transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
|
|
377
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own rows. `message` is
|
|
378
|
+
// the fourth connector spelling and takes the connector set — `fill=` and
|
|
379
|
+
// the three retired label keys stay listed for the same reason they are
|
|
380
|
+
// listed on the other three, so their dedicated diagnostics fire instead of
|
|
381
|
+
// a bare `does not take` — plus `in=` (sense 1: the fragment or operand this
|
|
382
|
+
// message occurs inside) and `description=`. It does NOT gain a key of its
|
|
383
|
+
// own: `lost=` was proposed and refused (`UNDELIVERED-MESSAGE-MARKING`), and `OPT_KEYS` is unchanged
|
|
384
|
+
// by this whole increment.
|
|
385
|
+
message:['style','class','fill','stroke','label','taillabel','headlabel','note','in','description'],
|
|
386
|
+
// A lifeline is drawn as a head box over a dashed line, so it has an
|
|
387
|
+
// interior and takes `fill=`. `in=` is sense 1.
|
|
388
|
+
lifeline:['class','fill','stroke','style','in','note','description'],
|
|
389
|
+
// `type=` is MANDATORY on `fragment` and is checked in `parseSeqDirective`,
|
|
390
|
+
// not here: a missing key is not an inapplicable key. No `fill=` — a
|
|
391
|
+
// combined fragment is a FRAME drawn over the messages it contains, and
|
|
392
|
+
// painting its interior would hide them.
|
|
393
|
+
fragment:['type','class','stroke','style','in','note','description'],
|
|
394
|
+
// `in=` is MANDATORY on `operand` (an operand is a compartment OF a
|
|
395
|
+
// fragment) and is likewise checked in `parseSeqDirective`.
|
|
396
|
+
operand:['in','class','stroke','style','note','description'],
|
|
357
397
|
// `PAINT-ORDER-CONSTRUCT`: the `plane` row is GONE, not emptied — the keyword is
|
|
358
398
|
// withdrawn from the language, so it has no acceptor row at all, the shape
|
|
359
399
|
// `path`/`routing` left behind. `z-index=` goes with it: it
|
|
@@ -400,6 +440,31 @@ const DIRECTIVE_OPTS={
|
|
|
400
440
|
cell:['fill','stroke','class'], width:[],
|
|
401
441
|
signal:['data','fill','stroke'], gap:[]
|
|
402
442
|
};
|
|
443
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the FIRST genre-conditional option row, and it
|
|
444
|
+
// exists because `DIRECTIVE_OPTS` is keyed by the SURFACE WORD an author
|
|
445
|
+
// wrote, while `GENRE-VOCABULARY-OBLIGATION` makes a surface word a per-genre declaration. Every other
|
|
446
|
+
// shared spelling in the language names the same construct in every genre
|
|
447
|
+
// that has it — `state` under `statechart` IS `node` renamed (`GENRE-NODE-SPELLING`), so it
|
|
448
|
+
// takes `node`'s row exactly — but `state` under `sequence` is a DIFFERENT
|
|
449
|
+
// construct under the same spelling: a StateInvariant (UML 2.5.1 §17.12.25)
|
|
450
|
+
// that REFERENCES a lifeline rather than declaring an id. It has no shape and
|
|
451
|
+
// no extent of its own, so `shape=`, `width=` and `height=` name nothing on
|
|
452
|
+
// it; it can sit inside a fragment, so it takes `in=` (`SEQUENCE-CONTAINMENT-SCOPE`); and it takes
|
|
453
|
+
// `description=` like the rest of this genre's directives.
|
|
454
|
+
//
|
|
455
|
+
// The lookup is one table indexed genre-first, so a genre with no entry falls
|
|
456
|
+
// through to `DIRECTIVE_OPTS` untouched and the whole 0.1/0.2/0.3 surface is
|
|
457
|
+
// byte-identical. It is NOT a second registry: every key named here is
|
|
458
|
+
// already an `OPT_KEYS` member accepted by some directive, so nothing about
|
|
459
|
+
// the closed option namespace changes.
|
|
460
|
+
const GENRE_DIRECTIVE_OPTS={
|
|
461
|
+
sequence:{
|
|
462
|
+
state:['class','fill','stroke','style','in','note','description']
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
const directiveOpts=(kw,genre)=>
|
|
466
|
+
(genre && GENRE_DIRECTIVE_OPTS[genre] && GENRE_DIRECTIVE_OPTS[genre][kw])
|
|
467
|
+
|| DIRECTIVE_OPTS[kw];
|
|
403
468
|
const STYLES=['solid','dashed','dotted'];
|
|
404
469
|
// `RULE-POSITION-ENUMERATION`: every LIVE option key whose value grammar is an enum,
|
|
405
470
|
// read off spec/vocabulary-sources.tsv (`shape` column = `enum`, `status`
|
|
@@ -498,7 +563,7 @@ const RETIRED_OPT_KEYS={
|
|
|
498
563
|
// appears.
|
|
499
564
|
'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)',
|
|
500
565
|
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)',
|
|
501
|
-
// `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until
|
|
566
|
+
// `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until 0.3 (`DRAWN-ANNOTATION-FORM`), and its
|
|
502
567
|
// row is gone because the key is LIVE again — SYNTAX-STYLE RULE 4.9
|
|
503
568
|
// obligation 3 forbids leaving the retirement message standing past the
|
|
504
569
|
// revival, on the ground that a message telling an author to write
|
|
@@ -566,7 +631,7 @@ const RETIRED_LAYER='layer has been WITHDRAWN: it was renamed plane, and plane w
|
|
|
566
631
|
// `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
|
|
567
632
|
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)';
|
|
568
633
|
// `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
|
|
569
|
-
const RETIRED_BOUNDARY='boundary has been renamed: use external (it declares an external I/O endpoint — the spec\'s own words — while
|
|
634
|
+
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)';
|
|
570
635
|
// `ROW-BREAK-NAMING`: the `bitfield` child keyword `wrap` became `break`.
|
|
571
636
|
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)';
|
|
572
637
|
// `PRESENCE-FLAG-SPELLING`: the 0.1 rename `optional` -> `conditional` (`PRESENCE-FLAG-SPELLING`)
|
|
@@ -958,6 +1023,25 @@ const FLOWCHART_SUBJECT_KW=['external'];
|
|
|
958
1023
|
// none of the six; `external` is additionally UML 2.5.1 §14's own
|
|
959
1024
|
// `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
|
|
960
1025
|
const STATECHART_SUBJECT_KW=[];
|
|
1026
|
+
// `sequence` (EXPERIMENTAL, 0.4, `SEQUENCE-SOURCE-STANDARD`-R182): THREE, and the array is
|
|
1027
|
+
// this genre's whole declaration of what a sequence figure is OF. `state` and
|
|
1028
|
+
// `fragment` and `operand` describe referents UML clause 17 defines —
|
|
1029
|
+
// `StateInvariant` (§17.12.25), `CombinedFragment` (§17.12.3) and
|
|
1030
|
+
// `InteractionOperand` (§17.12.14) — so they are subject vocabulary in exactly
|
|
1031
|
+
// `SUBJECT-VOCABULARY-SCOPE`'s sense, while `lifeline` and `message` are this genre's NODE and
|
|
1032
|
+
// CONNECTOR spellings and live in `GENRE_NODE_KW`/`GENRE_CONNECTOR_KW` with
|
|
1033
|
+
// the other genres' (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`). None of the six words the four scene genres
|
|
1034
|
+
// declare is here: `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, below), `external` `threshold`
|
|
1035
|
+
// `band` `bundle` have no measured need and no clause-17 referent, and
|
|
1036
|
+
// `flow`/`rank` are refused as a CONSEQUENCE of the genre's two axes being
|
|
1037
|
+
// declaration-ordered (draft §7) — they are simply absent from `GENRE_KW`.
|
|
1038
|
+
// `state` is SHARED with `statechart` as a spelling and is a SEPARATE
|
|
1039
|
+
// declaration with a different grammar: there slot 1 declares an id, here it
|
|
1040
|
+
// REFERENCES a lifeline (draft §29 Q5). Two genres agreeing on a spelling is
|
|
1041
|
+
// two declarations that agree, never one inherited — which is the whole of
|
|
1042
|
+
// `SUBJECT-VOCABULARY-SCOPE`, and is why this genre's `state` also takes its own option row
|
|
1043
|
+
// (GENRE_DIRECTIVE_OPTS).
|
|
1044
|
+
const SEQUENCE_SUBJECT_KW=['state','fragment','operand'];
|
|
961
1045
|
// `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
|
|
962
1046
|
// `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
|
|
963
1047
|
// `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
|
|
@@ -993,10 +1077,50 @@ const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
|
|
|
993
1077
|
// symbol this genre cannot spell is a COVERAGE GAP in FigDown, not a state
|
|
994
1078
|
// of the figure, and `node` is not its spelling — see
|
|
995
1079
|
// the project’s working record for the coverage ledger.
|
|
996
|
-
|
|
997
|
-
const
|
|
998
|
-
const
|
|
999
|
-
const
|
|
1080
|
+
// sequence lifeline message (OMG UML 2.5.1 §17)
|
|
1081
|
+
const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'state',sequence:'lifeline'};
|
|
1082
|
+
const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition',sequence:'message'};
|
|
1083
|
+
const NODE_SPELLINGS=new Set(['node','state','lifeline']);
|
|
1084
|
+
const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition','message']);
|
|
1085
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: WHERE EACH GENRE'S NODE ROWS LIVE IN THE PARSED DOC.
|
|
1086
|
+
// `GENRE_NODE_KW` above says what the WORD is; this says which `doc`
|
|
1087
|
+
// collection the parser puts that word's rows in. The two are separate facts
|
|
1088
|
+
// and only one of them is `nodes`: `statechart` renames the word and keeps the
|
|
1089
|
+
// collection (a `state` is a scene node), while `sequence` renames BOTH — a
|
|
1090
|
+
// `lifeline` is not a scene node and lands in `doc.lifelines`. Any GUI test of
|
|
1091
|
+
// the form "is this id a thing this genre declares" has to ask through here.
|
|
1092
|
+
// Hand-writing `doc.nodes` is the defect it closes: the Fill/Delete/Raise/
|
|
1093
|
+
// Lower enablement asked `lastDoc.nodes` and so was permanently false under
|
|
1094
|
+
// `sequence`, greying out four buttons whose edits (`SEQUENCE-SOURCE-STANDARD`-R182) already worked.
|
|
1095
|
+
const GENRE_NODE_COLL={block:'nodes',topology:'nodes',flowchart:'nodes',
|
|
1096
|
+
statechart:'nodes',sequence:'lifelines'};
|
|
1097
|
+
const docNodes=(doc)=>(doc&&doc[GENRE_NODE_COLL[doc.genre]||'nodes'])||[];
|
|
1098
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that have their own
|
|
1099
|
+
// parser (`message` rides the connector scanner). The set is what dispatches
|
|
1100
|
+
// to `parseSeqDirective`, and it is scoped by `doc.genre` at the call site so
|
|
1101
|
+
// `state` still reaches `statechart`'s node parser under `statechart`.
|
|
1102
|
+
const SEQ_KW=new Set(['lifeline','state','fragment','operand']);
|
|
1103
|
+
// UML 2.5.1 `InteractionOperatorKind` (§17.12.15.3), taken WHOLE — twelve
|
|
1104
|
+
// values, every one the standard's own single lowercase spelling, so RULE 4.2
|
|
1105
|
+
// admits the abbreviations `alt` `opt` `par` `neg` `seq` unchanged. The clause
|
|
1106
|
+
// number matters: §17.6.2 was cited for this enum in an earlier draft and is
|
|
1107
|
+
// registered FALSE in spec/standards-claims.tsv (S024). FigDown makes the key
|
|
1108
|
+
// MANDATORY where UML gives the attribute a default of `seq`
|
|
1109
|
+
// (`interactionOperator : InteractionOperatorKind [1..1] = seq`, §17.12.3.5) —
|
|
1110
|
+
// a DECLARED divergence: a default would let a fragment assert nothing while
|
|
1111
|
+
// looking like it asserts something, which is the `numbering=` precedent.
|
|
1112
|
+
const SEQ_OPERATORS_FRAG=['alt','opt','loop','par','strict','seq','critical',
|
|
1113
|
+
'neg','assert','ignore','consider','break'];
|
|
1114
|
+
const SEQ_FRAG_CLAUSE='UML 2.5.1 §17.12.15.3';
|
|
1115
|
+
// Draft §8.2/§15.4: every UML Message has a sendEvent AND a receiveEvent, so
|
|
1116
|
+
// a direction-less message is not a thing this domain has. `--` is a LINE
|
|
1117
|
+
// ERROR under `sequence` and legal in every other genre — the operator set is
|
|
1118
|
+
// per genre for the same reason the keywords are.
|
|
1119
|
+
const SEQ_OPERATORS=new Set(['->','<-','<->']);
|
|
1120
|
+
// Every connector spelling in the language reaches the dedicated scanner —
|
|
1121
|
+
// the WRONG one for the genre must get the named diagnostic, not
|
|
1122
|
+
// `unrecognized line` (`GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`), so the dispatch is over the whole set.
|
|
1123
|
+
const CONN_LINE_RE=new RegExp('^('+[...CONNECTOR_SPELLINGS].join('|')+')(\\s|$)');
|
|
1000
1124
|
// `KEYWORD-RENAME-SCOPE`: the flowchart rename is GATED BY THE DECLARED LANGUAGE
|
|
1001
1125
|
// VERSION, because `GENRE-CONNECTOR-SPELLING` applied it to `figdown 0.1` and that BROKE documents
|
|
1002
1126
|
// legal at v0.1.8 — `figdown 0.1 flowchart` + `edge` stopped parsing, with
|
|
@@ -1010,7 +1134,12 @@ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition']);
|
|
|
1010
1134
|
// forbids; two spellings across VERSIONS is ordinary language evolution, and
|
|
1011
1135
|
// each version accepts exactly one. `statechart` needs no gate of its own —
|
|
1012
1136
|
// the GENRE requires 0.2 (GENRES_BY_VERSION), so `state`/`transition` cannot
|
|
1013
|
-
// be reached from a 0.1 document at all.
|
|
1137
|
+
// be reached from a 0.1 document at all. `sequence` inherits that argument
|
|
1138
|
+
// unchanged: its genre token requires `figdown 0.4`, so
|
|
1139
|
+
// `lifeline`/`message` are unreachable from any earlier document and there is
|
|
1140
|
+
// no earlier spelling for them to have replaced. A `CONNECTOR_MIN_VERSION`
|
|
1141
|
+
// row for `message` would therefore gate nothing and would make the engine
|
|
1142
|
+
// claim a rename that never happened.
|
|
1014
1143
|
const GENRE_CONNECTOR_KW_AT={
|
|
1015
1144
|
'0.1':{block:'edge',topology:'edge',flowchart:'edge'},
|
|
1016
1145
|
'0.2':GENRE_CONNECTOR_KW
|
|
@@ -1033,17 +1162,22 @@ const WORD_WHY={
|
|
|
1033
1162
|
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"',
|
|
1034
1163
|
transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
|
|
1035
1164
|
node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
|
|
1036
|
-
state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)'
|
|
1165
|
+
state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)',
|
|
1166
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: both are WHOLE borrows from the genre's source
|
|
1167
|
+
// standard, verified against the clause text rather than against a
|
|
1168
|
+
// secondary description (spec/standards-claims.tsv).
|
|
1169
|
+
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',
|
|
1170
|
+
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'
|
|
1037
1171
|
};
|
|
1038
1172
|
// The named diagnostic `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING` owe: it says WHICH word this genre uses and
|
|
1039
1173
|
// WHY, and it names the migration, because every connector line in a
|
|
1040
1174
|
// reclassified document has to be rewritten (the cost `GENRE-CONNECTOR-SPELLING` accepted).
|
|
1041
1175
|
const WRONG_WORD=(surf,want,genre)=>
|
|
1042
1176
|
'"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
|
|
1043
|
-
'. Each
|
|
1177
|
+
'. 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)';
|
|
1044
1178
|
// `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
|
|
1045
1179
|
// and `"threshold" is not allowed in genre topology` would send an author
|
|
1046
|
-
// looking for a typo. Each cell below was legal until
|
|
1180
|
+
// looking for a typo. Each cell below was legal until 0.3 and states
|
|
1047
1181
|
// WHY that genre no longer declares it — the ruling's own ground, per cell,
|
|
1048
1182
|
// because the grounds differ and a single sentence could not carry them.
|
|
1049
1183
|
// Every one of these withdrawals was FREE: `topology`, `flowchart` and
|
|
@@ -1078,6 +1212,33 @@ const WITHDRAWN_FROM_GENRE=(kw,genre)=>
|
|
|
1078
1212
|
GENRE_WITHDRAWN[genre][kw]+
|
|
1079
1213
|
' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`): a spelling accepted by several genres is several '+
|
|
1080
1214
|
'independent declarations, and this genre\'s was withdrawn without touching any other\'s.'+WITHDREW_AT;
|
|
1215
|
+
// `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: THE OTHER HALF OF `SCENE-KEYWORD-MEMBERSHIP` — a spelling a genre REFUSED
|
|
1216
|
+
// AT BIRTH. It is a SEPARATE table from `GENRE_WITHDRAWN` and not a sixth row
|
|
1217
|
+
// of it, because the two state different facts and only one of them is a
|
|
1218
|
+
// migration: a WITHDRAWAL took a word away from documents that legally used
|
|
1219
|
+
// it (so the message names the release and the rewrite tool), while a REFUSAL
|
|
1220
|
+
// means the genre never declared the word and no document can have been
|
|
1221
|
+
// written against it. Folding them would make the engine tell a `sequence`
|
|
1222
|
+
// author "it was WITHDRAWN from this genre" about a keyword this genre has
|
|
1223
|
+
// never had, and would date every refusal to a migration that did not happen.
|
|
1224
|
+
// What the two SHARE is the thing `SCENE-KEYWORD-MEMBERSHIP` was for: the author gets the GROUND,
|
|
1225
|
+
// and what to write instead, rather than a spellcheck.
|
|
1226
|
+
//
|
|
1227
|
+
// The refusal is FREE in the `EDGE-GEOMETRY-CONSTRUCTS` sense — `sequence` is EXPERIMENTAL and this
|
|
1228
|
+
// is the release that gives it a vocabulary at all, so no document loses a
|
|
1229
|
+
// line. Each cell states its own ruling's ground, because the grounds differ.
|
|
1230
|
+
const REFUSED_IN={
|
|
1231
|
+
sequence:{
|
|
1232
|
+
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.',
|
|
1233
|
+
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.'
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
const REFUSED_AT=' (refused; MIGRATIONS 0.4)';
|
|
1237
|
+
const REFUSED_IN_GENRE=(kw,genre)=>
|
|
1238
|
+
'"'+kw+'" is not allowed in genre '+genre+' — this genre REFUSED it, it is not a typo and not a withdrawal: '+
|
|
1239
|
+
REFUSED_IN[genre][kw]+
|
|
1240
|
+
' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`), so a spelling another genre declares is that genre\'s '+
|
|
1241
|
+
'declaration and never this one\'s.'+REFUSED_AT;
|
|
1081
1242
|
// `MEMBERSHIP-KEY-ACCEPTANCE`: THE OPTION-KEY HALF OF `SCENE-KEYWORD-MEMBERSHIP`. A per-genre withdrawal can
|
|
1082
1243
|
// strand an option KEY as easily as it strands a keyword: `in=` states
|
|
1083
1244
|
// membership and its ONLY value domain is the id of a containing `group`, so
|
|
@@ -1113,6 +1274,25 @@ const WITHDRAWN_OPT_FROM_GENRE=(key,genre)=>
|
|
|
1113
1274
|
' An option key is per genre for the same reason a keyword is (core §3, `GENRE-VOCABULARY-OBLIGATION`): the key is accepted '+
|
|
1114
1275
|
'by the directive AND by the genre, and this genre\'s acceptance was withdrawn without touching any other\'s.'+
|
|
1115
1276
|
WITHDREW_OPT_AT;
|
|
1277
|
+
// `UNDELIVERED-MESSAGE-MARKING`: the OPTION-KEY half of the refusal table, and the one
|
|
1278
|
+
// place in this engine where a diagnostic fires for a key that is NOT in
|
|
1279
|
+
// `OPT_KEYS`. That is deliberate and is the ruling's headline: `lost=` was
|
|
1280
|
+
// proposed and REFUSED, so registering the spelling to get a named message
|
|
1281
|
+
// would put the key in the language's closed option registry — the exact
|
|
1282
|
+
// thing the ruling declines — and a reader counting `OPT_KEYS` would find a
|
|
1283
|
+
// key no directive accepts. So the refusal rides the UNKNOWN-OPTION path
|
|
1284
|
+
// instead: `badOpts` and the connector scanner both consult this table before
|
|
1285
|
+
// they say `unknown option`, which costs one lookup and adds no surface.
|
|
1286
|
+
const REFUSED_OPT_IN={
|
|
1287
|
+
sequence:{
|
|
1288
|
+
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.'
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
const REFUSED_OPT_IN_GENRE=(key,genre)=>
|
|
1292
|
+
key+'= is not allowed in genre '+genre+' — this genre REFUSED the key, it is not a typo and not a withdrawal: '+
|
|
1293
|
+
REFUSED_OPT_IN[genre][key]+
|
|
1294
|
+
' The spelling is not in the language\'s option registry at all, so no other genre accepts it either.'+
|
|
1295
|
+
REFUSED_AT;
|
|
1116
1296
|
const GENRE_KW={
|
|
1117
1297
|
block:new Set(SCENE_HOST_KW.concat(BLOCK_SUBJECT_KW, ['node','edge'])),
|
|
1118
1298
|
topology:new Set(SCENE_HOST_KW.concat(TOPOLOGY_SUBJECT_KW, ['node','edge'])),
|
|
@@ -1131,7 +1311,28 @@ const GENRE_KW={
|
|
|
1131
1311
|
bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
|
|
1132
1312
|
// chart is experimental and attaches to a table id in the same document
|
|
1133
1313
|
table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
|
|
1134
|
-
timing:new Set(GENRE_FREE_KW.concat(['class','timing']))
|
|
1314
|
+
timing:new Set(GENRE_FREE_KW.concat(['class','timing'])),
|
|
1315
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `sequence` gets its row, and the row is what
|
|
1316
|
+
// closes the Batch-1 finding that the genre "states a reading and constrains
|
|
1317
|
+
// nothing". It is NOT built on `SCENE_HOST_KW`, and the three absences are
|
|
1318
|
+
// each a decision rather than an oversight:
|
|
1319
|
+
// - `flow` and `rank` are REFUSED. A sequence figure's two axes are BOTH
|
|
1320
|
+
// declaration-ordered — lifelines left-to-right in declaration order,
|
|
1321
|
+
// occurrences top-to-bottom in declaration order (draft §7) — so a key
|
|
1322
|
+
// that reverses or re-ranks a drawing would make the picture disagree
|
|
1323
|
+
// with the source, which `DECLARATION-ORDER-SEMANTICS` forbids. There is nothing for them to set.
|
|
1324
|
+
// - `group` is REFUSED (`SEQUENCE-PARTICIPANT-GROUPING`, `REFUSED_IN` above).
|
|
1325
|
+
// - the REGION openers `bitfield`/`table`/`timing`/`chart` are absent
|
|
1326
|
+
// because this genre has no scene to compose them into: `GENRE-COMPOSITION` composition
|
|
1327
|
+
// stacks a region OUTSIDE the scene, and a ladder has no outside yet.
|
|
1328
|
+
// They are absent, not refused — a `sequence` document that wanted a
|
|
1329
|
+
// register layout beside its exchange is a real want with no ruling, and
|
|
1330
|
+
// it stays an open question rather than a silent no.
|
|
1331
|
+
// What is left is the genre-free core, `class`, this genre's two spellings
|
|
1332
|
+
// and its three subject words — nine top-level keywords, and that is the
|
|
1333
|
+
// whole of what a `figdown 0.4 sequence` document may write.
|
|
1334
|
+
sequence:new Set(GENRE_FREE_KW.concat(['class'], SEQUENCE_SUBJECT_KW,
|
|
1335
|
+
['lifeline','message']))
|
|
1135
1336
|
};
|
|
1136
1337
|
const CHILD_KW=new Set(['field','break','cell','width','signal','gap']);
|
|
1137
1338
|
|
|
@@ -1155,6 +1356,59 @@ function splitFigdownSections(text){
|
|
|
1155
1356
|
return secs;
|
|
1156
1357
|
}
|
|
1157
1358
|
|
|
1359
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's DERIVED reading, in one
|
|
1360
|
+
// function so the parser's checks and (from a later increment) the renderer
|
|
1361
|
+
// cannot disagree about what a document says.
|
|
1362
|
+
//
|
|
1363
|
+
// Two things are derived and nothing else is:
|
|
1364
|
+
// ROWS — the figure's total order. `messages` and `states` are the two
|
|
1365
|
+
// collections that carry an occurrence; each element records the
|
|
1366
|
+
// source `line` it was written on, and sorting the union of the two
|
|
1367
|
+
// on that number IS the order (draft §31: declaration order, and
|
|
1368
|
+
// the order is TOTAL — a declared divergence from UML's partial
|
|
1369
|
+
// order, taken so a reader never has to compute one).
|
|
1370
|
+
// CHAIN — the containment tree from `in=`. A fragment or an operand may
|
|
1371
|
+
// name a fragment or an operand, so the chain alternates in
|
|
1372
|
+
// practice but the walk does not assume it.
|
|
1373
|
+
// EXTENT is the span of row slots a container owns, transitively: an occurrence
|
|
1374
|
+
// inside an operand is inside that operand's fragment too. `cycles` names any
|
|
1375
|
+
// container that reaches itself, so a caller can refuse to read a tree that is
|
|
1376
|
+
// not one instead of walking it to a guard.
|
|
1377
|
+
function seqModel(doc){
|
|
1378
|
+
const rows=[];
|
|
1379
|
+
for(const m of doc.messages||[]) rows.push({kind:'message',el:m,line:m.line});
|
|
1380
|
+
for(const s of doc.states||[]) rows.push({kind:'state', el:s,line:s.line});
|
|
1381
|
+
rows.sort((a,b)=>a.line-b.line);
|
|
1382
|
+
rows.forEach((r,i)=>{ r.slot=i; });
|
|
1383
|
+
const cont={};
|
|
1384
|
+
for(const f of doc.fragments||[]) cont[f.id]={kind:'fragment',el:f,parent:f['in']||null};
|
|
1385
|
+
for(const o of doc.operands||[]) cont[o.id]={kind:'operand', el:o,parent:o['in']||null};
|
|
1386
|
+
const cycles=[];
|
|
1387
|
+
const chain=(id)=>{
|
|
1388
|
+
const out=[], seen=new Set();
|
|
1389
|
+
let c=id;
|
|
1390
|
+
while(c && cont[c]){
|
|
1391
|
+
if(seen.has(c)) break;
|
|
1392
|
+
seen.add(c); out.push(c); c=cont[c].parent;
|
|
1393
|
+
}
|
|
1394
|
+
return out;
|
|
1395
|
+
};
|
|
1396
|
+
for(const id in cont){
|
|
1397
|
+
const seen=new Set(); let c=cont[id].parent;
|
|
1398
|
+
while(c && cont[c] && !seen.has(c)){ if(c===id){ cycles.push(id); break; } seen.add(c); c=cont[c].parent; }
|
|
1399
|
+
}
|
|
1400
|
+
const owned={}; for(const id in cont) owned[id]=[];
|
|
1401
|
+
for(const r of rows){
|
|
1402
|
+
const inId=r.el['in']||null;
|
|
1403
|
+
r.chain=inId?chain(inId):[];
|
|
1404
|
+
for(const id of r.chain) if(owned[id]) owned[id].push(r.slot);
|
|
1405
|
+
}
|
|
1406
|
+
const extent={};
|
|
1407
|
+
for(const id in cont)
|
|
1408
|
+
extent[id]=owned[id].length?{lo:Math.min(...owned[id]),hi:Math.max(...owned[id])}:null;
|
|
1409
|
+
return {rows,cont,owned,extent,chain,cycles};
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1158
1412
|
// parse(text) -> {doc, errs, docs}
|
|
1159
1413
|
// Single-section: docs=[doc] (backward-compatible doc/errs).
|
|
1160
1414
|
// Multi-section: one doc per figdown header; errs use full-file line numbers;
|
|
@@ -1175,6 +1429,11 @@ function parse(text){
|
|
|
1175
1429
|
const docs=[]; const errs=[];
|
|
1176
1430
|
for(const sec of secs){
|
|
1177
1431
|
const r=parseOne(sec.text);
|
|
1432
|
+
// A section's element `.line` values are section-local, and a GEOMETRY-time
|
|
1433
|
+
// error (one only `render` can raise) is built long after this loop has
|
|
1434
|
+
// finished re-basing the parse messages. Record the offset on the doc so
|
|
1435
|
+
// that error can quote the same full-file line the author is looking at.
|
|
1436
|
+
r.doc.lineOffset=sec.startLine-1;
|
|
1178
1437
|
for(const e of r.errs){
|
|
1179
1438
|
const m=/^Line (\d+): (.*)$/.exec(e);
|
|
1180
1439
|
if(m) errs.push('Line '+(+m[1]+sec.startLine-1)+': '+m[2]);
|
|
@@ -1195,7 +1454,27 @@ function parseOne(text){
|
|
|
1195
1454
|
// a distinction the model must keep.
|
|
1196
1455
|
const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
|
|
1197
1456
|
flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
|
|
1198
|
-
classes:[],boundaries:[]
|
|
1457
|
+
classes:[],boundaries:[],
|
|
1458
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` model. FIVE collections,
|
|
1459
|
+
// one per keyword, and they are separate arrays rather than
|
|
1460
|
+
// aliases of `nodes`/`edges` because a message is not an edge: an
|
|
1461
|
+
// edge is a relation between two nodes and has no position, while
|
|
1462
|
+
// a message is an OCCURRENCE with a place in a total order (draft
|
|
1463
|
+
// §31 — order is declaration order and it is TOTAL). Folding them
|
|
1464
|
+
// into `nodes`/`edges` would let the scene renderer reach them
|
|
1465
|
+
// and would put a time-ordered thing in a collection whose model
|
|
1466
|
+
// says order is not meaning.
|
|
1467
|
+
//
|
|
1468
|
+
// The ORDER of `messages`, `states` and the containment they
|
|
1469
|
+
// declare is recovered from the `line` each element carries: the
|
|
1470
|
+
// parser appends in source order, so each array is already
|
|
1471
|
+
// ordered and the union of `messages` and `states` sorted on
|
|
1472
|
+
// `line` is the figure's trace. Nothing else records time.
|
|
1473
|
+
//
|
|
1474
|
+
// These stay EMPTY in every non-`sequence` document, and the
|
|
1475
|
+
// canonical JSON binding omits an empty one (the `externals`
|
|
1476
|
+
// rule), so no existing golden moves a byte.
|
|
1477
|
+
lifelines:[],messages:[],states:[],fragments:[],operands:[]};
|
|
1199
1478
|
const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
|
|
1200
1479
|
bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
|
|
1201
1480
|
// §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
|
|
@@ -1318,17 +1597,40 @@ function parseOne(text){
|
|
|
1318
1597
|
if(idHere()){ err(n,ID_RULE); return; }
|
|
1319
1598
|
const tk2=tokenize(s.slice(i).trim());
|
|
1320
1599
|
if(tk2.error){ err(n,tk2.error); return; }
|
|
1321
|
-
const {pos:p2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
|
|
1600
|
+
const {pos:p2,posq:pq2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
|
|
1322
1601
|
if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
|
|
1323
|
-
|
|
1324
|
-
|
|
1602
|
+
// `UNDELIVERED-MESSAGE-MARKING`: the connector's copy of the genre REFUSAL check for
|
|
1603
|
+
// an option key the language does not register. It sits on the
|
|
1604
|
+
// unknown-option path because `lost=` is not in `OPT_KEYS` — see
|
|
1605
|
+
// `REFUSED_OPT_IN` — and it must be here as well as in `badOpts` because
|
|
1606
|
+
// `message` is scanned by this function and never reaches that one.
|
|
1607
|
+
if(u2.length){
|
|
1608
|
+
const ro=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
|
|
1609
|
+
if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
|
|
1610
|
+
else err(n,'unknown option "'+u2[0]+'="');
|
|
1611
|
+
return; }
|
|
1612
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `message` adds ONE trailing positional — the
|
|
1613
|
+
// quoted message label — to the connector grammar, because that is where
|
|
1614
|
+
// every sequence source in the corpus writes it and the `[mid]` form
|
|
1615
|
+
// reads as an annotation rather than as the message itself. Every other
|
|
1616
|
+
// connector spelling keeps the grammar unchanged, so the surplus-argument
|
|
1617
|
+
// error is still the right answer for them.
|
|
1618
|
+
let seqLabel=null;
|
|
1619
|
+
if(kw==='message'){
|
|
1620
|
+
if(p2.length>1){ err(n,'unexpected argument "'+p2[1]+'"'); return; }
|
|
1621
|
+
if(p2.length===1){
|
|
1622
|
+
if(!pq2[0]){ err(n,'message label must be quoted: message '+a+' '+op+' '+b+' "'+p2[0]+'" — '+Q_WHY); return; }
|
|
1623
|
+
if(mid!==null){ err(n,'message has two labels — the inline -['+mid+']-> mid-label and the trailing "'+p2[0]+'". Write one'); return; }
|
|
1624
|
+
seqLabel=p2[0];
|
|
1625
|
+
}
|
|
1626
|
+
} else if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
|
|
1325
1627
|
// 0.1: `edge` has its own scanner, so the language-wide retired
|
|
1326
1628
|
// keys need their own check here or `edge` would be the one directive
|
|
1327
1629
|
// that reports the generic message for a retired spelling.
|
|
1328
1630
|
for(const rk in RETIRED_OPT_KEYS)
|
|
1329
1631
|
if(o2[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); return; }
|
|
1330
1632
|
for(const k in o2)
|
|
1331
|
-
if(!
|
|
1633
|
+
if(!directiveOpts(kw,doc.genre).includes(k)){ err(n,kw+' does not take '+k+'='); return; }
|
|
1332
1634
|
for(const k of ['label','taillabel','headlabel'])
|
|
1333
1635
|
if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: '+kw+' A [tail] -[mid]-> [head] B (MIGRATIONS 0.1)'); return; }
|
|
1334
1636
|
if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR(kw)); return; }
|
|
@@ -1366,6 +1668,38 @@ function parseOne(text){
|
|
|
1366
1668
|
if(!pc.ok){ err(n,pc.err); return; }
|
|
1367
1669
|
ecls=pc.ids;
|
|
1368
1670
|
}
|
|
1671
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` fork. It is the SAME scanner with
|
|
1672
|
+
// a different reading, and the model it writes is a different collection:
|
|
1673
|
+
// a message is an OCCURRENCE with a place in the figure's total order
|
|
1674
|
+
// (draft §31), an edge is a relation with no position at all.
|
|
1675
|
+
if(kw==='message'){
|
|
1676
|
+
// Draft §8.2/§15.4. `--` parses everywhere else and is a line error
|
|
1677
|
+
// here, so the check is at the fork rather than in the operator scanner.
|
|
1678
|
+
if(!SEQ_OPERATORS.has(op)){
|
|
1679
|
+
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 <->');
|
|
1680
|
+
return; }
|
|
1681
|
+
if(o2['in']!==undefined){
|
|
1682
|
+
const e=idErr(o2['in'], optHasQ(oT2,'in'), null);
|
|
1683
|
+
if(e){ err(n,e); return; }
|
|
1684
|
+
}
|
|
1685
|
+
if(o2.description!==undefined && !optQ(oT2,'description')){
|
|
1686
|
+
err(n,'description= must be quoted: description="'+o2.description+'" — '+Q_WHY); return; }
|
|
1687
|
+
// ONE label field, fed by either spelling. The shared scanner also
|
|
1688
|
+
// accepts the inline `-[mid]->` form, and the check above makes writing
|
|
1689
|
+
// both a line error, so the model can never hold two — but the model
|
|
1690
|
+
// must not hold the same text under two keys either, so `mid` is NOT
|
|
1691
|
+
// projected beside `label` here the way it is on an edge. That the two
|
|
1692
|
+
// spellings both reach this field at all is an ALIAS in `IDENTITY-ASSERTION`'s sense
|
|
1693
|
+
// and is a FINDING for the genre document (Batch 5) rather than a
|
|
1694
|
+
// ruling taken here: the draft settles the trailing form and says
|
|
1695
|
+
// nothing about the brackets. `[tail]` and `[head]` are kept — they are
|
|
1696
|
+
// different positions, not a second spelling of the same one.
|
|
1697
|
+
doc.messages.push({a,b,op,tail,head,
|
|
1698
|
+
label:seqLabel!==null?seqLabel:mid,
|
|
1699
|
+
style:o2.style,cls:ecls,stroke:o2.stroke,note:o2.note,
|
|
1700
|
+
desc:o2.description,in:o2['in']||null,line:n});
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1369
1703
|
// §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
|
|
1370
1704
|
// and `fill=` name the same channel (`stroke=` wins when both are
|
|
1371
1705
|
// written); `text=` colours the [tail]/[mid]/[head] labels.
|
|
@@ -1374,6 +1708,94 @@ function parseOne(text){
|
|
|
1374
1708
|
plane:o2.plane||'base',line:n});
|
|
1375
1709
|
}
|
|
1376
1710
|
|
|
1711
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the four `sequence` directives that are not the
|
|
1712
|
+
// connector. Reached only from the `doc.genre==='sequence'` dispatch, so
|
|
1713
|
+
// `state` under `statechart` never arrives here. Every option-VALUE check
|
|
1714
|
+
// (colours, `style=` enum, `class=` list, `in=` id spelling, `note=` and
|
|
1715
|
+
// `description=` quoting, `type=`'s bare-value rule) has already run in
|
|
1716
|
+
// `badOpts`; what is left is arity, mandatory arguments, and this genre's
|
|
1717
|
+
// own enum.
|
|
1718
|
+
const seqCls=(opts,optT)=>opts['class']!==undefined
|
|
1719
|
+
? parseClassList(opts['class'],optList(optT,'class')).ids : undefined;
|
|
1720
|
+
function parseSeqDirective(kw,n,pos,posq,opts,optT){
|
|
1721
|
+
if(kw==='lifeline'){
|
|
1722
|
+
// `lifeline <id> ["label"]` — the participant column. It DECLARES an id
|
|
1723
|
+
// and joins the document-wide id namespace, so a lifeline cannot share
|
|
1724
|
+
// a spelling with a fragment or an operand.
|
|
1725
|
+
const id=pos[1];
|
|
1726
|
+
const e=idErr(id,!!posq[1],'lifeline needs <id> ["label"]');
|
|
1727
|
+
if(e){ err(n,e); return; }
|
|
1728
|
+
if(dupId(id)||doc.lifelines.some(l=>l.id===id)){ err(n,'duplicate id "'+id+'"'); return; }
|
|
1729
|
+
if(BLK_LBL(n,'lifeline',id,pos,posq)) return;
|
|
1730
|
+
if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
|
|
1731
|
+
nodeIds.add(id);
|
|
1732
|
+
doc.lifelines.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in']||null,
|
|
1733
|
+
cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
|
|
1734
|
+
style:opts.style,note:opts.note,desc:opts.description,line:n});
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1737
|
+
if(kw==='state'){
|
|
1738
|
+
// `state <lifeline-id> "<state name>"` — a StateInvariant (UML 2.5.1
|
|
1739
|
+
// §17.12.25). Slot 1 REFERENCES a lifeline; it does NOT declare an id,
|
|
1740
|
+
// because nothing in this genre refers to a state occurrence. That is
|
|
1741
|
+
// the asymmetry with `statechart`'s `state`, where slot 1 declares
|
|
1742
|
+
// (draft §29, Q5), and it is why the two share a spelling and nothing
|
|
1743
|
+
// else. Slot 2 is MANDATORY: a state occurrence with no name asserts
|
|
1744
|
+
// nothing at all.
|
|
1745
|
+
const ref=pos[1];
|
|
1746
|
+
const e=idErr(ref,!!posq[1],'state needs <lifeline-id> "<state name>"');
|
|
1747
|
+
if(e){ err(n,e); return; }
|
|
1748
|
+
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; }
|
|
1749
|
+
if(!posq[2]){ err(n,'state name must be quoted: state '+ref+' "'+pos[2]+'" — '+Q_WHY); return; }
|
|
1750
|
+
if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
|
|
1751
|
+
doc.states.push({ref,name:pos[2],in:opts['in']||null,
|
|
1752
|
+
cls:seqCls(opts,optT),fill:opts.fill,stroke:opts.stroke,
|
|
1753
|
+
style:opts.style,note:opts.note,desc:opts.description,line:n});
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
if(kw==='fragment'){
|
|
1757
|
+
// `fragment <id> ["label"] type=<operator>` — a CombinedFragment
|
|
1758
|
+
// (§17.12.3). `type=` is MANDATORY where UML defaults it to `seq`: a
|
|
1759
|
+
// fragment with no interaction operator draws a box that asserts
|
|
1760
|
+
// nothing, and a default would make the box look like an assertion.
|
|
1761
|
+
const id=pos[1];
|
|
1762
|
+
const e=idErr(id,!!posq[1],'fragment needs <id> ["label"] type=<operator>');
|
|
1763
|
+
if(e){ err(n,e); return; }
|
|
1764
|
+
if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
|
|
1765
|
+
err(n,'duplicate id "'+id+'"'); return; }
|
|
1766
|
+
if(BLK_LBL(n,'fragment',id,pos,posq)) return;
|
|
1767
|
+
if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
|
|
1768
|
+
if(opts.type===undefined){
|
|
1769
|
+
err(n,'fragment needs type=<operator> — a fragment with no interaction operator asserts nothing ('+
|
|
1770
|
+
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; }
|
|
1771
|
+
if(!SEQ_OPERATORS_FRAG.includes(opts.type)){
|
|
1772
|
+
err(n,'unknown interaction operator "'+opts.type+'" — write one of '+
|
|
1773
|
+
SEQ_OPERATORS_FRAG.join('|')+' ('+SEQ_FRAG_CLAUSE+' InteractionOperatorKind, taken whole)'); return; }
|
|
1774
|
+
doc.fragments.push({id,label:pos[2]!==undefined?pos[2]:null,type:opts.type,
|
|
1775
|
+
in:opts['in']||null,cls:seqCls(opts,optT),stroke:opts.stroke,
|
|
1776
|
+
style:opts.style,note:opts.note,desc:opts.description,line:n});
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if(kw==='operand'){
|
|
1780
|
+
// `operand <id> ["guard"] in=<fragment-id>` — an InteractionOperand
|
|
1781
|
+
// (§17.12.14). `in=` is MANDATORY: an operand is a COMPARTMENT OF a
|
|
1782
|
+
// fragment and has no meaning apart from one.
|
|
1783
|
+
const id=pos[1];
|
|
1784
|
+
const e=idErr(id,!!posq[1],'operand needs <id> ["guard"] in=<fragment-id>');
|
|
1785
|
+
if(e){ err(n,e); return; }
|
|
1786
|
+
if(dupId(id)||doc.fragments.some(f=>f.id===id)||doc.operands.some(o=>o.id===id)){
|
|
1787
|
+
err(n,'duplicate id "'+id+'"'); return; }
|
|
1788
|
+
if(BLK_LBL(n,'operand',id,pos,posq)) return;
|
|
1789
|
+
if(pos[3]!==undefined){ err(n,'unexpected argument "'+pos[3]+'"'); return; }
|
|
1790
|
+
if(opts['in']===undefined){
|
|
1791
|
+
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; }
|
|
1792
|
+
doc.operands.push({id,label:pos[2]!==undefined?pos[2]:null,in:opts['in'],
|
|
1793
|
+
cls:seqCls(opts,optT),stroke:opts.stroke,style:opts.style,
|
|
1794
|
+
note:opts.note,desc:opts.description,line:n});
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1377
1799
|
for(let li=0; li<lines.length; li++){
|
|
1378
1800
|
const n=li+1;
|
|
1379
1801
|
let raw=lines[li];
|
|
@@ -1448,7 +1870,11 @@ function parseOne(text){
|
|
|
1448
1870
|
// the dispatch cannot be narrowed to the genre's own word — a `flowline`
|
|
1449
1871
|
// under `block` would then fall through to `unrecognized line`, which is
|
|
1450
1872
|
// exactly the answer these rulings owe an author better than.
|
|
1451
|
-
|
|
1873
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: `message` is the FOURTH spelling scanned here,
|
|
1874
|
+
// and it is derived from `CONNECTOR_SPELLINGS` rather than spelled again,
|
|
1875
|
+
// so a fifth genre's connector reaches the named diagnostic by joining
|
|
1876
|
+
// that set and not by remembering to edit a regex.
|
|
1877
|
+
const mConn=CONN_LINE_RE.exec(raw.trim());
|
|
1452
1878
|
if(mConn){
|
|
1453
1879
|
const ckw=mConn[1];
|
|
1454
1880
|
if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 <genre>"'); }
|
|
@@ -1490,12 +1916,25 @@ function parseOne(text){
|
|
|
1490
1916
|
// Directives not in DIRECTIVE_OPTS (title's single quoted string, unknown
|
|
1491
1917
|
// keywords) are handled by their own paths.
|
|
1492
1918
|
const badOpts=(k)=>{
|
|
1493
|
-
|
|
1919
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the row is read GENRE-FIRST. `state` is one
|
|
1920
|
+
// spelling with two declarations and two option sets (`GENRE-VOCABULARY-OBLIGATION`), and
|
|
1921
|
+
// `GENRE_DIRECTIVE_OPTS` is the only place that fact is recorded.
|
|
1922
|
+
const allowed=directiveOpts(k,doc.genre);
|
|
1494
1923
|
if(!allowed) return false;
|
|
1495
1924
|
let bad=false;
|
|
1496
1925
|
// same-line repeated option key (last-wins was silent data loss)
|
|
1497
1926
|
if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
|
|
1498
|
-
|
|
1927
|
+
// `UNDELIVERED-MESSAGE-MARKING`: a genre REFUSAL of a key the language never
|
|
1928
|
+
// registered is answered here, on the unknown-option path, because that
|
|
1929
|
+
// is the only path it can reach: `lost=` is deliberately absent from
|
|
1930
|
+
// `OPT_KEYS` (the ruling adds no option key), so `splitOpts` reports it
|
|
1931
|
+
// as unknown. Checked BEFORE the generic message so the author gets the
|
|
1932
|
+
// ground and the replacement spelling instead of a spellcheck.
|
|
1933
|
+
const roOpt=(doc.genre&&REFUSED_OPT_IN[doc.genre])||null;
|
|
1934
|
+
for(const u of unk){
|
|
1935
|
+
if(roOpt && roOpt[u]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u,doc.genre));
|
|
1936
|
+
else err(n,'unknown option "'+u+'="');
|
|
1937
|
+
bad=true; }
|
|
1499
1938
|
// `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
|
|
1500
1939
|
// after `unknown option`, so a key the LANGUAGE does not have keeps its
|
|
1501
1940
|
// own answer, and before every value check, so a withdrawn key is never
|
|
@@ -1990,6 +2429,19 @@ function parseOne(text){
|
|
|
1990
2429
|
// Dynamic-profile reserved words keep their dedicated message (before `GENRE-KEYWORD-ALLOWLIST`).
|
|
1991
2430
|
if(kw==='page'||kw==='set'||kw==='pulse'){
|
|
1992
2431
|
err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); continue; }
|
|
2432
|
+
// `SEQUENCE-TIME-GAP`: a genre REFUSAL is consulted BEFORE the typed-block
|
|
2433
|
+
// child exemption below, because `gap` is BOTH — `timing`'s child keyword
|
|
2434
|
+
// and the one spelling `sequence` refused that is also a child word. Left
|
|
2435
|
+
// in the old order, `gap "T1 fires"` in a sequence document answered
|
|
2436
|
+
// `"gap" is a typed-block child — it needs a bitfield/table/timing block
|
|
2437
|
+
// above it`, which is TRUE OF THE LANGUAGE and says nothing about the
|
|
2438
|
+
// ruling the author has actually run into. This clause fires only for a
|
|
2439
|
+
// spelling that is a child keyword AND refused by this genre, so no other
|
|
2440
|
+
// genre's answer moves; every other refused or withdrawn word reaches its
|
|
2441
|
+
// own message through the ordinary chain below.
|
|
2442
|
+
if(sawHeader && doc.genre && CHILD_KW.has(kw) &&
|
|
2443
|
+
REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw]){
|
|
2444
|
+
err(n, REFUSED_IN_GENRE(kw, doc.genre)); continue; }
|
|
1993
2445
|
// `GENRE-KEYWORD-ALLOWLIST`: after closing a typed region, top-level keywords
|
|
1994
2446
|
// must be in the header genre allowlist. Child keywords still use the
|
|
1995
2447
|
// "needs a bitfield/table/timing above" path when they appear with no cur.
|
|
@@ -2007,6 +2459,11 @@ function parseOne(text){
|
|
|
2007
2459
|
// needs the ground, not a spellcheck.
|
|
2008
2460
|
else if(GENRE_WITHDRAWN[doc.genre] && GENRE_WITHDRAWN[doc.genre][kw])
|
|
2009
2461
|
err(n, WITHDRAWN_FROM_GENRE(kw, doc.genre));
|
|
2462
|
+
// `SEQUENCE-TIME-GAP`/`SEQUENCE-PARTICIPANT-GROUPING`: and one step further again. A word this genre
|
|
2463
|
+
// REFUSED is not an unknown word either, and it is not a withdrawal —
|
|
2464
|
+
// the author needs the ruling's ground and the spelling that works.
|
|
2465
|
+
else if(REFUSED_IN[doc.genre] && REFUSED_IN[doc.genre][kw])
|
|
2466
|
+
err(n, REFUSED_IN_GENRE(kw, doc.genre));
|
|
2010
2467
|
else
|
|
2011
2468
|
err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
|
|
2012
2469
|
continue;
|
|
@@ -2019,6 +2476,13 @@ function parseOne(text){
|
|
|
2019
2476
|
continue;
|
|
2020
2477
|
}
|
|
2021
2478
|
|
|
2479
|
+
// `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's four own directives.
|
|
2480
|
+
// Dispatched BEFORE the switch and scoped by `doc.genre`, which is what
|
|
2481
|
+
// keeps `state` reaching `statechart`'s node parser under `statechart` —
|
|
2482
|
+
// `GENRE-VOCABULARY-OBLIGATION` in the dispatcher, not only in the allowlist.
|
|
2483
|
+
if(doc.genre==='sequence' && SEQ_KW.has(kw)){
|
|
2484
|
+
parseSeqDirective(kw,n,pos,posq,opts,optT); continue; }
|
|
2485
|
+
|
|
2022
2486
|
switch(kw){
|
|
2023
2487
|
case 'title': {
|
|
2024
2488
|
if(sawTitle){ err(n,'duplicate title line'); break; }
|
|
@@ -2520,6 +2984,117 @@ function parseOne(text){
|
|
|
2520
2984
|
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)');
|
|
2521
2985
|
if(e.plane && !planeIds.has(e.plane)) errs.push('Line '+e.line+': unknown plane "'+e.plane+'"');
|
|
2522
2986
|
}
|
|
2987
|
+
// ── `SEQUENCE-SOURCE-STANDARD`-R182: the `sequence` genre's semantic checks ────────
|
|
2988
|
+
// Everything here needs the WHOLE document, so none of it can live in
|
|
2989
|
+
// `parseSeqDirective`: forward references are legal (fixture 021 pins that
|
|
2990
|
+
// for the scene genres and the rule is language-wide), so an id can only be
|
|
2991
|
+
// resolved once every declaration has been read.
|
|
2992
|
+
if(doc.genre==='sequence'){
|
|
2993
|
+
const llIds=new Set(doc.lifelines.map(l=>l.id));
|
|
2994
|
+
const fragIds=new Set(doc.fragments.map(f=>f.id));
|
|
2995
|
+
const opIds=new Set(doc.operands.map(o=>o.id));
|
|
2996
|
+
// The `in=` OBJECT rule (`SEQUENCE-CONTAINMENT-SCOPE`), in one place because it is one rule: on
|
|
2997
|
+
// all five acceptors `in=` is sense 1 — *the element this one lives
|
|
2998
|
+
// inside* — and its value domain is a `fragment` or an `operand` id and
|
|
2999
|
+
// nothing else. The message names the domain AND the acceptor list,
|
|
3000
|
+
// because an author who wrote a lifeline id there has the relation right
|
|
3001
|
+
// and the object wrong, and needs to be told which.
|
|
3002
|
+
const IN_ACCEPTORS='message, operand, lifeline, state and fragment';
|
|
3003
|
+
const inErr=(line,val,what)=>{
|
|
3004
|
+
if(fragIds.has(val)||opIds.has(val)) return;
|
|
3005
|
+
errs.push('Line '+line+': unknown fragment or operand "'+val+'" — in= on a '+what+
|
|
3006
|
+
' names the fragment or operand this '+what+' occurs inside'+
|
|
3007
|
+
(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':'')+
|
|
3008
|
+
'. 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)');
|
|
3009
|
+
};
|
|
3010
|
+
for(const l of doc.lifelines) if(l['in']) inErr(l.line,l['in'],'lifeline');
|
|
3011
|
+
for(const m of doc.messages){
|
|
3012
|
+
if(!llIds.has(m.a)) errs.push('Line '+m.line+': unknown lifeline "'+m.a+'"');
|
|
3013
|
+
if(!llIds.has(m.b)) errs.push('Line '+m.line+': unknown lifeline "'+m.b+'"');
|
|
3014
|
+
if(m['in']) inErr(m.line,m['in'],'message');
|
|
3015
|
+
}
|
|
3016
|
+
for(const st of doc.states){
|
|
3017
|
+
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)');
|
|
3018
|
+
if(st['in']) inErr(st.line,st['in'],'state');
|
|
3019
|
+
}
|
|
3020
|
+
for(const f of doc.fragments) if(f['in']) inErr(f.line,f['in'],'fragment');
|
|
3021
|
+
// An operand's `in=` is MANDATORY and its object is narrower than the
|
|
3022
|
+
// general rule: a compartment belongs to a FRAGMENT, never to another
|
|
3023
|
+
// compartment, so an operand id there is a specific mistake with a
|
|
3024
|
+
// specific answer.
|
|
3025
|
+
for(const o of doc.operands)
|
|
3026
|
+
if(!fragIds.has(o['in']))
|
|
3027
|
+
errs.push('Line '+o.line+': unknown fragment "'+o['in']+'" — operand in= names a FRAGMENT'+
|
|
3028
|
+
(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)'
|
|
3029
|
+
: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'
|
|
3030
|
+
:''));
|
|
3031
|
+
// Two CONSECUTIVE `state` lines naming the same lifeline and the same
|
|
3032
|
+
// state name are a line error (draft §23.2). A state that has not changed
|
|
3033
|
+
// is never restated, so a genuine duplicate is always a mistake — and
|
|
3034
|
+
// because a transition is DERIVED from an adjacent pair, a reader
|
|
3035
|
+
// "tidying duplicates" could otherwise silently delete a fact.
|
|
3036
|
+
const lastState={};
|
|
3037
|
+
for(const st of doc.states.slice().sort((a,b)=>a.line-b.line)){
|
|
3038
|
+
if(lastState[st.ref]===st.name)
|
|
3039
|
+
errs.push('Line '+st.line+': lifeline "'+st.ref+'" is already in state "'+st.name+
|
|
3040
|
+
'" — 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)');
|
|
3041
|
+
lastState[st.ref]=st.name;
|
|
3042
|
+
}
|
|
3043
|
+
const SM=seqModel(doc);
|
|
3044
|
+
// A containment CYCLE is checked before anything that walks the chain, or
|
|
3045
|
+
// the walk terminates on a guard and every downstream answer is arbitrary.
|
|
3046
|
+
for(const id of SM.cycles)
|
|
3047
|
+
errs.push('Line '+SM.cont[id].el.line+': '+SM.cont[id].kind+' "'+id+
|
|
3048
|
+
'" is inside itself — in= containment is a tree, and a cycle denotes nothing at all');
|
|
3049
|
+
if(!SM.cycles.length){
|
|
3050
|
+
// THE ONE-LEVEL NESTING CAP (`SEQUENCE-CONTAINMENT-SCOPE`). A fragment may sit inside an
|
|
3051
|
+
// operand of ONE enclosing fragment and no deeper. The cap is taken on
|
|
3052
|
+
// the v0.1 `group` precedent — "one level is the whole of v0.1's
|
|
3053
|
+
// containment" (core §2.2, and the diagnostic `group does not take
|
|
3054
|
+
// in=`) — and it is a SCOPE decision, not a principle: a second level
|
|
3055
|
+
// lands on measured need, the same evidence any other cell needs.
|
|
3056
|
+
for(const f of doc.fragments){
|
|
3057
|
+
const anc=SM.chain(f['in']).filter(id=>SM.cont[id].kind==='fragment');
|
|
3058
|
+
if(anc.length>1)
|
|
3059
|
+
errs.push('Line '+f.line+': fragment "'+f.id+'" nests '+anc.length+
|
|
3060
|
+
' 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=');
|
|
3061
|
+
}
|
|
3062
|
+
// CONTIGUITY (draft §28.1). A fragment's or operand's members must be a
|
|
3063
|
+
// CONTIGUOUS run in declaration order, and the reason is the MODEL and
|
|
3064
|
+
// not the drawing: an operand denotes the ORDERED RUN of the
|
|
3065
|
+
// occurrences it contains, so an occurrence that is not in it cannot
|
|
3066
|
+
// happen between two that are. Non-contiguous membership denotes
|
|
3067
|
+
// nothing (UML 2.5.1 §17.6).
|
|
3068
|
+
// One offending row is reported ONCE, against the DEEPEST container it
|
|
3069
|
+
// splits. A row inside an operand's span is inside that operand's
|
|
3070
|
+
// fragment too, so an un-deduplicated pass reports the same line twice
|
|
3071
|
+
// and the outer report's advice is wrong: writing `in=<fragment>` would
|
|
3072
|
+
// repair the fragment and leave the operand split. The deepest
|
|
3073
|
+
// container is the one whose `in=` actually fixes the document.
|
|
3074
|
+
const split=new Map(); // row slot -> container id
|
|
3075
|
+
for(const id in SM.cont){
|
|
3076
|
+
const e=SM.extent[id];
|
|
3077
|
+
const kind=SM.cont[id].kind, aKind=(kind==='operand'?'an ':'a ')+kind;
|
|
3078
|
+
if(!e){
|
|
3079
|
+
errs.push('Line '+SM.cont[id].el.line+': '+kind+' "'+id+
|
|
3080
|
+
'" has no members — '+aKind+"'s extent is the span of the lines carrying in="+id+
|
|
3081
|
+
', and a container with no extent asserts nothing');
|
|
3082
|
+
continue; }
|
|
3083
|
+
const own=new Set(SM.owned[id]);
|
|
3084
|
+
for(let s=e.lo;s<=e.hi;s++){
|
|
3085
|
+
if(own.has(s)) continue;
|
|
3086
|
+
const prev=split.get(s);
|
|
3087
|
+
if(prev===undefined || SM.chain(id).length>SM.chain(prev).length) split.set(s,id);
|
|
3088
|
+
break;
|
|
3089
|
+
}
|
|
3090
|
+
}
|
|
3091
|
+
for(const [s,id] of [...split.entries()].sort((a,b)=>a[0]-b[0])){
|
|
3092
|
+
const r=SM.rows[s], e=SM.extent[id];
|
|
3093
|
+
errs.push('Line '+r.line+': this '+r.kind+' line splits '+SM.cont[id].kind+' "'+id+
|
|
3094
|
+
'" (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');
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
2523
3098
|
for(const r of doc.ranks) for(const id of r.ids)
|
|
2524
3099
|
if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
|
|
2525
3100
|
// `MARKER-TARGET-KINDS`: `in=` on `threshold`/`band` also resolves a REGION id —
|
|
@@ -2605,32 +3180,102 @@ function parseOne(text){
|
|
|
2605
3180
|
// `fill=` and no `stroke=`, used by an edge. Ignoring it would drop the
|
|
2606
3181
|
// edge's colour with nothing to warn on; honouring it would make `fill`
|
|
2607
3182
|
// mean "stroke" for that member. So it is a line error that names the
|
|
2608
|
-
// key to add. `edge`
|
|
3183
|
+
// key to add. `edge` was the only interior-less construct taking `class=`
|
|
3184
|
+
// until the `sequence` genre added three more (`message`, `fragment`,
|
|
3185
|
+
// `operand`), which is what 0.4 below is about.
|
|
2609
3186
|
//
|
|
2610
|
-
// 0.1 (`CLASS-PAINT-REQUIREMENT`)
|
|
2611
|
-
//
|
|
2612
|
-
//
|
|
2613
|
-
//
|
|
2614
|
-
//
|
|
2615
|
-
//
|
|
2616
|
-
//
|
|
2617
|
-
//
|
|
2618
|
-
//
|
|
2619
|
-
//
|
|
2620
|
-
//
|
|
2621
|
-
//
|
|
2622
|
-
//
|
|
2623
|
-
//
|
|
2624
|
-
//
|
|
2625
|
-
//
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
3187
|
+
// 0.1 (`CLASS-PAINT-REQUIREMENT`) added a SECOND half — a class that paints NEITHER
|
|
3188
|
+
// channel, joined by an edge, was a line error too — and 0.4 (`CLASS-CHANNEL-REACH`)
|
|
3189
|
+
// RETIRES that half. It is not deleted quietly: `CLASS-PAINT-REQUIREMENT`'s own release fixed
|
|
3190
|
+
// the harm it named. The stated defect was that such a class "shows
|
|
3191
|
+
// nothing in the legend", and the same release made the derived legend
|
|
3192
|
+
// draw the meaning with NO swatch (see the legend strip in `render`), so
|
|
3193
|
+
// the meaning does reach the reader. What survived was only "the member
|
|
3194
|
+
// takes its default paint" — which is exactly what 14 shipped `field`
|
|
3195
|
+
// members already get, legally, from meaning-only classes in
|
|
3196
|
+
// examples/gre.fd, quic.fd, srh.fd and showcase/tcp-header.fd. A rule that
|
|
3197
|
+
// cannot generalise past one collection was not a rule about channels. A
|
|
3198
|
+
// class that claims a meaning and declares no paint is therefore legal on
|
|
3199
|
+
// EVERY member (`CLASS-CHANNEL-REACH`, MIGRATIONS 0.4), which is also the form the
|
|
3200
|
+
// `sequence` genre is built on: `class` there carries what `group` (`SEQUENCE-PARTICIPANT-GROUPING`)
|
|
3201
|
+
// and `lost=` (`UNDELIVERED-MESSAGE-MARKING`) were refused in favour of, so a meaning with no paint
|
|
3202
|
+
// is that genre's designed idiom, not an oversight.
|
|
3203
|
+
//
|
|
3204
|
+
// `INTERIOR-LESS-ELEMENT-PAINT`'s half stands and now reaches EVERY collection that accepts
|
|
3205
|
+
// `class=` (`CLASS-CHANNEL-REACH`). Until this release the loop below ran over `doc.edges`
|
|
3206
|
+
// alone, so `class k "K" fill=#eee` plus `message c -> s "m" class=k` was
|
|
3207
|
+
// accepted, painted nothing, and put the class in the legend — a message
|
|
3208
|
+
// has its own collection because it has a position in time (`SEQUENCE-ORDER-MODEL`), and
|
|
3209
|
+
// the check never looked there.
|
|
3210
|
+
//
|
|
3211
|
+
// THE CHANNEL SETS ARE DERIVED FROM WHAT EACH RENDERER READS, not from
|
|
3212
|
+
// what the directive tables accept — a key the drawing never consults is
|
|
3213
|
+
// not a channel the member HAS. Read off the `chan()` call sites in
|
|
3214
|
+
// `renderSequence` and the `rsAll`/`dashOf` sites in `render`:
|
|
3215
|
+
// node, group, lifeline, state fill, stroke, style (box/pill: all three)
|
|
3216
|
+
// edge, message stroke, style (no interior)
|
|
3217
|
+
// fragment, operand stroke, style (frame/rule; a
|
|
3218
|
+
// fragment's interior would hide its own
|
|
3219
|
+
// members, so it has no `fill=` to set)
|
|
3220
|
+
// field, cell fill, stroke (`style=` left both
|
|
3221
|
+
// directives at `STYLE-KEY-SCOPE` and no `dashOf` reads
|
|
3222
|
+
// `f.style`/`mk.style`)
|
|
3223
|
+
// A member with all three channels can never fail this test; the rows are
|
|
3224
|
+
// listed anyway, because the table is the rule and a missing row would
|
|
3225
|
+
// read as "not considered".
|
|
3226
|
+
//
|
|
3227
|
+
// TWO CASES FIRE, and both are declared paint that cannot arrive:
|
|
3228
|
+
// (a) `fill=` with no `stroke=` on a class an INTERIOR-LESS member joins.
|
|
3229
|
+
// Not caught by (b), because `fill=` plus `style=` would pass it: on
|
|
3230
|
+
// a line `fill=` and `stroke=` NAME THE SAME CHANNEL (the same reason
|
|
3231
|
+
// `fill=` on an `edge` LINE is refused), so an author who wrote
|
|
3232
|
+
// `fill=` meant the line's colour and `style=` does not answer that.
|
|
3233
|
+
// (b) a class whose channels are ALL channels the member lacks — the
|
|
3234
|
+
// general shape, which reaches `style=`-only on a `field` or a `cell`.
|
|
3235
|
+
// Guarded on the class declaring at least one channel, so a
|
|
3236
|
+
// meaning-only class falls through it (`CLASS-CHANNEL-REACH`).
|
|
3237
|
+
// Both are per class, per channel — `INTERIOR-LESS-ELEMENT-PAINT`'s shape, for `INTERIOR-LESS-ELEMENT-PAINT`'s reason. A class
|
|
3238
|
+
// that also declares a channel the member HAS is fine and must stay fine:
|
|
3239
|
+
// `class hot "…" fill=#fee2e2 stroke=#dc2626` paints a node's box and an
|
|
3240
|
+
// edge's line from one meaning, and `class=hot,deprecated` splits one
|
|
3241
|
+
// meaning across two declarations (conformance case 308).
|
|
3242
|
+
const CLASS_CHANNELS={
|
|
3243
|
+
node: {has:['fill','stroke','style'], a:'a node'},
|
|
3244
|
+
group: {has:['fill','stroke','style'], a:'a group'},
|
|
3245
|
+
lifeline: {has:['fill','stroke','style'], a:'a lifeline'},
|
|
3246
|
+
state: {has:['fill','stroke','style'], a:'a state'},
|
|
3247
|
+
edge: {has:['stroke','style'], a:'an edge'},
|
|
3248
|
+
message: {has:['stroke','style'], a:'a message'},
|
|
3249
|
+
fragment: {has:['stroke','style'], a:'a fragment'},
|
|
3250
|
+
operand: {has:['stroke','style'], a:'an operand'},
|
|
3251
|
+
field: {has:['fill','stroke'], a:'a field'},
|
|
3252
|
+
cell: {has:['fill','stroke'], a:'a cell'},
|
|
3253
|
+
};
|
|
3254
|
+
const clsChan=(x,kind)=>{
|
|
3255
|
+
const K=CLASS_CHANNELS[kind];
|
|
3256
|
+
for(const cid of (x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]))){
|
|
3257
|
+
const c=doc.classes.find(y=>y.id===cid);
|
|
3258
|
+
if(!c) continue; // unknown id: its own error
|
|
3259
|
+
const decl=['fill','stroke','style'].filter(k=>c[k]!==undefined);
|
|
3260
|
+
if(!decl.length) continue; // meaning only — legal (`CLASS-CHANNEL-REACH`)
|
|
3261
|
+
if(!K.has.includes('fill')&&c.fill!==undefined&&c.stroke===undefined){
|
|
3262
|
+
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)');
|
|
3263
|
+
continue; }
|
|
3264
|
+
if(!decl.some(k=>K.has.includes(k)))
|
|
3265
|
+
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)');
|
|
3266
|
+
}
|
|
3267
|
+
};
|
|
3268
|
+
for(const x of doc.nodes) clsChan(x,'node');
|
|
3269
|
+
for(const x of doc.groups) clsChan(x,'group');
|
|
3270
|
+
for(const x of doc.edges) clsChan(x,'edge');
|
|
3271
|
+
for(const x of (doc.messages||[])) clsChan(x,'message');
|
|
3272
|
+
for(const x of (doc.lifelines||[])) clsChan(x,'lifeline');
|
|
3273
|
+
for(const x of (doc.states||[])) clsChan(x,'state');
|
|
3274
|
+
for(const x of (doc.fragments||[])) clsChan(x,'fragment');
|
|
3275
|
+
for(const x of (doc.operands||[])) clsChan(x,'operand');
|
|
3276
|
+
for(const b of doc.blocks){
|
|
3277
|
+
if(b.fields) for(const f of b.fields) clsChan(f,'field');
|
|
3278
|
+
if(b.marks) for(const mk of b.marks) clsChan(mk,'cell');
|
|
2634
3279
|
}
|
|
2635
3280
|
}
|
|
2636
3281
|
{ // class references must resolve (closed grammar)
|
|
@@ -3226,13 +3871,37 @@ function render(doc,ropts){
|
|
|
3226
3871
|
if(b.fields) for(const f of b.fields) rsAll(f);
|
|
3227
3872
|
if(b.marks) for(const mk of b.marks) rsAll(mk);
|
|
3228
3873
|
}
|
|
3874
|
+
// GEOMETRY-TIME DIAGNOSTICS. `parse` cannot see a coordinate, so a defect
|
|
3875
|
+
// that is only visible in the DRAWING (a group band enclosing a non-member
|
|
3876
|
+
// the author pinned there) has no channel to report through today. The scene
|
|
3877
|
+
// hands its diagnostics back here and `render` returns them beside the SVG;
|
|
3878
|
+
// a caller that writes an artifact must treat a non-empty list exactly as it
|
|
3879
|
+
// treats a parse error, because the alternative is writing a picture that
|
|
3880
|
+
// states something the source does not.
|
|
3881
|
+
let sceneErrs=[];
|
|
3229
3882
|
const parts=[]; let y=0, maxW=0;
|
|
3230
3883
|
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;
|
|
3231
3884
|
maxW=Math.max(maxW, cw(doc.title)*8.6); } // canvas must fit the title
|
|
3232
3885
|
let sceneMeta=null;
|
|
3233
|
-
|
|
3886
|
+
// THE LADDER. A sequence figure has NO SCENE: both of its axes
|
|
3887
|
+
// are declaration-ordered, so there is nothing for the scene layout to
|
|
3888
|
+
// place, and its elements live in their own five collections rather than in
|
|
3889
|
+
// `nodes`/`edges` — which is why the scene branch could never have drawn
|
|
3890
|
+
// one. The branch is an `else if` and not a second pass because the two
|
|
3891
|
+
// renderers are alternatives, never neighbours: `lifeline` and `node` cannot
|
|
3892
|
+
// both appear in one document (one genre, one node spelling).
|
|
3893
|
+
//
|
|
3894
|
+
// Everything AFTER this point is shared and unchanged — the region stack,
|
|
3895
|
+
// the derived `class` legend, the figure-level note, the canvas padding and
|
|
3896
|
+
// the title. A `class` on a `message` earns its legend entry from the same
|
|
3897
|
+
// code that derives a topology figure's.
|
|
3898
|
+
if(doc.genre==='sequence'&&(doc.lifelines||[]).length){
|
|
3899
|
+
const s=renderSequence(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
|
|
3900
|
+
}
|
|
3901
|
+
else if(doc.nodes.length||doc.edges.length||(doc.boundaries||[]).length){
|
|
3234
3902
|
const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
|
|
3235
3903
|
sceneMeta=s.meta;
|
|
3904
|
+
if(s.errs&&s.errs.length) sceneErrs=sceneErrs.concat(s.errs);
|
|
3236
3905
|
}
|
|
3237
3906
|
// `MARKER-TARGET-KINDS`: a region-scope `threshold`/`band` is drawn HERE and not
|
|
3238
3907
|
// in `renderScene`, because a region is not in the scene. Typed blocks stack
|
|
@@ -3351,12 +4020,23 @@ function render(doc,ropts){
|
|
|
3351
4020
|
+'<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">'
|
|
3352
4021
|
+'<line x1="0" y1="0" x2="0" y2="6" stroke="#bbb" stroke-width="2"/></pattern></defs>'
|
|
3353
4022
|
+'<g transform="translate('+PADL+','+PADT+')">'+parts.join('')+'</g></svg>', w:W, h:H,
|
|
3354
|
-
sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}};
|
|
4023
|
+
sceneMeta:sceneMeta, pad:{x:PADL,y:PADT}, errs:sceneErrs};
|
|
3355
4024
|
}
|
|
3356
4025
|
|
|
3357
4026
|
// ---- scene ----
|
|
3358
4027
|
function renderScene(doc,y0){
|
|
3359
4028
|
const nodes=doc.nodes.map(n=>({...n}));
|
|
4029
|
+
// Band padding around a group's members — the ONE place it is written. The
|
|
4030
|
+
// contiguity pass and the drawn rect must agree to the pixel: a pass that
|
|
4031
|
+
// separates against a slightly different rectangle from the one painted is a
|
|
4032
|
+
// pass that reports clean on a picture that is not.
|
|
4033
|
+
const BAND={l:14,r:14,t:26,b:12};
|
|
4034
|
+
// A geometry-time diagnostic carries a SOURCE line like every other error in
|
|
4035
|
+
// this engine, and in a multi-section document the line the author reads is
|
|
4036
|
+
// the FULL-FILE one. `parse` re-bases its own messages; a render error is
|
|
4037
|
+
// built here, after that pass, so the doc carries the offset itself (0 for a
|
|
4038
|
+
// single-section document, where the two numberings coincide).
|
|
4039
|
+
const srcLine=n=>(n===null||n===undefined)?null:n+(doc.lineOffset||0);
|
|
3360
4040
|
// §2.4 plane z = paint order, applied by every pass that stacks annotations
|
|
3361
4041
|
// (edges, bundle rings, threshold lines, zone bands). The sort is stable, so
|
|
3362
4042
|
// same-plane items keep document order — "a later line paints on top".
|
|
@@ -3576,7 +4256,45 @@ function renderScene(doc,y0){
|
|
|
3576
4256
|
for(let i=r0;i<r1;i++) mainGap[i]=Math.max(mainGap[i],need);
|
|
3577
4257
|
}
|
|
3578
4258
|
const center=n=>n.cross+cs(n)/2;
|
|
4259
|
+
// GROUP CONTIGUITY, ORDERING HALF. A group's band is the bounding box of its
|
|
4260
|
+
// members, so a non-member that the lane order happens to place BETWEEN two
|
|
4261
|
+
// of them is drawn inside the band — the picture then states a membership the
|
|
4262
|
+
// source never declared. The geometry pass further down can always evict the
|
|
4263
|
+
// intruder, but eviction leaves the members where they were and the band
|
|
4264
|
+
// keeps a hole where the intruder used to be; ordering them adjacent HERE,
|
|
4265
|
+
// before any coordinate exists, costs nothing and packs the group properly.
|
|
4266
|
+
//
|
|
4267
|
+
// Sorting by a key that is CONSTANT ACROSS A GROUP is what makes members
|
|
4268
|
+
// contiguous — equal keys land together — and the key is the group's MEAN
|
|
4269
|
+
// desired position, so the cluster still sits where the incoming order put
|
|
4270
|
+
// it. The sort is stable, so members keep the order they arrived in and a
|
|
4271
|
+
// lane with nothing interleaved is not reordered at all.
|
|
4272
|
+
//
|
|
4273
|
+
// A group with a PINNED member is left alone: the pin is the author's
|
|
4274
|
+
// coordinate, this pass cannot move it, and clustering the rest around a slot
|
|
4275
|
+
// the pin will overwrite only displaces free nodes for nothing. That figure
|
|
4276
|
+
// is the AUTHOR's half of the ruling and is reported, not redrawn.
|
|
4277
|
+
const clusterGroups=arr=>{ // arr of {n, d}; reordered in place
|
|
4278
|
+
if(!arr.some(x=>x.n.group)) return arr;
|
|
4279
|
+
const mean=new Map();
|
|
4280
|
+
for(const x of arr){ const g=x.n.group; if(!g) continue;
|
|
4281
|
+
if(!mean.has(g)) mean.set(g,{s:0,k:0,pin:false});
|
|
4282
|
+
const t=mean.get(g); t.s+=x.d; t.k++; if(pinned(x.n.id)) t.pin=true; }
|
|
4283
|
+
if(![...mean.values()].some(t=>!t.pin&&t.k>1)) return arr;
|
|
4284
|
+
const live=g=>g&&mean.has(g)&&!mean.get(g).pin;
|
|
4285
|
+
const key=x=>live(x.n.group)?mean.get(x.n.group).s/mean.get(x.n.group).k:x.d;
|
|
4286
|
+
const tag=x=>live(x.n.group)?x.n.group:'';
|
|
4287
|
+
arr.sort((p,q)=>key(p)-key(q)||(tag(p)<tag(q)?-1:tag(p)>tag(q)?1:0));
|
|
4288
|
+
return arr;
|
|
4289
|
+
};
|
|
3579
4290
|
ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
|
|
4291
|
+
// The seed is the ONLY ordering a single-rank figure ever gets: `sweep`
|
|
4292
|
+
// walks rank boundaries, so a scene with no edges never reaches `place`.
|
|
4293
|
+
// The six-line reproduction (`group g` + three nodes, the middle one not a
|
|
4294
|
+
// member) is exactly that figure, which is why the clustering runs here too
|
|
4295
|
+
// and not only in the sweep.
|
|
4296
|
+
const ord=clusterGroups(lane.map((n,k)=>({n,d:k}))).map(x=>x.n);
|
|
4297
|
+
lane.length=0; ord.forEach(n=>lane.push(n));
|
|
3580
4298
|
lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
|
|
3581
4299
|
// WHERE THE HOLD YIELDS, WHICH IS MOST OF THE RULE. Holding a chain node on
|
|
3582
4300
|
// its chain neighbour puts every OTHER neighbour of that node on one side of
|
|
@@ -3782,6 +4500,148 @@ function renderScene(doc,y0){
|
|
|
3782
4500
|
if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
|
|
3783
4501
|
else { n.x=p.fx; n.y=y0+20+p.fy; }
|
|
3784
4502
|
}
|
|
4503
|
+
// ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
|
|
4504
|
+
// A group's band is the BOUNDING BOX of its members (see gBox below), and
|
|
4505
|
+
// until this pass nothing checked that the box contained only members. A
|
|
4506
|
+
// non-member the layout happened to place between two members was therefore
|
|
4507
|
+
// drawn INSIDE the band, with no error and no warning: six legal lines
|
|
4508
|
+
// (`group g`, three nodes of which the middle one is not `in=g`, `layout`)
|
|
4509
|
+
// produced a picture that says the middle node is in the group. That is the
|
|
4510
|
+
// worst failure class this project has — a legal document that reads
|
|
4511
|
+
// confidently and wrongly — and it contradicts this genre's own rule that
|
|
4512
|
+
// membership is DECLARED and never inferred from rendered geometry.
|
|
4513
|
+
//
|
|
4514
|
+
// WHOEVER CHOSE THE POSITION BEARS THE RESPONSIBILITY. That single principle
|
|
4515
|
+
// splits the fix in two:
|
|
4516
|
+
//
|
|
4517
|
+
// the ENGINE chose it — auto-layout had freedom, so the engine MUST place
|
|
4518
|
+
// the members contiguously and the situation cannot arise. It is fixed
|
|
4519
|
+
// here, silently, at no cost to the author.
|
|
4520
|
+
// the AUTHOR chose it — a `pin` fixed the intruder (or fixed the members
|
|
4521
|
+
// whose extent IS the band) and the engine has no freedom left. It then
|
|
4522
|
+
// reports, naming the pin line, and the artifact is not written. The
|
|
4523
|
+
// engine never overrides the author's coordinate, and never draws a
|
|
4524
|
+
// statement the source did not make.
|
|
4525
|
+
//
|
|
4526
|
+
// It runs HERE — after pins are applied and before the group origins are
|
|
4527
|
+
// taken — because the defect exists in the final geometry and nowhere else.
|
|
4528
|
+
// The source looks fine; that is the whole point of the defect.
|
|
4529
|
+
const gErrs=[];
|
|
4530
|
+
{
|
|
4531
|
+
const SEP=16; // clearance left between a band and what is pushed out
|
|
4532
|
+
const MAXPASS=60; // resolution is monotone (always outward); this bounds
|
|
4533
|
+
// pathological alternation rather than expected work
|
|
4534
|
+
const real=nodes.filter(n=>!n.boundary);
|
|
4535
|
+
const memOf=id=>real.filter(n=>n.group===id);
|
|
4536
|
+
const cLo=n=>horiz?n.y:n.x, cSz=n=>horiz?n.h:n.w;
|
|
4537
|
+
const mv=(n,d)=>{ if(horiz) n.y+=d; else n.x+=d; };
|
|
4538
|
+
const groups=doc.groups.filter(g=>memOf(g.id).length);
|
|
4539
|
+
const minCross=()=>lay.length?Math.min(...lay.map(cLo)):0;
|
|
4540
|
+
const cross0=minCross(); // the envelope the layout had before this pass
|
|
4541
|
+
const bandOf=g=>{
|
|
4542
|
+
const m=memOf(g.id);
|
|
4543
|
+
const B={x0:Math.min(...m.map(n=>n.x))-BAND.l, x1:Math.max(...m.map(n=>n.x+n.w))+BAND.r,
|
|
4544
|
+
yA:Math.min(...m.map(n=>n.y))-BAND.t, yB:Math.max(...m.map(n=>n.y+n.h))+BAND.b};
|
|
4545
|
+
B.lo=horiz?B.yA:B.x0; B.hi=horiz?B.yB:B.x1;
|
|
4546
|
+
return B;
|
|
4547
|
+
};
|
|
4548
|
+
const inBand=(n,B)=>n.x<B.x1&&n.x+n.w>B.x0&&n.y<B.yB&&n.y+n.h>B.yA;
|
|
4549
|
+
const pinLine=id=>doc.pins[id]?doc.pins[id].line:null;
|
|
4550
|
+
// The MOVER is a whole group or a single free node — never half a group,
|
|
4551
|
+
// because moving one member of a group reshapes THAT group's band and the
|
|
4552
|
+
// next pass would only find the same class of defect one group along.
|
|
4553
|
+
const unitOf=n=>n.group?memOf(n.group):[n];
|
|
4554
|
+
const canMove=u=>u.every(n=>!pinned(n.id))
|
|
4555
|
+
&& !(u[0].group&&doc.pins[u[0].group]&&doc.pins[u[0].group].fx!==null);
|
|
4556
|
+
const said=new Set();
|
|
4557
|
+
const collect=()=>{
|
|
4558
|
+
const out=[];
|
|
4559
|
+
for(const g of groups){
|
|
4560
|
+
const B=bandOf(g);
|
|
4561
|
+
for(const n of real){
|
|
4562
|
+
if(n.group===g.id||said.has(g.id+' '+n.id)) continue;
|
|
4563
|
+
if(inBand(n,B)) out.push({g,n});
|
|
4564
|
+
}
|
|
4565
|
+
}
|
|
4566
|
+
return out;
|
|
4567
|
+
};
|
|
4568
|
+
let left=[];
|
|
4569
|
+
// EVERY conflict gets attention on every pass, and the band is recomputed
|
|
4570
|
+
// immediately before each resolution. Taking only the first conflict each
|
|
4571
|
+
// pass was tried and is wrong: one pair that alternates starves every other
|
|
4572
|
+
// pair for the whole iteration budget, and the run then reports as
|
|
4573
|
+
// "unresolvable" figures the pass had never once looked at.
|
|
4574
|
+
for(let pass=0;pass<MAXPASS;pass++){
|
|
4575
|
+
left=collect();
|
|
4576
|
+
if(!left.length) break;
|
|
4577
|
+
for(const c of left){
|
|
4578
|
+
const B=bandOf(c.g);
|
|
4579
|
+
if(!inBand(c.n,B)) continue; // an earlier resolution cleared it
|
|
4580
|
+
const gMem=memOf(c.g.id);
|
|
4581
|
+
// Who yields: the intruder if the engine placed it, otherwise the group
|
|
4582
|
+
// if the engine placed THAT, otherwise nobody and the author is told.
|
|
4583
|
+
let unit=unitOf(c.n), obst=B;
|
|
4584
|
+
if(!canMove(unit)){
|
|
4585
|
+
if(canMove(gMem)){ unit=gMem;
|
|
4586
|
+
obst={lo:cLo(c.n), hi:cLo(c.n)+cSz(c.n)}; }
|
|
4587
|
+
else {
|
|
4588
|
+
// No freedom anywhere: report, name the line that took it away, and
|
|
4589
|
+
// stop considering this pair so the loop still terminates.
|
|
4590
|
+
const who=pinned(c.n.id)?c.n.id
|
|
4591
|
+
:(c.n.group&&doc.pins[c.n.group]&&doc.pins[c.n.group].fx!==null?c.n.group
|
|
4592
|
+
:(gMem.find(m=>pinned(m.id))||{id:c.g.id}).id);
|
|
4593
|
+
const ln=srcLine(pinLine(who));
|
|
4594
|
+
gErrs.push('Line '+(ln!==null?ln:srcLine(c.g.line))+': pin puts "'+c.n.id
|
|
4595
|
+
+'" inside the band of group "'+c.g.id+'" — a band is the bounding box of the '
|
|
4596
|
+
+'group\'s members, so this draws "'+c.n.id+'" as one of them. Move the pin clear '
|
|
4597
|
+
+'of the group\'s extent, or say what the drawing says with in='+c.g.id+'.');
|
|
4598
|
+
said.add(c.g.id+' '+c.n.id); continue;
|
|
4599
|
+
}
|
|
4600
|
+
}
|
|
4601
|
+
const uLo=Math.min(...unit.map(cLo)), uHi=Math.max(...unit.map(n=>cLo(n)+cSz(n)));
|
|
4602
|
+
const dNeg=(obst.lo-SEP)-uHi, dPos=(obst.hi+SEP)-uLo;
|
|
4603
|
+
// NEARER SIDE, BUT NEVER OFF THE CANVAS. The obvious rule — move
|
|
4604
|
+
// whichever way is shorter — sends the unit past the layout's own
|
|
4605
|
+
// starting edge often enough to matter (`reference/topology` put L1 at
|
|
4606
|
+
// x=-90 and the viewBox clipped it away). Growing the canvas the other
|
|
4607
|
+
// way is not available either: the only uniform-shift machinery this
|
|
4608
|
+
// renderer has moves PINNED nodes with everything else, and a pinned
|
|
4609
|
+
// node that drifts because an unrelated node was added is the `RENDERING-DETERMINISM`
|
|
4610
|
+
// stability violation this engine has already paid for once. So the
|
|
4611
|
+
// constraint is applied HERE, to the choice: the negative direction is
|
|
4612
|
+
// taken only when the unit still lands inside the envelope the layout
|
|
4613
|
+
// had before this pass ran. Nothing outside the mover ever moves.
|
|
4614
|
+
const dNegOK=uLo+dNeg>=cross0;
|
|
4615
|
+
const d=(Math.abs(dNeg)<=Math.abs(dPos)&&dNegOK)?dNeg:dPos;
|
|
4616
|
+
const ranks=new Set(unit.map(n=>n.rank));
|
|
4617
|
+
const keep=new Set(unit.concat(unit===gMem?[]:gMem));
|
|
4618
|
+
// Everything the mover would be pushed ONTO travels with it: same rank,
|
|
4619
|
+
// same side, clear of the obstacle. Relative order and spacing inside a
|
|
4620
|
+
// lane are preserved, so the fix cannot manufacture an overlap.
|
|
4621
|
+
// A node that BELONGS to a group never travels this way — a group moves
|
|
4622
|
+
// whole or not at all, and dragging half of one along would reshape its
|
|
4623
|
+
// band, which is the same defect one group further on.
|
|
4624
|
+
for(const m of lay){
|
|
4625
|
+
if(keep.has(m)||!ranks.has(m.rank)) continue;
|
|
4626
|
+
if(!m.virtual&&m.group) continue;
|
|
4627
|
+
const mLo=cLo(m), mHi=mLo+cSz(m);
|
|
4628
|
+
if(d<0 ? (mHi<=uHi&&mHi<=obst.lo) : (mLo>=uLo&&mLo>=obst.hi)) mv(m,d);
|
|
4629
|
+
}
|
|
4630
|
+
for(const n of unit) mv(n,d);
|
|
4631
|
+
}
|
|
4632
|
+
}
|
|
4633
|
+
left=collect();
|
|
4634
|
+
// The invariant is CHECKED, not assumed: anything the pass could not place
|
|
4635
|
+
// is named. A figure that reaches this line with a hit is a defect in this
|
|
4636
|
+
// pass, and saying so beats drawing the false statement quietly.
|
|
4637
|
+
for(const c of left){
|
|
4638
|
+
if(said.has(c.g.id+' '+c.n.id)) continue;
|
|
4639
|
+
gErrs.push('Line '+srcLine(c.g.line)+': group "'+c.g.id+'" would enclose non-member "'
|
|
4640
|
+
+c.n.id+'" and the layout pass could not separate them; the figure is not drawn rather '
|
|
4641
|
+
+'than drawn wrongly. Give "'+c.n.id+'" a pin outside the group, or add it with in='
|
|
4642
|
+
+c.g.id+'.');
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
3785
4645
|
// Pass 3: an unpinned group has no anchor of its own; its display origin
|
|
3786
4646
|
// (drag anchor / data-gx,gy) is the top-left of its members' FINAL positions,
|
|
3787
4647
|
// so it reflects any pinned members and matches the group box drawn below.
|
|
@@ -3958,8 +4818,8 @@ function renderScene(doc,y0){
|
|
|
3958
4818
|
const mem=nodes.filter(n=>n.group===g.id);
|
|
3959
4819
|
if(!mem.length) continue;
|
|
3960
4820
|
const o=gOrigin[g.id];
|
|
3961
|
-
const x0=Math.min(...mem.map(n=>n.x))-
|
|
3962
|
-
const yA=Math.min(...mem.map(n=>n.y))-
|
|
4821
|
+
const x0=Math.min(...mem.map(n=>n.x))-BAND.l, x1=Math.max(...mem.map(n=>n.x+n.w))+BAND.r;
|
|
4822
|
+
const yA=Math.min(...mem.map(n=>n.y))-BAND.t, yB=Math.max(...mem.map(n=>n.y+n.h))+BAND.b;
|
|
3963
4823
|
gBox[g.id]={x0,x1,yA,yB};
|
|
3964
4824
|
const gdash=g.style==='dashed'?' stroke-dasharray="6 4"':(g.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
3965
4825
|
gsvg.push('<g data-group="'+g.id+'" data-gx="'+o.x+'" data-gy="'+o.y+'" style="cursor:move">'
|
|
@@ -4989,7 +5849,7 @@ function renderScene(doc,y0){
|
|
|
4989
5849
|
}
|
|
4990
5850
|
const yEnd=y0+20+Hh+10;
|
|
4991
5851
|
return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
|
|
4992
|
-
meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}};
|
|
5852
|
+
meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}, errs:gErrs};
|
|
4993
5853
|
}
|
|
4994
5854
|
// borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
|
|
4995
5855
|
// It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
|
|
@@ -5153,6 +6013,393 @@ function edgeRuns(v, p, n, span, ownAt){
|
|
|
5153
6013
|
return out;
|
|
5154
6014
|
}
|
|
5155
6015
|
|
|
6016
|
+
// ---- ladder (the `sequence` genre) ----
|
|
6017
|
+
//
|
|
6018
|
+
// THE ORDERING RULE, stated once, because everything below depends on it:
|
|
6019
|
+
//
|
|
6020
|
+
// The TIME axis is the declaration order of the `message` and `state` lines
|
|
6021
|
+
// taken JOINTLY. Line m above line n asserts that m occurs before n.
|
|
6022
|
+
// `lifeline` declaration order is the COLUMN axis, left to right. Both axes
|
|
6023
|
+
// are declaration-ordered, and that is why this genre has no `flow` and no
|
|
6024
|
+
// `rank`: a key that reordered the drawing would make the picture disagree
|
|
6025
|
+
// with the text (`SEQUENCE-SOURCE-STANDARD`-R182). `fragment` and `operand` lines are
|
|
6026
|
+
// DECLARATIONS and carry no time position of their own; a container's drawn
|
|
6027
|
+
// extent is the span of its members' positions. Implementation: every
|
|
6028
|
+
// element carries its source line number, so the row order is recovered by
|
|
6029
|
+
// ONE sort on that number in `seqModel` — the model never stores an ordinal.
|
|
6030
|
+
//
|
|
6031
|
+
// Everything else in this function is a DRAWING CONVENTION the engine owns
|
|
6032
|
+
// under `DOMAIN-CONVENTION-DIRECTIVES` and is marked CHOSEN where it is not obvious. The author names
|
|
6033
|
+
// MEANING (who talks to whom, in what order, inside which fragment); the
|
|
6034
|
+
// engine decides every coordinate, and there is no key that moves one.
|
|
6035
|
+
//
|
|
6036
|
+
// The layout is SIX DETERMINISTIC PASSES and no fixed-point iteration:
|
|
6037
|
+
// 1. container column spans (which columns each fragment/operand covers)
|
|
6038
|
+
// 2. the column axis (centre-to-centre distances, widened to fit)
|
|
6039
|
+
// 3. the time axis (one slot per row, plus container headroom)
|
|
6040
|
+
// 4. container box geometry (from the tops/bottoms the cursor recorded)
|
|
6041
|
+
// 5. paint (background, mid, ink — three ordered layers)
|
|
6042
|
+
// 6. the canvas extent (widest of columns, boxes and overhanging ink)
|
|
6043
|
+
//
|
|
6044
|
+
// NOT DRAWN: activation bars. UML's ExecutionSpecification is a separate
|
|
6045
|
+
// referent with a separate spelling, and the genre has no keyword for it — so
|
|
6046
|
+
// the renderer must not invent one out of message adjacency, which would put
|
|
6047
|
+
// an assertion in the picture that the source does not make.
|
|
6048
|
+
function renderSequence(doc,y0){
|
|
6049
|
+
const M=seqModel(doc);
|
|
6050
|
+
const lls=doc.lifelines;
|
|
6051
|
+
if(!lls.length) return {svg:'',y:y0,w:0};
|
|
6052
|
+
const col={}; lls.forEach((l,i)=>{ col[l.id]=i; });
|
|
6053
|
+
// `OMITTED-LABEL-RECORDING`/`EMPTY-LABEL-STATE` display fallback, applied here rather than in `render`: the model
|
|
6054
|
+
// records an omitted label as absent (null) and the RENDERER substitutes the
|
|
6055
|
+
// id, so `lifeline c` draws "c". An explicitly empty label draws nothing.
|
|
6056
|
+
const lblOf=(x)=>(x.label===null||x.label===undefined)?x.id:x.label;
|
|
6057
|
+
// class cascade — the same rule `render` applies to nodes and edges, applied
|
|
6058
|
+
// to this genre's elements (a `class` on a `message` is what `lost=` was
|
|
6059
|
+
// refused in favour of, `UNDELIVERED-MESSAGE-MARKING`). Read-only: the element is never patched.
|
|
6060
|
+
const C={}; for(const c of doc.classes||[]) C[c.id]=c;
|
|
6061
|
+
const clsIds=(x)=>x.cls===undefined||x.cls===null?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
|
|
6062
|
+
const chan=(x,k)=>{ if(x[k]!==undefined) return x[k];
|
|
6063
|
+
let v; for(const id of clsIds(x)) if(C[id]&&C[id][k]!==undefined) v=C[id][k]; return v; };
|
|
6064
|
+
// `seqModel` reports the containment chain; DEPTH is a view of it and lives
|
|
6065
|
+
// here because only the drawing needs it (nesting inset, paint order).
|
|
6066
|
+
const depth=(id)=>M.chain(id).length-1;
|
|
6067
|
+
|
|
6068
|
+
// ── geometry constants (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`) ────────────────────────────────────
|
|
6069
|
+
const HEAD_H=32, HEAD_PADX=13, HEAD_MINW=76;
|
|
6070
|
+
const ROW_H=34; // base row pitch. See the F5 note below.
|
|
6071
|
+
const SELF_W=40, SELF_EXTRA=26, STATE_H=22;
|
|
6072
|
+
const LBL_FS=11, LBL_LIFT=8; // label sits LBL_LIFT px above its own arrow
|
|
6073
|
+
const FRAG_TOP=26, FRAG_BOT=12, OPERAND_TOP=20, FRAG_PADX=22, FRAG_INSET=9;
|
|
6074
|
+
const ENC_PAD=6; // clearance a container's frame keeps off its members
|
|
6075
|
+
// F5 (spec/core.md §14.3) is a CONSTRAINT ON ROW_H, not an afterthought.
|
|
6076
|
+
// A message label's centre sits LBL_LIFT + fs*0.55 ≈ 14 px above its own
|
|
6077
|
+
// arrow, so its margin against the arrow one row away is ROW_H - 2*14.
|
|
6078
|
+
// F5 requires that to exceed M = 4 px, i.e. ROW_H > 32. ROW_H = 34 gives a
|
|
6079
|
+
// computed margin of 6 px at the worst case (two consecutive messages over
|
|
6080
|
+
// the same span) and much more in practice. This is the whole reason a
|
|
6081
|
+
// ladder is F5-cheap: the geometry separates labels by CONSTRUCTION, so the
|
|
6082
|
+
// margin is a property of the row pitch and not of any per-figure search.
|
|
6083
|
+
|
|
6084
|
+
const headW=lls.map(l=>Math.max(HEAD_MINW, cwMax(lblOf(l))*CH+2*HEAD_PADX));
|
|
6085
|
+
const lblPx=(s)=>s?cwMax(s)*(6.5*LBL_FS/11)+8:0;
|
|
6086
|
+
// A state pill's width is needed in TWO passes — the container-enclosure
|
|
6087
|
+
// pass and the paint pass — so it is written once. A pill wider than its
|
|
6088
|
+
// container's padding is exactly the case that made the enclosure pass
|
|
6089
|
+
// necessary (see PASS 4).
|
|
6090
|
+
const statePillW=(el)=>Math.max(46, cwMax(el.name)*6.6+18);
|
|
6091
|
+
|
|
6092
|
+
// ── PASS 1 — container column spans (needed BEFORE the column axis,
|
|
6093
|
+
// because a fragment's operator tab and label have to fit inside its
|
|
6094
|
+
// own box) ────────────────────────────────────────────────────────────
|
|
6095
|
+
const cspan={};
|
|
6096
|
+
for(const id in M.cont){
|
|
6097
|
+
const slots=M.owned[id];
|
|
6098
|
+
let cmin=Infinity,cmax=-Infinity;
|
|
6099
|
+
for(const sl of slots){ const r=M.rows[sl];
|
|
6100
|
+
if(r.kind==='message'){ const a=col[r.el.a],b=col[r.el.b];
|
|
6101
|
+
if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);}
|
|
6102
|
+
if(b!==undefined){cmin=Math.min(cmin,b);cmax=Math.max(cmax,b);} }
|
|
6103
|
+
else { const a=col[r.el.ref];
|
|
6104
|
+
if(a!==undefined){cmin=Math.min(cmin,a);cmax=Math.max(cmax,a);} } }
|
|
6105
|
+
cspan[id]=isFinite(cmin)?{cmin,cmax}:null;
|
|
6106
|
+
}
|
|
6107
|
+
// a fragment must be at least as wide as the operands it holds
|
|
6108
|
+
for(const id in M.cont){
|
|
6109
|
+
if(M.cont[id].kind!=='operand') continue;
|
|
6110
|
+
const p=M.cont[id].parent;
|
|
6111
|
+
if(p&&cspan[p]&&cspan[id]){ cspan[p].cmin=Math.min(cspan[p].cmin,cspan[id].cmin);
|
|
6112
|
+
cspan[p].cmax=Math.max(cspan[p].cmax,cspan[id].cmax); }
|
|
6113
|
+
}
|
|
6114
|
+
const tabW=(id)=>cwMax(M.cont[id].el.type||'')*6.6+16;
|
|
6115
|
+
const capW=(id)=>{ const c=M.cont[id];
|
|
6116
|
+
const lab=(c.el.label===null||c.el.label===undefined)?'':('['+c.el.label+']');
|
|
6117
|
+
return (c.kind==='fragment'?tabW(id)+14:8)+cwMax(lab)*6.6+10; };
|
|
6118
|
+
|
|
6119
|
+
// ── PASS 2 — the column axis: centre-to-centre distances ────────────────
|
|
6120
|
+
const nc=lls.length, cd=[];
|
|
6121
|
+
for(let k=0;k+1<nc;k++) cd.push(headW[k]/2+headW[k+1]/2+26);
|
|
6122
|
+
let rightPad=0;
|
|
6123
|
+
const widen=(i,j,need)=>{ // need = required span i..j
|
|
6124
|
+
if(j<=i) return;
|
|
6125
|
+
let have=0; for(let k=i;k<j;k++) have+=cd[k];
|
|
6126
|
+
if(have>=need) return;
|
|
6127
|
+
const add=(need-have)/(j-i); for(let k=i;k<j;k++) cd[k]+=add;
|
|
6128
|
+
};
|
|
6129
|
+
for(const r of M.rows){
|
|
6130
|
+
if(r.kind!=='message') continue;
|
|
6131
|
+
const ci=col[r.el.a], cj=col[r.el.b];
|
|
6132
|
+
if(ci===undefined||cj===undefined) continue;
|
|
6133
|
+
const w=lblPx(r.el.label)+26;
|
|
6134
|
+
if(ci===cj){ // self-message
|
|
6135
|
+
const need=SELF_W+lblPx(r.el.label)+16;
|
|
6136
|
+
if(ci+1<nc) widen(ci,ci+1,need); else rightPad=Math.max(rightPad,need);
|
|
6137
|
+
} else widen(Math.min(ci,cj),Math.max(ci,cj),w);
|
|
6138
|
+
}
|
|
6139
|
+
// the fragment caption is INSIDE the box, so it constrains the columns the
|
|
6140
|
+
// box spans — a caption that overflows its own box names nothing.
|
|
6141
|
+
for(const id in M.cont){
|
|
6142
|
+
const cs=cspan[id]; if(!cs) continue;
|
|
6143
|
+
const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
|
|
6144
|
+
if(cs.cmin===cs.cmax) rightPad=Math.max(rightPad,capW(id)-pad-headW[cs.cmax]/2);
|
|
6145
|
+
else widen(cs.cmin,cs.cmax,capW(id)-2*pad);
|
|
6146
|
+
}
|
|
6147
|
+
// The LEFT margin is structural too. Column 0's head box normally sets it,
|
|
6148
|
+
// but two things drawn on that column are wider than it: a `state` pill (as
|
|
6149
|
+
// wide as its state name) and a container frame whose left edge sits a
|
|
6150
|
+
// padding outside the column. Whichever overhangs furthest pushes the whole
|
|
6151
|
+
// axis right, so nothing is ever drawn at a negative x — the canvas has no
|
|
6152
|
+
// room there and the ink would simply be clipped away.
|
|
6153
|
+
let leftPad=0;
|
|
6154
|
+
for(const r of M.rows)
|
|
6155
|
+
if(r.kind==='state'&&col[r.el.ref]===0)
|
|
6156
|
+
leftPad=Math.max(leftPad, statePillW(r.el)/2+ENC_PAD-headW[0]/2);
|
|
6157
|
+
for(const id in M.cont){
|
|
6158
|
+
const cs=cspan[id]; if(!cs||cs.cmin!==0) continue;
|
|
6159
|
+
const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
|
|
6160
|
+
leftPad=Math.max(leftPad, pad+ENC_PAD+6-headW[0]/2);
|
|
6161
|
+
}
|
|
6162
|
+
const x=[]; x[0]=headW[0]/2+Math.max(0,leftPad);
|
|
6163
|
+
for(let k=1;k<nc;k++) x[k]=x[k-1]+cd[k-1];
|
|
6164
|
+
|
|
6165
|
+
// ── PASS 3 — the time axis: one slot per row, plus the space containers
|
|
6166
|
+
// need for their frames ────────────────────────────────────────────────
|
|
6167
|
+
const opensAt={}, closesAt={};
|
|
6168
|
+
for(const id in M.cont){ const e=M.extent[id]; if(!e) continue;
|
|
6169
|
+
(opensAt[e.lo]=opensAt[e.lo]||[]).push(id);
|
|
6170
|
+
(closesAt[e.hi]=closesAt[e.hi]||[]).push(id); }
|
|
6171
|
+
const sortDeep=(a)=>a.slice().sort((p,q)=>depth(p)-depth(q));
|
|
6172
|
+
const yTop=y0;
|
|
6173
|
+
let y=yTop+HEAD_H+22;
|
|
6174
|
+
// Box tops and bottoms are recorded AS THE CURSOR PASSES THEM, so an outer
|
|
6175
|
+
// fragment and the operand that opens with it get DIFFERENT tops and their
|
|
6176
|
+
// captions cannot land on each other. (Deriving both from the member row
|
|
6177
|
+
// overlapped them — visible in the rendered pixels, not in any metric.)
|
|
6178
|
+
const boxTop={}, boxBot={};
|
|
6179
|
+
for(const r of M.rows){
|
|
6180
|
+
for(const id of sortDeep(opensAt[r.slot]||[])){
|
|
6181
|
+
// a top-level fragment gets clear air above it, or two consecutive
|
|
6182
|
+
// fragments share a border and read as one box.
|
|
6183
|
+
if(depth(id)===0&&M.cont[id].kind==='fragment') y+=8;
|
|
6184
|
+
boxTop[id]=y; y+=(M.cont[id].kind==='fragment'?FRAG_TOP:OPERAND_TOP); }
|
|
6185
|
+
r.y0=y;
|
|
6186
|
+
let h=ROW_H;
|
|
6187
|
+
if(r.kind==='message'&&r.el.a===r.el.b) h+=SELF_EXTRA;
|
|
6188
|
+
// A label is drawn ABOVE its own arrow, so every extra line of it is
|
|
6189
|
+
// extra row pitch — otherwise line 2 lands ON the arrow.
|
|
6190
|
+
if(r.kind==='message') h+=Math.max(0,String(r.el.label||'').split('\n').length-1)*LBL_FS*1.3;
|
|
6191
|
+
// NOTE what is NOT here: `description=` reserves no row height, because it
|
|
6192
|
+
// puts NO INK on the page (core §10, §12.7 — "description= addresses the
|
|
6193
|
+
// machine and draws nothing, note= addresses the human and always draws").
|
|
6194
|
+
// The ladder honours that division: a description becomes an SVG <title>
|
|
6195
|
+
// on the element it names and nothing else. (The prototype this was ported
|
|
6196
|
+
// from drew it as grey prose under the arrow, which is the one thing the
|
|
6197
|
+
// key is defined not to do.)
|
|
6198
|
+
r.yMid=y+h/2;
|
|
6199
|
+
y+=h;
|
|
6200
|
+
r.y1=y;
|
|
6201
|
+
for(const id of sortDeep(closesAt[r.slot]||[]).reverse()){
|
|
6202
|
+
if(M.cont[id].kind==='fragment') y+=FRAG_BOT;
|
|
6203
|
+
boxBot[id]=y; }
|
|
6204
|
+
// The NEXT row's label is drawn ABOVE its own arrow, so a row that follows
|
|
6205
|
+
// a closing frame starts its label ~3 px under that frame's border and the
|
|
6206
|
+
// two read as one mark. A frame that closes therefore buys clear air below
|
|
6207
|
+
// it, on the same ground as the clear air a top-level fragment buys above.
|
|
6208
|
+
if((closesAt[r.slot]||[]).length && r.slot+1<M.rows.length) y+=10;
|
|
6209
|
+
}
|
|
6210
|
+
const bottom=y+10;
|
|
6211
|
+
|
|
6212
|
+
// ── PASS 4 — containers: box geometry ───────────────────────────────────
|
|
6213
|
+
const fbox={};
|
|
6214
|
+
for(const id in M.cont){
|
|
6215
|
+
const e=M.extent[id]; if(!e) continue;
|
|
6216
|
+
const cs=cspan[id]||{cmin:0,cmax:nc-1};
|
|
6217
|
+
const d=depth(id), pad=Math.max(6,FRAG_PADX-d*FRAG_INSET);
|
|
6218
|
+
fbox[id]={x0:x[cs.cmin]-pad, x1:Math.max(x[cs.cmax]+pad, x[cs.cmin]-pad+capW(id)),
|
|
6219
|
+
y0:boxTop[id], y1:boxBot[id],
|
|
6220
|
+
cmin:cs.cmin,cmax:cs.cmax,d,kind:M.cont[id].kind};
|
|
6221
|
+
}
|
|
6222
|
+
// A container's frame must ENCLOSE THE INK OF ITS MEMBERS, and the column
|
|
6223
|
+
// span alone does not guarantee that: a member's drawing can be wider than
|
|
6224
|
+
// the column it sits on. A `state` pill is centred on its lifeline and is as
|
|
6225
|
+
// wide as its state name, so a long name overhangs the fixed padding and the
|
|
6226
|
+
// pill pokes out through the frame that is supposed to contain it — visible
|
|
6227
|
+
// in the rendered pixels of the reference figure ("RENEWING" inside `loop`)
|
|
6228
|
+
// and invisible to every metric. The frame is therefore grown to the drawn
|
|
6229
|
+
// extent of what it owns. `owned` is TRANSITIVE, so an inner operand's
|
|
6230
|
+
// members widen the enclosing fragment too.
|
|
6231
|
+
for(const id in fbox){
|
|
6232
|
+
for(const sl of M.owned[id]){
|
|
6233
|
+
const r=M.rows[sl];
|
|
6234
|
+
let lo,hi;
|
|
6235
|
+
if(r.kind==='state'){ const ci=col[r.el.ref]; if(ci===undefined) continue;
|
|
6236
|
+
const w=statePillW(r.el); lo=x[ci]-w/2; hi=x[ci]+w/2; }
|
|
6237
|
+
else { const a=col[r.el.a], b=col[r.el.b]; if(a===undefined||b===undefined) continue;
|
|
6238
|
+
lo=Math.min(x[a],x[b]);
|
|
6239
|
+
hi=(a===b)?x[a]+SELF_W+8+lblPx(r.el.label):Math.max(x[a],x[b]); }
|
|
6240
|
+
fbox[id].x0=Math.min(fbox[id].x0,lo-ENC_PAD);
|
|
6241
|
+
fbox[id].x1=Math.max(fbox[id].x1,hi+ENC_PAD);
|
|
6242
|
+
}
|
|
6243
|
+
}
|
|
6244
|
+
// a fragment must contain its operands' boxes
|
|
6245
|
+
for(const id in fbox){
|
|
6246
|
+
if(fbox[id].kind!=='operand') continue;
|
|
6247
|
+
const p=M.cont[id].parent;
|
|
6248
|
+
if(p&&fbox[p]){ fbox[p].y1=Math.max(fbox[p].y1,fbox[id].y1);
|
|
6249
|
+
fbox[p].x0=Math.min(fbox[p].x0,fbox[id].x0-6);
|
|
6250
|
+
fbox[p].x1=Math.max(fbox[p].x1,fbox[id].x1+6); }
|
|
6251
|
+
}
|
|
6252
|
+
// An operand is a COMPARTMENT OF its fragment, so it is exactly as wide as
|
|
6253
|
+
// the fragment: its separator rule DIVIDES the frame and must reach both
|
|
6254
|
+
// borders, and its guard is read against the frame's left edge. Derived from
|
|
6255
|
+
// the parent LAST, after the parent has finished growing, so the divider can
|
|
6256
|
+
// never be shorter than the box it divides (UML 2.5.1 §17.12.3/§17.12.14).
|
|
6257
|
+
for(const id in fbox){
|
|
6258
|
+
if(fbox[id].kind!=='operand') continue;
|
|
6259
|
+
const p=M.cont[id].parent;
|
|
6260
|
+
if(p&&fbox[p]){ fbox[id].x0=fbox[p].x0; fbox[id].x1=fbox[p].x1; }
|
|
6261
|
+
}
|
|
6262
|
+
|
|
6263
|
+
// ── PASS 5 — paint ──────────────────────────────────────────────────────
|
|
6264
|
+
const bg=[], mid=[], ink=[];
|
|
6265
|
+
const HALO=' paint-order="stroke" stroke="#fff" stroke-width="3"';
|
|
6266
|
+
const arrowTri=(tip,from,c)=>{
|
|
6267
|
+
const dx=tip[0]-from[0], dy=tip[1]-from[1], L=Math.hypot(dx,dy)||1;
|
|
6268
|
+
const ux=dx/L, uy=dy/L, arm=10.08, hw=5.6;
|
|
6269
|
+
const bx=tip[0]-ux*arm, by=tip[1]-uy*arm;
|
|
6270
|
+
ink.push('<path d="M'+r2(tip[0])+' '+r2(tip[1])+' L'+r2(bx-uy*hw)+' '+r2(by+ux*hw)
|
|
6271
|
+
+' L'+r2(bx+uy*hw)+' '+r2(by-ux*hw)+' z" fill="'+c+'" stroke="none"/>');
|
|
6272
|
+
};
|
|
6273
|
+
const inkExtent=[];
|
|
6274
|
+
// `description=` → an SVG <title> and nothing else (core §10). `DESCRIPTION-KEY-SPELLING`'s rule
|
|
6275
|
+
// applies: a <title> names its PARENT, so it is never a loose sibling in the
|
|
6276
|
+
// figure's single <g> — where every description in the figure would name the
|
|
6277
|
+
// same element and a conforming UA would show one arbitrary tooltip for the
|
|
6278
|
+
// whole picture. Here each one wraps its own shape in a one-element <g>,
|
|
6279
|
+
// which keeps the shape SELF-CLOSING so the reference linter's edge and node
|
|
6280
|
+
// readers still find it.
|
|
6281
|
+
const titleEl=(s)=>(s===undefined||s===null)?'':'<title>'+esc(s)+'</title>';
|
|
6282
|
+
const withTitle=(s,shape)=>s===undefined||s===null?shape:'<g>'+titleEl(s)+shape+'</g>';
|
|
6283
|
+
|
|
6284
|
+
// lifelines: head box + descending dashed line.
|
|
6285
|
+
// The head is emitted as a `data-node` group — it IS the participant, and
|
|
6286
|
+
// the reference linter's node reader finds it there.
|
|
6287
|
+
lls.forEach((l,i)=>{
|
|
6288
|
+
const w=headW[i], hx=x[i]-w/2, lab=lblOf(l);
|
|
6289
|
+
const f=chan(l,'fill')||'#eef2ff', st=chan(l,'stroke')||'#4f46e5';
|
|
6290
|
+
bg.push('<line x1="'+r2(x[i])+'" y1="'+r2(yTop+HEAD_H+8)+'" x2="'+r2(x[i])+'" y2="'+r2(bottom)
|
|
6291
|
+
+'" stroke="#94a3b8" stroke-width="1" stroke-dasharray="4 4"/>');
|
|
6292
|
+
bg.push('<g data-node="'+esc(l.id)+'" data-x="'+r2(hx)+'" data-y="'+r2(yTop+8)+'">'
|
|
6293
|
+
+titleEl(l.desc)
|
|
6294
|
+
+'<rect x="'+r2(hx)+'" y="'+r2(yTop+8)+'" width="'+r2(w)+'" height="'+HEAD_H
|
|
6295
|
+
+'" rx="4" fill="'+f+'" stroke="'+st+'"'+dashOf(chan(l,'style'),'')+'/>'
|
|
6296
|
+
+textEl(x[i], yTop+8+HEAD_H/2+4.5, 13, 'middle', labelInk(f,'#1d1d1b'), lab, '')
|
|
6297
|
+
+'</g>');
|
|
6298
|
+
});
|
|
6299
|
+
|
|
6300
|
+
// fragment / operand boxes, outermost first so nesting paints correctly
|
|
6301
|
+
const boxIds=Object.keys(fbox).sort((a,b)=>fbox[a].d-fbox[b].d);
|
|
6302
|
+
for(const id of boxIds){
|
|
6303
|
+
const B=fbox[id], c=M.cont[id];
|
|
6304
|
+
// both containers take `stroke=` and `class=`; the DEFAULT differs,
|
|
6305
|
+
// because a fragment's frame is a border and an operand's rule is a
|
|
6306
|
+
// divider inside one (CHOSEN, `DOMAIN-CONVENTION-DIRECTIVES`).
|
|
6307
|
+
const st=chan(c.el,'stroke')||(c.kind==='fragment'?'#64748b':'#94a3b8');
|
|
6308
|
+
if(c.kind==='fragment'){
|
|
6309
|
+
mid.push(withTitle(c.el.desc,
|
|
6310
|
+
'<rect x="'+r2(B.x0)+'" y="'+r2(B.y0)+'" width="'+r2(B.x1-B.x0)+'" height="'+r2(B.y1-B.y0)
|
|
6311
|
+
+'" fill="none" stroke="'+st+'" stroke-width="1"'+dashOf(chan(c.el,'style'),'')+'/>'));
|
|
6312
|
+
// the operator tab — UML's pentagon in the top-left corner (§17.12.3;
|
|
6313
|
+
// the operator vocabulary itself is §17.12.15.3's InteractionOperatorKind)
|
|
6314
|
+
const tw0=cwMax(c.el.type)*6.6+16, th=15;
|
|
6315
|
+
mid.push('<path d="M'+r2(B.x0)+' '+r2(B.y0)+' h'+r2(tw0)+' l6,'+r2(th-6)+' v'+r2(6)
|
|
6316
|
+
+' h'+r2(-tw0-6)+' z" fill="#f8fafc" stroke="'+st+'" stroke-width="1"/>');
|
|
6317
|
+
mid.push(textEl(B.x0+7, B.y0+11, 10.5, 'start', '#334155', c.el.type, ''));
|
|
6318
|
+
if(c.el.label!==null&&c.el.label!==undefined)
|
|
6319
|
+
mid.push(textEl(B.x0+tw0+14, B.y0+11, 10.5, 'start', '#475569', '['+c.el.label+']', HALO));
|
|
6320
|
+
} else {
|
|
6321
|
+
// an operand compartment: a dashed rule above it (except the first) and
|
|
6322
|
+
// its guard at the left. UML draws the guard in square brackets.
|
|
6323
|
+
const p=M.cont[id].parent, sibs=doc.operands.filter(o=>o['in']===p);
|
|
6324
|
+
const first=sibs.length&&sibs[0].id===id;
|
|
6325
|
+
const rule=first?''
|
|
6326
|
+
:'<line x1="'+r2(B.x0)+'" y1="'+r2(B.y0+4)+'" x2="'+r2(B.x1)+'" y2="'+r2(B.y0+4)
|
|
6327
|
+
+'" stroke="'+st+'" stroke-width="1" stroke-dasharray="5 4"/>';
|
|
6328
|
+
const guard=(c.el.label!==null&&c.el.label!==undefined)
|
|
6329
|
+
? textEl(B.x0+8, B.y0+13, 10.5, 'start', '#475569', '['+c.el.label+']', HALO) : '';
|
|
6330
|
+
// An operand has no box of its own, so its <title> names the group
|
|
6331
|
+
// holding the two marks it DOES draw — the separator rule and the guard.
|
|
6332
|
+
if(rule||guard) mid.push(withTitle(c.el.desc, rule+guard));
|
|
6333
|
+
}
|
|
6334
|
+
}
|
|
6335
|
+
|
|
6336
|
+
// rows
|
|
6337
|
+
for(const r of M.rows){
|
|
6338
|
+
if(r.kind==='state'){
|
|
6339
|
+
// a state occurrence is a pill CENTRED ON ITS OWN COLUMN — the lifeline
|
|
6340
|
+
// it names in slot 1 (UML 2.5.1 §17.12.25's StateInvariant).
|
|
6341
|
+
const ci=col[r.el.ref]; if(ci===undefined) continue;
|
|
6342
|
+
const f=chan(r.el,'fill')||'#fff7ed', st=chan(r.el,'stroke')||'#c2410c';
|
|
6343
|
+
const w=statePillW(r.el);
|
|
6344
|
+
mid.push(withTitle(r.el.desc,
|
|
6345
|
+
'<rect x="'+r2(x[ci]-w/2)+'" y="'+r2(r.yMid-STATE_H/2)+'" width="'+r2(w)+'" height="'+STATE_H
|
|
6346
|
+
+'" rx="9" fill="'+f+'" stroke="'+st+'" stroke-width="1"'+dashOf(chan(r.el,'style'),'')+'/>'));
|
|
6347
|
+
ink.push(textEl(x[ci], r.yMid+4, LBL_FS, 'middle', labelInk(f,'#7c2d12'), r.el.name, ''));
|
|
6348
|
+
inkExtent.push(x[ci]+w/2);
|
|
6349
|
+
continue;
|
|
6350
|
+
}
|
|
6351
|
+
// message
|
|
6352
|
+
const e=r.el, ci=col[e.a], cj=col[e.b];
|
|
6353
|
+
if(ci===undefined||cj===undefined) continue;
|
|
6354
|
+
const st=chan(e,'stroke')||'#334155';
|
|
6355
|
+
const dash=dashOf(chan(e,'style'),'');
|
|
6356
|
+
if(ci===cj){ // self-message
|
|
6357
|
+
// a rectangular loop off the column and back to it. The shaft is one
|
|
6358
|
+
// `path` at the same stroke-width as a straight message, so the axis
|
|
6359
|
+
// readers see one edge and not three.
|
|
6360
|
+
const sx=x[ci], top=r.yMid-11, bot=r.yMid+11, ex=sx+SELF_W;
|
|
6361
|
+
mid.push(withTitle(e.desc,
|
|
6362
|
+
'<path d="M'+r2(sx)+' '+r2(top)+' L'+r2(ex)+' '+r2(top)+' L'+r2(ex)+' '+r2(bot)
|
|
6363
|
+
+' L'+r2(sx+11)+' '+r2(bot)+'" fill="none" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
|
|
6364
|
+
arrowTri([sx+2,bot],[sx+12,bot],st);
|
|
6365
|
+
if(e.label){ ink.push(textEl(ex+8, r.yMid+4, LBL_FS, 'start', '#1d1d1b', e.label, HALO));
|
|
6366
|
+
inkExtent.push(ex+8+lblPx(e.label)); }
|
|
6367
|
+
continue;
|
|
6368
|
+
}
|
|
6369
|
+
// A message between NON-ADJACENT columns crosses the lifelines between
|
|
6370
|
+
// them: the shaft is drawn straight from source centre to target centre
|
|
6371
|
+
// and the dashed columns it passes are left intact. This is UML's drawing
|
|
6372
|
+
// and it is also the honest one — a jog around an intervening lifeline
|
|
6373
|
+
// would suggest the message went somewhere it did not.
|
|
6374
|
+
const fwd=(e.op==='<-')?false:true; // '->' and '<->' read a→b
|
|
6375
|
+
let sx=fwd?x[ci]:x[cj], tx0=fwd?x[cj]:x[ci];
|
|
6376
|
+
const dir=Math.sign(tx0-sx)||1;
|
|
6377
|
+
sx+=dir*1.5;
|
|
6378
|
+
const ex=tx0-dir*1.5;
|
|
6379
|
+
mid.push(withTitle(e.desc,
|
|
6380
|
+
'<line x1="'+r2(sx)+'" y1="'+r2(r.yMid)+'" x2="'+r2(ex)+'" y2="'+r2(r.yMid)
|
|
6381
|
+
+'" stroke="'+st+'" stroke-width="1.6"'+dash+'/>'));
|
|
6382
|
+
arrowTri([ex,r.yMid],[ex-dir*10,r.yMid],st);
|
|
6383
|
+
// `<->` is ONE shaft with TWO heads: the model says one occurrence, so
|
|
6384
|
+
// the drawing must not show two lines and invite a reader to count two.
|
|
6385
|
+
if(e.op==='<->') arrowTri([sx,r.yMid],[sx+dir*10,r.yMid],st);
|
|
6386
|
+
if(e.label){
|
|
6387
|
+
const nl=String(e.label).split('\n').length;
|
|
6388
|
+
ink.push(textEl((sx+ex)/2, r.yMid-LBL_LIFT-(nl-1)*LBL_FS*1.3/2, LBL_FS, 'middle', '#1d1d1b', e.label, HALO));
|
|
6389
|
+
}
|
|
6390
|
+
}
|
|
6391
|
+
|
|
6392
|
+
// ── PASS 6 — the canvas extent ──────────────────────────────────────────
|
|
6393
|
+
const W=Math.max(x[nc-1]+headW[nc-1]/2, ...Object.keys(fbox).map(k=>fbox[k].x1),
|
|
6394
|
+
...inkExtent)+rightPad+4;
|
|
6395
|
+
return {svg:bg.join('')+mid.join('')+ink.join(''), y:bottom, w:W,
|
|
6396
|
+
box:{x0:0,x1:W,yA:yTop,yB:bottom}};
|
|
6397
|
+
}
|
|
6398
|
+
// coordinates are emitted at 2 decimal places: the ladder's arithmetic divides
|
|
6399
|
+
// (`widen` spreads a shortfall over a run of columns), and an unrounded double
|
|
6400
|
+
// would put a 17-digit tail in the artifact for no reader's benefit.
|
|
6401
|
+
function r2(v){ return Math.round(v*100)/100; }
|
|
6402
|
+
|
|
5156
6403
|
// ---- bitfield ----
|
|
5157
6404
|
function renderBitfield(b,y0){
|
|
5158
6405
|
const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
|