figdown 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/dist/figdown.js +1344 -149
- package/dist/figdown.mjs +1344 -149
- package/examples/evpn-fabric.svg +3 -4
- package/examples/showcase/arp-resolution.svg +1 -1
- package/examples/showcase/ethernet-frame.svg +1 -1
- package/examples/showcase/l2-forwarding-logic.svg +1 -1
- package/examples/showcase/tcp-handshake.svg +1 -1
- package/examples/showcase/tcp-header.svg +1 -1
- package/examples/showcase/tcp-state-machine.svg +1 -1
- package/guide/expressing.md +29 -17
- package/guide/layout.md +25 -17
- package/integrations/mcp-server/README.md +174 -0
- package/integrations/mcp-server/server.js +593 -0
- package/package.json +8 -3
- package/skill/figdown/SKILL.md +19 -9
- package/skill/figdown/figdown.html +1350 -152
- package/skill/figdown/reference/experimental/block.md +60 -0
- package/skill/figdown/reference/experimental/chart.md +32 -0
- package/skill/figdown/reference/experimental/flowchart.md +97 -10
- package/skill/figdown/reference/experimental/statechart.md +55 -9
- package/skill/figdown/reference/experimental/timing.md +1 -1
- package/skill/figdown/reference/experimental/topology.md +148 -22
- package/skill/figdown/reference/reading.md +13 -1
- package/skill/figdown/reference/scene.md +75 -22
- package/skill/figdown/reference/experimental/constructs.md +0 -90
|
@@ -121,7 +121,7 @@ const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
|
|
|
121
121
|
// input to that promise, and under core §13 a 0.x renderer may differ from
|
|
122
122
|
// the next — which makes the recorded version the only thing that can
|
|
123
123
|
// explain a diff between two renderings of one source.
|
|
124
|
-
const FIGDOWN_VERSION = '0.
|
|
124
|
+
const FIGDOWN_VERSION = '0.3.1';
|
|
125
125
|
// `STATECHART-GENRE-SCOPE`: the language number moved for the first time. The dev
|
|
126
126
|
// counter does NOT reset (core §13.0.4 — `N` counts source states of the
|
|
127
127
|
// engine and only ever increases), so 0.1 is followed by
|
|
@@ -131,13 +131,39 @@ const FIGDOWN_VERSION = '0.2.0';
|
|
|
131
131
|
// MUST state this, and stating the release version alone does not satisfy
|
|
132
132
|
// it). Declared here, in ONE place, so the header check and the documented
|
|
133
133
|
// interface cannot drift:
|
|
134
|
-
|
|
134
|
+
// `DRAWN-ANNOTATION-FORM`: `figdown 0.3` joins the set. `note=` is a NEW OPTION KEY,
|
|
135
|
+
// and core §13.0 makes a new key a `Y` change and not a `Z` one — "`Z`: Bug
|
|
136
|
+
// fixes only. No new features. The language does not move." Shipping `note=`
|
|
137
|
+
// under `v0.2.z` would make `figdown 0.2` name two different languages: the one
|
|
138
|
+
// `v0.2.0` published and the one with `note=`. So the language number moves.
|
|
139
|
+
const LANG_VERSIONS = ['0.1', '0.2', '0.3'];
|
|
135
140
|
// Genres per declared language version. `Y` never removes (core §13.0), so
|
|
136
141
|
// each row is a superset of the one above it, and `figdown 0.1 <anything>`
|
|
137
142
|
// resolves against exactly the list it resolved against before `STATECHART-GENRE-SCOPE`.
|
|
138
143
|
const GENRES_BY_VERSION = {
|
|
139
144
|
'0.1': ['block','topology','flowchart','bitfield','table','timing'],
|
|
140
|
-
'0.2': ['block','topology','flowchart','bitfield','table','timing','statechart']
|
|
145
|
+
'0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
|
|
146
|
+
'0.3': ['block','topology','flowchart','bitfield','table','timing','statechart']
|
|
147
|
+
};
|
|
148
|
+
// The version an OPTION KEY first becomes legal in — the `CONNECTOR_MIN_VERSION`
|
|
149
|
+
// device, applied to the option namespace. `DRAWN-ANNOTATION-FORM`: `note=` is gated on the
|
|
150
|
+
// declared version, and the gate's reason is specific to THIS key rather than
|
|
151
|
+
// generic to new keys. `note=` has a PRIOR MEANING on the record: it was the
|
|
152
|
+
// retired spelling of `description=` (`DESCRIPTION-KEY-SPELLING`) and its retirement
|
|
153
|
+
// diagnostic actively told authors to write `description=` for a tooltip.
|
|
154
|
+
// Accepting it silently under a `figdown 0.2` header would repaint a document
|
|
155
|
+
// whose author meant a never-drawn tooltip as one that puts ink on the page —
|
|
156
|
+
// core §13.0.1's named hazard, "a figure that looks right and means something
|
|
157
|
+
// else". A key that had never been spelled before would carry no such risk.
|
|
158
|
+
const OPT_MIN_VERSION={note:'0.3'};
|
|
159
|
+
// True when the document's declared version is older than the key's own.
|
|
160
|
+
// A document with no parsable header has already been diagnosed on line 1, so
|
|
161
|
+
// an absent version never gates a second time.
|
|
162
|
+
const belowOptVersion=(key,ver)=>{
|
|
163
|
+
const need=OPT_MIN_VERSION[key];
|
|
164
|
+
if(!need||!ver) return false;
|
|
165
|
+
const i=LANG_VERSIONS.indexOf(ver), j=LANG_VERSIONS.indexOf(need);
|
|
166
|
+
return i>=0 && j>=0 && i<j;
|
|
141
167
|
};
|
|
142
168
|
// Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
|
|
143
169
|
// retired option keys do: `cloud` was the one value that named a domain
|
|
@@ -322,7 +348,7 @@ function splitList(t,off){
|
|
|
322
348
|
// - a key=value token with an unregistered key is an "unknown option"
|
|
323
349
|
// line error (`UNKNOWN-OPTION-DEGRADATION`) — except inside timing `signal` lanes, where bare
|
|
324
350
|
// tokens may contain '=' and stay positional (laneMode).
|
|
325
|
-
// `fill` was registered here until
|
|
351
|
+
// `fill` was registered here until 0.1 solely to power a retired
|
|
326
352
|
// migration diagnostic on the old `line` directive; it left the registry with
|
|
327
353
|
// the `fill` → `band` KEYWORD rename, and 0.1 gave the word back to the
|
|
328
354
|
// option-key namespace as the primary presentation key (`color=` → `fill=`).
|
|
@@ -337,11 +363,11 @@ function splitList(t,off){
|
|
|
337
363
|
// time the language gains no replacement — v0.1 has no author-facing label
|
|
338
364
|
// colour at all (the default is derived, `LABEL-COLOUR-SOURCE`; the owner-level key that could
|
|
339
365
|
// be added today is the wrong shape, core §9 `ANNOTATION-LOCATOR-SPLIT`). It stays registered so
|
|
340
|
-
// the message can name BOTH eras: a `color=` written
|
|
341
|
-
// the FILL, one written meant the LABEL, and only a human
|
|
366
|
+
// the message can name BOTH eras: a `color=` written in one era meant
|
|
367
|
+
// the FILL, one written in another meant the LABEL, and only a human
|
|
342
368
|
// knows which document this is. `text` and `z` stay registered
|
|
343
369
|
// as RETIRED keys so each rename gets a named diagnostic. `offset` replaces
|
|
344
|
-
// `threshold at=` (the directive was spelled `guide` until
|
|
370
|
+
// `threshold at=` (the directive was spelled `guide` until 0.1);
|
|
345
371
|
// `at` stays live on `pin`.
|
|
346
372
|
// 0.1: `level` stays registered as a RETIRED key — the construct was
|
|
347
373
|
// DELETED (`CHART-LEVEL-KEY`), and a registered-but-retired key is the only way the
|
|
@@ -372,7 +398,7 @@ const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in
|
|
|
372
398
|
// - `external` is NEVER drawn (`EXTERNAL-EDGE-ENDPOINTS`) — no fill, no border, no dash; only its
|
|
373
399
|
// label exists, so it takes `text=` (plus `plane=`, organizational exactly
|
|
374
400
|
// as on a node);
|
|
375
|
-
// - `band` carried NO label channel at all until
|
|
401
|
+
// - `band` carried NO label channel at all until 0.1 (`BAND-LABEL-STATUS`); it now
|
|
376
402
|
// takes a mandatory quoted label, so `color=` applies to it like any
|
|
377
403
|
// other labelled element;
|
|
378
404
|
// - typed blocks (`bitfield`/`table`/`timing`) stack in document order OUTSIDE
|
|
@@ -391,29 +417,48 @@ const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in
|
|
|
391
417
|
// the key existed only because there was no label to colour.
|
|
392
418
|
const DIRECTIVE_OPTS={
|
|
393
419
|
figdown:[],
|
|
394
|
-
|
|
420
|
+
// `DRAWN-ANNOTATION-FORM`: `title` gains its FIRST option key. It took one
|
|
421
|
+
// positional string and nothing else until now, and the key
|
|
422
|
+
// it gains carries the figure-level annotation — 14% of the measured demand,
|
|
423
|
+
// 10 instances that name no single element ("Total: 2 blocks × 8 ways × 1k
|
|
424
|
+
// sets = 16,384 entries", a four-signal legend, a TODO about the figure).
|
|
425
|
+
// The figure HAS a declaration line, so attachment-by-position reaches it and
|
|
426
|
+
// no standalone keyword is needed. `UNIVERSAL-CORE-KEYWORDS` fixes what `title` MEANS across genres;
|
|
427
|
+
// it is not a bar on the directive taking options, so the key exists in every
|
|
428
|
+
// genre at once, which is correct — every genre has figures.
|
|
429
|
+
title:['note'],
|
|
430
|
+
node:['shape','fill','stroke','style','class','in','width','height','note'],
|
|
395
431
|
// `FLOWCHART-ROLE-KEYWORDS`: the three flowchart role keywords take EXACTLY the
|
|
396
432
|
// option keys `node` takes — they ARE nodes, with a role recorded. Listing
|
|
397
433
|
// `width`/`height` mirrors `node` so the same "use a pin line" diagnostic
|
|
398
434
|
// fires rather than a bare `unknown option`.
|
|
399
|
-
process:['shape','fill','stroke','style','class','in','
|
|
400
|
-
decision:['shape','fill','stroke','style','class','in','
|
|
401
|
-
terminator:['shape','fill','stroke','style','class','in','
|
|
435
|
+
process:['shape','fill','stroke','style','class','in','width','height','note'],
|
|
436
|
+
decision:['shape','fill','stroke','style','class','in','width','height','note'],
|
|
437
|
+
terminator:['shape','fill','stroke','style','class','in','width','height','note'],
|
|
402
438
|
// `GENRE-NODE-SPELLING`: `state` IS `node` under `statechart` — a rename, not a
|
|
403
439
|
// new directive, so it takes `node`'s keys exactly and nothing more.
|
|
404
|
-
state:['shape','fill','stroke','style','class','in','
|
|
405
|
-
group:['fill','stroke','style','gap','class','
|
|
406
|
-
external
|
|
407
|
-
|
|
440
|
+
state:['shape','fill','stroke','style','class','in','width','height','note'],
|
|
441
|
+
group:['fill','stroke','style','gap','class','note'],
|
|
442
|
+
// `PAINT-ORDER-CONSTRUCT`: `external` now takes NO option key at all. `plane=`
|
|
443
|
+
// was its only one — it is never drawn (`EXTERNAL-EDGE-ENDPOINTS`), so it has no fill, no border
|
|
444
|
+
// and no dash to set — and the withdrawal of `plane=` empties the row. An
|
|
445
|
+
// empty array is the declaration: every key falls through to the generic
|
|
446
|
+
// `external does not take <k>=`.
|
|
447
|
+
external:[],
|
|
448
|
+
edge:['style','class','fill','stroke','label','taillabel','headlabel','note'],
|
|
408
449
|
// `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: same rename argument — the connector's option set is one set
|
|
409
450
|
// under three spellings, listed three times only because the tables are
|
|
410
451
|
// keyed by the surface word an author actually wrote.
|
|
411
|
-
flowline:['style','class','fill','stroke','
|
|
412
|
-
transition:['style','class','fill','stroke','
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
452
|
+
flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
|
|
453
|
+
transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
|
|
454
|
+
// `PAINT-ORDER-CONSTRUCT`: the `plane` row is GONE, not emptied — the keyword is
|
|
455
|
+
// withdrawn from the language, so it has no acceptor row at all, the shape
|
|
456
|
+
// `path`/`routing` left behind. `z-index=` goes with it: it
|
|
457
|
+
// was legal on `plane` and on nothing else.
|
|
458
|
+
flow:[], rank:[],
|
|
459
|
+
bundle:['fill','stroke','style'],
|
|
460
|
+
threshold:['in','at','offset','fill','stroke','style'],
|
|
461
|
+
band:['in','extend','fill','stroke','style','from','to'],
|
|
417
462
|
// `ELEMENT-GEOMETRY-DIRECTIVE`: `size` merged into `pin`. ONE directive carries an
|
|
418
463
|
// element's whole DECLARED geometry — `at=` places it, `width=`/`height=`
|
|
419
464
|
// extend it — and one model object records it. All three keys are optional
|
|
@@ -424,7 +469,7 @@ const DIRECTIVE_OPTS={
|
|
|
424
469
|
// derives its geometry from its content).
|
|
425
470
|
pin:['at','width','height'],
|
|
426
471
|
layout:[],
|
|
427
|
-
'class':['fill','stroke','style'
|
|
472
|
+
'class':['fill','stroke','style'],
|
|
428
473
|
// 0.1: `class=` is NOT accepted on the typed-block OPENERS. The
|
|
429
474
|
// normative registry (core §10) lists its acceptors as node/group/edge/
|
|
430
475
|
// field/cell — the block openers were an engine-only extra with 0 uses in
|
|
@@ -448,7 +493,7 @@ const DIRECTIVE_OPTS={
|
|
|
448
493
|
// the field is ONE ELEMENT of a repeated run and gives the run's index
|
|
449
494
|
// range; the engine derives the elision row and the index labels from it,
|
|
450
495
|
// exactly as it derives the dash and the caption from `present=`.
|
|
451
|
-
field:['fill','stroke','class','description','present','index'], 'break':[],
|
|
496
|
+
field:['fill','stroke','class','description','present','index','note'], 'break':[],
|
|
452
497
|
cell:['fill','stroke','class'], width:[],
|
|
453
498
|
signal:['data','fill','stroke'], gap:[]
|
|
454
499
|
};
|
|
@@ -498,8 +543,8 @@ const RETIRED_OPT_KEYS={
|
|
|
498
543
|
text:'text= has been retired: v0.1 has NO label-colour key — the label colour is DERIVED from the fill it sits on (core §5), and the owner-level key that could replace it would colour an edge\'s [tail]/[mid]/[head] labels identically, which is the wrong shape (core §9 `ANNOTATION-LOCATOR-SPLIT`). Delete the key; if the distinction was knowledge, write it in the label or a class= meaning (§5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.1)',
|
|
499
544
|
// `COLOUR-KEY-STATUS`. This is the ONLY key in the language whose diagnostic
|
|
500
545
|
// must name two eras and refuse to choose between them: the same six
|
|
501
|
-
// characters meant the FILL
|
|
502
|
-
//
|
|
546
|
+
// characters meant the FILL in one era and the LABEL
|
|
547
|
+
// in another, and no engine can tell the two source files apart. Retiring
|
|
503
548
|
// the key is what makes the difference DIAGNOSABLE at all — while it was
|
|
504
549
|
// live, a pre-0.1 document parsed and drew a legal, wrong figure in
|
|
505
550
|
// silence.
|
|
@@ -516,8 +561,17 @@ const RETIRED_OPT_KEYS={
|
|
|
516
561
|
// `color=` family, which reads the same evidence to decide its refusals.
|
|
517
562
|
color:'color= has been retired: the same six characters set the box FILL in one era of this language and the LABEL colour in another, and this line does not say which — which is why the key is gone rather than renamed. READ IT OFF THE REST OF THE DOCUMENT. A file that also writes fill= cannot be from the FILL era (the two keys never coexisted), so its color= was a LABEL colour: delete it and let the derived default apply (core §5). A file still writing the spellings that were retired before the LABEL era (w= h= unit= via= dir= kind= layer= boundary wrap optional) cannot be from that era, so its color= was a FILL: write fill= instead. A file with NEITHER carries no evidence at all, and the two readings then differ only in what was DRAWN — as a FILL the value painted the box interior, as a LABEL colour it painted only the text. If the colour carried meaning, put that meaning in the label or a class= (§5, `PRESENTATION-AS-MEANING-CARRIER`). tools/migrate-figdown.js reads this evidence for you and REFUSES the wrong --color-means=fill|text (MIGRATIONS 0.1)',
|
|
518
563
|
kind:'kind= has been renamed: on a node use shape= (geometric; the label text carries the device semantics — MIGRATIONS 0.1), on a chart use type= (Vega, Chart.js and ECharts all spell the chart-type key "type" — MIGRATIONS 0.1). One spelling was retired on node and live on plot at the same time, inside one namespace; 0.1 closed that.',
|
|
519
|
-
layer
|
|
564
|
+
// `PAINT-ORDER-CONSTRUCT`: `layer=` was renamed `plane=`, and
|
|
565
|
+
// `plane=` has since been WITHDRAWN, so this message can no longer end at
|
|
566
|
+
// the rename — the `route`→`path` precedent, where a message
|
|
567
|
+
// pointing at a spelling that no longer exists had to state the whole chain.
|
|
568
|
+
layer:'layer= has been WITHDRAWN: it was renamed plane=, and plane= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`). There is no replacement spelling. Delete the key: everything paints in one plane, in document order. If the element is on a distinct logical layer of the SUBJECT — an overlay, a control plane — say so with a class= whose label states it, which is where that meaning belongs (core §5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.3)',
|
|
520
569
|
labels:'labels= has been renamed: use data= (WaveDrom\'s own key for exactly this is `data`, "an array of signal labels" — one per value cell of the lane) (MIGRATIONS 0.1)',
|
|
570
|
+
// `PAINT-ORDER-CONSTRUCT`. `plane=` referenced a declared `plane`; with the
|
|
571
|
+
// keyword withdrawn from every genre the key would keep exactly ONE legal
|
|
572
|
+
// value — the implicit `base` — so it is withdrawn with it rather than left
|
|
573
|
+
// as a key that can only ever restate the default.
|
|
574
|
+
plane:'plane= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. `plane=` named a declared plane, and with no way to declare one the key had a single legal value — `base`, the implicit plane every element is already on. Delete the key. What it did was PAINT ORDER, and paint order is document order: a later line paints on top. The measurement: stripping `plane` and `plane=` from examples/evpn-fabric.fd left the drawn SVG byte-identical but for one `data-edge` index, because the overlay meaning was carried by `class=overlay` throughout — which is where a logical layer of the SUBJECT belongs (core §5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS 0.3)',
|
|
521
575
|
// 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`). These six keys end in a WITHDRAWAL, not a rename, so
|
|
522
576
|
// their messages have a shape no earlier retirement in this table has: they
|
|
523
577
|
// name no replacement spelling, because there is none. `via=`/`src=`/`dst=`
|
|
@@ -532,14 +586,80 @@ const RETIRED_OPT_KEYS={
|
|
|
532
586
|
headport:'headport= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. Attachment to a named site addressed by semantic role IS inside the stable prior-art intersection; FigDown\'s realisation was not (a fraction on the EDGE is mxGraph-only, and written-order attachment has zero prior art in any surveyed system). Restoring it needs an edge-identity construct first. Delete the line; the edge draws under auto layout. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
|
|
533
587
|
routing:'routing= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. The per-edge routing SCOPE was inside the stable prior-art intersection and is deliberately lost with its host line — an override needs an edge to address, and FigDown has no edge-identity construct. Delete the line. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
|
|
534
588
|
unit:'unit= has been renamed: use word= (RFC 2360 §3.1: "a sequence of long words in network byte order, with each word horizontal on the page"; RFC 791 §3.1 measures the header in "32 bit words". Mermaid names the identical setting bitsPerRow — semantically right, camelCase barred. `unit=32` also inverts count-vs-unit, reading as "the unit is 32", and C\'s "unit" is the addressable storage unit, not the row width) (MIGRATIONS 0.1)',
|
|
535
|
-
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
|
|
589
|
+
// `PAINT-ORDER-CONSTRUCT`: `z-index=` itself. Its ONLY acceptor was `plane`, so
|
|
590
|
+
// with the keyword withdrawn the key has no directive left to sit on. Left
|
|
591
|
+
// in OPT_KEYS with no acceptor row it would have produced `<directive> does
|
|
592
|
+
// not take z-index=` — true, but it tells an author holding a 0.2 document
|
|
593
|
+
// that they picked the wrong host, when in fact there is no host. RULE 6.2
|
|
594
|
+
// placement: the spelling left the LANGUAGE, so it is reported wherever it
|
|
595
|
+
// appears.
|
|
596
|
+
'z-index':'z-index= has been WITHDRAWN with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`): it was legal on `plane` and on nothing else, so it left with its only acceptor. There is no replacement spelling and no other directive to move it to. Delete the key: paint order is document order, and a later line paints on top (MIGRATIONS 0.3)',
|
|
597
|
+
z:'z= has been WITHDRAWN: it was renamed z-index=, and z-index= was withdrawn with the `plane` keyword (`PAINT-ORDER-CONSTRUCT`) — it was legal on `plane` and on nothing else. There is no replacement spelling. Delete the key: paint order is document order, a later line paints on top (MIGRATIONS 0.3)',
|
|
598
|
+
// `note` was HERE (`DESCRIPTION-KEY-SPELLING`) until this release (`DRAWN-ANNOTATION-FORM`), and its
|
|
599
|
+
// row is gone because the key is LIVE again — SYNTAX-STYLE RULE 4.9
|
|
600
|
+
// obligation 3 forbids leaving the retirement message standing past the
|
|
601
|
+
// revival, on the ground that a message telling an author to write
|
|
602
|
+
// `description=` where `note=` is now the correct key is worse than no
|
|
603
|
+
// message: it is the language actively misinforming its user. What replaces
|
|
604
|
+
// it is not silence but two NARROWER messages — NOTE_VERSION for a document
|
|
605
|
+
// that declares a language version older than the key, and NOTE_ON_FIELD for
|
|
606
|
+
// the one directive that keeps refusing it. See OPT_MIN_VERSION above.
|
|
539
607
|
level:'level= has been DELETED, not renamed: it drew a reference plane through a 3-D bar chart, has zero uses in either downstream corpus and zero 3-D bar charts to draw it on, was the only construct whose caption the ENGINE wrote rather than the author, and its parseFloat grammar uniquely accepted 1e3 where every other number in the language is \\d+(\\.\\d+)? — delete the key (MIGRATIONS 0.1)'
|
|
540
608
|
};
|
|
609
|
+
// `DRAWN-ANNOTATION-FORM`. The two messages that REPLACE the `note=` retirement
|
|
610
|
+
// diagnostic. SYNTAX-STYLE RULE 4.9 obligation 3 requires the retirement
|
|
611
|
+
// message to be reversed in the same release that revives the spelling, and
|
|
612
|
+
// "reversed" does not mean "deleted": each of the two situations the old
|
|
613
|
+
// message used to cover keeps a named diagnostic of its own.
|
|
614
|
+
//
|
|
615
|
+
// (a) The document declares a language version older than the key. The gate is
|
|
616
|
+
// `KEYWORD-RENAME-SCOPE`'s device — name the version, offer the one-step fix — and its
|
|
617
|
+
// reason is stated in the message because `note=`'s prior meaning is what
|
|
618
|
+
// makes the gate necessary rather than merely tidy.
|
|
619
|
+
const NOTE_VERSION=(have)=>
|
|
620
|
+
'note= requires figdown 0.3 (this document declares '+have+'): under figdown '+
|
|
621
|
+
have+' the spelling is still the RETIRED one that meant description=, and an '+
|
|
622
|
+
'engine that accepted it here would repaint a tooltip as ink — a figure that '+
|
|
623
|
+
'looks right and means something else (core §13.0.1). note= is the DRAWN '+
|
|
624
|
+
'annotation: an explanation the human reader must SEE. Raise the header to '+
|
|
625
|
+
'figdown 0.3, or write description= if you meant prose only a machine reads '+
|
|
626
|
+
'(MIGRATIONS 0.3)';
|
|
627
|
+
// (b) The directive is `field`, which refuses the key at EVERY version. The
|
|
628
|
+
// bitfield genre already has `description=` for machine-facing prose, and
|
|
629
|
+
// no measured figure needs a DRAWN per-field aside — granting a directive
|
|
630
|
+
// both keys with no evidence spends the distinction before anyone needs it.
|
|
631
|
+
// The message states the distinction rather than naming a replacement,
|
|
632
|
+
// because `description=` is not a replacement: it reaches a different
|
|
633
|
+
// reader.
|
|
634
|
+
const NOTE_ON_FIELD=
|
|
635
|
+
'note= draws and is not accepted on field; use description= for prose a '+
|
|
636
|
+
'machine reads. The two keys divide by AUDIENCE, not by length: description= '+
|
|
637
|
+
'reaches the reading agent as an SVG <title> and puts no ink on the page, '+
|
|
638
|
+
'while note= is an explanation the human must see. A field\'s presence '+
|
|
639
|
+
'condition is present=, not either of them (MIGRATIONS 0.3)';
|
|
541
640
|
// `PLANE-KEYWORD-SPELLING`: the keyword `plane`/`plane=` was spelled `layer`/`layer=`.
|
|
542
|
-
|
|
641
|
+
// `PAINT-ORDER-CONSTRUCT`: `plane` is WITHDRAWN, so `layer`'s message states the
|
|
642
|
+
// whole chain and ends where `route`'s does — the precedent,
|
|
643
|
+
// when `path` was withdrawn out from under the spelling `route` pointed at.
|
|
644
|
+
// `PAINT-ORDER-CONSTRUCT`: `plane` is WITHDRAWN from the language. The construct
|
|
645
|
+
// left because every genre that could write it lost it at once, and for two
|
|
646
|
+
// different reasons that happen to converge:
|
|
647
|
+
// - `block` and `flowchart` had ZERO authored uses. Every authored use in
|
|
648
|
+
// the tree was a `topology` document.
|
|
649
|
+
// - `topology` had two, and they are the worst domain collision measured in
|
|
650
|
+
// the language: in networking a PLANE is the control / data / management
|
|
651
|
+
// partition of a device — one of the first distinctions the field teaches
|
|
652
|
+
// — and `topology` is precisely the genre network engineers author in.
|
|
653
|
+
// `examples/evpn-fabric.fd` showed the trap already closed: it wrote
|
|
654
|
+
// `plane overlay "VXLAN tunnels" z-index=2`, where `overlay` is itself a
|
|
655
|
+
// networking term, so the line read as a network-architectural assertion
|
|
656
|
+
// and was in fact a paint order.
|
|
657
|
+
// What replaced it was already there. Stripping both writings from that file
|
|
658
|
+
// left the drawn SVG byte-identical apart from one `data-edge` index, because
|
|
659
|
+
// `class=overlay` carried the meaning the whole time.
|
|
660
|
+
const WITHDRAWN_PLANE_WHERE=' The decision and its evidence: MIGRATIONS 0.3, decisions/registry.md.';
|
|
661
|
+
const RETIRED_PLANE='plane has been WITHDRAWN from the language (`PAINT-ORDER-CONSTRUCT`) — removed, not renamed, so there is no replacement spelling. It declared a DRAWING LAYER (a z-order), and in the genre that actually used it "plane" means the control / data / management partition of a network device, so the one word said the wrong thing to exactly the readers who write the figure. Delete the line and delete every plane= that referenced it: paint order is document order, a later line paints on top. If the elements form a logical layer of the SUBJECT, that is a class= whose label states it (core §5, `PRESENTATION-AS-MEANING-CARRIER`) — which is what the two authored uses were already doing alongside it.'+WITHDRAWN_PLANE_WHERE;
|
|
662
|
+
const RETIRED_LAYER='layer has been WITHDRAWN: it was renamed plane, and plane was withdrawn from the language (`PAINT-ORDER-CONSTRUCT`). There is no replacement spelling. Delete the line: everything paints in one plane and paint order is document order (a later line paints on top). A logical layer of the SUBJECT — an overlay, a control plane — is a class= whose label says so (core §5, `PRESENTATION-AS-MEANING-CARRIER`).'+WITHDRAWN_PLANE_WHERE;
|
|
543
663
|
// `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
|
|
544
664
|
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)';
|
|
545
665
|
// `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
|
|
@@ -561,7 +681,7 @@ const RETIRED_FIELD_CONDITIONAL='the field flag "conditional" has been retired:
|
|
|
561
681
|
// prose the model may not read. `present` is the attested spelling: X.680
|
|
562
682
|
// PRESENT, IP-XACT isPresent, SystemRDL ispresent, RFC 2784 "present only
|
|
563
683
|
// if", draft-mcquistin "present only when".
|
|
564
|
-
const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and replaced by an option key that carries the CONDITION: write present="<the condition>" (e.g. field "Checksum" 16 present="C = 1"), or present="" when the condition is not stated. The bare flag could say only THAT the field was conditional, so the condition had to live in note= — invisible to the human reading the figure, and prose the model may not parse (`BITFIELD-CONDITIONAL-OFFSETS`). present= DRAWS: the field stays dashed and a stated condition becomes a caption under the block (MIGRATIONS 0.1)';
|
|
684
|
+
const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and replaced by an option key that carries the CONDITION: write present="<the condition>" (e.g. field "Checksum" 16 present="C = 1"), or present="" when the condition is not stated. The bare flag could say only THAT the field was conditional, so the condition had to live in the field\'s documentation prose — the key spelled note= at the time and description= — where it was invisible to the human reading the figure, and prose the model may not parse (`BITFIELD-CONDITIONAL-OFFSETS`). (Today\'s note= is a different key: it is the DRAWN annotation revived, it is refused on field, and it is not where a presence condition belongs either.) present= DRAWS: the field stays dashed and a stated condition becomes a caption under the block (MIGRATIONS 0.1)';
|
|
565
685
|
// `TIMING-GENRE-NAMING`: the EXPERIMENTAL genre `wave` became `timing`, both as
|
|
566
686
|
// the header genre token and as the block opener. The old name was WaveDrom's
|
|
567
687
|
// MEMBER KEY, not its figure name: in WaveJSON `signal` is the root object and
|
|
@@ -593,7 +713,7 @@ const RETIRED_WAVE='wave has been renamed: use timing (in WaveJSON `signal` is t
|
|
|
593
713
|
// not "use X"), says what an author should do instead (delete the line and let
|
|
594
714
|
// auto layout draw it, with the content-zone means named), and points at where
|
|
595
715
|
// the decision is RECORDED so the reasoning is one lookup away.
|
|
596
|
-
const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md';
|
|
716
|
+
const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md.';
|
|
597
717
|
const RETIRED_PATH='path has been WITHDRAWN from the language (`EDGE-GEOMETRY-CONSTRUCTS`) — removed, not renamed, so there is no replacement spelling. A prior-art study of Visio, draw.io/mxGraph, Graphviz and ELK found author waypoints OUTSIDE the stable intersection: only 2 of the 4 model them, and those 2 disagree on what happens when an endpoint moves. The dock realisation was outside it too — written-order attachment has zero prior art in any surveyed system. Delete the line: the edge draws under auto layout, and `rank`, `flow`, declaration order and `pin` are the content-zone means of shaping it.'+WITHDRAWN_WHERE;
|
|
598
718
|
const RETIRED_ROUTING='routing has been WITHDRAWN from the language (`EDGE-GEOMETRY-CONSTRUCTS`) — removed, not renamed, so there is no replacement spelling. Two routing modes and two scopes ARE inside the stable prior-art intersection, so the need is recognised and its shape is known; what is missing is the evidence and the implementation (6 of the 8 in-repo `routing=orthogonal` writings were provable no-ops, and downstream adoption was zero), and the per-edge scope cannot be restored without an edge-identity construct FigDown does not have. Delete the line; the edges draw straight.'+WITHDRAWN_WHERE;
|
|
599
719
|
// `TIMING-LANE-ALPHABET`: the timing lane digits `2`-`9` left the closed alphabet.
|
|
@@ -620,7 +740,7 @@ const CELL_HL_ON_CELL='highlight is a ROW mark and takes the single-valued row f
|
|
|
620
740
|
const CELL_HL_ROW_CONFLICT=(r,c)=>'cell ('+r+','+c+') resolves to a fill on row '+r+', which is highlighted — the cell fill overrides the row tint, so the model says "row '+r+' is highlighted" while the drawing shows only part of the row tinted (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never delete it). Drop the row highlight, or move the cell fill to a row that carries none (MIGRATIONS 0.1)';
|
|
621
741
|
const NO_ITEM_STYLE=new Set(['field','cell','signal']);
|
|
622
742
|
const STYLE_NO_ITEM=k=>k+' does not take style= — '+(k==='field'
|
|
623
|
-
? 'on a field the dash IS conditional presence (`present=`, spelled `optional` until
|
|
743
|
+
? 'on a field the dash IS conditional presence (`present=`, spelled `optional` until 0.1), and style=solid erased it while the model still recorded the field as conditionally present (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never be its only carrier)'
|
|
624
744
|
: 'a dash on one '+k+' carried no meaning the block does not already carry, and 0 documents outside this repository wrote it')
|
|
625
745
|
// `DESCRIPTION-KEY-SPELLING` corrected the second half of this message. It used to
|
|
626
746
|
// offer `note=` as a place to put knowledge, which was wrong twice over:
|
|
@@ -888,9 +1008,53 @@ const LAYOUT_KW=['pin']; // `LAYOUT-ZONE-NAMESPACE`, NORMA
|
|
|
888
1008
|
const GENRE_FREE_KW=CORE_KW.concat(LAYOUT_KW);
|
|
889
1009
|
// `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: the NODE and CONNECTOR spellings are per genre, so
|
|
890
1010
|
// they are NOT in the shared list — every scene genre concats its own two.
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1011
|
+
//
|
|
1012
|
+
// `SUBJECT-VOCABULARY-SCOPE`: SUBJECT VOCABULARY IS PER GENRE, AND THERE IS NO SHARED
|
|
1013
|
+
// LIST OF IT. `SCENE_KW_TOP` and `SCENE_EXP_KW` are gone. They held the words
|
|
1014
|
+
// that say what a figure is OF — `group`, `external`, `threshold`, `band`,
|
|
1015
|
+
// `bundle`, `plane` — in one array concatenated into four genres, which is
|
|
1016
|
+
// the same defect core §3's "scene keywords" sentence recorded: an
|
|
1017
|
+
// INTERSECTION written down as if it were a namespace. Under `GENRE-VOCABULARY-OBLIGATION` a genre owns
|
|
1018
|
+
// its words, so each scene genre now names its own subject vocabulary in its
|
|
1019
|
+
// own array below. Two arrays agreeing is TWO DECLARATIONS that agree today,
|
|
1020
|
+
// never one declaration inherited, and either may be withdrawn, renamed or
|
|
1021
|
+
// constrained without touching the other.
|
|
1022
|
+
//
|
|
1023
|
+
// What stays shared, and why that is not a contradiction:
|
|
1024
|
+
// - `class` is STYLING declaration and `flow`/`rank` are LAYOUT INTENT.
|
|
1025
|
+
// None of the three describes a referent, so no genre's domain holds a
|
|
1026
|
+
// competing meaning for them and no genre can independently earn or lose
|
|
1027
|
+
// one. They are nearer `LAYOUT-ZONE-NAMESPACE`'s genre-independent layout namespace than `GENRE-VOCABULARY-OBLIGATION`.
|
|
1028
|
+
// - `bitfield`/`table`/`timing`/`chart` are `GENRE-COMPOSITION` REGION OPENERS: composition,
|
|
1029
|
+
// not subject vocabulary. The region's own namespace is the nested
|
|
1030
|
+
// genre's.
|
|
1031
|
+
const SCENE_STYLE_KW=['class','flow','rank'];
|
|
1032
|
+
const SCENE_REGION_KW=['bitfield','table','timing','chart'];
|
|
1033
|
+
const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_STYLE_KW, SCENE_REGION_KW);
|
|
1034
|
+
// --- Each scene genre's OWN subject vocabulary. One array per genre. ---
|
|
1035
|
+
// `block` (NORMATIVE): `group` and `external` are normative; `threshold` and
|
|
1036
|
+
// `band` are EXPERIMENTAL and are the `GENRE-EARNING-THRESHOLD` INTERIM scalar-marker pair, held here
|
|
1037
|
+
// deliberately unfrozen so the future scalar-marker genre can name them once
|
|
1038
|
+
// WITH a scale. They are not renamed now — a rename would hand that genre a
|
|
1039
|
+
// retired word.
|
|
1040
|
+
const BLOCK_SUBJECT_KW=['group','external','threshold','band'];
|
|
1041
|
+
// `topology` (EXPERIMENTAL): `bundle` is the one construct whose domain
|
|
1042
|
+
// reading and drawn reading are the same reading — a LAG (IEEE 802.1AX), an
|
|
1043
|
+
// ECMP set, an EVPN Ethernet Segment. `group`/`external` keep their block
|
|
1044
|
+
// spellings because every networking synonym is more taken (`zone` DNS and
|
|
1045
|
+
// firewall, `cluster` RFC 4456, `domain` RFC 7926, `area` OSPF, `site` EVPN)
|
|
1046
|
+
// and their collisions are SOFT — the picture contradicts the wrong reading.
|
|
1047
|
+
const TOPOLOGY_SUBJECT_KW=['group','external','bundle'];
|
|
1048
|
+
// `flowchart` (EXPERIMENTAL): `external` only — the off-page terminus, ISO
|
|
1049
|
+
// 5807 §9.4.2 *Terminator* being ISO's word for the concept and already this
|
|
1050
|
+
// genre's live keyword, so the spelling stays. `group` had one occurrence in
|
|
1051
|
+
// the whole tree and it was this genre's own reference figure.
|
|
1052
|
+
const FLOWCHART_SUBJECT_KW=['external'];
|
|
1053
|
+
// `statechart` (EXPERIMENTAL): NONE, and the empty array is the declaration.
|
|
1054
|
+
// Three authored statechart figures, all transcribed from RFCs, reach for
|
|
1055
|
+
// none of the six; `external` is additionally UML 2.5.1 §14's own
|
|
1056
|
+
// `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
|
|
1057
|
+
const STATECHART_SUBJECT_KW=[];
|
|
894
1058
|
// `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
|
|
895
1059
|
// `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
|
|
896
1060
|
// `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
|
|
@@ -963,7 +1127,7 @@ const WRONG_VERSION_WORD=(surf,want,genre,need,have)=>
|
|
|
963
1127
|
// "not allowed in genre X" tells an author nothing about what to write.
|
|
964
1128
|
const WORD_WHY={
|
|
965
1129
|
edge:'a block or topology figure is a graph, and `edge` is the graph word (DOT)',
|
|
966
|
-
flowline:'the connecting line in a flowchart is a FLOWLINE — the term
|
|
1130
|
+
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"',
|
|
967
1131
|
transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
|
|
968
1132
|
node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
|
|
969
1133
|
state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)'
|
|
@@ -974,10 +1138,82 @@ const WORD_WHY={
|
|
|
974
1138
|
const WRONG_WORD=(surf,want,genre)=>
|
|
975
1139
|
'"'+surf+'" is not the word genre '+genre+' uses for this — write "'+want+'": '+WORD_WHY[want]+
|
|
976
1140
|
'. Each scene genre takes the term its own domain uses (block/topology `node` `edge`, flowchart `node` `flowline`, statechart `state` `transition`) — run tools/migrate-figdown.js to rewrite it (MIGRATIONS 0.2)';
|
|
1141
|
+
// `SCENE-KEYWORD-MEMBERSHIP`: a word WITHDRAWN FROM ONE GENRE is not an unknown word,
|
|
1142
|
+
// and `"threshold" is not allowed in genre topology` would send an author
|
|
1143
|
+
// looking for a typo. Each cell below was legal until this release and states
|
|
1144
|
+
// WHY that genre no longer declares it — the ruling's own ground, per cell,
|
|
1145
|
+
// because the grounds differ and a single sentence could not carry them.
|
|
1146
|
+
// Every one of these withdrawals was FREE: `topology`, `flowchart` and
|
|
1147
|
+
// `statechart` are EXPERIMENTAL genres outside the compatibility promise, and
|
|
1148
|
+
// in `block` the two withdrawn words were EXPERIMENTAL keywords (`EDGE-GEOMETRY-CONSTRUCTS` precedent:
|
|
1149
|
+
// experimental withdrawal, no gate, no rewrite owed).
|
|
1150
|
+
const WITHDREW_AT=' (withdrawn, `SCENE-KEYWORD-MEMBERSHIP`; MIGRATIONS 0.3)';
|
|
1151
|
+
const GENRE_WITHDRAWN={
|
|
1152
|
+
block:{
|
|
1153
|
+
bundle:'`bundle` is now declared by `topology` only. It had ZERO authored uses under `block` — every authored link bundle in the corpus is a topology document — and the construct is defined by a REFERENT that only that genre has: a LAG (IEEE 802.1AX), an ECMP set, an EVPN Ethernet Segment. Under `block` it was a ring around parallel edges with nothing to name.'
|
|
1154
|
+
},
|
|
1155
|
+
topology:{
|
|
1156
|
+
threshold:'`threshold` is now declared by `block` only. It had ZERO occurrences under `topology` in the whole corpus, and in this genre\'s domain a threshold is a QUEUE DEPTH WITH A NUMERIC VALUE (RFC 2309 minth/maxth, RFC 7567) — while FigDown\'s takes no value= and its offset= is a fraction of the target\'s rendered extent, not a quantity. Author the figure as `block`, where the `GENRE-EARNING-THRESHOLD` scalar-marker evidence lives.',
|
|
1157
|
+
band:'`band` is now declared by `block` only. Its two occurrences under `topology` were both conformance fixtures, never a figure anyone needed, and in this genre a BAND is a frequency band — radio, wireless, microwave, optical transport — which is exactly the kind of figure a topology document draws. Author the figure as `block`.'
|
|
1158
|
+
},
|
|
1159
|
+
flowchart:{
|
|
1160
|
+
group:'`flowchart` no longer declares `group`. Its one occurrence in the corpus was this genre\'s own reference figure, which exists to demonstrate every form of every keyword — so citing it as evidence of need is circular. There were no authored uses.',
|
|
1161
|
+
threshold:'`flowchart` no longer declares `threshold`. Zero occurrences, and the construct does not apply: a threshold is a labelled reference value drawn at a percentage of the target\'s RENDERED EXTENT, and a process box\'s extent is an artifact of its label length, so the line asserts nothing a reader can read.',
|
|
1162
|
+
band:'`flowchart` no longer declares `band`. Zero occurrences, and a band is a RANGE over that same meaningless extent.',
|
|
1163
|
+
bundle:'`flowchart` no longer declares `bundle`. Zero occurrences, and parallel flowlines between the same two stages are different CONDITIONS; drawing a ring round them hides what the figure is for.'
|
|
1164
|
+
},
|
|
1165
|
+
statechart:{
|
|
1166
|
+
group:'`statechart` declares NO subject vocabulary at all. UML\'s grouping construct is the COMPOSITE STATE and its REGIONS, and under the single-source-vocabulary rule a statechart that needed grouping should take UML 2.5.1 §14\'s word for it — declared in this genre\'s own document — rather than inherit another genre\'s.',
|
|
1167
|
+
external:'`statechart` declares NO subject vocabulary at all, and `external` is additionally RESERVED here: UML 2.5.1 §14 defines TransitionKind as `external | internal | local`, so in this genre\'s own source standard "external" already names A TRANSITION THAT EXITS AND RE-ENTERS ITS SOURCE STATE. FigDown\'s `external` means an endpoint outside the figure that is never drawn — same word, same genre, same standard, unrelated meanings.',
|
|
1168
|
+
threshold:'`statechart` declares NO subject vocabulary at all. A state has no extent that means anything — its box is sized by its label — so a reference value drawn 60% down it asserts nothing.',
|
|
1169
|
+
band:'`statechart` declares NO subject vocabulary at all, and a band is a range over that same meaningless extent.',
|
|
1170
|
+
bundle:'`statechart` declares NO subject vocabulary at all, and here `bundle` is an ANTI-FEATURE: two transitions between the same pair of states are two different TRIGGERS, and the trigger is the whole content of the arc. Bundling them draws away exactly what the figure is for.'
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
const WITHDRAWN_FROM_GENRE=(kw,genre)=>
|
|
1174
|
+
'"'+kw+'" is not allowed in genre '+genre+' — it was WITHDRAWN from this genre, not misspelled: '+
|
|
1175
|
+
GENRE_WITHDRAWN[genre][kw]+
|
|
1176
|
+
' Subject vocabulary is per genre (core §3, `GENRE-VOCABULARY-OBLIGATION`): a spelling accepted by several genres is several '+
|
|
1177
|
+
'independent declarations, and this genre\'s was withdrawn without touching any other\'s.'+WITHDREW_AT;
|
|
1178
|
+
// `MEMBERSHIP-KEY-ACCEPTANCE`: THE OPTION-KEY HALF OF `SCENE-KEYWORD-MEMBERSHIP`. A per-genre withdrawal can
|
|
1179
|
+
// strand an option KEY as easily as it strands a keyword: `in=` states
|
|
1180
|
+
// membership and its ONLY value domain is the id of a containing `group`, so
|
|
1181
|
+
// once `SCENE-KEYWORD-MEMBERSHIP` stopped `flowchart` and `statechart` from declaring a `group` the
|
|
1182
|
+
// key stayed accepted with nothing it could name. The measured symptom was
|
|
1183
|
+
// `process a "A" in=g` answering `unknown group "g"` with NO spelling that
|
|
1184
|
+
// succeeds — a dangling reference every author reaches by writing the key at
|
|
1185
|
+
// all. The other acceptors of `in=` in the language are `threshold` and
|
|
1186
|
+
// `band`, whose domain `MARKER-TARGET-KINDS` widened to REGION ids; neither is a keyword of
|
|
1187
|
+
// either genre since `SCENE-KEYWORD-MEMBERSHIP`, and the widening never reached `node`, so a
|
|
1188
|
+
// `flowchart` document that declares `table q` still answers `unknown group
|
|
1189
|
+
// "q"` for `node a "A" in=q`. Nothing in either genre was left un-stranded,
|
|
1190
|
+
// which is why the withdrawal is by KEY here and not directive by directive.
|
|
1191
|
+
//
|
|
1192
|
+
// The grounds differ per genre and are stated per cell, as `SCENE-KEYWORD-MEMBERSHIP`'s are:
|
|
1193
|
+
// `flowchart`'s is that every value is a dead end, `statechart`'s is that the
|
|
1194
|
+
// spelling is RESERVED for a different domain. Both genres are EXPERIMENTAL,
|
|
1195
|
+
// so both withdrawals are free — the `EDGE-GEOMETRY-CONSTRUCTS` precedent, no gate and no rewrite
|
|
1196
|
+
// owed — which is also what makes re-adding `in=` to `statechart` later with
|
|
1197
|
+
// a `state`-id domain cost nothing.
|
|
1198
|
+
const WITHDREW_OPT_AT=' (withdrawn, `MEMBERSHIP-KEY-ACCEPTANCE`; MIGRATIONS 0.3)';
|
|
1199
|
+
const GENRE_WITHDRAWN_OPT={
|
|
1200
|
+
flowchart:{
|
|
1201
|
+
in:'`flowchart` no longer accepts `in=`. Its only value domain was the id of a containing `group`, and this genre has not declared `group` (`SCENE-KEYWORD-MEMBERSHIP`) — so EVERY value was a dead end: `in=x` answered `unknown group "x"` and no spelling succeeded. An unknown-option error that names the reason beats a dangling reference no author can satisfy. What expresses membership TODAY is `class=`: declare `class ingress "Ingress phase"` and write `class=ingress` on each stage — it earns a legend entry and applies to every member at once. Containment in a flowchart is an OPEN question and the construct the need is waiting on is a swimlane, not a box (spec/genres/experimental/flowchart.md, What is excluded).'
|
|
1202
|
+
},
|
|
1203
|
+
statechart:{
|
|
1204
|
+
in:'`statechart` no longer accepts `in=`, and the spelling is RESERVED rather than merely dropped. This genre declares NO subject vocabulary (`SUBJECT-VOCABULARY-SCOPE`), so the `group` id that was `in=`\'s only value domain cannot exist here and every value was a dead end. The reason for withdrawing rather than leaving it is the RESERVATION: `in=`\'s future domain in this genre is a STATE id — UML 2.5.1 §14.2.3.4 composite states would arrive as nesting on `state`, through this exact key — so a live key with a group-id domain taught the WRONG model using the very spelling reserved for the right one. Delete the key: a category shared by several states is a `class=` meaning. `in=` is expected back with a state-id domain when composite-state nesting is earned (`MEMBERSHIP-KEY-ACCEPTANCE`).'
|
|
1205
|
+
}
|
|
1206
|
+
};
|
|
1207
|
+
const WITHDRAWN_OPT_FROM_GENRE=(key,genre)=>
|
|
1208
|
+
key+'= is not allowed in genre '+genre+' — it was WITHDRAWN from this genre, not misspelled: '+
|
|
1209
|
+
GENRE_WITHDRAWN_OPT[genre][key]+
|
|
1210
|
+
' An option key is per genre for the same reason a keyword is (core §3, `GENRE-VOCABULARY-OBLIGATION`): the key is accepted '+
|
|
1211
|
+
'by the directive AND by the genre, and this genre\'s acceptance was withdrawn without touching any other\'s.'+
|
|
1212
|
+
WITHDREW_OPT_AT;
|
|
977
1213
|
const GENRE_KW={
|
|
978
|
-
block:new Set(SCENE_HOST_KW.concat(['node','edge'])),
|
|
979
|
-
topology:new Set(SCENE_HOST_KW.concat(['node','edge'])),
|
|
980
|
-
flowchart:new Set(SCENE_HOST_KW.concat(['node','flowline'], FLOWCHART_ROLE_KW)),
|
|
1214
|
+
block:new Set(SCENE_HOST_KW.concat(BLOCK_SUBJECT_KW, ['node','edge'])),
|
|
1215
|
+
topology:new Set(SCENE_HOST_KW.concat(TOPOLOGY_SUBJECT_KW, ['node','edge'])),
|
|
1216
|
+
flowchart:new Set(SCENE_HOST_KW.concat(FLOWCHART_SUBJECT_KW, ['node','flowline'], FLOWCHART_ROLE_KW)),
|
|
981
1217
|
// `STATECHART-GENRE-SCOPE`: `statechart` added no keyword of its own — it was the
|
|
982
1218
|
// scene host set and nothing else. `GENRE-NODE-SPELLING` gives it its two: the
|
|
983
1219
|
// scene host set with `state` and `transition` in the slots `node` and
|
|
@@ -985,7 +1221,10 @@ const GENRE_KW={
|
|
|
985
1221
|
// `terminator`: those are flowchart's words (`GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION`), and a `decision` in a
|
|
986
1222
|
// statechart is a category error, not a shorthand. The allowlist is what
|
|
987
1223
|
// makes that a line error with no extra code.
|
|
988
|
-
|
|
1224
|
+
// `SUBJECT-VOCABULARY-SCOPE`: its subject vocabulary is the empty array above, so
|
|
1225
|
+
// `state` + `transition` + core + layout + styling + region openers is now
|
|
1226
|
+
// the WHOLE of what a statechart document may write at top level.
|
|
1227
|
+
statechart:new Set(SCENE_HOST_KW.concat(STATECHART_SUBJECT_KW, ['state','transition'])),
|
|
989
1228
|
bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
|
|
990
1229
|
// chart is experimental and attaches to a table id in the same document
|
|
991
1230
|
table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
|
|
@@ -1048,7 +1287,10 @@ function parseOne(text){
|
|
|
1048
1287
|
// `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
|
|
1049
1288
|
// string — an author who writes `title ""` has made a distinction the model
|
|
1050
1289
|
// must keep, and the implicit `base` plane wrote no label at all.
|
|
1051
|
-
|
|
1290
|
+
// `DRAWN-ANNOTATION-FORM`: `note` sits beside `title` and starts ABSENT (null), on
|
|
1291
|
+
// `EMPTY-LABEL-STATE`'s rule for `title` itself — an author who writes `note=""` has made
|
|
1292
|
+
// a distinction the model must keep.
|
|
1293
|
+
const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
|
|
1052
1294
|
flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
|
|
1053
1295
|
classes:[],boundaries:[]};
|
|
1054
1296
|
const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
|
|
@@ -1201,6 +1443,16 @@ function parseOne(text){
|
|
|
1201
1443
|
const e=idErr(o2.plane, optHasQ(oT2,'plane'), null);
|
|
1202
1444
|
if(e){ err(n,e); return; }
|
|
1203
1445
|
}
|
|
1446
|
+
// `DRAWN-ANNOTATION-FORM`: the connector's copy of the `note=` version gate and
|
|
1447
|
+
// of the `QUOTING-RULES` quoted-prose rule. A connector is the acceptor the ruling
|
|
1448
|
+
// called decisive — an edge has no id, so an attribute is the ONLY form
|
|
1449
|
+
// that can reach it — and this scanner has to carry every language-wide
|
|
1450
|
+
// check itself or the one construct that most needs the key is the one
|
|
1451
|
+
// construct where the key is unchecked.
|
|
1452
|
+
if(o2.note!==undefined){
|
|
1453
|
+
if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); return; }
|
|
1454
|
+
if(!optQ(oT2,'note')){ err(n,'note= must be quoted: note="'+o2.note+'" — '+Q_WHY); return; }
|
|
1455
|
+
}
|
|
1204
1456
|
// `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
|
|
1205
1457
|
// Checked before the value, exactly as `badOpts` does it.
|
|
1206
1458
|
if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
|
|
@@ -1215,7 +1467,7 @@ function parseOne(text){
|
|
|
1215
1467
|
// and `fill=` name the same channel (`stroke=` wins when both are
|
|
1216
1468
|
// written); `text=` colours the [tail]/[mid]/[head] labels.
|
|
1217
1469
|
doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:ecls,
|
|
1218
|
-
stroke:o2.stroke,
|
|
1470
|
+
stroke:o2.stroke,note:o2.note,
|
|
1219
1471
|
plane:o2.plane||'base',line:n});
|
|
1220
1472
|
}
|
|
1221
1473
|
|
|
@@ -1341,6 +1593,19 @@ function parseOne(text){
|
|
|
1341
1593
|
// same-line repeated option key (last-wins was silent data loss)
|
|
1342
1594
|
if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
|
|
1343
1595
|
for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
|
|
1596
|
+
// `MEMBERSHIP-KEY-ACCEPTANCE`: the PER-GENRE option-key withdrawal, checked here —
|
|
1597
|
+
// after `unknown option`, so a key the LANGUAGE does not have keeps its
|
|
1598
|
+
// own answer, and before every value check, so a withdrawn key is never
|
|
1599
|
+
// told what its value would have meant. `gwHit` suppresses the id-value
|
|
1600
|
+
// rule below for the same key: ONE token, ONE error, the convention
|
|
1601
|
+
// `enumQ` already follows. The line is abandoned by the caller
|
|
1602
|
+
// (`if(badOpts(kw)) continue;`), so no cascade reaches the resolver and
|
|
1603
|
+
// the author never sees the `unknown group "…"` this ruling removes.
|
|
1604
|
+
const gwOpt=(doc.genre&&GENRE_WITHDRAWN_OPT[doc.genre])||null;
|
|
1605
|
+
const gwHit=new Set();
|
|
1606
|
+
if(gwOpt) for(const o in opts)
|
|
1607
|
+
if(gwOpt[o]!==undefined && allowed.includes(o)){
|
|
1608
|
+
err(n,WITHDRAWN_OPT_FROM_GENRE(o,doc.genre)); gwHit.add(o); bad=true; }
|
|
1344
1609
|
// Retired spelling: `color=` → `fill=`. Fires only where
|
|
1345
1610
|
// the key was accepted; on a directive that never took it the existing
|
|
1346
1611
|
// `<directive> does not take color=` is still the right answer.
|
|
@@ -1350,6 +1615,19 @@ function parseOne(text){
|
|
|
1350
1615
|
// spelling left the language rather than moving between directives.
|
|
1351
1616
|
for(const rk in RETIRED_OPT_KEYS)
|
|
1352
1617
|
if(opts[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); bad=true; }
|
|
1618
|
+
// `DRAWN-ANNOTATION-FORM`: the two `note=` refusals, in the order that gives
|
|
1619
|
+
// ONE error per line. `field` is checked first and unconditionally,
|
|
1620
|
+
// because it refuses the key at every version — telling a `figdown 0.2`
|
|
1621
|
+
// bitfield author to raise their header would send them to a version
|
|
1622
|
+
// that still refuses them. Every directive that does NOT list `note` in
|
|
1623
|
+
// its row falls through to the generic `<directive> does not take note=`
|
|
1624
|
+
// below, which is the right answer for `external`, `threshold`, `band`,
|
|
1625
|
+
// `bundle`, `plane`, `class` and `cell`: the key is in OPT_KEYS, so none
|
|
1626
|
+
// of them can report `unknown option` for a spelling the language has.
|
|
1627
|
+
if(opts.note!==undefined && allowed.includes('note')){
|
|
1628
|
+
if(k==='field'){ err(n,NOTE_ON_FIELD); bad=true; }
|
|
1629
|
+
else if(belowOptVersion('note',doc.version)){ err(n,NOTE_VERSION(doc.version)); bad=true; }
|
|
1630
|
+
}
|
|
1353
1631
|
// `RULE-POSITION-ENUMERATION`: RULE 2.4's enum half on the OPTION keys. One loop
|
|
1354
1632
|
// for every enum-valued key, the same device the id-valued keys below
|
|
1355
1633
|
// use — so a key that gains an enum grammar later is covered by
|
|
@@ -1398,7 +1676,11 @@ function parseOne(text){
|
|
|
1398
1676
|
// until then) and `present=` (`PRESENCE-CONDITION-EXPRESSION`). `present=""` is legal and is the
|
|
1399
1677
|
// "conditional, condition not stated" form — an EMPTY quoted value,
|
|
1400
1678
|
// not an unquoted one, so the same rule admits it.
|
|
1401
|
-
|
|
1679
|
+
// `DRAWN-ANNOTATION-FORM`: THREE since `note=` revived. It is prose in the
|
|
1680
|
+
// same sense, and it revives with the value shape it retired with —
|
|
1681
|
+
// which is RULE 4.9 obligation 2 satisfied in the parser rather than
|
|
1682
|
+
// only on paper.
|
|
1683
|
+
for(const sk of ['description','present','note'])
|
|
1402
1684
|
if(opts[sk]!==undefined && allowed.includes(sk) && !optQ(optT,sk)){
|
|
1403
1685
|
err(n,sk+'= must be quoted: '+sk+'="'+opts[sk]+'" — '+Q_WHY); bad=true; }
|
|
1404
1686
|
// `QUOTED-IDS`: `in=` and `plane=` are ID-VALUED options, so the
|
|
@@ -1406,7 +1688,7 @@ function parseOne(text){
|
|
|
1406
1688
|
// keeps its directive-specific message (`threshold needs in=…`); a written
|
|
1407
1689
|
// one that is quoted or not a legal id gets the ID RULE.
|
|
1408
1690
|
for(const k of ['in','plane'])
|
|
1409
|
-
if(opts[k]!==undefined && allowed.includes(k)){
|
|
1691
|
+
if(opts[k]!==undefined && allowed.includes(k) && !gwHit.has(k)){
|
|
1410
1692
|
const e=idErr(opts[k], optHasQ(optT,k), null);
|
|
1411
1693
|
if(e){ err(n,e); bad=true; }
|
|
1412
1694
|
}
|
|
@@ -1515,7 +1797,7 @@ function parseOne(text){
|
|
|
1515
1797
|
// noun in a topology figure (`SHAPE-ENUM-VOCABULARY`: no domain nouns in the presentation
|
|
1516
1798
|
// vocabulary) while the directive means geometric waypoints;
|
|
1517
1799
|
// - `render` was a verb naming a zone that admits only geometry
|
|
1518
|
-
// (`pin` — and, until
|
|
1800
|
+
// (`pin` — and, until 0.1, `path` and `routing`), and it
|
|
1519
1801
|
// collided with the renderer
|
|
1520
1802
|
// and the render options of §7. `layout` is the cross-tool word for
|
|
1521
1803
|
// this half of a diagram language, and the zone it opens carried the
|
|
@@ -1533,6 +1815,12 @@ function parseOne(text){
|
|
|
1533
1815
|
if(kw==='wrap'){ err(n,RETIRED_WRAP); continue; }
|
|
1534
1816
|
if(kw==='boundary'){ err(n,RETIRED_BOUNDARY); continue; }
|
|
1535
1817
|
if(kw==='layer'){ err(n,RETIRED_LAYER); continue; }
|
|
1818
|
+
// `PAINT-ORDER-CONSTRUCT`: `plane` joins this block. RULE 6.2 placement — the
|
|
1819
|
+
// spelling left the LANGUAGE, not one genre, so it fires wherever it
|
|
1820
|
+
// appears at line start, in every genre, AHEAD of the `GENRE-KEYWORD-ALLOWLIST` allowlist. A
|
|
1821
|
+
// `plane` line under `bitfield` gets the withdrawal, not "not allowed in
|
|
1822
|
+
// genre bitfield", which would be true and useless.
|
|
1823
|
+
if(kw==='plane'){ err(n,RETIRED_PLANE); continue; }
|
|
1536
1824
|
if(kw==='guide'){ err(n,RETIRED_GUIDE); continue; }
|
|
1537
1825
|
if(kw==='wave'){ err(n,RETIRED_WAVE); continue; }
|
|
1538
1826
|
if(kw==='size'){ err(n,RETIRED_SIZE); continue; }
|
|
@@ -1547,7 +1835,7 @@ function parseOne(text){
|
|
|
1547
1835
|
// per-field options. Classic form: field <name> <width> [options].
|
|
1548
1836
|
// Classic form: field <name> <width-in-bits|*> [fill=] [description=]
|
|
1549
1837
|
// [present=]
|
|
1550
|
-
// Conditional presence was a POSITIONAL FLAG until
|
|
1838
|
+
// Conditional presence was a POSITIONAL FLAG until 0.1:
|
|
1551
1839
|
// `optional` (…0.1), `conditional` (0.1…0.1),
|
|
1552
1840
|
// `optional` again (`PRESENCE-FLAG-SPELLING`). `PRESENCE-CONDITION-EXPRESSION` replaces the flag with
|
|
1553
1841
|
// `present=`, an option key whose value is the presence CONDITION as
|
|
@@ -1634,7 +1922,7 @@ function parseOne(text){
|
|
|
1634
1922
|
// the overflow.
|
|
1635
1923
|
if(m[2]!=='*' && +m[2]>cur.word){
|
|
1636
1924
|
// The suggestion spells the CLASSIC form, and the classic name is
|
|
1637
|
-
// QUOTED (`QUOTING-RULES`). Until
|
|
1925
|
+
// QUOTED (`QUOTING-RULES`). Until 0.1 this string said
|
|
1638
1926
|
// `write "field P 64"` — a second line error, so a user who
|
|
1639
1927
|
// followed the diagnostic was told off twice.
|
|
1640
1928
|
bad='"'+nm+':'+m[2]+'" is wider than word='+cur.word+' — a compact item must fit one row; write it in the classic form to span rows: field "'+nm+'" '+m[2]; break; }
|
|
@@ -1660,7 +1948,7 @@ function parseOne(text){
|
|
|
1660
1948
|
// `break` ends the row after the fields declared since the block
|
|
1661
1949
|
// opened (or since the previous break). With none there is nothing to
|
|
1662
1950
|
// break — genre doc: "break with no preceding field in the current row".
|
|
1663
|
-
// Spelled `wrap` until
|
|
1951
|
+
// Spelled `wrap` until 0.1 (`ROW-BREAK-NAMING`): in CSS/typography `wrap` is
|
|
1664
1952
|
// AUTOMATIC reflow — a mode — while this is an EXPLICIT break, an
|
|
1665
1953
|
// event; CSS Fragmentation calls exactly this "a forced break …
|
|
1666
1954
|
// explicitly indicated by the … author" and HTML spells it `br`.
|
|
@@ -1759,7 +2047,7 @@ function parseOne(text){
|
|
|
1759
2047
|
// / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
|
|
1760
2048
|
// members (a,b), or a count that does not match the lane's `=`
|
|
1761
2049
|
// cells are all line errors — never silent drop or shift.
|
|
1762
|
-
// Spelled `labels=` until
|
|
2050
|
+
// Spelled `labels=` until 0.1 (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
|
|
1763
2051
|
// `data`, "an array of signal labels" naming every value cell, and
|
|
1764
2052
|
// after the `2`-`9` retirement (`TIMING-LANE-ALPHABET`) the two scopes coincide exactly.
|
|
1765
2053
|
let labels;
|
|
@@ -1811,6 +2099,11 @@ function parseOne(text){
|
|
|
1811
2099
|
// dispatched by their own scanner above.)
|
|
1812
2100
|
if(NODE_SPELLINGS.has(kw) && GENRE_NODE_KW[doc.genre])
|
|
1813
2101
|
err(n, WRONG_WORD(kw, GENRE_NODE_KW[doc.genre], doc.genre));
|
|
2102
|
+
// `SCENE-KEYWORD-MEMBERSHIP`: same argument one step further. A word this genre
|
|
2103
|
+
// WITHDREW is not an unknown word either, and the author holding it
|
|
2104
|
+
// needs the ground, not a spellcheck.
|
|
2105
|
+
else if(GENRE_WITHDRAWN[doc.genre] && GENRE_WITHDRAWN[doc.genre][kw])
|
|
2106
|
+
err(n, WITHDRAWN_FROM_GENRE(kw, doc.genre));
|
|
1814
2107
|
else
|
|
1815
2108
|
err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
|
|
1816
2109
|
continue;
|
|
@@ -1837,10 +2130,23 @@ function parseOne(text){
|
|
|
1837
2130
|
// different values for the same visible text. One form, one
|
|
1838
2131
|
// meaning: the token is a normal quoted string and the generic
|
|
1839
2132
|
// tokenizer above has already resolved its escapes.
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
2133
|
+
// `DRAWN-ANNOTATION-FORM`: read the POSITIONALS, not the raw token stream.
|
|
2134
|
+
// `title` took no options until this release, so `tk.toks[1]` and
|
|
2135
|
+
// `tk.toks.length>2` were the same thing as `pos[1]` and
|
|
2136
|
+
// `pos.length>2`. They stop being the same thing the moment the line
|
|
2137
|
+
// may carry `note=`, and testing the raw stream would report the
|
|
2138
|
+
// annotation as a surplus positional. This is `OPTION-POSITION-PARSING`'s lesson (`bundle`
|
|
2139
|
+
// and `threshold` read `posq` for exactly this reason) applied to the
|
|
2140
|
+
// one directive that had never needed it.
|
|
2141
|
+
const t0v=pos[1], t0q=posq[1];
|
|
2142
|
+
if(t0v===undefined||!t0q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS 0.1)'); break; }
|
|
2143
|
+
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
2144
|
+
doc.title=t0v; sawTitle=true;
|
|
2145
|
+
// The figure-level note lives on the document, not on an element —
|
|
2146
|
+
// there is no element for it to live on, which is the whole reason
|
|
2147
|
+
// `title` is an acceptor.
|
|
2148
|
+
if(opts.note!==undefined) doc.note=opts.note;
|
|
2149
|
+
break;
|
|
1844
2150
|
}
|
|
1845
2151
|
case 'class': {
|
|
1846
2152
|
// semantic class (`CATEGORICAL-MEANING-MAPPING`): meaning + presentation defaults declared
|
|
@@ -1863,30 +2169,11 @@ function parseOne(text){
|
|
|
1863
2169
|
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
1864
2170
|
classIds.add(id);
|
|
1865
2171
|
// `plane=` on a class is the members' default plane (an element's own
|
|
1866
|
-
// plane
|
|
2172
|
+
// `PAINT-ORDER-CONSTRUCT`: `plane=` is withdrawn, so a class carries the
|
|
2173
|
+
// FOUR §5 attributes it can still set. The paint-order attribute is
|
|
2174
|
+
// gone from the language, not merely off this directive.
|
|
1867
2175
|
doc.classes.push({id,label:pos[2],fill:opts.fill,stroke:opts.stroke,
|
|
1868
|
-
style:opts.style,
|
|
1869
|
-
break;
|
|
1870
|
-
}
|
|
1871
|
-
case 'plane': {
|
|
1872
|
-
const id=pos[1];
|
|
1873
|
-
{ const e=idErr(id,posq[1],'plane needs an id'); if(e){ err(n,e); break; } }
|
|
1874
|
-
if(planeIds.has(id)){ err(n,'duplicate plane id "'+id+'"'); break; }
|
|
1875
|
-
if(pos[2]!==undefined&&!posq[2]){ err(n,'plane label must be quoted: plane '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
|
|
1876
|
-
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
1877
|
-
// 0.1: `z=` -> `z-index=` (CSS's own spelling for the
|
|
1878
|
-
// stacking concept, taken in full per RULE 4.2). The retired `z=`
|
|
1879
|
-
// is caught language-wide in RETIRED_OPT_KEYS before this runs.
|
|
1880
|
-
let z=doc.planes.length;
|
|
1881
|
-
const zi=opts['z-index'];
|
|
1882
|
-
if(zi!==undefined){
|
|
1883
|
-
if(!/^-?\d+$/.test(zi)){ err(n,'z-index must be a number'); break; }
|
|
1884
|
-
z=parseInt(zi,10);
|
|
1885
|
-
}
|
|
1886
|
-
planeIds.add(id);
|
|
1887
|
-
// `EMPTY-LABEL-STATE`: absent is absent, `""` is a written value — same
|
|
1888
|
-
// non-collapsing form as node/group/bundle and the typed blocks.
|
|
1889
|
-
doc.planes.push({id,label:pos[2]!==undefined?pos[2]:null,z});
|
|
2176
|
+
style:opts.style,line:n});
|
|
1890
2177
|
break;
|
|
1891
2178
|
}
|
|
1892
2179
|
// `FLOWCHART-ROLE-KEYWORDS`: `process` / `decision` / `terminator` DESUGAR to
|
|
@@ -1929,6 +2216,7 @@ function parseOne(text){
|
|
|
1929
2216
|
// Display falls back to the id in render(), so the figure is unchanged.
|
|
1930
2217
|
doc.nodes.push({id,label:pos[2]!==undefined?pos[2]:null,shape,role,fill:opts.fill,stroke:opts.stroke,
|
|
1931
2218
|
style:opts.style,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
|
|
2219
|
+
note:opts.note,
|
|
1932
2220
|
group:opts['in']||null,plane:opts.plane||'base',line:n});
|
|
1933
2221
|
break;
|
|
1934
2222
|
}
|
|
@@ -1948,6 +2236,7 @@ function parseOne(text){
|
|
|
1948
2236
|
}
|
|
1949
2237
|
doc.groups.push({id,label:pos[2]!==undefined?pos[2]:null,fill:opts.fill,stroke:opts.stroke,
|
|
1950
2238
|
style:opts.style,gap:ggap,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
|
|
2239
|
+
note:opts.note,
|
|
1951
2240
|
plane:opts.plane||null,line:n});
|
|
1952
2241
|
break;
|
|
1953
2242
|
}
|
|
@@ -1959,7 +2248,7 @@ function parseOne(text){
|
|
|
1959
2248
|
// Shares the node/group/block id namespace. Of the §5 attributes it
|
|
1960
2249
|
// can carry only the two that need no drawn shape: `text=` (the label
|
|
1961
2250
|
// colour) and `plane=` (organizational, exactly as on a node).
|
|
1962
|
-
// Spelled `boundary` until
|
|
2251
|
+
// Spelled `boundary` until 0.1 (`EXTERNAL-ENDPOINT-NAMING`): three standards claim that
|
|
1963
2252
|
// word for the OPPOSITE meaning (UML ECB «boundary» is an internal
|
|
1964
2253
|
// interface object, C4 System_Boundary is a dashed grouping container,
|
|
1965
2254
|
// BPMN's is an event), and this spec's own prose had already stopped
|
|
@@ -2114,7 +2403,7 @@ function parseOne(text){
|
|
|
2114
2403
|
case 'chart': {
|
|
2115
2404
|
// chart family: chart <table-id> [type=bar3d]
|
|
2116
2405
|
// rows -> X, columns -> Y, numeric cells -> Z (the table IS the data)
|
|
2117
|
-
// Spelled `plot` with `kind=bars3d` until
|
|
2406
|
+
// Spelled `plot` with `kind=bars3d` until 0.1 (`CHART-BLOCK-NAMING`).
|
|
2118
2407
|
// 0.1 (`CHART-LEVEL-KEY`): `level=` is DELETED. Zero uses corpus-wide, zero
|
|
2119
2408
|
// 3-D bar charts, zero requests; one in-repo example and two fixtures.
|
|
2120
2409
|
// It was the only construct whose caption the ENGINE wrote rather than
|
|
@@ -2155,7 +2444,7 @@ function parseOne(text){
|
|
|
2155
2444
|
if(!opts['in']){ err(n,'band needs in=<node-or-group-id>'); break; }
|
|
2156
2445
|
// 0.1: the `%` is MANDATORY, matching `threshold offset=` (`BARE-FRACTION-VALUES`).
|
|
2157
2446
|
// `band 15`, `band 15-35` and `band 15%-35` all parsed before (the
|
|
2158
|
-
// separator was a hyphen until
|
|
2447
|
+
// separator was a hyphen until 0.1); one concept in one
|
|
2159
2448
|
// document must not have two value grammars (RULE 4.4).
|
|
2160
2449
|
// `RANGE-SPELLING`: the separator is `..`, and the HYPHEN form it
|
|
2161
2450
|
// replaces gets its own named diagnostic. `15-35%` reads as
|
|
@@ -2253,7 +2542,7 @@ function parseOne(text){
|
|
|
2253
2542
|
// the cell border colour, `text=` the block caption colour.
|
|
2254
2543
|
// `TYPED-BLOCK-SILENT-FALLBACK`: word= empty or non-integer was a silent fallback/truncation
|
|
2255
2544
|
// (word= → 32, word=8.5 → 8). Positive integer only; absence → 32.
|
|
2256
|
-
// Spelled `unit=` until
|
|
2545
|
+
// Spelled `unit=` until 0.1 (`BITS-PER-ROW-KEY-NAMING`).
|
|
2257
2546
|
let word=32;
|
|
2258
2547
|
if(opts.word!==undefined){
|
|
2259
2548
|
if(opts.word===''||!/^\d+$/.test(opts.word)||+opts.word<1){
|
|
@@ -2330,11 +2619,39 @@ function parseOne(text){
|
|
|
2330
2619
|
}
|
|
2331
2620
|
for(const r of doc.ranks) for(const id of r.ids)
|
|
2332
2621
|
if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
|
|
2622
|
+
// `MARKER-TARGET-KINDS`: `in=` on `threshold`/`band` also resolves a REGION id —
|
|
2623
|
+
// a `bitfield`, `table` or `timing` block. This is a WIDENING of the value
|
|
2624
|
+
// domain, not a third sense of `in=` and not a new spelling: the relation is
|
|
2625
|
+
// sense 2 verbatim, *the element this one is drawn across*, and what changes
|
|
2626
|
+
// is only which declared ids the resolver will bind.
|
|
2627
|
+
//
|
|
2628
|
+
// It is UNGATED, and that is argued rather than assumed. A region-targeted
|
|
2629
|
+
// threshold did not merely mean something else before this release — it did
|
|
2630
|
+
// not PARSE. `threshold "Max" in=q offset=50%` over a `table q` answered
|
|
2631
|
+
// `unknown target "q" for threshold`, the same error a nonexistent id gets,
|
|
2632
|
+
// because this set was hard-coded to nodes and groups while `table <id>`
|
|
2633
|
+
// makes the id mandatory and `chart <table-id>` already consumes it from
|
|
2634
|
+
// another directive. So no `figdown 0.1` or `figdown 0.2` document changes
|
|
2635
|
+
// meaning and none becomes invalid; the only documents affected are ones
|
|
2636
|
+
// that produced no figure at all. Core §13.0.1's hazard — "a figure that
|
|
2637
|
+
// looks right and means something else" — needs two readings to choose
|
|
2638
|
+
// between, and here the alternative reading was an error message. That is
|
|
2639
|
+
// exactly why `note=` IS gated a few hundred lines up and this is not: the
|
|
2640
|
+
// key had a prior meaning, this had none. Nothing is added to the option
|
|
2641
|
+
// registry, so a reader of `figdown 0.2` consulting core §10 finds the same
|
|
2642
|
+
// 45 rows either way.
|
|
2643
|
+
//
|
|
2644
|
+
// The two WRED figures this unblocks are the whole of the measured demand
|
|
2645
|
+
// (core §9 `ANNOTATION-LOCATOR-SPLIT`). The locator COORDINATE grammar — `in=q(3)`, addressing a
|
|
2646
|
+
// row inside the region — is designed and deliberately NOT built: it has no
|
|
2647
|
+
// shipping consumer, and RULE 4.7 argues against spending a grammar before
|
|
2648
|
+
// one exists.
|
|
2649
|
+
const regionTarget=id=>blockIds.has(id);
|
|
2333
2650
|
for(const gl of doc.thresholds)
|
|
2334
|
-
if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target))
|
|
2651
|
+
if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target)&&!regionTarget(gl.target))
|
|
2335
2652
|
errs.push('Line '+gl.line+': unknown target "'+gl.target+'" for threshold');
|
|
2336
2653
|
for(const f of doc.bands)
|
|
2337
|
-
if(!groupIds.has(f.target)&&!nodeIds.has(f.target))
|
|
2654
|
+
if(!groupIds.has(f.target)&&!nodeIds.has(f.target)&&!regionTarget(f.target))
|
|
2338
2655
|
errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
|
|
2339
2656
|
for(const t of doc.trunks) for(const [a,b] of t.pairs){
|
|
2340
2657
|
if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
|
|
@@ -2393,7 +2710,7 @@ function parseOne(text){
|
|
|
2393
2710
|
// b class=p` was accepted, drew a #555 line, and rendered a legend swatch
|
|
2394
2711
|
// that showed nothing, so the class's meaning was invisible in its own
|
|
2395
2712
|
// derived legend. With `color=` retired (`COLOUR-KEY-STATUS`) the remaining shape of the
|
|
2396
|
-
// hole is a class carrying only `style
|
|
2713
|
+
// hole is a class carrying only `style=`, or nothing at
|
|
2397
2714
|
// all: the edge silently takes the default colour and the author who
|
|
2398
2715
|
// declared a class to CLASSIFY the edge gets no colour and no warning.
|
|
2399
2716
|
// Both halves are the same rule — a class an edge joins must declare at
|
|
@@ -2563,7 +2880,7 @@ function stackSectionSvgs(results){
|
|
|
2563
2880
|
// became the node's LABEL. It is now a line error wherever it is part of the
|
|
2564
2881
|
// GRAMMAR — that is, everywhere except the FOUR verbatim regions (this
|
|
2565
2882
|
// function handles three of them; the pipe row is the caller's, see below).
|
|
2566
|
-
// The count read "three" until
|
|
2883
|
+
// The count read "three" until 0.1, listing four:
|
|
2567
2884
|
// - inside a quoted string ("…;…"),
|
|
2568
2885
|
// - inside a comment (already stripped before this runs),
|
|
2569
2886
|
// - inside an edge label (edge a -[packet arrives; TMR != 0]-> b),
|
|
@@ -2709,6 +3026,92 @@ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
|
|
|
2709
3026
|
// §5 style= → SVG dash pattern. `def` is the construct's conventional
|
|
2710
3027
|
// default (the bundle ring and the threshold line are dashed by convention);
|
|
2711
3028
|
// an explicit style= always wins.
|
|
3029
|
+
// ── the note box (`DRAWN-ANNOTATION-FORM`) ───────────────────────────────────────
|
|
3030
|
+
// The drawn annotation's whole appearance lives in these three functions,
|
|
3031
|
+
// because `DOMAIN-CONVENTION-DIRECTIVES` gives the engine the drawing convention outright: `note=` takes
|
|
3032
|
+
// no `at=`, no `side=`, no colour and no size, so there is exactly one look and
|
|
3033
|
+
// it is decided here rather than by an author.
|
|
3034
|
+
//
|
|
3035
|
+
// The look is the UML note symbol — a rectangle with a folded top-right corner
|
|
3036
|
+
// — which is the notation of the metaclass the SPELLING is borrowed from
|
|
3037
|
+
// (UML 2.5.1's `Comment`; RULE 4.1 takes the standard's word, and taking its
|
|
3038
|
+
// glyph with it is what lets a reader recognise the box as an aside without a
|
|
3039
|
+
// legend entry). It is deliberately unlike a `node`: no rounded corners, a
|
|
3040
|
+
// paler wash, smaller type, and a corner no node shape has.
|
|
3041
|
+
const NOTE_FS=10, NOTE_PAD=6, NOTE_FOLD=9, NOTE_MAXCH=30;
|
|
3042
|
+
const NOTE_FILL='#fdfaf0', NOTE_STROKE='#c9c4b2', NOTE_INK='#5c584c';
|
|
3043
|
+
// The leader is deliberately DARKER than the box outline. Drawn in the box's
|
|
3044
|
+
// own stroke it was legible in the SVG and invisible on the page at 1x — a
|
|
3045
|
+
// leader nobody can see is a leader that is not there, and the note then reads
|
|
3046
|
+
// as annotating whatever it happens to sit above. Checked by eye, not by a
|
|
3047
|
+
// contrast number: the box is a surface and may recede, the leader is a
|
|
3048
|
+
// statement of attachment and may not.
|
|
3049
|
+
const NOTE_LEADER='#9c968a';
|
|
3050
|
+
// Deterministic greedy word wrap. Author newlines are honoured and never
|
|
3051
|
+
// merged; a run longer than the wrap width is broken only between words, so a
|
|
3052
|
+
// long identifier keeps its shape and simply widens the box.
|
|
3053
|
+
function noteWrap(text){
|
|
3054
|
+
const out=[];
|
|
3055
|
+
for(const para of String(text).split('\n')){
|
|
3056
|
+
const words=para.split(/ +/).filter(w=>w.length);
|
|
3057
|
+
if(!words.length){ out.push(''); continue; }
|
|
3058
|
+
let cur=words[0];
|
|
3059
|
+
for(let i=1;i<words.length;i++){
|
|
3060
|
+
if((cur+' '+words[i]).length<=NOTE_MAXCH) cur+=' '+words[i];
|
|
3061
|
+
else { out.push(cur); cur=words[i]; }
|
|
3062
|
+
}
|
|
3063
|
+
out.push(cur);
|
|
3064
|
+
}
|
|
3065
|
+
return out;
|
|
3066
|
+
}
|
|
3067
|
+
function noteBox(text){
|
|
3068
|
+
const lines=noteWrap(text);
|
|
3069
|
+
const lh=NOTE_FS*1.35;
|
|
3070
|
+
const w=Math.max(28, Math.max.apply(null,lines.map(tw))+NOTE_PAD*2+NOTE_FOLD);
|
|
3071
|
+
const h=lines.length*lh+NOTE_PAD*2-lh*0.15;
|
|
3072
|
+
return {w:Math.round(w*100)/100, h:Math.round(h*100)/100, lines, lh};
|
|
3073
|
+
}
|
|
3074
|
+
// The folded-corner outline, plus the small triangle that reads as the back of
|
|
3075
|
+
// the fold. One path each, so the shape is one primitive and the output is
|
|
3076
|
+
// byte-stable.
|
|
3077
|
+
function noteSvg(x,y,box,carrier){
|
|
3078
|
+
const F=NOTE_FOLD, w=box.w, h=box.h;
|
|
3079
|
+
const d='M'+x+' '+y+' H'+(x+w-F)+' L'+(x+w)+' '+(y+F)+' V'+(y+h)+' H'+x+' Z';
|
|
3080
|
+
const fold='M'+(x+w-F)+' '+y+' V'+(y+F)+' H'+(x+w)+' Z';
|
|
3081
|
+
const out=['<g class="fd-note"'+(carrier&&carrier.kind?' data-note-on="'+carrier.kind+'"':'')+'>',
|
|
3082
|
+
'<path d="'+d+'" fill="'+NOTE_FILL+'" stroke="'+NOTE_STROKE+'" stroke-width="1"/>',
|
|
3083
|
+
'<path d="'+fold+'" fill="'+NOTE_STROKE+'" fill-opacity="0.35" stroke="'+NOTE_STROKE+'" stroke-width="1"/>'];
|
|
3084
|
+
const first=y+NOTE_PAD+NOTE_FS*0.85;
|
|
3085
|
+
box.lines.forEach((ln,i)=>{
|
|
3086
|
+
out.push('<text x="'+(x+NOTE_PAD)+'" y="'+Math.round((first+i*box.lh)*100)/100+
|
|
3087
|
+
'" font-size="'+NOTE_FS+'" text-anchor="start" fill="'+NOTE_INK+'">'+esc(ln)+'</text>');
|
|
3088
|
+
});
|
|
3089
|
+
out.push('</g>');
|
|
3090
|
+
return out.join('');
|
|
3091
|
+
}
|
|
3092
|
+
// The leader is drawn ONLY when adjacency failed, and it is drawn AFTER the
|
|
3093
|
+
// box is placed, so it is correct by construction: it runs from the box edge
|
|
3094
|
+
// facing the carrier to the carrier's nearest point, and cannot be stale.
|
|
3095
|
+
function noteLeader(best,box,rect){
|
|
3096
|
+
const bx=best.x, by=best.y, bw=box.w, bh=box.h;
|
|
3097
|
+
const cx=rect.x+rect.w/2, cy=rect.y+rect.h/2;
|
|
3098
|
+
let x1,y1;
|
|
3099
|
+
if(best.side==='right') { x1=bx; y1=by+bh/2; }
|
|
3100
|
+
else if(best.side==='left') { x1=bx+bw; y1=by+bh/2; }
|
|
3101
|
+
else if(best.side==='below') { x1=bx+bw/2; y1=by; }
|
|
3102
|
+
else { x1=bx+bw/2; y1=by+bh; }
|
|
3103
|
+
// land on the carrier's border, not its centre, so the line stops at the
|
|
3104
|
+
// thing it points at
|
|
3105
|
+
const x2=Math.max(rect.x, Math.min(rect.x+rect.w, x1));
|
|
3106
|
+
const y2=Math.max(rect.y, Math.min(rect.y+rect.h, y1));
|
|
3107
|
+
return '<line x1="'+x1+'" y1="'+y1+'" x2="'+(rect.w||rect.h?x2:cx)+'" y2="'+(rect.w||rect.h?y2:cy)+
|
|
3108
|
+
'" stroke="'+NOTE_LEADER+'" stroke-width="1" stroke-dasharray="4 3"/>';
|
|
3109
|
+
}
|
|
3110
|
+
// A band's optional edge stroke. `renderScene` has had this as a local since
|
|
3111
|
+
// 0.1; `MARKER-TARGET-KINDS` needs the same rule for a REGION-scope band, which is drawn
|
|
3112
|
+
// outside the scene, so the one expression moves to module scope rather than
|
|
3113
|
+
// being written twice with a chance to drift.
|
|
3114
|
+
const bandEdgeOf=f=>(f.stroke||f.style)?' stroke="'+(f.stroke||'#8a8880')+'"'+dashOf(f.style,''):'';
|
|
2712
3115
|
function dashOf(style,def){
|
|
2713
3116
|
const p = style==='dashed'?'6 4' : style==='dotted'?'2 4' : style==='solid'?'' : def;
|
|
2714
3117
|
return p?' stroke-dasharray="'+p+'"':'';
|
|
@@ -2904,21 +3307,17 @@ function render(doc,ropts){
|
|
|
2904
3307
|
if(C[id] && C[id][k]!==undefined) x[k]=C[id][k];
|
|
2905
3308
|
}
|
|
2906
3309
|
};
|
|
2907
|
-
// a class carries
|
|
2908
|
-
//
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
2912
|
-
if(C[id] && C[id].plane!==undefined) x.plane=C[id].plane;
|
|
2913
|
-
}
|
|
2914
|
-
};
|
|
2915
|
-
const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); rsl(x); };
|
|
3310
|
+
// `PAINT-ORDER-CONSTRUCT`: a class carries the FOUR §5 attributes that survive.
|
|
3311
|
+
// The fifth was `plane`, and its cascade helper (`rsl`) is gone with the
|
|
3312
|
+
// key — a class can no longer set a paint order because the language has
|
|
3313
|
+
// no paint order to set. Everything is on the implicit `base` plane.
|
|
3314
|
+
const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); };
|
|
2916
3315
|
for(const n of doc.nodes){ rsAll(n); if(n.style===undefined) n.style='solid'; }
|
|
2917
3316
|
for(const g of doc.groups){ rsAll(g); }
|
|
2918
3317
|
// 0.1 (§8.4): an edge has no interior, so it takes every class
|
|
2919
3318
|
// channel EXCEPT `fill` — which the parser has already guaranteed is
|
|
2920
3319
|
// accompanied by a `stroke` on any class an edge joins.
|
|
2921
|
-
for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style');
|
|
3320
|
+
for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style'); if(e.style===undefined) e.style='solid'; }
|
|
2922
3321
|
for(const b of doc.blocks){
|
|
2923
3322
|
rsAll(b);
|
|
2924
3323
|
if(b.fields) for(const f of b.fields) rsAll(f);
|
|
@@ -2932,13 +3331,54 @@ function render(doc,ropts){
|
|
|
2932
3331
|
const s=renderScene(doc,y); parts.push(s.svg); y=s.y; maxW=Math.max(maxW,s.w);
|
|
2933
3332
|
sceneMeta=s.meta;
|
|
2934
3333
|
}
|
|
3334
|
+
// `MARKER-TARGET-KINDS`: a region-scope `threshold`/`band` is drawn HERE and not
|
|
3335
|
+
// in `renderScene`, because a region is not in the scene. Typed blocks stack
|
|
3336
|
+
// in document order OUTSIDE the scene (core §2, the `plane=` carve-out says
|
|
3337
|
+
// so in as many words), so at the moment `renderScene` emits its own
|
|
3338
|
+
// thresholds the region has no geometry yet and sits at a `y` the scene never
|
|
3339
|
+
// sees. The mark therefore travels with its target: each block reports its
|
|
3340
|
+
// box, and the marks that name it are painted over that box in the same
|
|
3341
|
+
// coordinate shape (`x0`/`x1`/`yA`/`yB`) the scene uses for a group.
|
|
3342
|
+
const regionBox={};
|
|
2935
3343
|
for(const b of doc.blocks){
|
|
2936
3344
|
let s;
|
|
2937
3345
|
if(b.type==='bitfield') s=renderBitfield(b,y);
|
|
2938
3346
|
else if(b.type==='table') s=renderTable(b,y);
|
|
2939
3347
|
else if(b.type==='chart') s=renderChart(b,y,doc);
|
|
2940
3348
|
else s=renderTiming(b,y);
|
|
2941
|
-
parts.push(s.svg);
|
|
3349
|
+
parts.push(s.svg);
|
|
3350
|
+
if(s.box) regionBox[b.id]=s.box;
|
|
3351
|
+
y=s.y+24; maxW=Math.max(maxW,s.w);
|
|
3352
|
+
}
|
|
3353
|
+
{
|
|
3354
|
+
const rsvg=[];
|
|
3355
|
+
for(const f of (doc.bands||[])){
|
|
3356
|
+
const B=regionBox[f.target]; if(!B) continue;
|
|
3357
|
+
const w=B.x1-B.x0, h=B.yB-B.yA;
|
|
3358
|
+
let bx,by,bw,bh;
|
|
3359
|
+
if(f.dir==='up') { bx=B.x0; by=B.yB-h*f.to/100; bw=w; bh=h*(f.to-f.from)/100; }
|
|
3360
|
+
else if(f.dir==='down') { bx=B.x0; by=B.yA+h*f.from/100; bw=w; bh=h*(f.to-f.from)/100; }
|
|
3361
|
+
else if(f.dir==='right'){ bx=B.x0+w*f.from/100; by=B.yA; bw=w*(f.to-f.from)/100; bh=h; }
|
|
3362
|
+
else { bx=B.x1-w*f.to/100; by=B.yA; bw=w*(f.to-f.from)/100; bh=h; }
|
|
3363
|
+
rsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.35"'+bandEdgeOf(f)+'/>');
|
|
3364
|
+
rsvg.push(textEl(bx+bw/2, by+bh/2+4, 11, 'middle', labelInk(f.fill,'#334155'), f.label,
|
|
3365
|
+
' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
3366
|
+
}
|
|
3367
|
+
for(const gl of (doc.thresholds||[])){
|
|
3368
|
+
const B=regionBox[gl.target]; if(!B) continue;
|
|
3369
|
+
const ly=B.yB-(B.yB-B.yA)*gl.pct/100;
|
|
3370
|
+
const col=gl.stroke||'#ef4444';
|
|
3371
|
+
rsvg.push('<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+col+
|
|
3372
|
+
'" stroke-width="'+(gl.pct>=100?4:2)+'"'+dashOf(gl.style,'7 4')+'/>');
|
|
3373
|
+
rsvg.push(textEl(B.x1+8, ly+4, 11, 'start', col, gl.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
3374
|
+
maxW=Math.max(maxW, B.x1+8+tw(gl.label));
|
|
3375
|
+
}
|
|
3376
|
+
// A band is a translucent wash UNDER the grid ink; a threshold is a mark
|
|
3377
|
+
// OVER it. The region has already been pushed, so both go after it and the
|
|
3378
|
+
// band leans on opacity rather than paint order for the "under" reading —
|
|
3379
|
+
// the same compromise `renderChart` makes, and the reason the opacity here
|
|
3380
|
+
// is lower than the scene's 0.9.
|
|
3381
|
+
if(rsvg.length) parts.push(rsvg.join(''));
|
|
2942
3382
|
}
|
|
2943
3383
|
// 0.1 (`CLASS-EMPTY-MEANING`): a class whose meaning is the EMPTY string claims no
|
|
2944
3384
|
// meaning, so it has nothing to explain and draws NO legend entry — it is
|
|
@@ -2976,6 +3416,30 @@ function render(doc,ropts){
|
|
|
2976
3416
|
parts.push(es.join(''));
|
|
2977
3417
|
y=ly+rowH;
|
|
2978
3418
|
}
|
|
3419
|
+
// `DRAWN-ANNOTATION-FORM`: the FIGURE-level note — `title "…" note="…"`. It carries
|
|
3420
|
+
// the 14% of measured annotations that name no single element ("Total: 8k
|
|
3421
|
+
// tunnel indexes", a four-signal legend, a TODO about the whole figure), and
|
|
3422
|
+
// it is the acceptor that removes the last argument for a standalone `note`
|
|
3423
|
+
// keyword: the figure HAS a declaration line, so attachment-by-position
|
|
3424
|
+
// reaches it too.
|
|
3425
|
+
//
|
|
3426
|
+
// It has no geometry to sit beside, so it takes no candidates and NEVER takes
|
|
3427
|
+
// a leader — a leader must point at something, and "the figure" is not a
|
|
3428
|
+
// thing on the canvas. It is placed with the caption, at the bottom, after
|
|
3429
|
+
// the scene, the regions and the derived legend. That is a placement rule and
|
|
3430
|
+
// not an author's choice (`DOMAIN-CONVENTION-DIRECTIVES`), and it is deterministic by construction:
|
|
3431
|
+
// there is exactly one figure-level note and exactly one place for it.
|
|
3432
|
+
//
|
|
3433
|
+
// The title itself is NOT drawn by default (`DEFAULT-VALUE-SELECTION` — an embedded figure sits
|
|
3434
|
+
// under a host caption), and the note does not follow it: the note is the
|
|
3435
|
+
// thing that draws. An author who wants the sentence in the picture writes
|
|
3436
|
+
// it here whether or not the renderer is showing the title.
|
|
3437
|
+
if(doc.note!==null&&doc.note!==undefined){
|
|
3438
|
+
const nb=noteBox(doc.note);
|
|
3439
|
+
const ny=y+8;
|
|
3440
|
+
parts.push(noteSvg(0,ny,nb,{kind:'title'}));
|
|
3441
|
+
y=ny+nb.h+2; maxW=Math.max(maxW,nb.w);
|
|
3442
|
+
}
|
|
2979
3443
|
const PADL=18, PADT=6;
|
|
2980
3444
|
const W=Math.ceil(maxW)+PADL+8, H=Math.ceil(y)+PADT+4;
|
|
2981
3445
|
return {svg:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+H+'" width="'+W+'" height="'+H+'" font-family="system-ui,sans-serif">'
|
|
@@ -3071,6 +3535,44 @@ function renderScene(doc,y0){
|
|
|
3071
3535
|
return rk[u]=r;
|
|
3072
3536
|
};
|
|
3073
3537
|
nodes.forEach(n=>n.rank=rankOf(find(n.id)));
|
|
3538
|
+
// SPINE CHAIN (item 27, ordering phase). A figure whose reading order is a
|
|
3539
|
+
// chain must draw that chain in ONE lane; the incumbent barycenter sweep
|
|
3540
|
+
// cannot, because a chain node and its branch sibling share one desired
|
|
3541
|
+
// position and the sibling — declared first — takes the slot, so the chain
|
|
3542
|
+
// steps aside once per rank and a logical column renders as a staircase
|
|
3543
|
+
// (spine.fd: +165 px per rank, 2208 px wide for a 200 px column).
|
|
3544
|
+
//
|
|
3545
|
+
// The chain is found HERE, before any coordinate exists, and it is the
|
|
3546
|
+
// longest rank-consecutive path of real nodes. Deterministic throughout:
|
|
3547
|
+
// longest-path DP taken in decreasing rank, every tie broken by document
|
|
3548
|
+
// order, so one document has exactly one chain.
|
|
3549
|
+
const chainNext=new Map(), chainPrev=new Map();
|
|
3550
|
+
const CHAIN_MIN=4; // nodes; below this a figure has no spine to
|
|
3551
|
+
{ // hold and the incumbent sweep is left alone
|
|
3552
|
+
const sc=new Map();
|
|
3553
|
+
for(const e of doc.edges){
|
|
3554
|
+
const A=byId[e.a],B=byId[e.b];
|
|
3555
|
+
if(!A||!B||isBack.has(e)||e.a===e.b) continue;
|
|
3556
|
+
if(e.op!=='->') continue; // a chain is a READING order, and
|
|
3557
|
+
// only a directed edge states one
|
|
3558
|
+
if(B.rank!==A.rank+1) continue; // a chain is rank-consecutive
|
|
3559
|
+
if(pinned(e.a)||pinned(e.b)) continue; // a pin is the author's word
|
|
3560
|
+
if(!sc.has(A)) sc.set(A,[]); sc.get(A).push(B);
|
|
3561
|
+
}
|
|
3562
|
+
const len=new Map(), nxt=new Map();
|
|
3563
|
+
for(const n of [...nodes].sort((p,q)=>q.rank-p.rank||p.di-q.di)){
|
|
3564
|
+
let best=null,bl=0;
|
|
3565
|
+
for(const s of sc.get(n)||[]){
|
|
3566
|
+
const l=len.get(s)||1;
|
|
3567
|
+
if(l>bl||(l===bl&&best&&s.di<best.di)){ bl=l; best=s; }
|
|
3568
|
+
}
|
|
3569
|
+
len.set(n,bl+1); if(best) nxt.set(n,best);
|
|
3570
|
+
}
|
|
3571
|
+
let head=null;
|
|
3572
|
+
for(const n of nodes) if(!head||len.get(n)>len.get(head)) head=n; // ties: doc order
|
|
3573
|
+
if(head&&len.get(head)>=CHAIN_MIN)
|
|
3574
|
+
for(let n=head,m=nxt.get(n);m;n=m,m=nxt.get(n)){ chainNext.set(n,m); chainPrev.set(m,n); }
|
|
3575
|
+
}
|
|
3074
3576
|
// positions: children spread around their parents' lane (barycenter
|
|
3075
3577
|
// sweeps down/up/down). Edges that span multiple layers get invisible
|
|
3076
3578
|
// waypoint slots so they no longer cut through intermediate nodes.
|
|
@@ -3080,6 +3582,33 @@ function renderScene(doc,y0){
|
|
|
3080
3582
|
const lblPx=s=>cwMax(s)*6.5;
|
|
3081
3583
|
const lay=[...nodes]; // layout participants
|
|
3082
3584
|
const chains=new Map(); // edge -> [A, ...waypoints, B]
|
|
3585
|
+
// Bus eligibility, TOPOLOGICAL half (item 26 stage 1). Three or more forward
|
|
3586
|
+
// `->` edges arriving at one target with one label, one stroke and one dash,
|
|
3587
|
+
// no endpoint labels and no pinned endpoint. It is computed here, before the
|
|
3588
|
+
// geometry, because the render pass needs the group SET whole: the figure
|
|
3589
|
+
// decides all-or-none, so it has to know how many groups it is deciding for.
|
|
3590
|
+
//
|
|
3591
|
+
// Placement is deliberately NOT touched. A member still reserves its
|
|
3592
|
+
// waypoint column, which is the cost item 27 records; suppressing those
|
|
3593
|
+
// columns was implemented and measured (spine.fd 2208 -> 1318 px and a
|
|
3594
|
+
// visibly better drawing) and is NOT landed here, because the suppression
|
|
3595
|
+
// has to be decided before coordinates exist while adoption can only be
|
|
3596
|
+
// decided after, and a figure that suppresses and then declines draws
|
|
3597
|
+
// straight through its own boxes (bfd-session: score 30 -> 35 with a new
|
|
3598
|
+
// `thru`). That belongs with item 27's ordering change, priced.
|
|
3599
|
+
const busGroups=[];
|
|
3600
|
+
{
|
|
3601
|
+
const g=new Map();
|
|
3602
|
+
for(const e of doc.edges){
|
|
3603
|
+
const A=byId[e.a], B=byId[e.b];
|
|
3604
|
+
if(!A||!B||e.a===e.b||isBack.has(e)) continue;
|
|
3605
|
+
if(e.op!=='->'||e.tail||e.head) continue;
|
|
3606
|
+
if(pinned(e.a)||pinned(e.b)||B.rank<=A.rank) continue;
|
|
3607
|
+
const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'');
|
|
3608
|
+
if(!g.has(k)) g.set(k,[]); g.get(k).push(e);
|
|
3609
|
+
}
|
|
3610
|
+
for(const [,m] of g) if(m.length>=3) busGroups.push(m);
|
|
3611
|
+
}
|
|
3083
3612
|
for(const e of doc.edges){
|
|
3084
3613
|
const A=byId[e.a], B=byId[e.b];
|
|
3085
3614
|
if(!A||!B||isBack.has(e)) continue;
|
|
@@ -3146,7 +3675,92 @@ function renderScene(doc,y0){
|
|
|
3146
3675
|
const center=n=>n.cross+cs(n)/2;
|
|
3147
3676
|
ranksArr.forEach(lane=>{ if(!lane) return; let c=0; // seed: doc order
|
|
3148
3677
|
lane.forEach((n,k)=>{ n.cross=c; c+=cs(n)+(k<lane.length-1?gapOf(n,lane[k+1]):0); }); });
|
|
3149
|
-
|
|
3678
|
+
// WHERE THE HOLD YIELDS, WHICH IS MOST OF THE RULE. Holding a chain node on
|
|
3679
|
+
// its chain neighbour puts every OTHER neighbour of that node on one side of
|
|
3680
|
+
// it, and where the figure diverges or converges that is the wrong drawing:
|
|
3681
|
+
// item 27's Brandes-Köpf rejection measured this exact mechanism from the
|
|
3682
|
+
// other end — aligning on ONE neighbour where the barycentre uses the AVERAGE
|
|
3683
|
+
// made 11 of 19 figures worse, and "the average is what a human draws". So
|
|
3684
|
+
// the hold is dropped wherever it would displace a spread the reader reads.
|
|
3685
|
+
//
|
|
3686
|
+
// `realDeg` is degree as the READER sees it at one rank boundary: real
|
|
3687
|
+
// neighbours, plus the waypoints of long edges whose far end is OFF the
|
|
3688
|
+
// chain. A long edge that leaves the chain and rejoins it later is not a
|
|
3689
|
+
// spread — counting it would drop the hold on exactly the columns this pass
|
|
3690
|
+
// exists to create (bfd-session's ADMINDOWN is entered by UP and by two
|
|
3691
|
+
// waypoints of edges that left DOWN and INIT) — while a long edge arriving
|
|
3692
|
+
// from elsewhere is one, and its target belongs at the average (that is
|
|
3693
|
+
// packet-ingress's `Forward`, entered by `IPv4 checksum OK?` beside it and by
|
|
3694
|
+
// two waypoints from the IPv6 and ARP branches).
|
|
3695
|
+
const onChain=n=>chainNext.has(n)||chainPrev.has(n);
|
|
3696
|
+
const realDeg=(m,side)=>{
|
|
3697
|
+
let k=0;
|
|
3698
|
+
for(const s of (side===1?succs:preds).get(m)||[]){
|
|
3699
|
+
if(!s.virtual){ k++; continue; }
|
|
3700
|
+
const o=side===1?s.homeB:s.homeA;
|
|
3701
|
+
if(o&&!onChain(o)) k++;
|
|
3702
|
+
}
|
|
3703
|
+
return k;
|
|
3704
|
+
};
|
|
3705
|
+
const realFan=(n,dir)=>{
|
|
3706
|
+
const m=(dir===1?chainPrev:chainNext).get(n); return m?realDeg(m,dir):0;
|
|
3707
|
+
};
|
|
3708
|
+
// PROSPECTIVE BUSES, AND WHY THE HOLD YIELDS TO THEM RATHER THAN SERVING
|
|
3709
|
+
// THEM. Item 26 records the trap this pass had to answer: a bus member's
|
|
3710
|
+
// waypoint column can be suppressed only BEFORE coordinates exist, while the
|
|
3711
|
+
// bus is adopted only AFTER, so a figure that suppresses and then declines
|
|
3712
|
+
// routes through its own boxes (bfd-session 30 -> 35 with a new `thru`).
|
|
3713
|
+
// Nothing here suppresses anything. It takes the one direction of that
|
|
3714
|
+
// decision which is safe under a decline: it WITHHOLDS the hold from the
|
|
3715
|
+
// source of a bus group that is topologically eligible, and withholding is
|
|
3716
|
+
// the incumbent behaviour — a figure that declines is drawn exactly as it is
|
|
3717
|
+
// drawn today, with nothing to undo. Holding them is the unsafe direction:
|
|
3718
|
+
// it stacks the sources of one convergence into a single column, and a bus
|
|
3719
|
+
// leg dropping from the earliest then pierces the latest — patterns/
|
|
3720
|
+
// flowchart-a loses the trunk it gained that way, measured.
|
|
3721
|
+
//
|
|
3722
|
+
// ...and only for a group that could ever BE a rail. A bus drops every source
|
|
3723
|
+
// onto one cross-axis rail, so a group whose sources sit on top of each other
|
|
3724
|
+
// along the FLOW axis — one source an ancestor of another — is unadoptable
|
|
3725
|
+
// whatever ordering does, and withholding there would cost the column and buy
|
|
3726
|
+
// nothing (bfd-session's three `admin disable` edges leave DOWN, INIT and UP,
|
|
3727
|
+
// and DOWN reaches both of the others).
|
|
3728
|
+
const busSrc=new Set();
|
|
3729
|
+
{
|
|
3730
|
+
const fwd=new Map();
|
|
3731
|
+
for(const e of doc.edges){
|
|
3732
|
+
if(!byId[e.a]||!byId[e.b]||isBack.has(e)||e.a===e.b) continue;
|
|
3733
|
+
if(!fwd.has(e.a)) fwd.set(e.a,[]); fwd.get(e.a).push(e.b);
|
|
3734
|
+
}
|
|
3735
|
+
const reaches=(u,v)=>{ // forward-DAG reachability
|
|
3736
|
+
const seen=new Set([u]), st=[u];
|
|
3737
|
+
while(st.length){ const x=st.pop();
|
|
3738
|
+
for(const y of fwd.get(x)||[]){ if(y===v) return true;
|
|
3739
|
+
if(!seen.has(y)){ seen.add(y); st.push(y); } } }
|
|
3740
|
+
return false;
|
|
3741
|
+
};
|
|
3742
|
+
for(const m of busGroups){
|
|
3743
|
+
const s=m.map(e=>e.a);
|
|
3744
|
+
let stacked=false;
|
|
3745
|
+
for(const a of s) for(const b of s) if(a!==b&&reaches(a,b)) stacked=true;
|
|
3746
|
+
if(!stacked) for(const a of s) busSrc.add(a);
|
|
3747
|
+
}
|
|
3748
|
+
}
|
|
3749
|
+
// A chain node is HELD — it follows its chain neighbour rather than the
|
|
3750
|
+
// average of all of them — unless it is a bus source (above), unless the
|
|
3751
|
+
// neighbour it would follow spreads three or more ways into this rank, or
|
|
3752
|
+
// unless the node itself is where three or more come together (block-a's
|
|
3753
|
+
// Collector, lifted off the middle lane by BK, is the recorded instance of
|
|
3754
|
+
// the latter).
|
|
3755
|
+
const held=(n,dir)=>onChain(n)
|
|
3756
|
+
&&!(n.id&&busSrc.has(n.id))
|
|
3757
|
+
&&realFan(n,dir)<3&&realDeg(n,-dir)<3;
|
|
3758
|
+
// A whole LANE keeps its barycentre recentring if anything in it converges,
|
|
3759
|
+
// even where the chain node itself does not: recentring on the chain node
|
|
3760
|
+
// moves every other member of that lane, and a convergence is read from the
|
|
3761
|
+
// spread of its inputs.
|
|
3762
|
+
const laneConverges=(lane,dir)=>lane.some(n=>!n.virtual&&realDeg(n,-dir)>=3);
|
|
3763
|
+
const place=(lane,des,dir)=>{ // order by desired center, resolve overlaps,
|
|
3150
3764
|
const arr=lane.map(n=>({n,d:des.get(n)})); // recenter the lane
|
|
3151
3765
|
arr.sort((p,q)=>p.d-q.d||p.n.di-q.n.di);
|
|
3152
3766
|
let cEnd=-Infinity;
|
|
@@ -3154,7 +3768,25 @@ function renderScene(doc,y0){
|
|
|
3154
3768
|
x.n.cross=Math.max(x.d-cs(x.n)/2, cEnd);
|
|
3155
3769
|
cEnd=x.n.cross+cs(x.n)+(i<arr.length-1?gapOf(x.n,arr[i+1].n):0);
|
|
3156
3770
|
});
|
|
3157
|
-
|
|
3771
|
+
// Recentre. Normally on the lane's MEAN error, which shares the packing
|
|
3772
|
+
// displacement out over every member — and that is exactly what walks a
|
|
3773
|
+
// chain sideways, since the chain node is one member among many. When the
|
|
3774
|
+
// lane carries the chain (at most one node per rank, by construction) the
|
|
3775
|
+
// lane is recentred on THAT node instead: it lands on its desired position
|
|
3776
|
+
// exactly, its siblings keep the order and spacing the sort gave them, and
|
|
3777
|
+
// the chain is straight by construction rather than by iteration.
|
|
3778
|
+
// Two more lanes keep the mean. A lane holding a PINNED node, because the
|
|
3779
|
+
// pin's coordinate is the author's word and does not move with the lane, so
|
|
3780
|
+
// sliding the lane against it can only put free nodes on a fixed one
|
|
3781
|
+
// (reference/block's `Drop?` diamond landed on the pinned `Rule set` that
|
|
3782
|
+
// way, `novlp 1`). And the chain's LAST lane in the sweep direction, where
|
|
3783
|
+
// there is no next step to keep aligned, so the hold buys no straightness
|
|
3784
|
+
// and only redistributes that lane's other members (annotated-datapath
|
|
3785
|
+
// redrew for no gain until this clause was added).
|
|
3786
|
+
const anc=(lane.some(n=>!n.virtual&&pinned(n.id))||laneConverges(lane,dir))
|
|
3787
|
+
?null:arr.find(x=>held(x.n,dir)&&(dir===1?chainNext:chainPrev).has(x.n));
|
|
3788
|
+
const err=anc?center(anc.n)-anc.d
|
|
3789
|
+
:arr.reduce((s,x)=>s+center(x.n)-x.d,0)/arr.length;
|
|
3158
3790
|
arr.forEach(x=>{ x.n.cross-=err; });
|
|
3159
3791
|
lane.length=0; arr.forEach(x=>lane.push(x.n));
|
|
3160
3792
|
};
|
|
@@ -3169,6 +3801,13 @@ function renderScene(doc,y0){
|
|
|
3169
3801
|
for(const n of lane){
|
|
3170
3802
|
const ref=(dir===1?preds:succs).get(n);
|
|
3171
3803
|
let d=ref&&ref.length ? ref.reduce((s,m)=>s+center(m),0)/ref.length : center(n);
|
|
3804
|
+
// A chain node follows its CHAIN neighbour alone, not the average of
|
|
3805
|
+
// its neighbours: a branch that leaves the chain and rejoins it later
|
|
3806
|
+
// otherwise drags the chain off its own lane, which is the drift this
|
|
3807
|
+
// pass exists to remove. Its other neighbours still order themselves
|
|
3808
|
+
// around it in the sweep below.
|
|
3809
|
+
const cn=(dir===1?chainPrev:chainNext).get(n);
|
|
3810
|
+
if(cn&&held(n,dir)) d=center(cn);
|
|
3172
3811
|
// Waypoint excursion bound (item 17): a multi-rank forward edge's dummy
|
|
3173
3812
|
// vertices may follow the barycenter freely WITHIN the cross-axis band
|
|
3174
3813
|
// their own endpoints span — that is where the ordering that separates
|
|
@@ -3189,7 +3828,7 @@ function renderScene(doc,y0){
|
|
|
3189
3828
|
}
|
|
3190
3829
|
des.set(n,d);
|
|
3191
3830
|
}
|
|
3192
|
-
place(lane,des);
|
|
3831
|
+
place(lane,des,dir);
|
|
3193
3832
|
}
|
|
3194
3833
|
};
|
|
3195
3834
|
sweep(1); sweep(-1); sweep(1);
|
|
@@ -3211,24 +3850,44 @@ function renderScene(doc,y0){
|
|
|
3211
3850
|
if(horiz) n.x=M-n.x-n.w; else n.y=y0+20+(M-(n.y-y0-20))-n.h; }
|
|
3212
3851
|
}
|
|
3213
3852
|
// Two-level coordinates (`PIN-COORDINATE-SCOPE`): a pinned GROUP anchors its local origin in
|
|
3214
|
-
// canvas px; a pinned MEMBER is group-local (relative to that origin);
|
|
3853
|
+
// canvas px; a pinned MEMBER of it is group-local (relative to that origin);
|
|
3215
3854
|
// ungrouped pins are canvas px. Moving a group = editing one pin line.
|
|
3855
|
+
//
|
|
3856
|
+
// A member of an UNPINNED group has NO anchored origin to be relative to, so
|
|
3857
|
+
// its pin is canvas px exactly like an ungrouped node's. This is `LAYOUT-STABILITY` rigidity:
|
|
3858
|
+
// the pin is the author's word and MUST land where written, whether or not
|
|
3859
|
+
// the node is a group member. The prior code derived an unpinned group's
|
|
3860
|
+
// origin from its members' AUTO-LAYOUT extent and then added the member pin
|
|
3861
|
+
// to it, so the pin was neither honoured (it read canvas 400 as origin+400)
|
|
3862
|
+
// nor stable (the origin moved whenever an unrelated edit reshaped the auto
|
|
3863
|
+
// layout — a pinned member drifted 160.9px under a synthetic added edge,
|
|
3864
|
+
// violating `RENDERING-DETERMINISM` stability; task #47). The pin now wins and the group BOX grows
|
|
3865
|
+
// to CONTAIN the member wherever it lands (box is measured from final member
|
|
3866
|
+
// positions below), rather than the member being repositioned to fit the box.
|
|
3216
3867
|
const gOrigin={};
|
|
3868
|
+
// Pass 1: a pinned group anchors its origin in canvas px (`ELEMENT-GEOMETRY-DIRECTIVE`: only a pin
|
|
3869
|
+
// carrying `at=` anchors one). An unpinned group gets no origin here, so its
|
|
3870
|
+
// members fall to the canvas-px branch below.
|
|
3217
3871
|
for(const g of doc.groups){
|
|
3218
3872
|
const p=doc.pins[g.id];
|
|
3219
|
-
|
|
3220
|
-
if(p&&p.fx!==null){ gOrigin[g.id]={x:p.fx, y:y0+20+p.fy}; }
|
|
3221
|
-
else{
|
|
3222
|
-
const mem=nodes.filter(n=>n.group===g.id);
|
|
3223
|
-
if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
|
|
3224
|
-
y:Math.min(...mem.map(n=>n.y))};
|
|
3225
|
-
}
|
|
3873
|
+
if(p&&p.fx!==null) gOrigin[g.id]={x:p.fx, y:y0+20+p.fy};
|
|
3226
3874
|
}
|
|
3875
|
+
// Pass 2: place pinned nodes. A member of a PINNED group is group-local; an
|
|
3876
|
+
// ungrouped node OR a member of an UNPINNED group is canvas px.
|
|
3227
3877
|
for(const n of nodes){ const p=doc.pins[n.id]; if(!p||p.fx===null) continue;
|
|
3228
3878
|
const o=n.group?gOrigin[n.group]:null;
|
|
3229
3879
|
if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
|
|
3230
3880
|
else { n.x=p.fx; n.y=y0+20+p.fy; }
|
|
3231
3881
|
}
|
|
3882
|
+
// Pass 3: an unpinned group has no anchor of its own; its display origin
|
|
3883
|
+
// (drag anchor / data-gx,gy) is the top-left of its members' FINAL positions,
|
|
3884
|
+
// so it reflects any pinned members and matches the group box drawn below.
|
|
3885
|
+
for(const g of doc.groups){
|
|
3886
|
+
if(gOrigin[g.id]) continue;
|
|
3887
|
+
const mem=nodes.filter(n=>n.group===g.id);
|
|
3888
|
+
if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
|
|
3889
|
+
y:Math.min(...mem.map(n=>n.y))};
|
|
3890
|
+
}
|
|
3232
3891
|
// Boundary adjacency in pinned scenes (presentation-only): auto-layout ranks
|
|
3233
3892
|
// a degree-1 boundary relative to the free lanes, so in a scene where the
|
|
3234
3893
|
// real content is pinned to a compact box the boundary can drift to a far
|
|
@@ -3330,6 +3989,27 @@ function renderScene(doc,y0){
|
|
|
3330
3989
|
const B=byId[t], m=g.length;
|
|
3331
3990
|
g.forEach((e,k)=>{ chPlan.get(e).ex=B.x+B.w*(m-k)/(m+1); });
|
|
3332
3991
|
}
|
|
3992
|
+
// RETURN LANES — the other axis. A back edge got a lane in ONE axis and
|
|
3993
|
+
// not the other: each route was handed its own COLUMN out in the channel
|
|
3994
|
+
// and then every route into one target came home along that target's
|
|
3995
|
+
// CENTRE line, so N returns drew as one line. bfd-session put three of
|
|
3996
|
+
// them (452 px, 263 px, 263 px of shared ink) on y=46, and the figure
|
|
3997
|
+
// showed one horizontal stroke with three arrowheads stacked on it.
|
|
3998
|
+
// The entry now fans across the target's border exactly as a ring hub
|
|
3999
|
+
// entry fans across its top, and the ORDER is what keeps the returns from
|
|
4000
|
+
// crossing one another: an outer return has to pass every inner column on
|
|
4001
|
+
// its way in, so it must arrive BEYOND where those columns stop —
|
|
4002
|
+
// innermost ring takes the lane furthest from the channel's turn-in side,
|
|
4003
|
+
// outermost the nearest. The fraction is stored, not the coordinate,
|
|
4004
|
+
// because the three entry forms need it on different edges of the box
|
|
4005
|
+
// (right border, bottom border, detour into the bottom). One back edge
|
|
4006
|
+
// into a target still lands on the centre line (m=1 -> 1/2), so every
|
|
4007
|
+
// figure without a fan-in is byte-unchanged.
|
|
4008
|
+
for(const t in byT){
|
|
4009
|
+
const g=byT[t].filter(e=>!chPlan.get(e).ringOK&&e.a!==e.b);
|
|
4010
|
+
const m=g.length;
|
|
4011
|
+
g.forEach((e,k)=>{ chPlan.get(e).ef=(m-k)/(m+1); });
|
|
4012
|
+
}
|
|
3333
4013
|
// ring return rows run above the top rank; shift the whole scene down
|
|
3334
4014
|
// when they would spill into the title band. The shift is uniform
|
|
3335
4015
|
// (relative geometry, incl. pins, is preserved) and meta.top reports
|
|
@@ -3440,6 +4120,167 @@ function renderScene(doc,y0){
|
|
|
3440
4120
|
const v=chain[1+Math.floor((chain.length-3)/2)];
|
|
3441
4121
|
occR=Math.max(occR, v.x+v.w/2+9+lblPx(e.mid));
|
|
3442
4122
|
});
|
|
4123
|
+
// ── merge bus (item 26 stage 1) ──────────────────────────────────────────
|
|
4124
|
+
// Three or more edges that arrive at the SAME target carrying the SAME
|
|
4125
|
+
// (or no) label are one statement — "all of these go there" — and a drawing
|
|
4126
|
+
// tool draws it once: each source drops to a shared rail, the rail runs to
|
|
4127
|
+
// one trunk, the trunk enters the target with ONE arrowhead and ONE label,
|
|
4128
|
+
// and the joins are marked with junction dots. Drawing three lines to one
|
|
4129
|
+
// box and repeating one label three times is what this removes.
|
|
4130
|
+
//
|
|
4131
|
+
// Each member still emits its OWN full path from its source outline to the
|
|
4132
|
+
// target outline — shape-check asserts exactly that, and `data-edge` carries
|
|
4133
|
+
// one source line — so the shared trunk is stroked once per member. That
|
|
4134
|
+
// coincidence is the convention and not a defect, and the members say so:
|
|
4135
|
+
// every bus path carries `data-bus="<target>"`, which is what lets a reader
|
|
4136
|
+
// (and layout-lint) tell a deliberate trunk from two edges hidden under each
|
|
4137
|
+
// other.
|
|
4138
|
+
//
|
|
4139
|
+
// ── THE FIGURE-LEVEL STYLE DECISION (item 26's unresolved tension) ────────
|
|
4140
|
+
// A bus is axis-aligned by construction, so a figure that takes one has
|
|
4141
|
+
// taken an orthogonal convention. Item 26 records the failure mode: keeping
|
|
4142
|
+
// the incumbent PER EDGE leaves a figure with diagonal and orthogonal routes
|
|
4143
|
+
// mixed, and the mixture itself reads unprofessional (`dhcp-client` was
|
|
4144
|
+
// rejected on exactly that). So the decision is taken ONCE PER FIGURE and it
|
|
4145
|
+
// is ALL-OR-NONE:
|
|
4146
|
+
//
|
|
4147
|
+
// 1. enumerate every eligible group (the topological test above: three or
|
|
4148
|
+
// more forward `->` edges, one target, one label, one stroke and dash,
|
|
4149
|
+
// no endpoint labels, no pinned endpoint, no source an ancestor of
|
|
4150
|
+
// another source);
|
|
4151
|
+
// 2. build and test each one — every leg must clear every node it does not
|
|
4152
|
+
// touch and every group box it does not belong to, the sources must all
|
|
4153
|
+
// lie on one side of the target along the flow axis with room for a
|
|
4154
|
+
// rail, and the bus must not cross more of the figure than the routes
|
|
4155
|
+
// it replaces (item 26's "kept unless strictly beaten", moved from the
|
|
4156
|
+
// edge to the group);
|
|
4157
|
+
// 3. IF ANY ELIGIBLE GROUP FAILS, THE FIGURE ADOPTS NO BUS AT ALL.
|
|
4158
|
+
//
|
|
4159
|
+
// Clause 3 is the whole of the style rule. A figure with one convergence
|
|
4160
|
+
// merged into a trunk and another left as a fan is the mixed drawing; a
|
|
4161
|
+
// figure where every convergence is a trunk, or none is, is one drawing
|
|
4162
|
+
// either way. There is deliberately no per-edge escape.
|
|
4163
|
+
const busRoute=new Map();
|
|
4164
|
+
{
|
|
4165
|
+
const RAIL_GAP=22, RAIL_CLEAR=12, RAIL_ROOM=30;
|
|
4166
|
+
const fLo=n=>horiz?n.x:n.y, fHi=n=>horiz?n.x+n.w:n.y+n.h;
|
|
4167
|
+
const cC =n=>horiz?n.y+n.h/2:n.x+n.w/2;
|
|
4168
|
+
const P=(f,c)=>horiz?[f,c]:[c,f]; // (flow,cross) -> [x,y]
|
|
4169
|
+
const gObs=[];
|
|
4170
|
+
for(const k in gBox){ const b=gBox[k]; gObs.push({x:b.x0,y:b.yA,w:b.x1-b.x0,h:b.yB-b.yA}); }
|
|
4171
|
+
const obsFor=(s,t)=>{
|
|
4172
|
+
const o=nodes.filter(n=>n!==s&&n!==t&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
|
|
4173
|
+
const inG=(b,q)=>q[0]>b.x&&q[0]<b.x+b.w&&q[1]>b.y&&q[1]<b.y+b.h;
|
|
4174
|
+
const ps=[s.x+s.w/2,s.y+s.h/2], pt=[t.x+t.w/2,t.y+t.h/2];
|
|
4175
|
+
for(const b of gObs) if(!inG(b,ps)&&!inG(b,pt)) o.push(b);
|
|
4176
|
+
return o;
|
|
4177
|
+
};
|
|
4178
|
+
// The incumbent a bus is measured against, reconstructed exactly as the
|
|
4179
|
+
// edge loop would draw it in THIS layout: a multi-rank edge follows its
|
|
4180
|
+
// waypoint chain, everything else is the straight border-to-border line.
|
|
4181
|
+
// Placement is untouched by the bus, so this is a like-for-like comparison
|
|
4182
|
+
// inside one drawing — not a comparison across two layouts, which is the
|
|
4183
|
+
// mistake item 27 was rejected for.
|
|
4184
|
+
const incumbent=e=>{
|
|
4185
|
+
const A=byId[e.a], B=byId[e.b], ch=chains.get(e);
|
|
4186
|
+
const pp=[];
|
|
4187
|
+
if(ch) for(const v of ch.slice(1,-1)) pp.push([v.x+v.w/2,v.y+v.h/2]);
|
|
4188
|
+
const first=pp.length?pp[0]:[B.x+B.w/2,B.y+B.h/2];
|
|
4189
|
+
const last =pp.length?pp[pp.length-1]:[A.x+A.w/2,A.y+A.h/2];
|
|
4190
|
+
return [borderPoint(A,first[0],first[1]),...pp,borderPoint(B,last[0],last[1])];
|
|
4191
|
+
};
|
|
4192
|
+
// crossing count of a polyline against the rest of the figure's incumbent
|
|
4193
|
+
// geometry — the term item 26's score weights highest, and the only one on
|
|
4194
|
+
// which "never worse" is worth promising for a construct whose whole point
|
|
4195
|
+
// is to share ink.
|
|
4196
|
+
const busMem=new Set(); for(const m of busGroups) for(const e of m) busMem.add(e);
|
|
4197
|
+
const others=[];
|
|
4198
|
+
for(const e of edges){
|
|
4199
|
+
if(!byId[e.a]||!byId[e.b]||e.a===e.b) continue;
|
|
4200
|
+
if(busMem.has(e)) continue;
|
|
4201
|
+
if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)) continue; // channel routes: not reconstructible here
|
|
4202
|
+
others.push(incumbent(e));
|
|
4203
|
+
}
|
|
4204
|
+
const xseg=(a,b,c,d)=>{
|
|
4205
|
+
const rx=b[0]-a[0], ry=b[1]-a[1], sx=d[0]-c[0], sy=d[1]-c[1];
|
|
4206
|
+
const den=rx*sy-ry*sx; if(Math.abs(den)<1e-9) return false;
|
|
4207
|
+
const t=((c[0]-a[0])*sy-(c[1]-a[1])*sx)/den, u=((c[0]-a[0])*ry-(c[1]-a[1])*rx)/den;
|
|
4208
|
+
return t>1e-6&&t<1-1e-6&&u>1e-6&&u<1-1e-6;
|
|
4209
|
+
};
|
|
4210
|
+
// ...and a route that pierces a box counts the same as a crossing, because
|
|
4211
|
+
// a short crossing-free line that goes straight through a node is not a
|
|
4212
|
+
// better drawing than a long one that goes round it.
|
|
4213
|
+
const pierceCount=(rts,mem)=>{
|
|
4214
|
+
let n=0;
|
|
4215
|
+
rts.forEach((r,i)=>{
|
|
4216
|
+
const A=byId[mem[i].a], B=byId[mem[i].b];
|
|
4217
|
+
const obs=obsFor(A,B);
|
|
4218
|
+
for(let k=0;k+1<r.length;k++) if(segHitsObs(r[k],r[k+1],obs)){ n++; break; }
|
|
4219
|
+
});
|
|
4220
|
+
return n;
|
|
4221
|
+
};
|
|
4222
|
+
const crossCount=rts=>{
|
|
4223
|
+
let n=0;
|
|
4224
|
+
const pairs=rts.map(r=>r).concat(others);
|
|
4225
|
+
for(let i=0;i<rts.length;i++) for(let j=0;j<pairs.length;j++){
|
|
4226
|
+
if(pairs[j]===rts[i]) continue;
|
|
4227
|
+
if(j<rts.length&&j<i) continue; // count each member pair once
|
|
4228
|
+
for(let a=0;a+1<rts[i].length;a++) for(let b=0;b+1<pairs[j].length;b++)
|
|
4229
|
+
if(xseg(rts[i][a],rts[i][a+1],pairs[j][b],pairs[j][b+1])) n++;
|
|
4230
|
+
}
|
|
4231
|
+
return n;
|
|
4232
|
+
};
|
|
4233
|
+
const built=[];
|
|
4234
|
+
let figureOK=busGroups.length>0;
|
|
4235
|
+
for(const mem of busGroups){
|
|
4236
|
+
if(!figureOK) break;
|
|
4237
|
+
const T=byId[mem[0].b], src=mem.map(e=>byId[e.a]);
|
|
4238
|
+
let dir=0;
|
|
4239
|
+
if(src.every(s=>fLo(T)-fHi(s)>=RAIL_ROOM)) dir=1;
|
|
4240
|
+
else if(src.every(s=>fLo(s)-fHi(T)>=RAIL_ROOM)) dir=-1;
|
|
4241
|
+
else { figureOK=false; break; } // no room for a rail
|
|
4242
|
+
const railF=dir>0
|
|
4243
|
+
? Math.min(fLo(T)-RAIL_CLEAR, Math.max(fLo(T)-RAIL_GAP, Math.max(...src.map(fHi))+RAIL_CLEAR))
|
|
4244
|
+
: Math.max(fHi(T)+RAIL_CLEAR, Math.min(fHi(T)+RAIL_GAP, Math.min(...src.map(fLo))-RAIL_CLEAR));
|
|
4245
|
+
const tc=cC(T);
|
|
4246
|
+
const cand=[]; let ok=true;
|
|
4247
|
+
for(const e of mem){
|
|
4248
|
+
const s=byId[e.a], cs=cC(s);
|
|
4249
|
+
const j=P(railF,cs), h=P(railF,tc);
|
|
4250
|
+
const pts=Math.abs(cs-tc)<0.5
|
|
4251
|
+
? [borderPoint(s,h[0],h[1]), h, borderPoint(T,h[0],h[1])]
|
|
4252
|
+
: [borderPoint(s,j[0],j[1]), j, h, borderPoint(T,h[0],h[1])];
|
|
4253
|
+
const obs=obsFor(s,T);
|
|
4254
|
+
for(let i=0;i+1<pts.length;i++) if(segHitsObs(pts[i],pts[i+1],obs)) ok=false;
|
|
4255
|
+
if(!ok) break;
|
|
4256
|
+
cand.push({e,cs,pts});
|
|
4257
|
+
}
|
|
4258
|
+
if(!ok){ figureOK=false; break; } // a leg pierces something
|
|
4259
|
+
const bpts=cand.map(c=>c.pts), ipts=mem.map(incumbent);
|
|
4260
|
+
const bc=crossCount(bpts)+pierceCount(bpts,mem);
|
|
4261
|
+
const ic=crossCount(ipts)+pierceCount(ipts,mem);
|
|
4262
|
+
if(bc>ic){
|
|
4263
|
+
figureOK=false; break; // not beaten: keep the incumbents
|
|
4264
|
+
}
|
|
4265
|
+
// junction dots mark the interior joins only: the two ends of the rail
|
|
4266
|
+
// are corners, not junctions, and a dot on a corner is wrong.
|
|
4267
|
+
const xs=cand.map(c=>c.cs).concat([tc]);
|
|
4268
|
+
const cLo=Math.min(...xs), cHi=Math.max(...xs);
|
|
4269
|
+
const dots=[];
|
|
4270
|
+
for(const c of cand) if(c.cs>cLo+0.5&&c.cs<cHi-0.5) dots.push(P(railF,c.cs));
|
|
4271
|
+
if(tc>cLo+0.5&&tc<cHi-0.5&&!dots.some(d=>Math.abs(d[horiz?1:0]-tc)<0.5)) dots.push(P(railF,tc));
|
|
4272
|
+
// one label and one arrowhead for the whole bus: the member whose rail
|
|
4273
|
+
// run is longest carries the label, document order breaks the tie.
|
|
4274
|
+
let lead=cand[0], best=-1;
|
|
4275
|
+
for(const c of cand){ const d=Math.abs(c.cs-tc); if(d>best+0.5){ best=d; lead=c; } }
|
|
4276
|
+
built.push({T,cand,dots,lead});
|
|
4277
|
+
}
|
|
4278
|
+
// Nothing to undo when the figure declines: the bus is a routing pass and
|
|
4279
|
+
// the layout it declines is the layout it already had.
|
|
4280
|
+
if(figureOK) for(const g of built)
|
|
4281
|
+
g.cand.forEach((c,i)=>busRoute.set(c.e,{pts:c.pts,bus:g.T.id,lead:c===g.lead,
|
|
4282
|
+
dots:i===g.cand.length-1?g.dots:null, arrow:i===g.cand.length-1}));
|
|
4283
|
+
}
|
|
3443
4284
|
for(const e of edges){
|
|
3444
4285
|
const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
|
|
3445
4286
|
// an edge is pure stroke: `stroke=` and `fill=` name the same channel
|
|
@@ -3459,8 +4300,31 @@ function renderScene(doc,y0){
|
|
|
3459
4300
|
const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
|
|
3460
4301
|
const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
|
|
3461
4302
|
const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
|
|
4303
|
+
const bus=busRoute.get(e);
|
|
4304
|
+
if(bus){
|
|
4305
|
+
const pts=bus.pts;
|
|
4306
|
+
// data-bus is written LAST so every reader that keys on the
|
|
4307
|
+
// `d=… fill=none stroke=… stroke-width=1.6` prefix is unaffected.
|
|
4308
|
+
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+' data-bus="'+esc(bus.bus)+'"/>');
|
|
4309
|
+
noteSegs(e,pts);
|
|
4310
|
+
for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
|
|
4311
|
+
if(bus.dots) for(const d of bus.dots)
|
|
4312
|
+
lblsvg.push('<circle cx="'+d[0]+'" cy="'+d[1]+'" r="3" fill="'+col+'" stroke="none"/>');
|
|
4313
|
+
// the trunk is drawn once by every member; the label and the arrowhead
|
|
4314
|
+
// are drawn ONCE for the bus, which is the whole point of merging it.
|
|
4315
|
+
if(bus.lead&&e.mid){ // longest rail run carries the one label
|
|
4316
|
+
let bi=0,bl=-1;
|
|
4317
|
+
for(let i=0;i+1<pts.length;i++){
|
|
4318
|
+
const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
|
|
4319
|
+
if(l>bl){ bl=l; bi=i; }
|
|
4320
|
+
}
|
|
4321
|
+
reqLabel({p:pts[bi],q:pts[bi+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:bi===0});
|
|
4322
|
+
}
|
|
4323
|
+
if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
|
|
4324
|
+
continue;
|
|
4325
|
+
}
|
|
3462
4326
|
if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
|
|
3463
|
-
// ── ROUTING-CHANGE ARCHITECTURE NOTE ──────────
|
|
4327
|
+
// ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
|
|
3464
4328
|
// Edge labels are DEFERRED: every label is registered against its
|
|
3465
4329
|
// FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
|
|
3466
4330
|
// greedy pass after all edges are drawn; arrowheads are computed from
|
|
@@ -3472,10 +4336,36 @@ function renderScene(doc,y0){
|
|
|
3472
4336
|
// arrowheads, orphaned labels): that was external splicing, not an
|
|
3473
4337
|
// engine gap. Patch routing here; do not "fix" the label machinery.
|
|
3474
4338
|
if(A===B){
|
|
3475
|
-
// Self-transition: a small side loop on the node, the
|
|
4339
|
+
// Self-transition (`SELF-EDGE-DRAWING`): a small side loop on the node, the
|
|
3476
4340
|
// convention of every drawing tool — never a lap of the figure
|
|
3477
4341
|
// through the back-edge channel. Side order r,l,b,t; first side
|
|
3478
4342
|
// whose loop box overlaps no other node wins (deterministic).
|
|
4343
|
+
//
|
|
4344
|
+
// THE LOOP AND THE CHANNEL SHARE THIS SIDE, AND THAT IS A KNOWN,
|
|
4345
|
+
// MEASURED, UNFIXED DEFECT. A loop hangs off one side of the box on
|
|
4346
|
+
// the box's MID line; a channel back edge leaves and enters on the
|
|
4347
|
+
// SAME side (right under vertical flow, bottom under horizontal) at
|
|
4348
|
+
// rows near that same mid line — so a state that both loops and takes
|
|
4349
|
+
// a channel route has a line drawn across a 20 px ornament. It is
|
|
4350
|
+
// CROSSING, not shared ink: measured over the whole corpus, no
|
|
4351
|
+
// self-loop shares more than 0 px of collinear ink with anything.
|
|
4352
|
+
// bfd-session is the only figure where it bites (turnstile's two loops
|
|
4353
|
+
// are clean), and there it is 12 crossings over four loops.
|
|
4354
|
+
//
|
|
4355
|
+
// THE OBVIOUS FIX WAS BUILT AND REJECTED, so it is not re-attempted
|
|
4356
|
+
// blind: treat the channel side as occupied and take the next free
|
|
4357
|
+
// side. bfd-session's crossings fall 15 -> 4 and every loop comes
|
|
4358
|
+
// clean — but DOWN, INIT and UP have only 'l' free (their 'b' and 't'
|
|
4359
|
+
// boxes sit on the spine, which loopHit does not test), and the left
|
|
4360
|
+
// of a scene is only PADL=18 px wide. Their three trigger labels were
|
|
4361
|
+
// placed at x = -102.6, -73.4 and -57.1 and CLIPPED OFF THE CANVAS —
|
|
4362
|
+
// three labels lost to buy eleven crossings, which is the wrong trade
|
|
4363
|
+
// in the direction label placement has been moving all week.
|
|
4364
|
+
// WHAT WOULD REOPEN IT: a left-margin mechanism for the scene (the
|
|
4365
|
+
// uniform-shift pattern bShift/chShift already use, applied before the
|
|
4366
|
+
// label pass), so a loop and its label can hang off the left at all.
|
|
4367
|
+
// Until then the loop stays on the channel side and the crossing is
|
|
4368
|
+
// recorded rather than papered over.
|
|
3479
4369
|
const scy=A.y+A.h/2, scx=A.x+A.w/2;
|
|
3480
4370
|
const mkLoop=sd=>sd==='r'?[[A.x+A.w,scy-8],[A.x+A.w+20,scy-8],[A.x+A.w+20,scy+8],[A.x+A.w,scy+8]]
|
|
3481
4371
|
:sd==='l'?[[A.x,scy-8],[A.x-20,scy-8],[A.x-20,scy+8],[A.x,scy+8]]
|
|
@@ -3492,7 +4382,15 @@ function renderScene(doc,y0){
|
|
|
3492
4382
|
for(const p of sp){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
|
|
3493
4383
|
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(sp)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
3494
4384
|
noteSegs(e,sp);
|
|
3495
|
-
|
|
4385
|
+
// A self-loop's outer run is 16 px long, so sliding the label ALONG it
|
|
4386
|
+
// buys ~15 px and no escape at all from a line crossing it — and a
|
|
4387
|
+
// back edge leaves the same node on the same side at the same mid-y,
|
|
4388
|
+
// which is how bfd-session drew three self-loop labels with a line
|
|
4389
|
+
// through them. Parameters outside [0,1] are offered too: they park the
|
|
4390
|
+
// box just above or just below the loop, still hard against it, which
|
|
4391
|
+
// is a placement a reader still reads as belonging to the loop.
|
|
4392
|
+
if(e.mid) reqLabel({p:sp[1],q:sp[2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false,
|
|
4393
|
+
ts:[0.5,0.2,0.8,-0.7,1.7,-1.4,2.4],tw:10});
|
|
3496
4394
|
if(e.tail) seg(sp[0],sp[1],0.5,e.tail,10);
|
|
3497
4395
|
if(e.head) seg(sp[3],sp[2],0.5,e.head,10);
|
|
3498
4396
|
if(wantsStart) arrowTri(sp[0],sp[1],col);
|
|
@@ -3508,47 +4406,68 @@ function renderScene(doc,y0){
|
|
|
3508
4406
|
const lane=r=>(ranksArr[r]||[]).filter(n=>!n.virtual);
|
|
3509
4407
|
const P=chPlan.get(e), ring=P.ring;
|
|
3510
4408
|
const pts=[];
|
|
4409
|
+
// WHERE A BACK-EDGE LABEL GOES. It used to be registered on
|
|
4410
|
+
// the CHANNEL run — the long leg out in the side channel, past every node
|
|
4411
|
+
// in the figure. That is the furthest point on the route from either
|
|
4412
|
+
// endpoint, and every back edge's channel run is in the same channel, so
|
|
4413
|
+
// the labels landed in one column with nothing but proximity to say which
|
|
4414
|
+
// line each named (bfd-session parked three of them around x=1100 while
|
|
4415
|
+
// its four states occupied x 57-200). The label now rides the first
|
|
4416
|
+
// stretch of the route AS IT LEAVES THE SOURCE, where the reader can see
|
|
4417
|
+
// which box the line comes out of. The stub is capped so the candidate
|
|
4418
|
+
// parameters land the box beside the source rather than halfway to the
|
|
4419
|
+
// channel; a shorter first leg just uses all of itself. The cap has to
|
|
4420
|
+
// scale with the LABEL, not be a constant: at the middle of a stub the
|
|
4421
|
+
// box spans the midpoint plus and minus half its width, so a stub
|
|
4422
|
+
// shorter than the label puts the box back on top of the source box
|
|
4423
|
+
// whatever parameter is chosen (bfd-session's "Detect expired, Echo
|
|
4424
|
+
// failed" is 169 px wide and a fixed 64 px stub buried it in INIT).
|
|
4425
|
+
const srcStub=(pp,wpx)=>{
|
|
4426
|
+
const a=pp[0], b=pp[1], L=Math.hypot(b[0]-a[0],b[1]-a[1])||1;
|
|
4427
|
+
const k=Math.min(1,Math.max(64,wpx+24)/L);
|
|
4428
|
+
return [a,[a[0]+(b[0]-a[0])*k, a[1]+(b[1]-a[1])*k]];
|
|
4429
|
+
};
|
|
3511
4430
|
if(horiz){ // channel runs below the lanes
|
|
3512
4431
|
const chY=occB+28+P.slot; // labels ride ON the channel
|
|
3513
4432
|
const colR=r=>Math.max(...lane(r).map(n=>n.x+n.w));
|
|
3514
4433
|
const blockedV=(y1,y2,xx,skip)=>nodes.some(n=>n!==skip&&!n.boundary&&n.x<xx&&n.x+n.w>xx&&n.y+n.h>y1&&n.y<y2);
|
|
3515
|
-
const sx=A===B?A.x+A.w*0.3:A.x+A.w/2, tx=A===B?B.x+B.w*0.7:B.x+B.w
|
|
4434
|
+
const sx=A===B?A.x+A.w*0.3:A.x+A.w/2, tx=A===B?B.x+B.w*0.7:B.x+B.w*P.ef;
|
|
3516
4435
|
if(A!==B&&blockedV(A.y+A.h,chY,sx,A)){
|
|
3517
4436
|
const gx=colR(A.rank)+10+ring*7;
|
|
3518
4437
|
pts.push([outSide(A,'r'),A.y+A.h/2],[gx,A.y+A.h/2],[gx,chY]);
|
|
3519
4438
|
} else pts.push([sx,outSide(A,'b')],[sx,chY]);
|
|
3520
4439
|
if(A!==B&&blockedV(B.y+B.h,chY,tx,B)){
|
|
3521
4440
|
const gx=colR(B.rank)+10+ring*7;
|
|
3522
|
-
pts.push([gx,chY],[gx,B.y+B.h
|
|
4441
|
+
pts.push([gx,chY],[gx,B.y+B.h*P.ef],[outSide(B,'r'),B.y+B.h*P.ef]);
|
|
3523
4442
|
} else pts.push([tx,chY],[tx,outSide(B,'b')]);
|
|
3524
4443
|
if(e.mid){
|
|
3525
|
-
const c1=pts.findIndex(p=>p[1]===chY);
|
|
3526
|
-
reqLabel({p:
|
|
4444
|
+
const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[1]===chY);
|
|
4445
|
+
reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
|
|
3527
4446
|
}
|
|
3528
4447
|
} else if(P.ringOK){ // concentric ring: under, around, over, in
|
|
3529
4448
|
const sx=A.x+A.w/2;
|
|
3530
4449
|
const gy=occB+14+ring*12, chX=occR+28+P.slot, topY=chTop-14-ring*12;
|
|
3531
4450
|
pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
|
|
3532
4451
|
if(e.mid){
|
|
3533
|
-
const c1=pts.findIndex(p=>p[0]===chX);
|
|
3534
|
-
reqLabel({p:
|
|
4452
|
+
const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
|
|
4453
|
+
reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
|
|
3535
4454
|
}
|
|
3536
4455
|
} else { // channel runs right of the lanes
|
|
3537
4456
|
const chX=occR+28+P.slot;
|
|
3538
4457
|
const laneB=r=>Math.max(...lane(r).map(n=>n.y+n.h));
|
|
3539
4458
|
const blockedH=(x1,x2,yy,skip)=>nodes.some(n=>n!==skip&&!n.boundary&&n.y<yy&&n.y+n.h>yy&&n.x+n.w>x1&&n.x<x2);
|
|
3540
|
-
const sy=A===B?A.y+A.h*0.3:A.y+A.h/2, ty=A===B?B.y+B.h*0.7:B.y+B.h
|
|
4459
|
+
const sy=A===B?A.y+A.h*0.3:A.y+A.h/2, ty=A===B?B.y+B.h*0.7:B.y+B.h*P.ef;
|
|
3541
4460
|
if(A!==B&&blockedH(A.x+A.w,chX,sy,A)){
|
|
3542
4461
|
const gy=laneB(A.rank)+10+ring*7;
|
|
3543
4462
|
pts.push([A.x+A.w/2,outSide(A,'b')],[A.x+A.w/2,gy],[chX,gy]);
|
|
3544
4463
|
} else pts.push([outSide(A,'r'),sy],[chX,sy]);
|
|
3545
4464
|
if(A!==B&&blockedH(B.x+B.w,chX,ty,B)){
|
|
3546
4465
|
const gy=laneB(B.rank)+10+ring*7;
|
|
3547
|
-
pts.push([chX,gy],[B.x+B.w
|
|
4466
|
+
pts.push([chX,gy],[B.x+B.w*P.ef,gy],[B.x+B.w*P.ef,outSide(B,'b')]);
|
|
3548
4467
|
} else pts.push([chX,ty],[outSide(B,'r'),ty]);
|
|
3549
4468
|
if(e.mid){
|
|
3550
|
-
const c1=pts.findIndex(p=>p[0]===chX);
|
|
3551
|
-
reqLabel({p:
|
|
4469
|
+
const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
|
|
4470
|
+
reqLabel({p:ss[0],q:ss[1],alt:[pts[c1],pts[c1+1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
|
|
3552
4471
|
}
|
|
3553
4472
|
}
|
|
3554
4473
|
// non-incident nodes are obstacles for the channel runs too: a run
|
|
@@ -3625,7 +4544,7 @@ function renderScene(doc,y0){
|
|
|
3625
4544
|
// their neighbours turns a 40-point staircase into the 2–4 bends a
|
|
3626
4545
|
// dummy-vertex chain should have, without moving the drawn line.
|
|
3627
4546
|
simplifyPts(pts);
|
|
3628
|
-
// Waypoint prune: after collinear simplification a
|
|
4547
|
+
// Waypoint prune (`EDGE-BEND-RETENTION`): after collinear simplification a
|
|
3629
4548
|
// chain can still carry a staircase of near-collinear jogs — the drift
|
|
3630
4549
|
// clamp allows only a few px of sideways movement per rank, so a run
|
|
3631
4550
|
// that wants to move 35px sideways alternates short diagonals and
|
|
@@ -3737,8 +4656,29 @@ function renderScene(doc,y0){
|
|
|
3737
4656
|
// the arrowheads, and the other edges — plus a pull back toward the
|
|
3738
4657
|
// preferred point on the segment. The lowest score wins. No randomness,
|
|
3739
4658
|
// no iteration to a fixed point: one deterministic pass.
|
|
3740
|
-
if(lblReq.length)
|
|
4659
|
+
// `DRAWN-ANNOTATION-FORM`: this block used to be guarded by `if(lblReq.length)`,
|
|
4660
|
+
// with `obst`, `ovl`, `segHit` and `placed` local to it. The note pass below
|
|
4661
|
+
// is a SECOND claimant on exactly that machinery and must see exactly the
|
|
4662
|
+
// same `placed` list — a note that did not know where the edge labels went
|
|
4663
|
+
// could not yield to them, which is the first of the four placement rules.
|
|
4664
|
+
// So the scaffolding is hoisted and only the LOOP keeps the guard.
|
|
4665
|
+
{
|
|
3741
4666
|
const obst=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h,n}));
|
|
4667
|
+
// An `external` is never DRAWN as a shape, so its 12x12 anchor is not ink
|
|
4668
|
+
// and is rightly excluded above — but its LABEL is ink, and this pass could
|
|
4669
|
+
// not see it. arp-resolution put a 234 px edge label straight through
|
|
4670
|
+
// "rest of the LAN / (hosts C, D, ...)". The label box is added here with
|
|
4671
|
+
// the same geometry the node pass below emits it at, so the obstacle and
|
|
4672
|
+
// the drawing cannot disagree.
|
|
4673
|
+
for(const n of nodes){
|
|
4674
|
+
if(!n.boundary||!n.label) continue;
|
|
4675
|
+
const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
|
|
4676
|
+
const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
|
|
4677
|
+
let ox,oy;
|
|
4678
|
+
if(Math.abs(bdx)>=Math.abs(bdy)){ ox=bdx>=0?cx+10:cx-10-bw; oy=cy+3.5-13*bl/2-1.5; }
|
|
4679
|
+
else { ox=cx-bw/2; oy=(bdy>=0?cy+17:cy-10)-13*bl/2-1.5; }
|
|
4680
|
+
obst.push({x:ox,y:oy,w:bw,h:bh,n:null});
|
|
4681
|
+
}
|
|
3742
4682
|
const ovl=(a,b)=>{
|
|
3743
4683
|
const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
|
|
3744
4684
|
const iy=Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y);
|
|
@@ -3759,26 +4699,54 @@ function renderScene(doc,y0){
|
|
|
3759
4699
|
return t1>t0;
|
|
3760
4700
|
};
|
|
3761
4701
|
const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
|
|
3762
|
-
|
|
4702
|
+
// SLOPE CLEARANCE (`cl`): "3 px above the line" clears the line only where
|
|
4703
|
+
// the box touches it. The offsets are axis-aligned while the segment is
|
|
4704
|
+
// not, so on a diagonal the line keeps climbing across the box's WIDTH and
|
|
4705
|
+
// re-enters it — which is why a label could sit squarely across its own
|
|
4706
|
+
// edge and the drawing showed a strikethrough. Over half a box the line
|
|
4707
|
+
// rises |dy/dx|*w/2, so that much extra offset is exactly what puts the
|
|
4708
|
+
// whole box on one side of the line. It is offered as a SECOND candidate
|
|
4709
|
+
// per side (cl=1) rather than imposed, priced per pixel of displacement
|
|
4710
|
+
// below: a label 7 px further out to stop being struck is worth it, a
|
|
4711
|
+
// 90 px shove for a long label on a 45 degree line is not, and the scorer
|
|
4712
|
+
// decides which case it is holding.
|
|
4713
|
+
const cand=(r,t,side,cl)=>{
|
|
3763
4714
|
const lines=String(r.text).split('\n'), n=lines.length;
|
|
3764
4715
|
const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
|
|
3765
4716
|
const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
|
|
3766
4717
|
const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
|
|
3767
4718
|
const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
|
|
4719
|
+
const sdx=Math.abs(r.q[0]-r.p[0]), sdy=Math.abs(r.q[1]-r.p[1]);
|
|
4720
|
+
let ex=0;
|
|
4721
|
+
if(cl){
|
|
4722
|
+
if(side==='above'||side==='below') ex=sdx>1e-9?Math.min(1,sdy/sdx)*w/2:0;
|
|
4723
|
+
else if(side==='right'||side==='left') ex=sdy>1e-9?Math.min(1,sdx/sdy)*h/2:0;
|
|
4724
|
+
}
|
|
3768
4725
|
let bx,by,x,anchor=n>1?'middle':'start';
|
|
3769
4726
|
if(side==='on') { bx=mx-w/2; by=my-4-up; anchor='middle'; }
|
|
3770
|
-
else if(side==='above') { bx=mx-w/2; by=my-3-h;
|
|
3771
|
-
else if(side==='below') { bx=mx-w/2; by=my+3;
|
|
3772
|
-
else if(side==='right') { bx=mx+6;
|
|
3773
|
-
else { bx=mx-6-w; by=my-h/2; }
|
|
4727
|
+
else if(side==='above') { bx=mx-w/2; by=my-3-h-ex; anchor='middle'; }
|
|
4728
|
+
else if(side==='below') { bx=mx-w/2; by=my+3+ex; anchor='middle'; }
|
|
4729
|
+
else if(side==='right') { bx=mx+6+ex; by=my-h/2; }
|
|
4730
|
+
else { bx=mx-6-w-ex; by=my-h/2; }
|
|
3774
4731
|
x=anchor==='middle'?bx+w/2:bx;
|
|
3775
|
-
return {x,y:by+up,anchor,t,side,box:{x:bx,y:by,w,h}};
|
|
4732
|
+
return {x,y:by+up,anchor,t,side,ex,box:{x:bx,y:by,w,h}};
|
|
3776
4733
|
};
|
|
3777
4734
|
const placed=[];
|
|
3778
|
-
|
|
4735
|
+
// A request may name a SECOND carrying segment (`alt`). Back edges do: the
|
|
4736
|
+
// stub leaving the source is the preferred carrier because it says which
|
|
4737
|
+
// box the line comes out of, but on a figure where two edges leave the same
|
|
4738
|
+
// node the stub can only put the label where an earlier one already sits
|
|
4739
|
+
// (flowchart-b drew "no" twice, one under the other, and neither said which
|
|
4740
|
+
// line it named). The alternate carrier — the channel run — is offered at a
|
|
4741
|
+
// flat surcharge so it is taken only when the stub really has nowhere.
|
|
4742
|
+
if(lblReq.length) for(const r0 of lblReq){
|
|
4743
|
+
const carriers=[[r0.p,r0.q]].concat(r0.alt?[r0.alt]:[]);
|
|
4744
|
+
let best=null,bestS=Infinity;
|
|
4745
|
+
for(let ci=0;ci<carriers.length;ci++){
|
|
4746
|
+
const r=ci?Object.assign({},r0,{p:carriers[ci][0],q:carriers[ci][1]}):r0;
|
|
3779
4747
|
const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
|
|
3780
4748
|
const across=Math.abs(dx)>=Math.abs(dy);
|
|
3781
|
-
let sides, ts, tPref;
|
|
4749
|
+
let sides, ts, tPref, apWant=null;
|
|
3782
4750
|
if(r.kind==='end'){
|
|
3783
4751
|
// endpoint labels keep their historical spot as first choice
|
|
3784
4752
|
sides=['on'].concat(across?['above','below']:['right','left']);
|
|
@@ -3786,6 +4754,30 @@ function renderScene(doc,y0){
|
|
|
3786
4754
|
ts=[r.t0,r.t0-0.06,r.t0+0.06,r.t0-0.12,r.t0+0.12].map(CLAMP);
|
|
3787
4755
|
} else {
|
|
3788
4756
|
sides=across?['above','below']:['right','left'];
|
|
4757
|
+
// ANTI-PARALLEL PAIRS: the label belongs on the OUTSIDE of its own
|
|
4758
|
+
// stroke. `apOff` moved the two strokes of an A->B / B->A pair to
|
|
4759
|
+
// opposite sides of the pair's centre line so they stop coinciding —
|
|
4760
|
+
// "so opposite directions land on opposite sides" — and the label
|
|
4761
|
+
// rides its own offset segment. But which SIDE of that segment the
|
|
4762
|
+
// text lands on was decided here, independently, by score, and the two
|
|
4763
|
+
// strokes are only 7 px apart, so the two candidate sets are nearly
|
|
4764
|
+
// identical. Both labels took the same side and the pair drew as two
|
|
4765
|
+
// lines of text stacked 1.6 px apart (tcp-state-machine: "passive OPEN
|
|
4766
|
+
// / create TCB" directly over "CLOSE / delete TCB", 117 px of shared
|
|
4767
|
+
// width, one of them lying across the partner's stroke).
|
|
4768
|
+
// The offset vector IS the index that decided which side the stroke
|
|
4769
|
+
// took, so `apWant` is read straight off it. It is not merely ORDERED
|
|
4770
|
+
// first: measured on that pair, the outside candidate cost 52 and the
|
|
4771
|
+
// stacked one 36, because a stack that does not actually OVERLAP costs
|
|
4772
|
+
// the scorer NOTHING while the outside position crossed one edge (26).
|
|
4773
|
+
// Ordering is worth 10 and could not move it. The wrong side is
|
|
4774
|
+
// therefore PRICED, in the band the identical-text term already uses
|
|
4775
|
+
// (34): an anti-parallel pair is exactly two lines a reader must tell
|
|
4776
|
+
// apart, and a label on the inside of its own stroke — between the two,
|
|
4777
|
+
// or beyond the partner — has stopped saying which one it names, which
|
|
4778
|
+
// is the same defect that term exists to charge for.
|
|
4779
|
+
const apv=apOff.get(r.e);
|
|
4780
|
+
apWant=apv?(across?(apv[1]<0?'above':'below'):(apv[0]<0?'left':'right')):null;
|
|
3789
4781
|
// flowchart convention: a short branch marker leaving a decision node
|
|
3790
4782
|
// reads as that branch's name only if it sits next to the decision.
|
|
3791
4783
|
// `FLOWCHART-ROLE-KEYWORDS`: the test is the ROLE, not the geometry. Until
|
|
@@ -3796,25 +4788,184 @@ function renderScene(doc,y0){
|
|
|
3796
4788
|
const branch=r.first && r.A && r.A.role==='decision' &&
|
|
3797
4789
|
String(r.text).length<=3 && !String(r.text).includes('\n');
|
|
3798
4790
|
tPref=branch?0.22:0.5;
|
|
3799
|
-
ts=branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72];
|
|
4791
|
+
ts=r.ts?r.ts:(branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72]);
|
|
3800
4792
|
}
|
|
3801
|
-
let
|
|
3802
|
-
|
|
3803
|
-
const c=cand(r,t,sides[si]);
|
|
4793
|
+
for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]){
|
|
4794
|
+
const c=cand(r,t,sides[si],cl);
|
|
3804
4795
|
let s=0;
|
|
3805
4796
|
for(const b of placed) s+=3*ovl(c.box,b);
|
|
3806
4797
|
for(const o of obst) s+=(o.n===r.A||o.n===r.B?6:2.4)*ovl(c.box,o);
|
|
3807
4798
|
for(const a of arrowBox) s+=4*ovl(c.box,a);
|
|
3808
|
-
|
|
3809
|
-
|
|
4799
|
+
// The label's OWN edge is charged like any other. It used to be exempt
|
|
4800
|
+
// (`g.e!==r.e`), which made a label lying across the line it names FREE
|
|
4801
|
+
// — and that is the single commonest way a label stops saying which
|
|
4802
|
+
// line it belongs to, so the exemption was paying for the defect.
|
|
4803
|
+
for(const g of edgeSegs) if(segHit(g.p,g.q,c.box)) s+=26;
|
|
4804
|
+
s+=0.35*c.ex; // price of the slope-clearance displacement
|
|
4805
|
+
s+=ci*30; // price of leaving the preferred carrier
|
|
4806
|
+
// Two identical texts sitting side by side is the defect in its purest
|
|
4807
|
+
// form: neither of them says which line it belongs to, and no overlap
|
|
4808
|
+
// test can see it because they do not overlap.
|
|
4809
|
+
for(const b of placed) if(b.text===r.text &&
|
|
4810
|
+
Math.hypot(b.x+b.w/2-c.box.x-c.box.w/2, b.y+b.h/2-c.box.y-c.box.h/2)<64) s+=34;
|
|
4811
|
+
// the inside of an anti-parallel pair — see `apWant` above
|
|
4812
|
+
if(apWant&&c.side!==apWant) s+=34;
|
|
4813
|
+
// The pull back toward the preferred point is priced in PARAMETER
|
|
4814
|
+
// units, so the same number means 70/L per pixel: cheap along a 900 px
|
|
4815
|
+
// channel leg, ruinous along a 16 px self-loop run. A request that
|
|
4816
|
+
// offers parameters outside [0,1] states its own weight so its escape
|
|
4817
|
+
// positions cost what they are worth in pixels rather than being
|
|
4818
|
+
// priced out by the length of the thing they slide along.
|
|
4819
|
+
s+=(r.tw||70)*Math.abs(t-tPref)+si*10;
|
|
3810
4820
|
if(c.box.x<2) s+=400; // would fall off the left margin
|
|
3811
4821
|
if(s<bestS-1e-9){ bestS=s; best=c; }
|
|
3812
4822
|
}
|
|
3813
|
-
|
|
3814
|
-
|
|
4823
|
+
}
|
|
4824
|
+
lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,r0.halo);
|
|
4825
|
+
placed.push(Object.assign({text:r0.text},best.box));
|
|
3815
4826
|
W=Math.max(W, best.box.x+best.box.w+4);
|
|
3816
4827
|
Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
|
|
3817
4828
|
}
|
|
4829
|
+
// ── note placement (`DRAWN-ANNOTATION-FORM`) — the SAME pass, entered LAST ────────────────
|
|
4830
|
+
// Four rules, and they are in the spec rather than only here because two
|
|
4831
|
+
// engines have to agree on them:
|
|
4832
|
+
//
|
|
4833
|
+
// 1. Notes register LAST, after every edge label and arrowhead. The
|
|
4834
|
+
// reason is semantic, not convenient: a label is ON the thing it names
|
|
4835
|
+
// and an arrowhead IS part of the connector, whereas a note is BESIDE
|
|
4836
|
+
// what it is about. A NOTE YIELDS; NOTHING YIELDS TO A NOTE. That is
|
|
4837
|
+
// why this loop runs after the one above, reads the same `placed`, and
|
|
4838
|
+
// is read by nothing after it.
|
|
4839
|
+
// 2. Candidates are generated around the CARRIER's final geometry — a
|
|
4840
|
+
// node box, a group rect, or the edge's segment list, all of which are
|
|
4841
|
+
// rects or segments by the time this pass runs — and scored by the same
|
|
4842
|
+
// overlap function against `placed`, `obst`, `arrowBox` and `edgeSegs`.
|
|
4843
|
+
// 3. A LEADER LINE is drawn ONLY when the box could not be placed adjacent
|
|
4844
|
+
// to its carrier. This is where attachment-by-syntax pays off twice:
|
|
4845
|
+
// the carrier is known from the line, so the PREFERRED position is
|
|
4846
|
+
// always adjacency and the leader is a fallback the engine reaches for
|
|
4847
|
+
// rather than a permanent part of the construct. It is drawn AFTER
|
|
4848
|
+
// placement, so it is correct by construction — the property `SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`
|
|
4849
|
+
// already records for arrowheads.
|
|
4850
|
+
// 4. Determinism is not optional. `RENDERING-DETERMINISM` promises byte-reproducible output,
|
|
4851
|
+
// and one greedy pass in registration order with no iteration is what
|
|
4852
|
+
// delivers it. Registration order here is DOCUMENT order — carriers are
|
|
4853
|
+
// sorted by source line, across kinds — so moving a `group` line above
|
|
4854
|
+
// a `node` line moves the notes with it and nothing else changes.
|
|
4855
|
+
//
|
|
4856
|
+
// `DOMAIN-CONVENTION-DIRECTIVES` binds throughout: `note=` accepts no `at=`, no `side=`, no
|
|
4857
|
+
// `left of`/`right of`. The author names the meaning; the engine owns the
|
|
4858
|
+
// drawing convention. The convention is the UML note symbol — a rectangle
|
|
4859
|
+
// with a folded top-right corner — which is what makes a note readable AS
|
|
4860
|
+
// a note without a legend entry, and is the notation belonging to the
|
|
4861
|
+
// metaclass the spelling is borrowed from (RULE 4.1).
|
|
4862
|
+
const noteCarriers=[];
|
|
4863
|
+
for(const n of nodes) if(n.note!==undefined&&n.note!==null&&!n.boundary)
|
|
4864
|
+
noteCarriers.push({line:n.line,text:n.note,kind:'node',rect:{x:n.x,y:n.y,w:n.w,h:n.h},n});
|
|
4865
|
+
for(const g of doc.groups) if(g.note!==undefined&&g.note!==null){
|
|
4866
|
+
const B=gBox[g.id]; if(!B) continue;
|
|
4867
|
+
noteCarriers.push({line:g.line,text:g.note,kind:'group',
|
|
4868
|
+
rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}});
|
|
4869
|
+
}
|
|
4870
|
+
for(const e of doc.edges) if(e.note!==undefined&&e.note!==null){
|
|
4871
|
+
// An edge is a polyline, not a rect. Its carrier POINT is the midpoint of
|
|
4872
|
+
// the middle registered segment — deterministic, and it is the same
|
|
4873
|
+
// "middle of the run" an author means when they annotate a wire. A note
|
|
4874
|
+
// whose edge never made it to the canvas (an endpoint that did not
|
|
4875
|
+
// resolve) simply has no carrier and is not drawn; the missing endpoint
|
|
4876
|
+
// is already its own line error.
|
|
4877
|
+
const segs=edgeSegs.filter(g=>g.e===e); if(!segs.length) continue;
|
|
4878
|
+
const m=segs[Math.floor((segs.length-1)/2)];
|
|
4879
|
+
const cx=(m.p[0]+m.q[0])/2, cy=(m.p[1]+m.q[1])/2;
|
|
4880
|
+
noteCarriers.push({line:e.line,text:e.note,kind:'edge',rect:{x:cx,y:cy,w:0,h:0},e});
|
|
4881
|
+
}
|
|
4882
|
+
noteCarriers.sort((a,b)=>a.line-b.line);
|
|
4883
|
+
for(const c of noteCarriers){
|
|
4884
|
+
const box=noteBox(c.text);
|
|
4885
|
+
// Adjacency first, then the same four sides pushed out far enough that a
|
|
4886
|
+
// leader is legible. `si` orders the sides; `far` is what decides the
|
|
4887
|
+
// leader, and it costs enough that adjacency wins every time adjacency is
|
|
4888
|
+
// merely imperfect rather than blocked.
|
|
4889
|
+
// Sides in preference order, and for the two that straddle the carrier
|
|
4890
|
+
// an ALIGNMENT as well. Centring a wide note over a narrow carrier at the
|
|
4891
|
+
// left edge of the canvas puts the box off it; aligning the box's left
|
|
4892
|
+
// edge with the carrier's is the same "beside this thing" reading and
|
|
4893
|
+
// stays on the page. Found by eye — see the off-canvas note below.
|
|
4894
|
+
const SIDES=[['right','c'],['left','c'],
|
|
4895
|
+
['below','c'],['below','l'],['below','r'],
|
|
4896
|
+
['above','c'],['above','l'],['above','r']];
|
|
4897
|
+
// Three distance tiers, not two. `near` is adjacency and takes no leader;
|
|
4898
|
+
// the two `far` tiers do. The third exists because a crowded figure can
|
|
4899
|
+
// have NO free space within one leader length of the carrier — a note
|
|
4900
|
+
// then had to sit on top of an edge label, which is the exact inversion
|
|
4901
|
+
// of rule 1 (a note yields; nothing yields to a note). Given somewhere
|
|
4902
|
+
// further to go, it goes there and the canvas grows to fit.
|
|
4903
|
+
let best=null,bestS=Infinity, fallback=null,fallbackS=Infinity;
|
|
4904
|
+
for(const tier of [0,1,2]){
|
|
4905
|
+
const far=tier>0, gap=[10,46,96][tier];
|
|
4906
|
+
for(let si=0;si<SIDES.length;si++){
|
|
4907
|
+
const side=SIDES[si][0], al=SIDES[si][1];
|
|
4908
|
+
let bx,by;
|
|
4909
|
+
if(side==='right'){ bx=c.rect.x+c.rect.w+gap; by=c.rect.y+c.rect.h/2-box.h/2; }
|
|
4910
|
+
else if(side==='left'){ bx=c.rect.x-gap-box.w; by=c.rect.y+c.rect.h/2-box.h/2; }
|
|
4911
|
+
else {
|
|
4912
|
+
bx=al==='l'?c.rect.x
|
|
4913
|
+
:al==='r'?c.rect.x+c.rect.w-box.w
|
|
4914
|
+
:c.rect.x+c.rect.w/2-box.w/2;
|
|
4915
|
+
by=side==='below'?c.rect.y+c.rect.h+gap:c.rect.y-gap-box.h;
|
|
4916
|
+
}
|
|
4917
|
+
const cb={x:bx,y:by,w:box.w,h:box.h};
|
|
4918
|
+
let s=0;
|
|
4919
|
+
// A NOTE YIELDS; NOTHING YIELDS TO A NOTE. `placed` holds the edge
|
|
4920
|
+
// labels and the notes already sited, and its weight is the HIGHEST
|
|
4921
|
+
// of the three — higher than a node's — because a label is a small
|
|
4922
|
+
// box and an AREA-weighted penalty would otherwise let a note sit on
|
|
4923
|
+
// one for less than it costs to clip a node's corner. Found by eye: a
|
|
4924
|
+
// transition note landed across two edge labels while a node overlap
|
|
4925
|
+
// three times the area scored higher.
|
|
4926
|
+
for(const b of placed) s+=10*ovl(cb,b);
|
|
4927
|
+
for(const o of obst) s+=6*ovl(cb,o);
|
|
4928
|
+
for(const a of arrowBox) s+=8*ovl(cb,a);
|
|
4929
|
+
for(const g of edgeSegs) if(segHit(g.p,g.q,cb)) s+=26;
|
|
4930
|
+
for(const g of doc.groups){ const B=gBox[g.id];
|
|
4931
|
+
if(B) s+=1.2*ovl(cb,{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}); }
|
|
4932
|
+
s+=si*12;
|
|
4933
|
+
s+=tier*900; // the leader is a LAST resort
|
|
4934
|
+
// Falling off the top or the left margin is not a BAD placement, it
|
|
4935
|
+
// is NO placement. The canvas grows right and down but has no
|
|
4936
|
+
// mechanism here to grow up or left, so such a box is clipped away
|
|
4937
|
+
// and the annotation VANISHES — the author wrote a sentence and the
|
|
4938
|
+
// reader never sees it, which is the worst outcome available. So it
|
|
4939
|
+
// is a HARD FILTER and not a score term: every other penalty is an
|
|
4940
|
+
// AREA and grows without bound, so no constant can outrank one
|
|
4941
|
+
// reliably. Found by eye on a statechart, where the note on the
|
|
4942
|
+
// leftmost state was emitted at x=-267.8 and simply did not appear —
|
|
4943
|
+
// and then found AGAIN when a large constant was tried first and the
|
|
4944
|
+
// box landed on top of two states instead.
|
|
4945
|
+
// `<0`, not `<2`. The edge-label pass keeps a 2px margin because a
|
|
4946
|
+
// label is loose text; a note is a BOX whose left edge at x=0 IS the
|
|
4947
|
+
// canvas origin and is perfectly placed. With the label pass's
|
|
4948
|
+
// threshold copied over, the one candidate that rescues a wide note
|
|
4949
|
+
// on a leftmost element — below, left-aligned, at exactly x=0 — was
|
|
4950
|
+
// filtered as off-canvas, and the note went to the only survivor: on
|
|
4951
|
+
// top of the next two states.
|
|
4952
|
+
if(cb.x<0||cb.y<y0){
|
|
4953
|
+
if(s<fallbackS-1e-9){ fallbackS=s; fallback={x:bx,y:by,far,side}; }
|
|
4954
|
+
continue;
|
|
4955
|
+
}
|
|
4956
|
+
if(s<bestS-1e-9){ bestS=s; best={x:bx,y:by,far,side}; }
|
|
4957
|
+
}
|
|
4958
|
+
}
|
|
4959
|
+
// Only if EVERY candidate was off-canvas: take the least-bad one and
|
|
4960
|
+
// clamp it on. It may overlap something; visible and overlapping is
|
|
4961
|
+
// recoverable by an author, invisible is not.
|
|
4962
|
+
if(!best){ best=fallback; best.x=Math.max(0,best.x); best.y=Math.max(y0,best.y); }
|
|
4963
|
+
lblsvg.push(noteSvg(best.x,best.y,box,c));
|
|
4964
|
+
if(best.far) lblsvg.push(noteLeader(best,box,c.rect));
|
|
4965
|
+
placed.push({x:best.x,y:best.y,w:box.w,h:box.h});
|
|
4966
|
+
W=Math.max(W, best.x+box.w+4);
|
|
4967
|
+
Hh=Math.max(Hh, best.y+box.h+4-y0-20);
|
|
4968
|
+
}
|
|
3818
4969
|
}
|
|
3819
4970
|
// nodes on top (each wrapped in a draggable, identifiable group)
|
|
3820
4971
|
const nsvg=[];
|
|
@@ -4127,7 +5278,7 @@ function renderBitfield(b,y0){
|
|
|
4127
5278
|
//
|
|
4128
5279
|
// and 0.1 already ruled that a spanning field follows the RFC's
|
|
4129
5280
|
// drawing rather than a FigDown one; this is the same ruling applied to the
|
|
4130
|
-
// other construct in the same figure. Until
|
|
5281
|
+
// other construct in the same figure. Until 0.1 the engine drew ONE
|
|
4131
5282
|
// occurrence and hung `[first] … [last]` on the strip — a FigDown invention
|
|
4132
5283
|
// where a convention already existed.
|
|
4133
5284
|
//
|
|
@@ -4174,7 +5325,7 @@ function renderBitfield(b,y0){
|
|
|
4174
5325
|
const shiftFor=(row)=>elisBands(row)*EL_H;
|
|
4175
5326
|
// `FIELD-WIDER-THAN-WORD`: ONE FIELD IS ONE BOX.
|
|
4176
5327
|
//
|
|
4177
|
-
// A field wider than `word=` occupies several rows. Until
|
|
5328
|
+
// A field wider than `word=` occupies several rows. Until 0.1 each
|
|
4178
5329
|
// row was a separate fully-bordered <rect> carrying the full label, so a
|
|
4179
5330
|
// 128-bit address at word=32 drew as FOUR captioned boxes and a reader saw
|
|
4180
5331
|
// four fields where the model has one. In examples/srh.fd it was worse: the
|
|
@@ -4370,7 +5521,7 @@ function renderBitfield(b,y0){
|
|
|
4370
5521
|
}
|
|
4371
5522
|
boxes.forEach(function(bx,bi){
|
|
4372
5523
|
// `DESCRIPTION-KEY-SPELLING`: the `<title>` is a CHILD of the shape it names, not
|
|
4373
|
-
// a sibling. Until
|
|
5524
|
+
// a sibling. Until 0.1 it was pushed into the block's stream
|
|
4374
5525
|
// after the rect and the label, so it landed under the figure's single
|
|
4375
5526
|
// <g> — and SVG says a <title> names its PARENT, so every description in
|
|
4376
5527
|
// a figure named the same <g> and a conforming UA showed one arbitrary
|
|
@@ -4529,7 +5680,8 @@ function renderBitfield(b,y0){
|
|
|
4529
5680
|
}
|
|
4530
5681
|
yb+=2;
|
|
4531
5682
|
}
|
|
4532
|
-
return {svg:svg.join(''), y:yb, w:wb
|
|
5683
|
+
return {svg:svg.join(''), y:yb, w:wb,
|
|
5684
|
+
box:{x0:0, x1:wb, yA:y0+18, yB:yb}};
|
|
4533
5685
|
}
|
|
4534
5686
|
|
|
4535
5687
|
// ---- table (with ^ rowspan / < colspan merging and per-cell marks) ----
|
|
@@ -4625,7 +5777,30 @@ function renderTable(t,y0){
|
|
|
4625
5777
|
i=>[xAt[i], xAt[i+1]], i=>[cellAt(r-1,i), cellAt(r,i)]));
|
|
4626
5778
|
svg.push(edgeSvg(EDG, DEF));
|
|
4627
5779
|
const yEnd=yTop+yAt[grid.length];
|
|
4628
|
-
|
|
5780
|
+
// `MARKER-TARGET-KINDS`: the GRID's box, so a region-scope `threshold`/`band`
|
|
5781
|
+
// can be drawn across it. It is the grid and not the returned slot: the slot
|
|
5782
|
+
// includes the caption row and the trailing gap, and `offset=50%` on a table
|
|
5783
|
+
// must mean half way down the ROWS, not half way down the whitespace.
|
|
5784
|
+
// `MARKER-TARGET-KINDS`: the box a region-scope `threshold`/`band` is measured
|
|
5785
|
+
// against spans the DATA ROWS, not the whole grid. Measured over the grid,
|
|
5786
|
+
// `offset=85%` on a three-row table lands on the COLUMN HEADINGS and strikes
|
|
5787
|
+
// through them — found by eye on the WRED figure this widening exists for.
|
|
5788
|
+
// The header tiers are chrome: they name the columns, they are not values,
|
|
5789
|
+
// and a threshold is a statement about values. `h1..hN` and `1..` are already
|
|
5790
|
+
// separate address spaces in this genre (genres/table.md), so the split is
|
|
5791
|
+
// the genre's own and not invented here.
|
|
5792
|
+
// THE SECTION IS AS WIDE AS ITS WIDEST INK, AND THE CAPTION IS INK.
|
|
5793
|
+
// `w` was the GRID's width alone, so a caption longer than the table it names
|
|
5794
|
+
// ran past the right edge of the section and was CLIPPED — patterns/table-b
|
|
5795
|
+
// shipped as "Feature Matrix — rowspan/colspan merges with c", losing 86 px
|
|
5796
|
+
// of a sentence that is the only place the figure says what it is about. The
|
|
5797
|
+
// grid is not the figure; the caption is not decoration.
|
|
5798
|
+
// Bold at 13 px is wider than `CH` (a regular-weight advance), so the caption
|
|
5799
|
+
// is measured with the same 8% allowance the raster needed — verified by
|
|
5800
|
+
// rendering, not assumed.
|
|
5801
|
+
const capW=cwMax(t.label)*CH*1.08+2;
|
|
5802
|
+
return {svg:svg.join(''), y:yEnd+6, w:Math.max(totalW+2,capW),
|
|
5803
|
+
box:{x0:0, x1:totalW, yA:yTop+yAt[H], yB:yEnd}};
|
|
4629
5804
|
}
|
|
4630
5805
|
|
|
4631
5806
|
// ---- chart bar3d: deterministic isometric projection of a table ----
|
|
@@ -4642,7 +5817,26 @@ function renderChart(b,y0,doc){
|
|
|
4642
5817
|
const R=rows.length, C=cLab.length;
|
|
4643
5818
|
const zmax=Math.max(...rows.flat(), 1);
|
|
4644
5819
|
const W2=20,H2=10,ZS=130/zmax,BAR=0.72;
|
|
4645
|
-
|
|
5820
|
+
// LEFT GUTTER for the row labels. They are anchored `end` at the floor's
|
|
5821
|
+
// left corner and hang LEFTWARD from it, and nothing reserved room for them:
|
|
5822
|
+
// the section's width is measured from the floor's RIGHT corner, and a
|
|
5823
|
+
// section has no mechanism to grow leftwards, so any row label wider than
|
|
5824
|
+
// its corner's own offset was clipped away at x<0 and the reader saw a
|
|
5825
|
+
// sliver or nothing. Measured on the shipped corpus: telemetry-export lost
|
|
5826
|
+
// 16.0 px of "Export ring" (24.6% of the box) and 41.9 px of "gRPC encoder"
|
|
5827
|
+
// (59.1%), and table-experimental shaved "00:05".
|
|
5828
|
+
// The LABEL is not moved. A row label belongs beside its row — that
|
|
5829
|
+
// adjacency is what makes it a row label rather than a caption — so the
|
|
5830
|
+
// ORIGIN moves right instead, by exactly what the widest label overhangs.
|
|
5831
|
+
// That is the "grow the canvas" answer, and it is the right one here
|
|
5832
|
+
// because the space is genuinely needed: no placement of a right-anchored
|
|
5833
|
+
// label at the left edge of the floor can avoid needing a margin, and the
|
|
5834
|
+
// gutter costs only the width it actually uses (0 when no label overhangs,
|
|
5835
|
+
// so every chart whose labels already fitted is byte-unchanged).
|
|
5836
|
+
const rLabPx=l=>cwMax(l)*6.5*10/11; // textEl draws these at font-size 10
|
|
5837
|
+
const ox0=R*W2+8;
|
|
5838
|
+
const gut=Math.max(0,...rLab.map((l,r)=>rLabPx(l)+4-(ox0-(r+0.65)*W2)));
|
|
5839
|
+
const ox=ox0+gut, oy=y0+18+ZS*zmax+6;
|
|
4646
5840
|
const P=(r,c,z)=>[ox+(c-r)*W2, oy+(c+r)*H2-z*ZS];
|
|
4647
5841
|
const svg=[];
|
|
4648
5842
|
svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bar3d</text>');
|
|
@@ -4729,7 +5923,8 @@ function renderTiming(w,y0){
|
|
|
4729
5923
|
svg.push('<path d="M'+x+','+(y+4)+' q4,'+(hTotal/4)+' 0,'+(hTotal/2)+' q-4,'+(hTotal/4)+' 0,'+(hTotal/2)+'" fill="none" stroke="#999" stroke-width="2"/>');
|
|
4730
5924
|
}
|
|
4731
5925
|
const H=y+8+w.signals.length*(laneH+laneGap);
|
|
4732
|
-
return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2
|
|
5926
|
+
return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2,
|
|
5927
|
+
box:{x0:nameW, x1:nameW+cycles*cycleW, yA:y0+18, yB:H}};
|
|
4733
5928
|
}
|
|
4734
5929
|
|
|
4735
5930
|
// ============================================================
|
|
@@ -4844,7 +6039,7 @@ pin mon at=(340,0)`,
|
|
|
4844
6039
|
|
|
4845
6040
|
'EVPN fabric (topology + supplementary tables)':
|
|
4846
6041
|
`figdown 0.1 topology
|
|
4847
|
-
title "VXLAN/EVPN Leaf-Spine Fabric —
|
|
6042
|
+
title "VXLAN/EVPN Leaf-Spine Fabric — underlay and overlay"
|
|
4848
6043
|
|
|
4849
6044
|
# ── Topology ────────────────────────────────────────────────────────
|
|
4850
6045
|
node sp1 "Spine-1" shape=rounded fill=#e0e7ff
|
|
@@ -4871,8 +6066,7 @@ edge lf2 -- h2
|
|
|
4871
6066
|
edge lf3 -- h3
|
|
4872
6067
|
|
|
4873
6068
|
# overlay: VXLAN tunnel between the two VTEPs sharing VNI 10010
|
|
4874
|
-
|
|
4875
|
-
edge lf1 <-[VXLAN VNI 10010]-> lf2 style=dashed stroke=#dc2626 plane=overlay
|
|
6069
|
+
edge lf1 <-[VXLAN VNI 10010]-> lf2 style=dashed stroke=#dc2626
|
|
4876
6070
|
|
|
4877
6071
|
# ── Supplementary knowledge ─────────────────────────────────────────
|
|
4878
6072
|
table vni "VNI mapping"
|
|
@@ -5126,12 +6320,16 @@ let lastSVG='', lastMeta=null, lastPad={x:0,y:0}, lastDoc=null;
|
|
|
5126
6320
|
// pin/layout are layout-zone only — they must never
|
|
5127
6321
|
// be treated as "last scene line" (P0: drag then +Node was writing
|
|
5128
6322
|
// into the layout zone → "node is a semantic directive" white screen).
|
|
5129
|
-
|
|
6323
|
+
// `PAINT-ORDER-CONSTRUCT`: `plane` leaves this list with the keyword. The list is
|
|
6324
|
+
// SPELLINGS, not per-genre legality (the parser owns legality), so the
|
|
6325
|
+
// per-genre withdrawals of `SCENE-KEYWORD-MEMBERSHIP` change nothing here — `threshold`, `band` and
|
|
6326
|
+
// `bundle` are still content lines in the genres that still declare them.
|
|
6327
|
+
const CONTENT_KW=/^(figdown|title|node|state|group|external|edge|flowline|transition|flow|rank|threshold|band|bundle|class)\b/;
|
|
5130
6328
|
// `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: `state` is a node-declaring keyword under
|
|
5131
6329
|
// `statechart`, so an id it declares must count as USED here or the GUI
|
|
5132
6330
|
// hands out a colliding id. The list is spellings, not per-genre legality —
|
|
5133
6331
|
// the parser owns legality.
|
|
5134
|
-
const ID_DECL_KW=/^(node|state|group|external|bundle|class|
|
|
6332
|
+
const ID_DECL_KW=/^(node|state|group|external|bundle|class|bitfield|table|timing)\s+(\S+)/;
|
|
5135
6333
|
function lastContentLineIdx(lines){
|
|
5136
6334
|
let last=-1;
|
|
5137
6335
|
const layoutIdx=lines.findIndex(l=>l.trim()==='layout');
|