figdown 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/figdown.js +2518 -187
- package/dist/figdown.mjs +2518 -187
- package/examples/evpn-fabric.svg +1 -1
- package/examples/showcase/arp-resolution.svg +1 -1
- package/examples/showcase/ethernet-frame.svg +1 -1
- package/examples/showcase/l2-forwarding-logic.svg +1 -1
- package/examples/showcase/tcp-handshake.svg +1 -1
- package/examples/showcase/tcp-header.svg +1 -1
- package/examples/showcase/tcp-state-machine.svg +53 -24
- package/guide/expressing.md +25 -0
- package/guide/showcase.md +12 -12
- package/package.json +1 -1
- package/skill/figdown/SKILL.md +29 -9
- package/skill/figdown/figdown.html +2532 -201
|
@@ -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.4.
|
|
124
|
+
const FIGDOWN_VERSION = '0.4.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
|
|
@@ -136,7 +136,7 @@ const FIGDOWN_VERSION = '0.4.0';
|
|
|
136
136
|
// fixes only. No new features. The language does not move." Shipping `note=`
|
|
137
137
|
// under `v0.2.z` would make `figdown 0.2` name two different languages: the one
|
|
138
138
|
// `v0.2.0` published and the one with `note=`. So the language number moves.
|
|
139
|
-
// `SEQUENCE-
|
|
139
|
+
// `SEQUENCE-GENRE-VOCABULARY`: `figdown 0.4` joins the set, and it joins it for the
|
|
140
140
|
// same reason `0.2` did — a GENRE token is language surface, and core §13.0
|
|
141
141
|
// makes added surface a `Y` and never a `Z`. `sequence` is that token. It adds
|
|
142
142
|
// no keyword yet (see GENRES_BY_VERSION below), which is exactly `STATECHART-GENRE-SCOPE`'s shape:
|
|
@@ -149,7 +149,7 @@ const GENRES_BY_VERSION = {
|
|
|
149
149
|
'0.1': ['block','topology','flowchart','bitfield','table','timing'],
|
|
150
150
|
'0.2': ['block','topology','flowchart','bitfield','table','timing','statechart'],
|
|
151
151
|
'0.3': ['block','topology','flowchart','bitfield','table','timing','statechart'],
|
|
152
|
-
// `SEQUENCE-
|
|
152
|
+
// `SEQUENCE-GENRE-VOCABULARY`: `sequence` is dispatchable from here on. Like
|
|
153
153
|
// `statechart` at `STATECHART-GENRE-SCOPE` it arrived with NO vocabulary of its own, and the
|
|
154
154
|
// consequence was stated here rather than left to be discovered: there was no
|
|
155
155
|
// `GENRE_KW.sequence` row, and the allowlist guard is written
|
|
@@ -157,7 +157,7 @@ const GENRES_BY_VERSION = {
|
|
|
157
157
|
// row is NOT narrowed — a `figdown 0.4 sequence` document could write any
|
|
158
158
|
// registered keyword and it parsed. The document that increment meant to
|
|
159
159
|
// land was the header ALONE.
|
|
160
|
-
// `SEQUENCE-
|
|
160
|
+
// `SEQUENCE-GENRE-VOCABULARY`: CLOSED. `GENRE_KW.sequence` exists below, so the
|
|
161
161
|
// genre now constrains what it names — five keywords of its own, `class`,
|
|
162
162
|
// and the genre-free core — and `flow`/`rank`/`group` are line errors under
|
|
163
163
|
// it. The genre still has NO RENDERER: a valid `sequence` document parses to
|
|
@@ -471,7 +471,7 @@ const DIRECTIVE_OPTS={
|
|
|
471
471
|
// keyed by the surface word an author actually wrote.
|
|
472
472
|
flowline:['style','class','fill','stroke','label','taillabel','headlabel','note'],
|
|
473
473
|
transition:['style','class','fill','stroke','label','taillabel','headlabel','note'],
|
|
474
|
-
// `SEQUENCE-
|
|
474
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's four own rows. `message` is
|
|
475
475
|
// the fourth connector spelling and takes the connector set — `fill=` and
|
|
476
476
|
// the three retired label keys stay listed for the same reason they are
|
|
477
477
|
// listed on the other three, so their dedicated diagnostics fire instead of
|
|
@@ -537,7 +537,7 @@ const DIRECTIVE_OPTS={
|
|
|
537
537
|
cell:['fill','stroke','class'], width:[],
|
|
538
538
|
signal:['data','fill','stroke'], gap:[]
|
|
539
539
|
};
|
|
540
|
-
// `SEQUENCE-
|
|
540
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the FIRST genre-conditional option row, and it
|
|
541
541
|
// exists because `DIRECTIVE_OPTS` is keyed by the SURFACE WORD an author
|
|
542
542
|
// wrote, while `GENRE-VOCABULARY-OBLIGATION` makes a surface word a per-genre declaration. Every other
|
|
543
543
|
// shared spelling in the language names the same construct in every genre
|
|
@@ -1120,7 +1120,7 @@ const FLOWCHART_SUBJECT_KW=['external'];
|
|
|
1120
1120
|
// none of the six; `external` is additionally UML 2.5.1 §14's own
|
|
1121
1121
|
// `TransitionKind` literal and is reserved for it (`RESERVED-SPELLINGS`).
|
|
1122
1122
|
const STATECHART_SUBJECT_KW=[];
|
|
1123
|
-
// `sequence` (EXPERIMENTAL, 0.4, `SEQUENCE-
|
|
1123
|
+
// `sequence` (EXPERIMENTAL, 0.4, `SEQUENCE-GENRE-VOCABULARY`): THREE, and the array is
|
|
1124
1124
|
// this genre's whole declaration of what a sequence figure is OF. `state` and
|
|
1125
1125
|
// `fragment` and `operand` describe referents UML clause 17 defines —
|
|
1126
1126
|
// `StateInvariant` (§17.12.25), `CombinedFragment` (§17.12.3) and
|
|
@@ -1152,6 +1152,23 @@ const FLOWCHART_ROLE_KW=['process','decision','terminator'];
|
|
|
1152
1152
|
// only its default drawing, and `shape=` on the same line overrides the
|
|
1153
1153
|
// drawing without touching the role (§12.7).
|
|
1154
1154
|
const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
|
|
1155
|
+
// Geometry DERIVED from the NODE DIRECTIVE ITSELF, for a genre whose node has
|
|
1156
|
+
// exactly one kind and therefore no role to map (backlog 52).
|
|
1157
|
+
// `statechart`'s own §Defaults rules "do not encode state-ness in a shape (`SHAPE-ENUM-VOCABULARY`)
|
|
1158
|
+
// — the genre already says it", and until now the engine contradicted it: a
|
|
1159
|
+
// `state` fell through to `box`, so every author who obeyed the rule got
|
|
1160
|
+
// right-angle rectangles that read as ISO 5807 process boxes, and four of the
|
|
1161
|
+
// six shipped statechart figures wrote `shape=rounded` on EVERY state line to
|
|
1162
|
+
// get out of it — twenty-one hand-keyed presentation keys restating one genre
|
|
1163
|
+
// fact. The derivation is the same move `flowchart` already makes through
|
|
1164
|
+
// ROLE_SHAPE, one level up: there the ROLE picks the drawing, here the
|
|
1165
|
+
// DIRECTIVE does, because `state` is the whole model (`GENRE-NODE-SPELLING` — no role is
|
|
1166
|
+
// recorded and none is missing). `SHAPE-ENUM-VOCABULARY` is satisfied for the same reason it is
|
|
1167
|
+
// satisfied for `decision`: the SOURCE says what the thing is, and the shape
|
|
1168
|
+
// is only how that thing happens to be drawn. `shape=` on the same line stays
|
|
1169
|
+
// legal and still overrides the DRAWING ONLY (§12.7), exactly as on a
|
|
1170
|
+
// `decision` — a `state x "S" shape=box` is still a state in the model.
|
|
1171
|
+
const KW_SHAPE={state:'rounded'};
|
|
1155
1172
|
|
|
1156
1173
|
// `GENRE-CONNECTOR-SPELLING`/`GENRE-NODE-SPELLING`: PER-GENRE NODE AND CONNECTOR SPELLINGS.
|
|
1157
1174
|
// Each scene genre takes the word its own domain uses for the line between
|
|
@@ -1179,7 +1196,7 @@ const GENRE_NODE_KW={block:'node',topology:'node',flowchart:'node',statechart:'s
|
|
|
1179
1196
|
const GENRE_CONNECTOR_KW={block:'edge',topology:'edge',flowchart:'flowline',statechart:'transition',sequence:'message'};
|
|
1180
1197
|
const NODE_SPELLINGS=new Set(['node','state','lifeline']);
|
|
1181
1198
|
const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition','message']);
|
|
1182
|
-
// `SEQUENCE-
|
|
1199
|
+
// `SEQUENCE-GENRE-VOCABULARY`: WHERE EACH GENRE'S NODE ROWS LIVE IN THE PARSED DOC.
|
|
1183
1200
|
// `GENRE_NODE_KW` above says what the WORD is; this says which `doc`
|
|
1184
1201
|
// collection the parser puts that word's rows in. The two are separate facts
|
|
1185
1202
|
// and only one of them is `nodes`: `statechart` renames the word and keeps the
|
|
@@ -1188,11 +1205,11 @@ const CONNECTOR_SPELLINGS=new Set(['edge','flowline','transition','message']);
|
|
|
1188
1205
|
// the form "is this id a thing this genre declares" has to ask through here.
|
|
1189
1206
|
// Hand-writing `doc.nodes` is the defect it closes: the Fill/Delete/Raise/
|
|
1190
1207
|
// Lower enablement asked `lastDoc.nodes` and so was permanently false under
|
|
1191
|
-
// `sequence`, greying out four buttons whose edits (`SEQUENCE-
|
|
1208
|
+
// `sequence`, greying out four buttons whose edits (`SEQUENCE-GENRE-VOCABULARY`) already worked.
|
|
1192
1209
|
const GENRE_NODE_COLL={block:'nodes',topology:'nodes',flowchart:'nodes',
|
|
1193
1210
|
statechart:'nodes',sequence:'lifelines'};
|
|
1194
1211
|
const docNodes=(doc)=>(doc&&doc[GENRE_NODE_COLL[doc.genre]||'nodes'])||[];
|
|
1195
|
-
// `SEQUENCE-
|
|
1212
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the four `sequence` directives that have their own
|
|
1196
1213
|
// parser (`message` rides the connector scanner). The set is what dispatches
|
|
1197
1214
|
// to `parseSeqDirective`, and it is scoped by `doc.genre` at the call site so
|
|
1198
1215
|
// `state` still reaches `statechart`'s node parser under `statechart`.
|
|
@@ -1260,7 +1277,7 @@ const WORD_WHY={
|
|
|
1260
1277
|
transition:'the connecting line in a statechart is a TRANSITION — the term UML 2.5.1 §14 uses for it',
|
|
1261
1278
|
node:'this genre has more kinds of thing than it has words for, so `node` is the general one',
|
|
1262
1279
|
state:'a statechart has exactly ONE kind of node and it is a STATE (UML 2.5.1 §14)',
|
|
1263
|
-
// `SEQUENCE-
|
|
1280
|
+
// `SEQUENCE-GENRE-VOCABULARY`: both are WHOLE borrows from the genre's source
|
|
1264
1281
|
// standard, verified against the clause text rather than against a
|
|
1265
1282
|
// secondary description (spec/standards-claims.tsv).
|
|
1266
1283
|
lifeline:'a sequence figure has exactly ONE kind of participant column and it is a LIFELINE — the term UML 2.5.1 §17.3.3.1 defines and §17.3.4.1 draws',
|
|
@@ -1409,7 +1426,7 @@ const GENRE_KW={
|
|
|
1409
1426
|
// chart is experimental and attaches to a table id in the same document
|
|
1410
1427
|
table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
|
|
1411
1428
|
timing:new Set(GENRE_FREE_KW.concat(['class','timing'])),
|
|
1412
|
-
// `SEQUENCE-
|
|
1429
|
+
// `SEQUENCE-GENRE-VOCABULARY`: `sequence` gets its row, and the row is what
|
|
1413
1430
|
// closes the Batch-1 finding that the genre "states a reading and constrains
|
|
1414
1431
|
// nothing". It is NOT built on `SCENE_HOST_KW`, and the three absences are
|
|
1415
1432
|
// each a decision rather than an oversight:
|
|
@@ -1453,7 +1470,7 @@ function splitFigdownSections(text){
|
|
|
1453
1470
|
return secs;
|
|
1454
1471
|
}
|
|
1455
1472
|
|
|
1456
|
-
// `SEQUENCE-
|
|
1473
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's DERIVED reading, in one
|
|
1457
1474
|
// function so the parser's checks and (from a later increment) the renderer
|
|
1458
1475
|
// cannot disagree about what a document says.
|
|
1459
1476
|
//
|
|
@@ -1552,7 +1569,7 @@ function parseOne(text){
|
|
|
1552
1569
|
const doc={title:null,note:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
|
|
1553
1570
|
flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
|
|
1554
1571
|
classes:[],boundaries:[],
|
|
1555
|
-
// `SEQUENCE-
|
|
1572
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the `sequence` model. FIVE collections,
|
|
1556
1573
|
// one per keyword, and they are separate arrays rather than
|
|
1557
1574
|
// aliases of `nodes`/`edges` because a message is not an edge: an
|
|
1558
1575
|
// edge is a relation between two nodes and has no position, while
|
|
@@ -1706,7 +1723,7 @@ function parseOne(text){
|
|
|
1706
1723
|
if(ro && ro[u2[0]]!==undefined) err(n,REFUSED_OPT_IN_GENRE(u2[0],doc.genre));
|
|
1707
1724
|
else err(n,'unknown option "'+u2[0]+'="');
|
|
1708
1725
|
return; }
|
|
1709
|
-
// `SEQUENCE-
|
|
1726
|
+
// `SEQUENCE-GENRE-VOCABULARY`: `message` adds ONE trailing positional — the
|
|
1710
1727
|
// quoted message label — to the connector grammar, because that is where
|
|
1711
1728
|
// every sequence source in the corpus writes it and the `[mid]` form
|
|
1712
1729
|
// reads as an annotation rather than as the message itself. Every other
|
|
@@ -1765,7 +1782,7 @@ function parseOne(text){
|
|
|
1765
1782
|
if(!pc.ok){ err(n,pc.err); return; }
|
|
1766
1783
|
ecls=pc.ids;
|
|
1767
1784
|
}
|
|
1768
|
-
// `SEQUENCE-
|
|
1785
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the `sequence` fork. It is the SAME scanner with
|
|
1769
1786
|
// a different reading, and the model it writes is a different collection:
|
|
1770
1787
|
// a message is an OCCURRENCE with a place in the figure's total order
|
|
1771
1788
|
// (draft §31), an edge is a relation with no position at all.
|
|
@@ -1805,7 +1822,7 @@ function parseOne(text){
|
|
|
1805
1822
|
plane:o2.plane||'base',line:n});
|
|
1806
1823
|
}
|
|
1807
1824
|
|
|
1808
|
-
// `SEQUENCE-
|
|
1825
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the four `sequence` directives that are not the
|
|
1809
1826
|
// connector. Reached only from the `doc.genre==='sequence'` dispatch, so
|
|
1810
1827
|
// `state` under `statechart` never arrives here. Every option-VALUE check
|
|
1811
1828
|
// (colours, `style=` enum, `class=` list, `in=` id spelling, `note=` and
|
|
@@ -1967,7 +1984,7 @@ function parseOne(text){
|
|
|
1967
1984
|
// the dispatch cannot be narrowed to the genre's own word — a `flowline`
|
|
1968
1985
|
// under `block` would then fall through to `unrecognized line`, which is
|
|
1969
1986
|
// exactly the answer these rulings owe an author better than.
|
|
1970
|
-
// `SEQUENCE-
|
|
1987
|
+
// `SEQUENCE-GENRE-VOCABULARY`: `message` is the FOURTH spelling scanned here,
|
|
1971
1988
|
// and it is derived from `CONNECTOR_SPELLINGS` rather than spelled again,
|
|
1972
1989
|
// so a fifth genre's connector reaches the named diagnostic by joining
|
|
1973
1990
|
// that set and not by remembering to edit a regex.
|
|
@@ -2013,7 +2030,7 @@ function parseOne(text){
|
|
|
2013
2030
|
// Directives not in DIRECTIVE_OPTS (title's single quoted string, unknown
|
|
2014
2031
|
// keywords) are handled by their own paths.
|
|
2015
2032
|
const badOpts=(k)=>{
|
|
2016
|
-
// `SEQUENCE-
|
|
2033
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the row is read GENRE-FIRST. `state` is one
|
|
2017
2034
|
// spelling with two declarations and two option sets (`GENRE-VOCABULARY-OBLIGATION`), and
|
|
2018
2035
|
// `GENRE_DIRECTIVE_OPTS` is the only place that fact is recorded.
|
|
2019
2036
|
const allowed=directiveOpts(k,doc.genre);
|
|
@@ -2573,7 +2590,7 @@ function parseOne(text){
|
|
|
2573
2590
|
continue;
|
|
2574
2591
|
}
|
|
2575
2592
|
|
|
2576
|
-
// `SEQUENCE-
|
|
2593
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's four own directives.
|
|
2577
2594
|
// Dispatched BEFORE the switch and scoped by `doc.genre`, which is what
|
|
2578
2595
|
// keeps `state` reaching `statechart`'s node parser under `statechart` —
|
|
2579
2596
|
// `GENRE-VOCABULARY-OBLIGATION` in the dispatcher, not only in the allowlist.
|
|
@@ -2654,8 +2671,10 @@ function parseOne(text){
|
|
|
2654
2671
|
// `GENRE-NODE-SPELLING`: `state` joins the same case. It is `node` renamed
|
|
2655
2672
|
// for one genre, not a role — a statechart node has exactly one kind,
|
|
2656
2673
|
// so there is no role to record and `role` stays null, exactly as a
|
|
2657
|
-
// bare `node` does. The DRAWING
|
|
2658
|
-
// which is what let the five corpus figures migrate byte-identically
|
|
2674
|
+
// bare `node` does. The DRAWING was unchanged too (`shape=box` default),
|
|
2675
|
+
// which is what let the five corpus figures migrate byte-identically;
|
|
2676
|
+
// 0.4 gave `state` its own derived geometry (KW_SHAPE, rounded)
|
|
2677
|
+
// because the box default was making the genre's `SHAPE-ENUM-VOCABULARY` rule unfollowable.
|
|
2659
2678
|
case 'process': case 'decision': case 'terminator':
|
|
2660
2679
|
case 'state':
|
|
2661
2680
|
case 'node': {
|
|
@@ -2668,7 +2687,7 @@ function parseOne(text){
|
|
|
2668
2687
|
// DRAWING only. §12.7: presentation is not meaning on its own, so
|
|
2669
2688
|
// `decision q "…" shape=box` is still a decision in the model — the
|
|
2670
2689
|
// role is the model, the shape is how it happens to be drawn.
|
|
2671
|
-
const shape=opts.shape||(role?ROLE_SHAPE[role]:'box');
|
|
2690
|
+
const shape=opts.shape||(role?ROLE_SHAPE[role]:KW_SHAPE[kw]||'box');
|
|
2672
2691
|
if(RETIRED_SHAPES[shape]){ err(n,RETIRED_SHAPES[shape]); break; }
|
|
2673
2692
|
if(!SHAPES.includes(shape)){ err(n,'unknown shape "'+shape+'" ('+SHAPES.join('|')+')'); break; }
|
|
2674
2693
|
if(pos[2]!==undefined&&!posq[2]){ err(n,kw+' label must be quoted: '+kw+' '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
|
|
@@ -3081,7 +3100,7 @@ function parseOne(text){
|
|
|
3081
3100
|
else if(groupIds.has(e.b)) errs.push('Line '+e.line+': edge endpoint "'+e.b+'" is a group — connect to a member node (group edges are not in v0.1)');
|
|
3082
3101
|
if(e.plane && !planeIds.has(e.plane)) errs.push('Line '+e.line+': unknown plane "'+e.plane+'"');
|
|
3083
3102
|
}
|
|
3084
|
-
// ── `SEQUENCE-
|
|
3103
|
+
// ── `SEQUENCE-GENRE-VOCABULARY`: the `sequence` genre's semantic checks ────────
|
|
3085
3104
|
// Everything here needs the WHOLE document, so none of it can live in
|
|
3086
3105
|
// `parseSeqDirective`: forward references are legal (fixture 021 pins that
|
|
3087
3106
|
// for the scene genres and the rule is language-wide), so an id can only be
|
|
@@ -3130,12 +3149,51 @@ function parseOne(text){
|
|
|
3130
3149
|
// is never restated, so a genuine duplicate is always a mistake — and
|
|
3131
3150
|
// because a transition is DERIVED from an adjacent pair, a reader
|
|
3132
3151
|
// "tidying duplicates" could otherwise silently delete a fact.
|
|
3152
|
+
//
|
|
3153
|
+
// THE CHECK IS OPERAND-SCOPED (item 35, ruled 2026-08-17).
|
|
3154
|
+
// It used to compare each occurrence with the previous one IN DECLARATION
|
|
3155
|
+
// ORDER and never asked whether the two are reachable together, so an
|
|
3156
|
+
// `alt` whose `granted` operand ends `state c "INIT"` and whose `refused`
|
|
3157
|
+
// operand ends `state c "INIT"` was refused — while AT MOST ONE OF THOSE
|
|
3158
|
+
// TWO EVER HAPPENS, so neither can restate the other. The diagnostic was
|
|
3159
|
+
// right about the sequence and wrong about the figure, and
|
|
3160
|
+
// `examples/sequence/dhcp-lease.fd` carried the fact it could not draw in
|
|
3161
|
+
// a `description=` because of it.
|
|
3162
|
+
//
|
|
3163
|
+
// The scope is the CONTAINER (UML 2.5.1 §17.12.13: a StateInvariant is an
|
|
3164
|
+
// InteractionFragment, and an InteractionOperand contains the fragments
|
|
3165
|
+
// that occur when its branch is taken). So an occurrence is compared with
|
|
3166
|
+
// the most recent occurrence in a scope that ENCLOSES OR IS its own —
|
|
3167
|
+
// itself, its fragment, the document — and never with one in a SIBLING
|
|
3168
|
+
// operand, which is on a path it is exclusive with. Two consequences are
|
|
3169
|
+
// deliberate and are the answer to the "what is the lifeline in after the
|
|
3170
|
+
// fragment" question the item asked: an occurrence in an operand still
|
|
3171
|
+
// restates one written outside it (the outer state is on every path into
|
|
3172
|
+
// the branch), and an occurrence AFTER a fragment is compared with the
|
|
3173
|
+
// last one outside it, because which branch ran is exactly what the
|
|
3174
|
+
// figure does not say. Outside operands nothing changes: the linear
|
|
3175
|
+
// sequence is compared as before.
|
|
3176
|
+
const scopeOwner={};
|
|
3177
|
+
for(const o of doc.operands) scopeOwner[o.id]=o['in']||'';
|
|
3178
|
+
for(const f of doc.fragments) scopeOwner[f.id]=f['in']||'';
|
|
3179
|
+
const chainOf=id=>{
|
|
3180
|
+
const out=[], seen=new Set();
|
|
3181
|
+
let cur=id||'';
|
|
3182
|
+
while(cur&&!seen.has(cur)){ seen.add(cur); out.push(cur); cur=scopeOwner[cur]||''; }
|
|
3183
|
+
out.push('');
|
|
3184
|
+
return out;
|
|
3185
|
+
};
|
|
3133
3186
|
const lastState={};
|
|
3134
3187
|
for(const st of doc.states.slice().sort((a,b)=>a.line-b.line)){
|
|
3135
|
-
|
|
3188
|
+
const chain=chainOf(st['in']);
|
|
3189
|
+
const seen=lastState[st.ref]||(lastState[st.ref]={});
|
|
3190
|
+
let prev=null;
|
|
3191
|
+
for(const sc of chain){ const r=seen[sc]; if(r&&(!prev||r.line>prev.line)) prev=r; }
|
|
3192
|
+
if(prev&&prev.name===st.name)
|
|
3136
3193
|
errs.push('Line '+st.line+': lifeline "'+st.ref+'" is already in state "'+st.name+
|
|
3137
|
-
'" — a state that has not changed is never restated, and a transition is derived from the adjacent pair, so a restatement would assert a transition that did not happen (draft §23.2)'
|
|
3138
|
-
|
|
3194
|
+
'" — a state that has not changed is never restated, and a transition is derived from the adjacent pair, so a restatement would assert a transition that did not happen (draft §23.2)'+
|
|
3195
|
+
(doc.operands.length?'. Occurrences in DIFFERENT operands of one fragment are exclusive and do not restate each other; this pair is not that':''));
|
|
3196
|
+
seen[chain[0]]={name:st.name,line:st.line};
|
|
3139
3197
|
}
|
|
3140
3198
|
const SM=seqModel(doc);
|
|
3141
3199
|
// A containment CYCLE is checked before anything that walks the chain, or
|
|
@@ -3683,7 +3741,33 @@ function cwMax(s){ return Math.max(...String(s).split('\n').map(cw)); }
|
|
|
3683
3741
|
// glyph with it is what lets a reader recognise the box as an aside without a
|
|
3684
3742
|
// legend entry). It is deliberately unlike a `node`: no rounded corners, a
|
|
3685
3743
|
// paler wash, smaller type, and a corner no node shape has.
|
|
3686
|
-
|
|
3744
|
+
// NOTE_MAXW is the note's CORRIDOR: the widest line of note text the box will
|
|
3745
|
+
// hold, in pixels, measured in the type the note is actually SET in.
|
|
3746
|
+
//
|
|
3747
|
+
// It replaces `NOTE_MAXCH=30`, a CHARACTER count, and the character count was
|
|
3748
|
+
// wrong twice over (item 56a). It was width-blind — thirty `i`s and thirty
|
|
3749
|
+
// `W`s are not one measure — and, worse, the box built around it was sized by
|
|
3750
|
+
// `tw()`, which measures at the NODE font (13 px) and adds the NODE padding.
|
|
3751
|
+
// A note is set at 10 px. So a note wrapped at thirty characters was fitted
|
|
3752
|
+
// into a box a third wider than the text it wrapped for: `annotated-datapath`
|
|
3753
|
+
// broke "Only valid while the port is up." across two lines and then drew it
|
|
3754
|
+
// in a 250 px box with 70 px of empty paper on the right. A line that fits
|
|
3755
|
+
// its corridor now stays on one line, and the box is the text's own width.
|
|
3756
|
+
//
|
|
3757
|
+
// 230 px is chosen to KEEP the box the old rule produced (230 + padding +
|
|
3758
|
+
// fold = 251 px, the width every wide note in the corpus already draws at)
|
|
3759
|
+
// and make the wrap actually mean it. Measured over the whole corpus: the two
|
|
3760
|
+
// short notes lose 60 px of empty box each, "Only valid while the port is up."
|
|
3761
|
+
// goes from two lines to one, and the figure-level note from three to two.
|
|
3762
|
+
const NOTE_FS=10, NOTE_PAD=6, NOTE_FOLD=9, NOTE_MAXW=230;
|
|
3763
|
+
// The note's own per-unit advance: `CH` is calibrated for FONT (13 px), and a
|
|
3764
|
+
// note is set at NOTE_FS. One constant, so the wrap and the box cannot
|
|
3765
|
+
// disagree about how wide a line is.
|
|
3766
|
+
const NOTE_CH=CH*NOTE_FS/FONT;
|
|
3767
|
+
// A bold run is wider than the same glyphs regular. 6% is the usual figure for
|
|
3768
|
+
// a synthesised/duplexed weight and is charged so the target-label prefix
|
|
3769
|
+
// (item 56b) cannot overflow the corridor it was measured against.
|
|
3770
|
+
const NOTE_BOLD=1.06;
|
|
3687
3771
|
const NOTE_FILL='#fdfaf0', NOTE_STROKE='#c9c4b2', NOTE_INK='#5c584c';
|
|
3688
3772
|
// The leader is deliberately DARKER than the box outline. Drawn in the box's
|
|
3689
3773
|
// own stroke it was legible in the SVG and invisible on the page at 1x — a
|
|
@@ -3695,24 +3779,46 @@ const NOTE_LEADER='#9c968a';
|
|
|
3695
3779
|
// Deterministic greedy word wrap. Author newlines are honoured and never
|
|
3696
3780
|
// merged; a run longer than the wrap width is broken only between words, so a
|
|
3697
3781
|
// long identifier keeps its shape and simply widens the box.
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3782
|
+
// A wrapped line is a list of RUNS — {t: text, b: bold} — because a note may
|
|
3783
|
+
// open with its target's label in bold (item 56b) and the prefix can wrap into
|
|
3784
|
+
// the body like any other words. A note with no prefix is one regular run per
|
|
3785
|
+
// line, which is what every note was before.
|
|
3786
|
+
function noteRunW(r){ return cw(r.t)*NOTE_CH*(r.b?NOTE_BOLD:1); }
|
|
3787
|
+
function noteLineW(runs){ return runs.reduce((a,r)=>a+noteRunW(r),0); }
|
|
3788
|
+
// Deterministic greedy word wrap, measured in PIXELS against NOTE_MAXW.
|
|
3789
|
+
// Author newlines are honoured and never merged; a single word wider than the
|
|
3790
|
+
// corridor is never broken, so a long identifier keeps its shape and simply
|
|
3791
|
+
// widens the box.
|
|
3792
|
+
function noteWrap(text,prefix){
|
|
3793
|
+
const words=[];
|
|
3794
|
+
if(prefix) for(const w of String(prefix).split(/ +/).filter(w=>w.length)) words.push({t:w,b:true,br:false});
|
|
3795
|
+
const paras=String(text).split('\n');
|
|
3796
|
+
paras.forEach((para,pi)=>{
|
|
3797
|
+
const ws=para.split(/ +/).filter(w=>w.length);
|
|
3798
|
+
if(!ws.length){ words.push({t:'',b:false,br:pi>0}); return; }
|
|
3799
|
+
ws.forEach((w,wi)=>words.push({t:w,b:false,br:pi>0&&wi===0}));
|
|
3800
|
+
});
|
|
3801
|
+
const SPW=cw(' ')*NOTE_CH;
|
|
3802
|
+
const out=[]; let cur=[], curW=0;
|
|
3803
|
+
const push=()=>{ out.push(cur.length?cur:[{t:'',b:false}]); cur=[]; curW=0; };
|
|
3804
|
+
for(const w of words){
|
|
3805
|
+
const ww=cw(w.t)*NOTE_CH*(w.b?NOTE_BOLD:1);
|
|
3806
|
+
if(w.br&&cur.length) push();
|
|
3807
|
+
const sp=cur.length?SPW:0;
|
|
3808
|
+
if(cur.length&&curW+sp+ww>NOTE_MAXW){ push(); }
|
|
3809
|
+
const gap=cur.length?' ':'';
|
|
3810
|
+
const last=cur[cur.length-1];
|
|
3811
|
+
if(last&&last.b===w.b) last.t+=gap+w.t;
|
|
3812
|
+
else cur.push({t:gap+w.t,b:w.b});
|
|
3813
|
+
curW+=(gap?SPW:0)+ww;
|
|
3814
|
+
}
|
|
3815
|
+
push();
|
|
3710
3816
|
return out;
|
|
3711
3817
|
}
|
|
3712
|
-
function noteBox(text){
|
|
3713
|
-
const lines=noteWrap(text);
|
|
3818
|
+
function noteBox(text,prefix){
|
|
3819
|
+
const lines=noteWrap(text,prefix);
|
|
3714
3820
|
const lh=NOTE_FS*1.35;
|
|
3715
|
-
const w=Math.max(28, Math.max.apply(null,lines.map(
|
|
3821
|
+
const w=Math.max(28, Math.max.apply(null,lines.map(noteLineW))+NOTE_PAD*2+NOTE_FOLD);
|
|
3716
3822
|
const h=lines.length*lh+NOTE_PAD*2-lh*0.15;
|
|
3717
3823
|
return {w:Math.round(w*100)/100, h:Math.round(h*100)/100, lines, lh};
|
|
3718
3824
|
}
|
|
@@ -3723,13 +3829,51 @@ function noteSvg(x,y,box,carrier){
|
|
|
3723
3829
|
const F=NOTE_FOLD, w=box.w, h=box.h;
|
|
3724
3830
|
const d='M'+x+' '+y+' H'+(x+w-F)+' L'+(x+w)+' '+(y+F)+' V'+(y+h)+' H'+x+' Z';
|
|
3725
3831
|
const fold='M'+(x+w-F)+' '+y+' V'+(y+F)+' H'+(x+w)+' Z';
|
|
3726
|
-
|
|
3832
|
+
// `data-note-on` names the KIND; `data-note-for` names the TARGET (item 40b).
|
|
3833
|
+
// Without the second attribute a machine reader holding only the drawing
|
|
3834
|
+
// cannot recover WHICH element a note is about — it can see that a note is
|
|
3835
|
+
// about "a group" and no more — so the association the source states was
|
|
3836
|
+
// lost on the way to the page even when the placement was perfect. The pair
|
|
3837
|
+
// (kind, ref) is unambiguous: `ref` is the element's identity in this very
|
|
3838
|
+
// drawing — a `data-node` id, a `data-group` id, or the `data-edge` line
|
|
3839
|
+
// number a connector is keyed by, since a connector has no id to carry.
|
|
3840
|
+
// `title` is the one kind with no ref and needs none: it names the FIGURE,
|
|
3841
|
+
// and there is exactly one. The attribute changes no pixel.
|
|
3842
|
+
const ref=carrier&&carrier.ref!==undefined&&carrier.ref!==null?' data-note-for="'+esc(String(carrier.ref))+'"':'';
|
|
3843
|
+
const out=['<g class="fd-note"'+(carrier&&carrier.kind?' data-note-on="'+carrier.kind+'"':'')+ref+'>',
|
|
3727
3844
|
'<path d="'+d+'" fill="'+NOTE_FILL+'" stroke="'+NOTE_STROKE+'" stroke-width="1"/>',
|
|
3728
3845
|
'<path d="'+fold+'" fill="'+NOTE_STROKE+'" fill-opacity="0.35" stroke="'+NOTE_STROKE+'" stroke-width="1"/>'];
|
|
3729
3846
|
const first=y+NOTE_PAD+NOTE_FS*0.85;
|
|
3847
|
+
// A line is a list of RUNS (item 56b): the target-label prefix is bold, the
|
|
3848
|
+
// note's own words are regular. One `<text>` per line either way, so a note
|
|
3849
|
+
// with no prefix emits exactly what it always did but for the tspan wrapper
|
|
3850
|
+
// — and a single regular run is written bare, so those notes are unchanged
|
|
3851
|
+
// byte for byte.
|
|
3730
3852
|
box.lines.forEach((ln,i)=>{
|
|
3731
|
-
|
|
3732
|
-
|
|
3853
|
+
const yy=Math.round((first+i*box.lh)*100)/100;
|
|
3854
|
+
const runs=Array.isArray(ln)?ln:[{t:ln,b:false}];
|
|
3855
|
+
let body;
|
|
3856
|
+
if(runs.length===1&&!runs[0].b) body=esc(runs[0].t);
|
|
3857
|
+
else {
|
|
3858
|
+
// EVERY RUN CARRIES ITS OWN `x`. A bare `<tspan>` is supposed to
|
|
3859
|
+
// continue where the last one stopped, and browsers do that — but a
|
|
3860
|
+
// note is an ARTIFACT that outlives this engine and gets rasterised by
|
|
3861
|
+
// whatever the reader has. Two library rasterisers restart each tspan
|
|
3862
|
+
// at the parent's `x`, which draws the prefix and the sentence on top
|
|
3863
|
+
// of each other. The advance is already measured (the wrap measured it
|
|
3864
|
+
// to decide this line), so it is written down rather than inferred.
|
|
3865
|
+
let off=0; const parts=[];
|
|
3866
|
+
for(const r of runs){
|
|
3867
|
+
const lead=/^ /.test(r.t)?1:0, t=lead?r.t.slice(1):r.t;
|
|
3868
|
+
off+=lead*cw(' ')*NOTE_CH;
|
|
3869
|
+
parts.push('<tspan x="'+Math.round((x+NOTE_PAD+off)*100)/100+'" y="'+yy+'"'+
|
|
3870
|
+
(r.b?' font-weight="600"':'')+'>'+esc(t)+'</tspan>');
|
|
3871
|
+
off+=cw(t)*NOTE_CH*(r.b?NOTE_BOLD:1);
|
|
3872
|
+
}
|
|
3873
|
+
body=parts.join('');
|
|
3874
|
+
}
|
|
3875
|
+
out.push('<text x="'+(x+NOTE_PAD)+'" y="'+yy+
|
|
3876
|
+
'" font-size="'+NOTE_FS+'" text-anchor="start" fill="'+NOTE_INK+'">'+body+'</text>');
|
|
3733
3877
|
});
|
|
3734
3878
|
out.push('</g>');
|
|
3735
3879
|
return out.join('');
|
|
@@ -3737,7 +3881,7 @@ function noteSvg(x,y,box,carrier){
|
|
|
3737
3881
|
// The leader is drawn ONLY when adjacency failed, and it is drawn AFTER the
|
|
3738
3882
|
// box is placed, so it is correct by construction: it runs from the box edge
|
|
3739
3883
|
// facing the carrier to the carrier's nearest point, and cannot be stale.
|
|
3740
|
-
function
|
|
3884
|
+
function noteLeaderPts(best,box,rect){
|
|
3741
3885
|
const bx=best.x, by=best.y, bw=box.w, bh=box.h;
|
|
3742
3886
|
const cx=rect.x+rect.w/2, cy=rect.y+rect.h/2;
|
|
3743
3887
|
let x1,y1;
|
|
@@ -3749,7 +3893,15 @@ function noteLeader(best,box,rect){
|
|
|
3749
3893
|
// thing it points at
|
|
3750
3894
|
const x2=Math.max(rect.x, Math.min(rect.x+rect.w, x1));
|
|
3751
3895
|
const y2=Math.max(rect.y, Math.min(rect.y+rect.h, y1));
|
|
3752
|
-
return
|
|
3896
|
+
return [[x1,y1],[rect.w||rect.h?x2:cx, rect.w||rect.h?y2:cy]];
|
|
3897
|
+
}
|
|
3898
|
+
// A LEADER IS INK AND A LATER NOTE MUST YIELD TO IT (item 40). The pass hands
|
|
3899
|
+
// the drawn segment back so the notes placed after this one are charged for
|
|
3900
|
+
// crossing it — a leader covered by the next note's box is a leader that is
|
|
3901
|
+
// not there, which is the same defect the LEADER's own colour was fixed for.
|
|
3902
|
+
function noteLeader(best,box,rect){
|
|
3903
|
+
const [p,q]=noteLeaderPts(best,box,rect);
|
|
3904
|
+
return '<line x1="'+p[0]+'" y1="'+p[1]+'" x2="'+q[0]+'" y2="'+q[1]+
|
|
3753
3905
|
'" stroke="'+NOTE_LEADER+'" stroke-width="1" stroke-dasharray="4 3"/>';
|
|
3754
3906
|
}
|
|
3755
3907
|
// A band's optional edge stroke. `renderScene` has had this as a local since
|
|
@@ -4276,6 +4428,22 @@ function renderScene(doc,y0){
|
|
|
4276
4428
|
// decided after, and a figure that suppresses and then declines draws
|
|
4277
4429
|
// straight through its own boxes (bfd-session: score 30 -> 35 with a new
|
|
4278
4430
|
// `thru`). That belongs with item 27's ordering change, priced.
|
|
4431
|
+
// THE ADMISSIBILITY KEY, WRITTEN OUT (item 60). Members share a trunk only
|
|
4432
|
+
// if they share the target, the label, the stroke, the dash AND THE CLASS
|
|
4433
|
+
// SET. The first four were always tested; the class was implied by them,
|
|
4434
|
+
// because the class cascade has already resolved `stroke=` and `style=` onto
|
|
4435
|
+
// the edge by the time this runs — but "implied" is not "stated", and two
|
|
4436
|
+
// classes that happen to paint alike are still two meanings. So the key says
|
|
4437
|
+
// it. Same class (or all unclassed with identical labels) → admissible;
|
|
4438
|
+
// MIXED CLASSES → refused, which is item 46's refusal and the reason
|
|
4439
|
+
// `bfd-session`'s three returns into DOWN (advance/fail/admin, three
|
|
4440
|
+
// different sentences) stay three lanes while its three `admin disable`
|
|
4441
|
+
// transitions become one trunk. Narrowing only: no group admitted before is
|
|
4442
|
+
// admitted less.
|
|
4443
|
+
const clsKey=x=>{
|
|
4444
|
+
const ids=(x.cls===undefined||x.cls===null)?[]:(Array.isArray(x.cls)?x.cls:[x.cls]);
|
|
4445
|
+
return ids.slice().sort().join(',');
|
|
4446
|
+
};
|
|
4279
4447
|
const busGroups=[];
|
|
4280
4448
|
{
|
|
4281
4449
|
const g=new Map();
|
|
@@ -4284,11 +4452,52 @@ function renderScene(doc,y0){
|
|
|
4284
4452
|
if(!A||!B||e.a===e.b||isBack.has(e)) continue;
|
|
4285
4453
|
if(e.op!=='->'||e.tail||e.head) continue;
|
|
4286
4454
|
if(pinned(e.a)||pinned(e.b)||B.rank<=A.rank) continue;
|
|
4287
|
-
const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'');
|
|
4455
|
+
const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'')+' '+clsKey(e);
|
|
4288
4456
|
if(!g.has(k)) g.set(k,[]); g.get(k).push(e);
|
|
4289
4457
|
}
|
|
4290
4458
|
for(const [,m] of g) if(m.length>=3) busGroups.push(m);
|
|
4291
4459
|
}
|
|
4460
|
+
// ── RETURN MERGE BUS (item 46, and item 26's >=3 rule reaching the RETURNS) ──
|
|
4461
|
+
// The forward bus (above) collapses three or more arrows arriving at one
|
|
4462
|
+
// target into one trunk. The same statement is made just as often by RETURN
|
|
4463
|
+
// edges — N states falling back to one — and the forward construct cannot
|
|
4464
|
+
// reach them, because a return is drawn by the side-channel plan and not by
|
|
4465
|
+
// the straight router (item 26 recorded `patterns/state-b`'s four `reset`
|
|
4466
|
+
// edges as exactly this decline). `state-b` draws four CONCENTRIC RINGS, each
|
|
4467
|
+
// labelled "reset", 818 px wide for a 500 px figure: four copies of one
|
|
4468
|
+
// sentence.
|
|
4469
|
+
//
|
|
4470
|
+
// A return bus is the ring stack COLLAPSED ONTO ITS INNERMOST RING — not a
|
|
4471
|
+
// new geometry. Every member drops from its own source to the shared rail,
|
|
4472
|
+
// the rail and the trunk and the entry point are the innermost member's, one
|
|
4473
|
+
// arrowhead lands on the target and one label rides the shared run. The
|
|
4474
|
+
// members' own drops stay distinct and carry the junction dots, so the reader
|
|
4475
|
+
// still counts the lines. Because the collapsed route is a route the
|
|
4476
|
+
// innermost member already drew, no member can gain a crossing the figure did
|
|
4477
|
+
// not already have — which is why this construct needs no adoption test where
|
|
4478
|
+
// the forward rail (a NEW line across the figure) does.
|
|
4479
|
+
//
|
|
4480
|
+
// The admission rule is the forward bus's, unchanged, because the reasons are
|
|
4481
|
+
// the same: ONE label (a trunk draws one, so members that say different things
|
|
4482
|
+
// may not share it), ONE stroke and ONE dash (the trunk is stroked once per
|
|
4483
|
+
// member; three colours on one line is three claims about one stroke, and in a
|
|
4484
|
+
// classed figure those colours carry the legend's meaning), no endpoint
|
|
4485
|
+
// labels, distinct sources. Measured over the corpus, exactly one group
|
|
4486
|
+
// qualifies and two are refused for stated reasons — see the DONE note on 46.
|
|
4487
|
+
const retBusGroups=[];
|
|
4488
|
+
{
|
|
4489
|
+
const g=new Map();
|
|
4490
|
+
for(const e of doc.edges){
|
|
4491
|
+
const A=byId[e.a], B=byId[e.b];
|
|
4492
|
+
if(!A||!B||e.a===e.b||!isBack.has(e)) continue;
|
|
4493
|
+
if(e.op!=='->'||e.tail||e.head) continue;
|
|
4494
|
+
if(pinned(e.a)||pinned(e.b)) continue; // the channel plan skips those
|
|
4495
|
+
const k=e.b+' '+(e.mid||'')+' '+(e.stroke||'')+' '+(e.style||'')+' '+clsKey(e);
|
|
4496
|
+
if(!g.has(k)) g.set(k,[]); g.get(k).push(e);
|
|
4497
|
+
}
|
|
4498
|
+
for(const [,m] of g)
|
|
4499
|
+
if(m.length>=3&&new Set(m.map(e=>e.a)).size===m.length) retBusGroups.push(m);
|
|
4500
|
+
}
|
|
4292
4501
|
for(const e of doc.edges){
|
|
4293
4502
|
const A=byId[e.a], B=byId[e.b];
|
|
4294
4503
|
if(!A||!B||isBack.has(e)) continue;
|
|
@@ -4597,6 +4806,165 @@ function renderScene(doc,y0){
|
|
|
4597
4806
|
if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
|
|
4598
4807
|
else { n.x=p.fx; n.y=y0+20+p.fy; }
|
|
4599
4808
|
}
|
|
4809
|
+
// ── CORRIDOR BUDGETING (item 44) ─────────────────────────────────────────
|
|
4810
|
+
// A SEAM IS ASKED WHAT LIVES IN IT. Until this pass, every gap in a scene —
|
|
4811
|
+
// between two ranks, between two neighbours in one rank — was derived from
|
|
4812
|
+
// NODE GEOMETRY ALONE: `GAPX`/`GAPY` plus, for horizontal flow only, the
|
|
4813
|
+
// width of a mid label that rides on the edge. Nothing else in the drawing
|
|
4814
|
+
// was ever asked for its area. But a gap is where a figure keeps most of its
|
|
4815
|
+
// non-node ink: endpoint labels, bundle lassos and their captions, and the
|
|
4816
|
+
// padded band a group draws around its members (whose TOP strip, `BAND.t`,
|
|
4817
|
+
// exists precisely to hold the group's name). The reference topology figure
|
|
4818
|
+
// is what that blindness costs — measured on the 0.4 artifact, the
|
|
4819
|
+
// spine→leaf corridor was 4 px of clearance asked to house three lassos
|
|
4820
|
+
// (2 x ry 26 each), eight endpoint labels, two bundle captions and the
|
|
4821
|
+
// `Leaf` band's whole label strip; the l1—l2 gap was 56 px asked to house a
|
|
4822
|
+
// 92 px-wide lasso, which therefore ate both node boxes.
|
|
4823
|
+
//
|
|
4824
|
+
// THE MODEL. For each seam the demand is derived FIRST, from ink alone —
|
|
4825
|
+
// no coordinate is read to compute it:
|
|
4826
|
+
// need = max( bandGap , max(lasso, endpointLabels) + nameStrip )
|
|
4827
|
+
// `bandGap` is what the two sides' group BANDS need of each other: the
|
|
4828
|
+
// padding each projects into the seam plus `SEP_BAND` of white, so two bands
|
|
4829
|
+
// read as two (nothing when one group spans the seam — its band covers the
|
|
4830
|
+
// seam anyway). `lasso` is the breadth a bundle ring needs to cross the seam
|
|
4831
|
+
// without reaching a node box, from the ring's own MINIMUM radii.
|
|
4832
|
+
// `endpointLabels` is the tail/head labels an edge through the seam must
|
|
4833
|
+
// stack. `nameStrip` is the ONE part of a band that is ink rather than
|
|
4834
|
+
// backdrop — the strip carrying the group's NAME.
|
|
4835
|
+
//
|
|
4836
|
+
// WHY MAX AND NOT SUM. A band is a BACKDROP: every figure in this corpus
|
|
4837
|
+
// draws edges across one, and a lasso crossing a band's fill is ordinary
|
|
4838
|
+
// layering, not a collision. So the band requirement and the free-ink
|
|
4839
|
+
// requirement are two claims on the same span and the binding one is the
|
|
4840
|
+
// larger — only the band's NAME is ink the free ink must clear, and that is
|
|
4841
|
+
// the term which adds. Summing them was built and measured first: it put the
|
|
4842
|
+
// reference figure's corridor at 108 px, which then DETACHED the endpoint
|
|
4843
|
+
// labels from their ports (they are anchored at a FRACTION of edge length —
|
|
4844
|
+
// item 42 — so they drift as the seam deepens) and cost four new F5
|
|
4845
|
+
// label-association defects on a figure that had none. A seam wide enough to
|
|
4846
|
+
// break the ink it was widened for is the wrong answer; item 42's fixed-
|
|
4847
|
+
// distance anchoring is what would lift that ceiling.
|
|
4848
|
+
//
|
|
4849
|
+
// IT IS APPLIED TO THE REALISED SEAM, NOT TO `mainGap`. A pre-coordinate
|
|
4850
|
+
// widening cannot see a PIN: pins are absolute canvas coordinates, applied
|
|
4851
|
+
// above, and the reference figure's spine pin spends 40 px of its own rank's
|
|
4852
|
+
// gap — a `mainGap` bumped to 108 would have produced a 68 px corridor and
|
|
4853
|
+
// reported success. So the demand is measured against the geometry that
|
|
4854
|
+
// exists, here, after the pins have had their say.
|
|
4855
|
+
//
|
|
4856
|
+
// THREE THINGS IT DOES NOT DO, ON PURPOSE. It never moves a PINNED node
|
|
4857
|
+
// (the group-band precedent: the coordinate is the author's word). It never
|
|
4858
|
+
// NARROWS a seam — an adequate seam is left bit-for-bit alone, which is why
|
|
4859
|
+
// 31 of the corpus's 34 scene figures are byte-identical across this change.
|
|
4860
|
+
// And it changes no ROUTE and no SHAPE: edge paths, lasso radii and label
|
|
4861
|
+
// anchors are all computed downstream from these coordinates and are
|
|
4862
|
+
// untouched — they simply get room. The lasso that is still drawn as a
|
|
4863
|
+
// near-circle regardless of its members' direction is item 43; the endpoint
|
|
4864
|
+
// label still anchored by fraction rather than distance is item 42; the note
|
|
4865
|
+
// and element-label association frontiers are items 40 and 45. This pass
|
|
4866
|
+
// buys those fixes the space they need and deliberately leaves them open.
|
|
4867
|
+
//
|
|
4868
|
+
// WHERE IT DECLINES. If two ranks OVERLAP on the main axis — the author
|
|
4869
|
+
// pinned them into each other, or into a different order than the ranking —
|
|
4870
|
+
// there is no seam to budget and the pass says nothing. 39 of the corpus's
|
|
4871
|
+
// 204 seams are that shape, all of them in substantially pinned scenes.
|
|
4872
|
+
{
|
|
4873
|
+
const SEP_BAND=6, // white left between two distinct group bands
|
|
4874
|
+
LASSO_CLEAR=6, // a bundle ring's clearance from a node box
|
|
4875
|
+
RY_MIN=26, RX_MIN=46, // the ring's own floor radii (see doc.trunks)
|
|
4876
|
+
EPL_H=13, EPL_PAD=4; // an endpoint label's line box and its gutter
|
|
4877
|
+
const mLo=n=>horiz?n.x:n.y, mSz=n=>horiz?n.w:n.h;
|
|
4878
|
+
const cLo=n=>horiz?n.y:n.x, cSz=n=>horiz?n.h:n.w;
|
|
4879
|
+
const mvM=(n,d)=>{ if(horiz) n.x+=d; else n.y+=d; };
|
|
4880
|
+
const mvC=(n,d)=>{ if(horiz) n.y+=d; else n.x+=d; };
|
|
4881
|
+
const ranked=nodes.filter(n=>!n.boundary&&n.rank!==undefined);
|
|
4882
|
+
const byRank=new Map();
|
|
4883
|
+
for(const n of ranked){ if(!byRank.has(n.rank)) byRank.set(n.rank,[]); byRank.get(n.rank).push(n); }
|
|
4884
|
+
// Ranks are ordered by where they ACTUALLY sit, not by their index: `flow
|
|
4885
|
+
// up`/`flow left` mirror the main axis after placement, so rank order and
|
|
4886
|
+
// coordinate order are opposite there, and a seam is a fact about the
|
|
4887
|
+
// drawing.
|
|
4888
|
+
const rMid=r=>{ const a=byRank.get(r); return a.reduce((s,n)=>s+mLo(n)+mSz(n)/2,0)/a.length; };
|
|
4889
|
+
const rks=[...byRank.keys()].sort((a,b)=>rMid(a)-rMid(b));
|
|
4890
|
+
// A group's rank span, so a band that runs THROUGH a seam asks for nothing.
|
|
4891
|
+
const gSpan={};
|
|
4892
|
+
for(const g of doc.groups){
|
|
4893
|
+
const m=ranked.filter(n=>n.group===g.id); if(!m.length) continue;
|
|
4894
|
+
gSpan[g.id]={lo:Math.min(...m.map(n=>n.rank)), hi:Math.max(...m.map(n=>n.rank))};
|
|
4895
|
+
}
|
|
4896
|
+
const lblW=s=>(s===null||s===undefined||s==='')?0:lblPx(s);
|
|
4897
|
+
// ── main axis: rank against rank ──────────────────────────────────────
|
|
4898
|
+
for(let k=0;k+1<rks.length;k++){
|
|
4899
|
+
const i=rks[k], j=rks[k+1];
|
|
4900
|
+
const hiI=Math.max(...byRank.get(i).map(n=>mLo(n)+mSz(n)));
|
|
4901
|
+
const loJ=Math.min(...byRank.get(j).map(n=>mLo(n)));
|
|
4902
|
+
const actual=loJ-hiI;
|
|
4903
|
+
if(actual<0) continue; // ranks interleave: not a seam
|
|
4904
|
+
let bA=0,bB=0;
|
|
4905
|
+
for(const id in gSpan){ const s=gSpan[id];
|
|
4906
|
+
if(s.lo<=i&&s.hi>=j) continue; // the band spans the seam itself
|
|
4907
|
+
if(s.hi===i) bA=Math.max(bA,horiz?BAND.r:BAND.b);
|
|
4908
|
+
if(s.lo===j) bB=Math.max(bB,horiz?BAND.l:BAND.t);
|
|
4909
|
+
}
|
|
4910
|
+
const bandGap=bA+bB+((bA&&bB)?SEP_BAND:0);
|
|
4911
|
+
// The one piece of band padding that is INK rather than backdrop: under
|
|
4912
|
+
// vertical flow the lower group's `BAND.t` strip is where its NAME is
|
|
4913
|
+
// written, and a lasso or a label drawn through it lands on that name.
|
|
4914
|
+
// Under horizontal flow the name strip is on the cross axis, so a
|
|
4915
|
+
// main-axis seam does not contain it and the guard is nothing.
|
|
4916
|
+
const guard=horiz?0:bB;
|
|
4917
|
+
const spans=(a,b)=>{ const A=byId[a],B=byId[b];
|
|
4918
|
+
return A&&B&&A.rank!==undefined&&B.rank!==undefined
|
|
4919
|
+
&&Math.min(A.rank,B.rank)<=i&&Math.max(A.rank,B.rank)>=j; };
|
|
4920
|
+
// A ONE-MEMBER bundle draws no ring (item 53), so it asks the seam for
|
|
4921
|
+
// no ring breadth. Its caption is placed by the collision-aware label
|
|
4922
|
+
// pass, which can use any white the figure already has.
|
|
4923
|
+
let lasso=0;
|
|
4924
|
+
for(const t of doc.trunks||[]) if(t.pairs.length>1) for(const [a,b] of t.pairs) if(spans(a,b))
|
|
4925
|
+
lasso=Math.max(lasso, 2*(horiz?RX_MIN:RY_MIN)+2*LASSO_CLEAR,
|
|
4926
|
+
horiz?lblW(t.label)+2*LASSO_CLEAR:0);
|
|
4927
|
+
let epl=0;
|
|
4928
|
+
for(const e of doc.edges){ if(!spans(e.a,e.b)) continue;
|
|
4929
|
+
const c=(e.tail?1:0)+(e.head?1:0); if(!c) continue;
|
|
4930
|
+
epl=Math.max(epl, c*EPL_H+(c+1)*EPL_PAD); }
|
|
4931
|
+
const d=Math.max(bandGap, Math.max(lasso,epl)+guard)-actual;
|
|
4932
|
+
if(d<=0) continue;
|
|
4933
|
+
// Everything at or beyond the far lip travels; a pinned node holds its
|
|
4934
|
+
// ground. The movers are selected by POSITION, not by rank membership:
|
|
4935
|
+
// boundary anchors and long-edge waypoints have ranks of their own that
|
|
4936
|
+
// may hold no drawn node at all, and selecting by rank left the
|
|
4937
|
+
// reference figure's `WAN` anchor behind while the leaf rank it hangs
|
|
4938
|
+
// off moved 86 px away — the external's label then landed on the leaf
|
|
4939
|
+
// group's name. `actual >= 0` guarantees no near-side node is past the
|
|
4940
|
+
// lip, so this cannot pick up the rank the seam is measured from.
|
|
4941
|
+
for(const n of lay) if(mLo(n)>=loJ-0.001&&!(n.id&&pinned(n.id))) mvM(n,d);
|
|
4942
|
+
}
|
|
4943
|
+
// ── cross axis: neighbour against neighbour inside one rank ───────────
|
|
4944
|
+
for(const r of rks){
|
|
4945
|
+
const lane=byRank.get(r).slice().sort((a,b)=>cLo(a)-cLo(b));
|
|
4946
|
+
for(let q=0;q+1<lane.length;q++){
|
|
4947
|
+
const A=lane[q], B=lane[q+1];
|
|
4948
|
+
const actual=cLo(B)-(cLo(A)+cSz(A));
|
|
4949
|
+
if(actual<0) continue;
|
|
4950
|
+
let bandGap=0, guard=0;
|
|
4951
|
+
if(A.group!==B.group){
|
|
4952
|
+
const pa=A.group?(horiz?BAND.b:BAND.r):0, pb=B.group?(horiz?BAND.t:BAND.l):0;
|
|
4953
|
+
bandGap=pa+pb+((pa&&pb)?SEP_BAND:0);
|
|
4954
|
+
guard=horiz?pb:0; // the name strip, on the cross axis this time
|
|
4955
|
+
}
|
|
4956
|
+
let lasso=0;
|
|
4957
|
+
for(const t of doc.trunks||[]) if(t.pairs.length>1) for(const [a,b] of t.pairs)
|
|
4958
|
+
if((a===A.id&&b===B.id)||(a===B.id&&b===A.id))
|
|
4959
|
+
lasso=Math.max(lasso, 2*(horiz?RY_MIN:RX_MIN)+2*LASSO_CLEAR,
|
|
4960
|
+
horiz?0:lblW(t.label)+2*LASSO_CLEAR);
|
|
4961
|
+
const d=Math.max(bandGap, lasso?lasso+guard:0)-actual;
|
|
4962
|
+
if(d<=0) continue;
|
|
4963
|
+
const from=cLo(B);
|
|
4964
|
+
for(const n of lay) if(n.rank===r&&cLo(n)>=from&&!(n.id&&pinned(n.id))) mvC(n,d);
|
|
4965
|
+
}
|
|
4966
|
+
}
|
|
4967
|
+
}
|
|
4600
4968
|
// ── GROUP BAND CONTIGUITY ────────────────────────────────────────────────
|
|
4601
4969
|
// A group's band is the BOUNDING BOX of its members (see gBox below), and
|
|
4602
4970
|
// until this pass nothing checked that the box contained only members. A
|
|
@@ -4748,6 +5116,55 @@ function renderScene(doc,y0){
|
|
|
4748
5116
|
if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
|
|
4749
5117
|
y:Math.min(...mem.map(n=>n.y))};
|
|
4750
5118
|
}
|
|
5119
|
+
// ── A NODE THAT COVERS ANOTHER COMPLETELY IS A FALSE DRAWING (item 47b) ──
|
|
5120
|
+
// The group-band precedent, one construct over. There the rule is: whoever
|
|
5121
|
+
// chose the position bears responsibility, and an author pin that forces a
|
|
5122
|
+
// drawing to state something the source does not is a GEOMETRY-TIME error,
|
|
5123
|
+
// not a lint number. `examples/rpf-check.fd` is the case that asked the
|
|
5124
|
+
// question: `pin drop at=(-30,0)` aged against a moving layout until
|
|
5125
|
+
// `Drop (RPF fail)` sat entirely ON `Accept and forward` — PIN ROT — and the
|
|
5126
|
+
// covered node was not merely crowded but INVISIBLE, which is the line
|
|
5127
|
+
// between "ugly is not an error" and "wrong is". PARTIAL overlap stays on
|
|
5128
|
+
// the `novlp` axis and is never an error: two boxes that clip each other are
|
|
5129
|
+
// ugly and both readable, and drawing tools let them.
|
|
5130
|
+
//
|
|
5131
|
+
// THE MEASUREMENT NARROWED THE RULE, AND THE NARROWING IS THE FINDING.
|
|
5132
|
+
// Over the corpus (59 figures, 231 nodes, 1640 ordered node pairs) full
|
|
5133
|
+
// containment occurs 28 times and every one of them is `examples/rainbow.fd`
|
|
5134
|
+
// — seven concentric pinned ellipses around a pinned core, the figure whose
|
|
5135
|
+
// entire subject is that DOCUMENT ORDER IS PAINT ORDER (`PAINT-ORDER-CONSTRUCT`). So the
|
|
5136
|
+
// premise a blanket rule would rest on ("if the cover is intentional the
|
|
5137
|
+
// figure cannot state it — there is no z-order construct") is FALSE in this
|
|
5138
|
+
// language: line order is that construct, and this corpus ships its
|
|
5139
|
+
// reference figure. What distinguishes rainbow from rpf-check is not the
|
|
5140
|
+
// geometry, which is identical, but WHO CHOSE IT: rainbow's covered node is
|
|
5141
|
+
// pinned by the author on its own line, while rpf-check's was placed by the
|
|
5142
|
+
// engine and drifted underneath. So the error fires when the cover is not
|
|
5143
|
+
// wholly the author's: at least one of the two boxes is at a coordinate the
|
|
5144
|
+
// author never wrote. Both pinned = an author statement in the one paint
|
|
5145
|
+
// order the language has, and it is drawn.
|
|
5146
|
+
{
|
|
5147
|
+
const real=nodes.filter(n=>!n.boundary&&n.w>0&&n.h>0);
|
|
5148
|
+
const inside=(a,b)=>a.x>=b.x-0.01&&a.y>=b.y-0.01
|
|
5149
|
+
&&a.x+a.w<=b.x+b.w+0.01&&a.y+a.h<=b.y+b.h+0.01;
|
|
5150
|
+
const covered=new Set();
|
|
5151
|
+
for(const a of real) for(const b of real){
|
|
5152
|
+
if(a===b||covered.has(a.id+' '+b.id)) continue;
|
|
5153
|
+
if(!inside(a,b)) continue;
|
|
5154
|
+
if(pinned(a.id)&&pinned(b.id)) continue; // both coordinates are the author's
|
|
5155
|
+
covered.add(a.id+' '+b.id);
|
|
5156
|
+
const who=pinned(b.id)?b.id:(pinned(a.id)?a.id:null);
|
|
5157
|
+
const ln=who!==null?srcLine(doc.pins[who].line):srcLine(a.line);
|
|
5158
|
+
gErrs.push('Line '+ln+': '+(who!==null?'pin puts':'the layout puts')+' "'+b.id+'" completely over "'
|
|
5159
|
+
+a.id+'" — a node drawn entirely inside another is invisible, so the figure states that "'
|
|
5160
|
+
+a.id+'" is not there. '+(who!==null
|
|
5161
|
+
? 'Move the pin off "'+a.id+'", or pin "'+a.id+'" too if the cover is deliberate — '
|
|
5162
|
+
+'document order is paint order (`PAINT-ORDER-CONSTRUCT`), so two author-placed boxes may overlap however '
|
|
5163
|
+
+'the author says.'
|
|
5164
|
+
: 'Pin one of them clear of the other.')
|
|
5165
|
+
+' Partial overlap is not this error; only a complete cover is.');
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
4751
5168
|
// Boundary adjacency in pinned scenes (presentation-only): auto-layout ranks
|
|
4752
5169
|
// a degree-1 boundary relative to the free lanes, so in a scene where the
|
|
4753
5170
|
// real content is pinned to a compact box the boundary can drift to a far
|
|
@@ -4820,9 +5237,83 @@ function renderScene(doc,y0){
|
|
|
4820
5237
|
// drawn as a full concentric ring — drop row, channel, return row and hub
|
|
4821
5238
|
// entry nested in the same order, entering the hub's top edge on the
|
|
4822
5239
|
// channel side — so fan-in hubs (N states -> IDLE reset) have no crossings.
|
|
5240
|
+
// ── A RECIPROCAL PAIR RUNS ANTIPARALLEL-ADJACENT (item 58) ───────────────
|
|
5241
|
+
// `A -> B` and `B -> A` between one node pair are ONE EXCHANGE: request
|
|
5242
|
+
// beside reply, and reading it means reading them together. The engine
|
|
5243
|
+
// already had the drawing — `apOff` below fans a same-pair straight group
|
|
5244
|
+
// onto opposite sides of its own centre line, and the label pass puts each
|
|
5245
|
+
// label on the OUTER flank of its own stroke — but it could not reach the
|
|
5246
|
+
// common case, because the second direction of any pair CLOSES A CYCLE and
|
|
5247
|
+
// is therefore a back edge, which the channel plan takes and routes the
|
|
5248
|
+
// long way round the figure. `showcase/arp-resolution` is the case: the ARP
|
|
5249
|
+
// reply left Host B, ran down under the whole figure and climbed back into
|
|
5250
|
+
// Host A, 236 px of detour to say the thing the request says in 100.
|
|
5251
|
+
//
|
|
5252
|
+
// So a reciprocal partner is EXEMPTED FROM THE CHANNEL PLAN and drawn
|
|
5253
|
+
// straight, which puts both members into `apOff` and gives the pair its
|
|
5254
|
+
// two adjacent lines. Note what is NOT touched: `isBack` itself. That set
|
|
5255
|
+
// ranks the figure — it is what breaks the cycle so the layers can be
|
|
5256
|
+
// computed at all — and a rank DAG with the cycle put back has no layering.
|
|
5257
|
+
// This is a ROUTING exemption and nothing else, so it is a second set that
|
|
5258
|
+
// the three routing decisions consult and the ranking never sees.
|
|
5259
|
+
//
|
|
5260
|
+
// THE PAIR MUST BE EXACTLY A PAIR: one edge each way, neither a self-loop,
|
|
5261
|
+
// and the forward member drawn straight itself (a multi-rank forward edge
|
|
5262
|
+
// rides a waypoint chain, and a chain has no single centre line to fan a
|
|
5263
|
+
// partner about). Anything else is a different figure and keeps the routing
|
|
5264
|
+
// it had.
|
|
5265
|
+
const recip=new Set();
|
|
5266
|
+
{
|
|
5267
|
+
const dir=new Map();
|
|
5268
|
+
for(const e of doc.edges){
|
|
5269
|
+
if(!byId[e.a]||!byId[e.b]||e.a===e.b) continue;
|
|
5270
|
+
const k=e.a+'\t'+e.b;
|
|
5271
|
+
if(!dir.has(k)) dir.set(k,[]); dir.get(k).push(e);
|
|
5272
|
+
}
|
|
5273
|
+
// A MERGE BUS OUTRANKS A PAIR. `state_a -> idle` is BOTH the return
|
|
5274
|
+
// half of a reciprocal pair AND one of four returns that item 46 merges
|
|
5275
|
+
// into one trunk, and only one of the two drawings can be on the page.
|
|
5276
|
+
// The bus wins, on its own argument: it says something about FOUR edges
|
|
5277
|
+
// ("all of these fall back to one place") that the pairwise reading
|
|
5278
|
+
// cannot say, and `patterns/state-b` measured 397 ink/edge as four
|
|
5279
|
+
// concentric rings against 101 as four straight lines — but the four
|
|
5280
|
+
// straight lines are four separate statements where the figure makes
|
|
5281
|
+
// one. Taking the pair here would also have silently deleted item 54's
|
|
5282
|
+
// acceptance figure.
|
|
5283
|
+
const inBus=new Set();
|
|
5284
|
+
for(const m of busGroups) for(const e of m) inBus.add(e);
|
|
5285
|
+
for(const m of retBusGroups) for(const e of m) inBus.add(e);
|
|
5286
|
+
// EACH LABEL MUST FIT BESIDE ITS OWN LINE. This is not a nicety bolted
|
|
5287
|
+
// on: it is the second half of the direction ("labels on outer flanks,
|
|
5288
|
+
// each label unambiguously beside its own line"), and a label longer
|
|
5289
|
+
// than the line it names cannot satisfy it — it overhangs both ends and
|
|
5290
|
+
// lands on whatever is past them. Measured: `statechart/bfd-session`'s
|
|
5291
|
+
// `INIT -> DOWN` carries a 200 px two-line label on an 80 px run, and
|
|
5292
|
+
// forcing the pair there cost the figure 3 new label collisions, 3
|
|
5293
|
+
// coincident lines and a label emitted off the canvas. 1.25 is the
|
|
5294
|
+
// allowance — a quarter of the run may overhang, which is what a label
|
|
5295
|
+
// centred on a short link needs and what a label that has nothing to do
|
|
5296
|
+
// with the link exceeds.
|
|
5297
|
+
const RECIP_FIT=1.25;
|
|
5298
|
+
const fits=(e,len)=>!e.mid||lblPx(e.mid)<=len*RECIP_FIT;
|
|
5299
|
+
for(const [k,fwd] of dir){
|
|
5300
|
+
const [a,b]=k.split('\t');
|
|
5301
|
+
const rev=dir.get(b+'\t'+a);
|
|
5302
|
+
if(!rev||fwd.length!==1||rev.length!==1) continue;
|
|
5303
|
+
if(chains.get(fwd[0])||chains.get(rev[0])) continue;
|
|
5304
|
+
if(inBus.has(fwd[0])||inBus.has(rev[0])) continue;
|
|
5305
|
+
const A=byId[a], B=byId[b];
|
|
5306
|
+
const p=borderPoint(A,B.x+B.w/2,B.y+B.h/2), q=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
|
|
5307
|
+
const len=Math.hypot(q[0]-p[0],q[1]-p[1]);
|
|
5308
|
+
if(!fits(fwd[0],len)||!fits(rev[0],len)) continue;
|
|
5309
|
+
recip.add(fwd[0]); recip.add(rev[0]);
|
|
5310
|
+
}
|
|
5311
|
+
}
|
|
5312
|
+
// A back edge the routing treats as straight, for item 58's reason.
|
|
5313
|
+
const chBack=e=>isBack.has(e)&&!recip.has(e);
|
|
4823
5314
|
const chPlan=new Map(); let chTop=0, chShift=0;
|
|
4824
5315
|
{
|
|
4825
|
-
const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&
|
|
5316
|
+
const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&chBack(e)&&!pinned(e.a)&&!pinned(e.b));
|
|
4826
5317
|
const near=e=>{ const A=byId[e.a]; return horiz?A.y+A.h/2:A.x+A.w/2; };
|
|
4827
5318
|
const order=chList.map((e,i)=>({e,i}));
|
|
4828
5319
|
order.sort((p,q)=>near(q.e)-near(p.e)||p.i-q.i);
|
|
@@ -4870,6 +5361,45 @@ function renderScene(doc,y0){
|
|
|
4870
5361
|
const m=g.length;
|
|
4871
5362
|
g.forEach((e,k)=>{ chPlan.get(e).ef=(m-k)/(m+1); });
|
|
4872
5363
|
}
|
|
5364
|
+
// RETURN BUS COLLAPSE (item 46). Every member of an admitted group takes
|
|
5365
|
+
// the INNERMOST member's ring, slot, entry point and entry fraction, so the
|
|
5366
|
+
// rail, the trunk and the arrival are one line instead of N nested ones.
|
|
5367
|
+
// Deterministic: the innermost member is the one the sort above already put
|
|
5368
|
+
// closest to the channel (lowest ring; ties by document order). The LAST
|
|
5369
|
+
// member in document order draws the arrowhead, the junction dots and the
|
|
5370
|
+
// single label; every other member draws its path only. The label rides the
|
|
5371
|
+
// SHARED run rather than one member's source stub — a stub label would say
|
|
5372
|
+
// the word names that one member, and what the bus asserts is that all of
|
|
5373
|
+
// them say it.
|
|
5374
|
+
//
|
|
5375
|
+
// A group whose members disagree about `ringOK` is not collapsed: the two
|
|
5376
|
+
// route forms enter the target on different edges of the box, so a part-ring
|
|
5377
|
+
// part-channel bus would be two trunks, not one.
|
|
5378
|
+
for(const m of retBusGroups){
|
|
5379
|
+
if(!m.every(e=>chPlan.has(e))) continue;
|
|
5380
|
+
const P0=chPlan.get(m.reduce((a,b)=>chPlan.get(a).ring<=chPlan.get(b).ring?a:b));
|
|
5381
|
+
if(!m.every(e=>chPlan.get(e).ringOK===P0.ringOK)) continue;
|
|
5382
|
+
// Two roles, and they are not the same member. The LABEL goes to the
|
|
5383
|
+
// member whose own leg into the trunk is LONGEST — the forward bus's rule
|
|
5384
|
+
// verbatim, and the reason is the same: the longest leg is the one whose
|
|
5385
|
+
// longest segment is the shared rail, so the word lands on ink every
|
|
5386
|
+
// member draws instead of on the return row above the figure (where the
|
|
5387
|
+
// canvas, which grows right and down only, clips it). The ARROWHEAD and
|
|
5388
|
+
// the junction dots go to the LAST member in document order, because the
|
|
5389
|
+
// dots can only be drawn once every member's own join point exists.
|
|
5390
|
+
const T=byId[m[0].b], tc=horiz?T.y+T.h/2:T.x+T.w/2;
|
|
5391
|
+
let lead=m[0], bestD=-1;
|
|
5392
|
+
for(const e of m){
|
|
5393
|
+
const A2=byId[e.a], d=Math.abs((horiz?A2.y+A2.h/2:A2.x+A2.w/2)-tc);
|
|
5394
|
+
if(d>bestD+0.5){ bestD=d; lead=e; }
|
|
5395
|
+
}
|
|
5396
|
+
const last=m[m.length-1];
|
|
5397
|
+
for(const e of m){
|
|
5398
|
+
const P=chPlan.get(e);
|
|
5399
|
+
P.ring=P0.ring; P.slot=P0.slot; P.ex=P0.ex; P.ef=P0.ef;
|
|
5400
|
+
P.bus=e.b; P.busLead=(e===lead); P.busTail=(e===last);
|
|
5401
|
+
}
|
|
5402
|
+
}
|
|
4873
5403
|
// ring return rows run above the top rank; shift the whole scene down
|
|
4874
5404
|
// when they would spill into the title band. The shift is uniform
|
|
4875
5405
|
// (relative geometry, incl. pins, is preserved) and meta.top reports
|
|
@@ -4891,7 +5421,7 @@ function renderScene(doc,y0){
|
|
|
4891
5421
|
// opposite sides; endpoints and labels shift together.
|
|
4892
5422
|
const apOff=new Map();
|
|
4893
5423
|
{
|
|
4894
|
-
const straight=e=>byId[e.a]&&byId[e.b]&&!(
|
|
5424
|
+
const straight=e=>byId[e.a]&&byId[e.b]&&!(chBack(e)&&!pinned(e.a)&&!pinned(e.b))&&!chains.get(e);
|
|
4895
5425
|
const pk=e=>e.a<e.b?e.a+'\t'+e.b:e.b+'\t'+e.a;
|
|
4896
5426
|
const pairN={}, seen={};
|
|
4897
5427
|
for(const e of doc.edges) if(straight(e)){ const k=pk(e); pairN[k]=(pairN[k]||0)+1; }
|
|
@@ -4906,6 +5436,391 @@ function renderScene(doc,y0){
|
|
|
4906
5436
|
apOff.set(e,[-dy/L*off, dx/L*off]);
|
|
4907
5437
|
}
|
|
4908
5438
|
}
|
|
5439
|
+
// ── LONG-EDGE CORRIDOR ROUTER — SIDE BY OCCUPANCY, THEN LANES ────────────
|
|
5440
|
+
// Items 41 and 46, and the approach-column half of item 26, for the long
|
|
5441
|
+
// edges the side-channel plan above does not reach: multi-rank forward edges,
|
|
5442
|
+
// and returns whose endpoints are PINNED (the channel plan takes only the
|
|
5443
|
+
// unpinned ones, so a hand-placed statechart's returns were routed by the
|
|
5444
|
+
// straight router and had no corridor at all).
|
|
5445
|
+
//
|
|
5446
|
+
// TWO DEFECTS, ONE MECHANISM.
|
|
5447
|
+
// 41 — `patterns/flowchart-b` routes BOTH its long edges down the right of a
|
|
5448
|
+
// single-column spine and they cross, while the left margin is empty
|
|
5449
|
+
// for the figure's whole height. The router never asked which side was
|
|
5450
|
+
// free: the diagonal took the geometric straight line and the channel
|
|
5451
|
+
// defaults to one side, so two long edges on one side of one spine are
|
|
5452
|
+
// guaranteed to meet.
|
|
5453
|
+
// 46 — `statechart/dhcp-client` sends `REQUESTING -> INIT` and
|
|
5454
|
+
// `BOUND -> INIT` over the top of the figure at near-identical y. They
|
|
5455
|
+
// read as ONE line with two label stacks.
|
|
5456
|
+
// Both are the same missing question — WHICH corridor, at WHAT height — so
|
|
5457
|
+
// they are answered by one pass: a long edge is offered an orthogonal
|
|
5458
|
+
// corridor route on each margin, at a LANE within that corridor, and the
|
|
5459
|
+
// figure keeps whichever costs least.
|
|
5460
|
+
//
|
|
5461
|
+
// THE COST, and it is item 26's, unchanged:
|
|
5462
|
+
// 10000*(crossings + pierces) + 1000*corridor conflicts + 40*bends + ink
|
|
5463
|
+
// Crossings and pierces dominate because a line through a box or through
|
|
5464
|
+
// another line is a misreading; the CONFLICT term is what this pass adds —
|
|
5465
|
+
// two routes running parallel within a lane width over more than 40 px are
|
|
5466
|
+
// one line to the reader, which is exactly dhcp-client's defect and is
|
|
5467
|
+
// invisible to a crossing count. Ink is charged 1/px so a corridor that buys
|
|
5468
|
+
// nothing loses to the straight line. **The incumbent stays in the candidate
|
|
5469
|
+
// set and is kept unless STRICTLY beaten**, which is what makes this pass
|
|
5470
|
+
// monotone rather than a gamble: a figure whose long edges are already clean
|
|
5471
|
+
// is byte-identical after it.
|
|
5472
|
+
//
|
|
5473
|
+
// THE LANE ORDER IS NOT A CHOICE — it is forced, and the rule is the one the
|
|
5474
|
+
// ring plan above already states. Sources are ordered by DISTANCE FROM THE
|
|
5475
|
+
// TARGET along the flow axis (ties by document order, so one document has one
|
|
5476
|
+
// assignment); the NEAREST source takes lane 0, hard against the figure, and
|
|
5477
|
+
// the FARTHEST takes the outermost lane. It has to be that way round: a far
|
|
5478
|
+
// source on an inner lane has its long run cut by every nearer source's climb
|
|
5479
|
+
// out to an outer lane. For the same reason the ARRIVAL fans across the
|
|
5480
|
+
// target's border — an outer lane must land BEYOND where every inner lane
|
|
5481
|
+
// stops, or it crosses them all on the way in.
|
|
5482
|
+
//
|
|
5483
|
+
// A GROUP IS (target, side of approach). Sources on opposite sides of one
|
|
5484
|
+
// target are two corridors, not one, and laning them together would send a
|
|
5485
|
+
// route the long way round the figure to reach a lane it has no reason to be
|
|
5486
|
+
// in.
|
|
5487
|
+
//
|
|
5488
|
+
// THE LEFT MARGIN EXISTS NOW. The canvas grows right and down only, so a
|
|
5489
|
+
// corridor on the low side needs the scene shifted — the uniform-shift
|
|
5490
|
+
// pattern `bShift`/`chShift` already use, and the thing the self-loop note
|
|
5491
|
+
// below records as missing. Relative geometry (pins included) is preserved
|
|
5492
|
+
// and `meta.left`/`meta.top` report the shifted origin, so the editor's
|
|
5493
|
+
// drag->pin round-trip stays stable.
|
|
5494
|
+
const longPlan=new Map(); // edge -> {side, lane, frac}
|
|
5495
|
+
let lShift=0, tShift=0;
|
|
5496
|
+
if(nodes.length){
|
|
5497
|
+
const CORR_GAP=26, LANE_PITCH=18, PAR_MIN=40;
|
|
5498
|
+
// group bands, computed exactly as gBox draws them below
|
|
5499
|
+
const gbb=[];
|
|
5500
|
+
for(const g of doc.groups){
|
|
5501
|
+
const mem=nodes.filter(n=>n.group===g.id); if(!mem.length) continue;
|
|
5502
|
+
const x0=Math.min(...mem.map(n=>n.x))-BAND.l, x1=Math.max(...mem.map(n=>n.x+n.w))+BAND.r;
|
|
5503
|
+
const yA=Math.min(...mem.map(n=>n.y))-BAND.t, yB=Math.max(...mem.map(n=>n.y+n.h))+BAND.b;
|
|
5504
|
+
gbb.push({x:x0,y:yA,w:x1-x0,h:yB-yA});
|
|
5505
|
+
}
|
|
5506
|
+
const cLo=n=>horiz?n.y:n.x, cHi=n=>horiz?n.y+n.h:n.x+n.w;
|
|
5507
|
+
const fC =n=>horiz?n.x+n.w/2:n.y+n.h/2;
|
|
5508
|
+
const bLo=Math.min(...nodes.map(cLo), ...gbb.map(b=>horiz?b.y:b.x));
|
|
5509
|
+
const bHi=Math.max(...nodes.map(cHi), ...gbb.map(b=>horiz?b.y+b.h:b.x+b.w));
|
|
5510
|
+
const corrAt=(side,off)=>side<0?bLo-CORR_GAP-off:bHi+CORR_GAP+off;
|
|
5511
|
+
// A LANE IS AS WIDE AS WHAT IT CARRIES. A fixed 18 px pitch put
|
|
5512
|
+
// dhcp-client's second return straight through the first one's two-line
|
|
5513
|
+
// label, which is the ink-blind spacing item 44 removed from ranks arriving
|
|
5514
|
+
// again one axis over. The step between two lanes is therefore the inner
|
|
5515
|
+
// lane's own LABEL extent — height when the corridor runs along the flow
|
|
5516
|
+
// axis, width when it runs across it — floored at the bare pitch.
|
|
5517
|
+
const lblExt=e=>Math.max(LANE_PITCH,
|
|
5518
|
+
!e.mid?0:(horiz?String(e.mid).split('\n').length*13+10:lblPx(e.mid)+10));
|
|
5519
|
+
// Lanes are allocated from a per-side pool, not per convergence: two
|
|
5520
|
+
// components that were decided separately (their INCUMBENTS did not share a
|
|
5521
|
+
// corridor) still share the corridor once both are adopted, and dhcp-client
|
|
5522
|
+
// drew exactly that — `RENEWING -> INIT` and `REBINDING -> INIT` laid on one
|
|
5523
|
+
// line along the bottom, `coinc 2`. The pool remembers every adopted run's
|
|
5524
|
+
// span, so a later component takes the next free height over the stretch it
|
|
5525
|
+
// actually occupies and nothing else.
|
|
5526
|
+
const usedLanes=new Map();
|
|
5527
|
+
const allocOff=(side,lo,hi,ext,taken)=>{
|
|
5528
|
+
const list=(usedLanes.get(side)||[]).concat(taken||[]);
|
|
5529
|
+
let off=0;
|
|
5530
|
+
for(let guard=0;guard<64;guard++){
|
|
5531
|
+
let clash=null;
|
|
5532
|
+
for(const u of list)
|
|
5533
|
+
if(Math.min(u.hi,hi)-Math.max(u.lo,lo)>PAR_MIN&&Math.abs(u.off-off)<Math.max(u.ext,ext)
|
|
5534
|
+
&&(!clash||u.off>clash.off)) clash=u;
|
|
5535
|
+
if(!clash) break;
|
|
5536
|
+
off=clash.off+Math.max(clash.ext,ext);
|
|
5537
|
+
}
|
|
5538
|
+
return off;
|
|
5539
|
+
};
|
|
5540
|
+
// A CORRIDOR ROUTE IS BUILT ORTHOGONALLY OR NOT OFFERED AT ALL.
|
|
5541
|
+
// The first cut handed the four-point L to `routeAround` when it hit
|
|
5542
|
+
// something, which is the wrong tool here: Dijkstra over a visibility graph
|
|
5543
|
+
// returns the SHORTEST clear polyline, not an orthogonal one, and on
|
|
5544
|
+
// `patterns/block-b` it returned a five-bend zigzag that doubled back
|
|
5545
|
+
// across the figure (0 -> 9 on the lint, 3 crossings and a `thru`). A
|
|
5546
|
+
// corridor's whole claim is "this margin is free"; when it is not free for
|
|
5547
|
+
// this edge, the honest answer is to withdraw the candidate, not to bend it
|
|
5548
|
+
// until it fits.
|
|
5549
|
+
//
|
|
5550
|
+
// So a leg out of a node is one of exactly two shapes: straight across to
|
|
5551
|
+
// the corridor, or — when something stands in that column — out of the
|
|
5552
|
+
// node's FLOW side, along to the nearest clear column, and across. That is
|
|
5553
|
+
// the escape the side-channel plan above already uses (`blockedV`/
|
|
5554
|
+
// `blockedH`), in the same two forms, and it keeps every corridor route
|
|
5555
|
+
// axis-aligned end to end. If neither shape is clear, `build` returns null
|
|
5556
|
+
// and the figure keeps its incumbent.
|
|
5557
|
+
//
|
|
5558
|
+
// Because the planned route is clean of every node and every group band by
|
|
5559
|
+
// construction, the draw pass runs NO detour on it: plan and drawing are the
|
|
5560
|
+
// same polyline, which is what makes the cost above a prediction rather
|
|
5561
|
+
// than a guess.
|
|
5562
|
+
const P=(f,c)=>horiz?[f,c]:[c,f];
|
|
5563
|
+
const fLoN=n=>horiz?n.x:n.y, fHiN=n=>horiz?n.x+n.w:n.y+n.h;
|
|
5564
|
+
const cLoN=n=>horiz?n.y:n.x, cHiN=n=>horiz?n.y+n.h:n.x+n.w;
|
|
5565
|
+
const oF=o=>horiz?[o.x,o.x+o.w]:[o.y,o.y+o.h];
|
|
5566
|
+
const oC=o=>horiz?[o.y,o.y+o.h]:[o.x,o.x+o.w];
|
|
5567
|
+
const blockCol=(f,c0,c1,obs)=>obs.some(o=>{
|
|
5568
|
+
const [fa,fb]=oF(o), [ca,cb]=oC(o);
|
|
5569
|
+
return f>fa-2&&f<fb+2&&Math.min(c1,cb)>Math.max(c0,ca)+0.5;
|
|
5570
|
+
});
|
|
5571
|
+
const blockRow=(c,f0,f1,obs)=>obs.some(o=>{
|
|
5572
|
+
const [fa,fb]=oF(o), [ca,cb]=oC(o);
|
|
5573
|
+
return c>ca-2&&c<cb+2&&Math.min(f1,fb)>Math.max(f0,fa)+0.5;
|
|
5574
|
+
});
|
|
5575
|
+
// the leg from node N (at flow position f) out to the corridor at cross c,
|
|
5576
|
+
// ordered node-first; null when neither shape is clear
|
|
5577
|
+
const legTo=(N,f,c,obs)=>{
|
|
5578
|
+
const cb=c>cHiN(N)?cHiN(N):cLoN(N);
|
|
5579
|
+
if(!blockCol(f,Math.min(cb,c),Math.max(cb,c),obs)) return [P(f,cb),P(f,c)];
|
|
5580
|
+
const cc=(cLoN(N)+cHiN(N))/2, gs=[];
|
|
5581
|
+
for(const o of obs){ const [fa,fb]=oF(o); gs.push(fa-10,fb+10); }
|
|
5582
|
+
gs.sort((a,b)=>Math.abs(a-f)-Math.abs(b-f)||b-a);
|
|
5583
|
+
for(const g of gs){
|
|
5584
|
+
if(blockCol(g,Math.min(cc,c),Math.max(cc,c),obs)) continue;
|
|
5585
|
+
if(blockRow(cc,Math.min(f,g),Math.max(f,g),obs)) continue;
|
|
5586
|
+
return [P(g>f?fHiN(N):fLoN(N),cc),P(g,cc),P(g,c)];
|
|
5587
|
+
}
|
|
5588
|
+
return null;
|
|
5589
|
+
};
|
|
5590
|
+
const build=(e,q)=>{
|
|
5591
|
+
const A=byId[e.a], B=byId[e.b], c=corrAt(q.side,q.off);
|
|
5592
|
+
const obs=obsFor(A,B);
|
|
5593
|
+
const la=legTo(A,fC(A),c,obs);
|
|
5594
|
+
const lb=legTo(B,horiz?B.x+B.w*q.frac:B.y+B.h*q.frac,c,obs);
|
|
5595
|
+
if(!la||!lb) return null;
|
|
5596
|
+
const fa=la[la.length-1][horiz?0:1], fb=lb[lb.length-1][horiz?0:1];
|
|
5597
|
+
if(blockRow(c,Math.min(fa,fb),Math.max(fa,fb),obs)) return null;
|
|
5598
|
+
const pts=la.concat(lb.slice().reverse());
|
|
5599
|
+
// drop a zero-length hop where the two legs meet on one column
|
|
5600
|
+
for(let i=1;i<pts.length;i++)
|
|
5601
|
+
if(Math.hypot(pts[i][0]-pts[i-1][0],pts[i][1]-pts[i-1][1])<0.5){ pts.splice(i,1); i--; }
|
|
5602
|
+
return pts;
|
|
5603
|
+
};
|
|
5604
|
+
// TWO OBSTACLE SETS, AND THE DIFFERENCE IS DELIBERATE.
|
|
5605
|
+
// `obsFor` — nodes AND foreign group bands — is what a corridor candidate
|
|
5606
|
+
// must be CLEAR OF to be offered at all: a corridor drawn through a band it
|
|
5607
|
+
// neither starts nor ends inside asserts a membership the document does not.
|
|
5608
|
+
// `nodeObs` — nodes only — is what the pierce TERM charges, because that is
|
|
5609
|
+
// what the drawing is measured on (`thru` counts node rectangles). Charging
|
|
5610
|
+
// the incumbent for a band crossing instead made `patterns/block-b`'s `ptr`
|
|
5611
|
+
// edge look two pierces worse than it is measured to be, and the corridor
|
|
5612
|
+
// won a comparison the figure then paid for in crossings it did not have
|
|
5613
|
+
// (0 -> 9). An edge crossing a foreign band is a real defect and it is
|
|
5614
|
+
// backlog items 6 and 19; it is not this pass's to trade measured axes for.
|
|
5615
|
+
const obsFor=(A,B)=>{
|
|
5616
|
+
const o=nodeObs(A,B);
|
|
5617
|
+
const inb=(b,q)=>q[0]>b.x&&q[0]<b.x+b.w&&q[1]>b.y&&q[1]<b.y+b.h;
|
|
5618
|
+
const pa=[A.x+A.w/2,A.y+A.h/2], pb=[B.x+B.w/2,B.y+B.h/2];
|
|
5619
|
+
for(const b of gbb) if(!inb(b,pa)&&!inb(b,pb)) o.push(b);
|
|
5620
|
+
return o;
|
|
5621
|
+
};
|
|
5622
|
+
const nodeObs=(A,B)=>nodes.filter(n=>n!==A&&n!==B&&!n.boundary)
|
|
5623
|
+
.map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
|
|
5624
|
+
// occupancy the channel plan will use, recomputed from the same inputs so
|
|
5625
|
+
// the two cannot drift; the shift below moves both together.
|
|
5626
|
+
let occRg=0, occBg=0;
|
|
5627
|
+
for(const n of nodes){ occRg=Math.max(occRg,n.x+n.w); occBg=Math.max(occBg,n.y+n.h); }
|
|
5628
|
+
for(const b of gbb){ occRg=Math.max(occRg,b.x+b.w); occBg=Math.max(occBg,b.y+b.h); }
|
|
5629
|
+
if(!horiz) chains.forEach((chain,e)=>{ if(!e.mid) return;
|
|
5630
|
+
const v=chain[1+Math.floor((chain.length-3)/2)];
|
|
5631
|
+
occRg=Math.max(occRg, v.x+v.w/2+9+lblPx(e.mid)); });
|
|
5632
|
+
// A channel route reconstructed as the draw pass will build it — the plain
|
|
5633
|
+
// form of each of the three shapes. The blocked-detour variants are not
|
|
5634
|
+
// reconstructed: they differ only in a short escape leg near an endpoint,
|
|
5635
|
+
// and this is a comparison of corridors, not of stubs.
|
|
5636
|
+
const chGuess=e=>{
|
|
5637
|
+
const A=byId[e.a], B=byId[e.b], P=chPlan.get(e);
|
|
5638
|
+
if(A===B) return null;
|
|
5639
|
+
if(horiz){
|
|
5640
|
+
const chY=occBg+28+P.slot;
|
|
5641
|
+
return [[A.x+A.w/2,outSide(A,'b')],[A.x+A.w/2,chY],[B.x+B.w*P.ef,chY],[B.x+B.w*P.ef,outSide(B,'b')]];
|
|
5642
|
+
}
|
|
5643
|
+
if(P.ringOK){
|
|
5644
|
+
const sx=A.x+A.w/2, gy=occBg+14+P.ring*12, chX=occRg+28+P.slot, topY=chTop-14-P.ring*12;
|
|
5645
|
+
return [[sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]];
|
|
5646
|
+
}
|
|
5647
|
+
const chX=occRg+28+P.slot;
|
|
5648
|
+
return [[outSide(A,'r'),A.y+A.h/2],[chX,A.y+A.h/2],[chX,B.y+B.h*P.ef],[outSide(B,'r'),B.y+B.h*P.ef]];
|
|
5649
|
+
};
|
|
5650
|
+
const guess=e=>{
|
|
5651
|
+
const A=byId[e.a], B=byId[e.b];
|
|
5652
|
+
if(!A||!B||A===B) return null;
|
|
5653
|
+
if(longPlan.has(e)) return longPlan.get(e).pts;
|
|
5654
|
+
if(chPlan.has(e)) return chGuess(e);
|
|
5655
|
+
const ch=chains.get(e), pp=[];
|
|
5656
|
+
if(ch) for(const v of ch.slice(1,-1)) pp.push([v.x+v.w/2,v.y+v.h/2]);
|
|
5657
|
+
const f=pp.length?pp[0]:[B.x+B.w/2,B.y+B.h/2];
|
|
5658
|
+
const l=pp.length?pp[pp.length-1]:[A.x+A.w/2,A.y+A.h/2];
|
|
5659
|
+
const st=[borderPoint(A,f[0],f[1]),...pp,borderPoint(B,l[0],l[1])];
|
|
5660
|
+
// THE INCUMBENT MUST BE GUESSED AS IT WILL BE DRAWN, DETOURS INCLUDED.
|
|
5661
|
+
// Guessing the undetoured line charged the incumbent for pierces the
|
|
5662
|
+
// drawing never contains, and the corridor then won a comparison it had
|
|
5663
|
+
// not earned: `patterns/block-b`'s `ptr` edge scored the incumbent at two
|
|
5664
|
+
// pierces (a chain run measured straight through `Unit B`, which the
|
|
5665
|
+
// chain branch detours around) and took a top corridor that cost the
|
|
5666
|
+
// figure 3 crossings and a `thru` — 0 -> 9. Every guess now runs the same
|
|
5667
|
+
// routeAround pass the draw branches run.
|
|
5668
|
+
const obs=obsFor(A,B);
|
|
5669
|
+
if(st.length>2){
|
|
5670
|
+
for(let i=0;i+1<st.length;i++){
|
|
5671
|
+
const d=routeAround(st[i],st[i+1],obs);
|
|
5672
|
+
if(!d) continue;
|
|
5673
|
+
const ins=d.slice(1,-1); st.splice(i+1,0,...ins); i+=ins.length;
|
|
5674
|
+
}
|
|
5675
|
+
return st;
|
|
5676
|
+
}
|
|
5677
|
+
const rr=routeAround(st[0],st[1],obs);
|
|
5678
|
+
if(!rr) return st;
|
|
5679
|
+
rr[0]=borderPoint(A,rr[1][0],rr[1][1]);
|
|
5680
|
+
rr[rr.length-1]=borderPoint(B,rr[rr.length-2][0],rr[rr.length-2][1]);
|
|
5681
|
+
return rr;
|
|
5682
|
+
};
|
|
5683
|
+
const xseg=(a,b,c,d)=>{
|
|
5684
|
+
const rx=b[0]-a[0], ry=b[1]-a[1], sx=d[0]-c[0], sy=d[1]-c[1];
|
|
5685
|
+
const den=rx*sy-ry*sx; if(Math.abs(den)<1e-9) return false;
|
|
5686
|
+
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;
|
|
5687
|
+
return t>1e-6&&t<1-1e-6&&u>1e-6&&u<1-1e-6;
|
|
5688
|
+
};
|
|
5689
|
+
const crossN=(r,o)=>{ let n=0;
|
|
5690
|
+
for(let i=0;i+1<r.length;i++) for(let j=0;j+1<o.length;j++)
|
|
5691
|
+
if(xseg(r[i],r[i+1],o[j],o[j+1])) n++;
|
|
5692
|
+
return n; };
|
|
5693
|
+
const pierceN=(r,A,B)=>{ const obs=nodeObs(A,B); let n=0;
|
|
5694
|
+
for(let i=0;i+1<r.length;i++) if(segHitsObs(r[i],r[i+1],obs)) n++;
|
|
5695
|
+
return n; };
|
|
5696
|
+
const runsOf=r=>{ const out=[];
|
|
5697
|
+
for(let i=0;i+1<r.length;i++){
|
|
5698
|
+
const p=r[i], q=r[i+1], dx=q[0]-p[0], dy=q[1]-p[1];
|
|
5699
|
+
if(Math.abs(dx)<0.5&&Math.abs(dy)>=PAR_MIN) out.push({v:1,c:p[0],lo:Math.min(p[1],q[1]),hi:Math.max(p[1],q[1])});
|
|
5700
|
+
else if(Math.abs(dy)<0.5&&Math.abs(dx)>=PAR_MIN) out.push({v:0,c:p[1],lo:Math.min(p[0],q[0]),hi:Math.max(p[0],q[0])});
|
|
5701
|
+
}
|
|
5702
|
+
return out; };
|
|
5703
|
+
const conflictN=(r,o)=>{ let n=0;
|
|
5704
|
+
for(const a of runsOf(r)) for(const b of runsOf(o))
|
|
5705
|
+
if(a.v===b.v&&Math.abs(a.c-b.c)<LANE_PITCH-1&&Math.min(a.hi,b.hi)-Math.max(a.lo,b.lo)>PAR_MIN) n++;
|
|
5706
|
+
return n; };
|
|
5707
|
+
const inkOf=r=>{ let s=0;
|
|
5708
|
+
for(let i=0;i+1<r.length;i++){
|
|
5709
|
+
const dx=r[i+1][0]-r[i][0], dy=r[i+1][1]-r[i][1], L=Math.hypot(dx,dy);
|
|
5710
|
+
s+=(Math.abs(dx)<0.5||Math.abs(dy)<0.5)?L:L*1.7;
|
|
5711
|
+
}
|
|
5712
|
+
return s; };
|
|
5713
|
+
const costOf=(r,e,peers)=>{
|
|
5714
|
+
if(!r) return Infinity;
|
|
5715
|
+
const A=byId[e.a], B=byId[e.b];
|
|
5716
|
+
let cr=0, cf=0;
|
|
5717
|
+
for(const q of peers){ if(q.e===e||!q.r) continue; cr+=crossN(r,q.r); cf+=conflictN(r,q.r); }
|
|
5718
|
+
return 10000*(cr+pierceN(r,A,B))+1000*cf+40*Math.max(0,r.length-2)+inkOf(r);
|
|
5719
|
+
};
|
|
5720
|
+
const inBus=new Set();
|
|
5721
|
+
for(const m of busGroups) for(const e of m) inBus.add(e);
|
|
5722
|
+
for(const m of retBusGroups) for(const e of m) inBus.add(e);
|
|
5723
|
+
const longE=doc.edges.filter(e=>{
|
|
5724
|
+
const A=byId[e.a], B=byId[e.b];
|
|
5725
|
+
return A&&B&&e.a!==e.b&&!inBus.has(e)&&!chPlan.has(e)
|
|
5726
|
+
&&(isBack.has(e)||Math.abs(B.rank-A.rank)>1);
|
|
5727
|
+
});
|
|
5728
|
+
const grp=new Map();
|
|
5729
|
+
for(const e of longE){
|
|
5730
|
+
const A=byId[e.a], B=byId[e.b];
|
|
5731
|
+
const k=e.b+'|'+(fC(A)>=fC(B)?'+':'-');
|
|
5732
|
+
if(!grp.has(k)) grp.set(k,[]); grp.get(k).push(e);
|
|
5733
|
+
}
|
|
5734
|
+
// THE UNIT OF DECISION IS THE CONFLICT COMPONENT, not the whole convergence.
|
|
5735
|
+
// Measured on dhcp-client: laning ALL FIVE returns to INIT costs 8 crossings
|
|
5736
|
+
// and 9 conflicts against the incumbent's 3 and 2 — the five sources sit in
|
|
5737
|
+
// three different rows, so four of them have to climb PAST the figure to
|
|
5738
|
+
// reach one corridor and cross everything on the way. What is actually
|
|
5739
|
+
// defective there is the PAIR whose runs already share a corridor
|
|
5740
|
+
// (`REQUESTING -> INIT` and `BOUND -> INIT`, one line to the reader). So a
|
|
5741
|
+
// group is split into connected components under "your incumbent route runs
|
|
5742
|
+
// parallel to mine inside one lane width", and each component is decided on
|
|
5743
|
+
// its own. A component of one is still decided — that is item 41's
|
|
5744
|
+
// single-edge side choice, and flowchart-b is a component of one.
|
|
5745
|
+
const comps=[];
|
|
5746
|
+
for(const [,mem] of grp){
|
|
5747
|
+
const inc0=new Map(); for(const e of mem) inc0.set(e,guess(e));
|
|
5748
|
+
const par=mem.map((_,i)=>i);
|
|
5749
|
+
const find=i=>par[i]===i?i:(par[i]=find(par[i]));
|
|
5750
|
+
for(let i=0;i<mem.length;i++) for(let j=i+1;j<mem.length;j++)
|
|
5751
|
+
if(conflictN(inc0.get(mem[i]),inc0.get(mem[j]))) par[find(j)]=find(i);
|
|
5752
|
+
const byRoot=new Map();
|
|
5753
|
+
mem.forEach((e,i)=>{ const r=find(i); if(!byRoot.has(r)) byRoot.set(r,[]); byRoot.get(r).push(e); });
|
|
5754
|
+
for(const [,c] of byRoot) comps.push(c);
|
|
5755
|
+
}
|
|
5756
|
+
for(const mem of comps){
|
|
5757
|
+
const T=byId[mem[0].b], tf=fC(T);
|
|
5758
|
+
const ord=mem.map((e,i)=>({e,i}))
|
|
5759
|
+
.sort((p,q)=>Math.abs(fC(byId[p.e.a])-tf)-Math.abs(fC(byId[q.e.a])-tf)||p.i-q.i)
|
|
5760
|
+
.map(o=>o.e);
|
|
5761
|
+
const nM=ord.length, up=fC(byId[ord[0].a])>=tf;
|
|
5762
|
+
// arrival fractions: lane 0 lands nearest the sources, the outermost
|
|
5763
|
+
// lane beyond every inner lane's turn-in
|
|
5764
|
+
const fracs=ord.map((e,k)=>nM===1?0.5:(up?(nM-k)/(nM+1):(k+1)/(nM+1)));
|
|
5765
|
+
const peerE=doc.edges.filter(x=>byId[x.a]&&byId[x.b]&&x.a!==x.b&&mem.indexOf(x)<0);
|
|
5766
|
+
const peers=peerE.map(x=>({e:x,r:guess(x)}));
|
|
5767
|
+
const inc=ord.map(e=>({e,r:guess(e)}));
|
|
5768
|
+
const base=inc.reduce((s,q)=>s+costOf(q.r,q.e,peers.concat(inc)),0);
|
|
5769
|
+
let best=null, bestC=base, bestSide=0;
|
|
5770
|
+
for(const side of [-1,1]){
|
|
5771
|
+
const taken=[], cand=[];
|
|
5772
|
+
for(let k=0;k<ord.length;k++){
|
|
5773
|
+
const e=ord[k], A=byId[e.a], B=byId[e.b], frac=fracs[k];
|
|
5774
|
+
const a=fC(A), b=horiz?B.x+B.w*frac:B.y+B.h*frac;
|
|
5775
|
+
const lo=Math.min(a,b), hi=Math.max(a,b), ext=lblExt(e);
|
|
5776
|
+
const off=allocOff(side,lo,hi,ext,taken);
|
|
5777
|
+
taken.push({off,lo,hi,ext});
|
|
5778
|
+
const q={side,off,frac};
|
|
5779
|
+
cand.push({e,q,r:build(e,q),u:{off,lo,hi,ext}});
|
|
5780
|
+
}
|
|
5781
|
+
if(cand.some(z=>!z.r)) continue; // a corridor that is not free here
|
|
5782
|
+
const c=cand.reduce((s,z)=>s+costOf(z.r,z.e,peers.concat(cand)),0);
|
|
5783
|
+
if(c<bestC-1e-6){ bestC=c; best=cand; bestSide=side; }
|
|
5784
|
+
}
|
|
5785
|
+
if(best){
|
|
5786
|
+
if(!usedLanes.has(bestSide)) usedLanes.set(bestSide,[]);
|
|
5787
|
+
for(const z of best){ z.q.pts=z.r; longPlan.set(z.e,z.q); usedLanes.get(bestSide).push(z.u); }
|
|
5788
|
+
}
|
|
5789
|
+
}
|
|
5790
|
+
// the low-side margin the adopted corridors need, taken as a uniform shift
|
|
5791
|
+
let minC=Infinity;
|
|
5792
|
+
for(const [,q] of longPlan) if(q.side<0) minC=Math.min(minC,corrAt(q.side,q.off));
|
|
5793
|
+
if(minC!==Infinity){
|
|
5794
|
+
if(horiz) tShift=Math.max(0,(y0+26)-minC);
|
|
5795
|
+
else lShift=Math.max(0,6-minC);
|
|
5796
|
+
}
|
|
5797
|
+
if(lShift||tShift){
|
|
5798
|
+
for(const n of lay){ n.x+=lShift; n.y+=tShift; }
|
|
5799
|
+
for(const k in gOrigin){ gOrigin[k].x+=lShift; gOrigin[k].y+=tShift; }
|
|
5800
|
+
for(const [,q] of longPlan) q.pts=q.pts.map(z=>[z[0]+lShift,z[1]+tShift]);
|
|
5801
|
+
chTop+=tShift;
|
|
5802
|
+
}
|
|
5803
|
+
}
|
|
5804
|
+
// THE SOURCE STUB a long edge's label rides: the first stretch of the route as
|
|
5805
|
+
// it LEAVES the source, capped so the box lands beside the box the line comes
|
|
5806
|
+
// out of rather than halfway down the corridor. The cap scales with the LABEL
|
|
5807
|
+
// (a stub shorter than the label puts the box back on top of the source
|
|
5808
|
+
// whatever parameter is chosen), and it is shared by the channel routes and
|
|
5809
|
+
// the corridor routes because both have the same problem: a long run whose
|
|
5810
|
+
// far end is nowhere near either endpoint.
|
|
5811
|
+
const srcStubOf=(pp,wpx)=>{
|
|
5812
|
+
const a=pp[0], b=pp[1], L=Math.hypot(b[0]-a[0],b[1]-a[1])||1;
|
|
5813
|
+
const k=Math.min(1,Math.max(64,wpx+24)/L);
|
|
5814
|
+
return [a,[a[0]+(b[0]-a[0])*k, a[1]+(b[1]-a[1])*k]];
|
|
5815
|
+
};
|
|
5816
|
+
// THE DRAWN ROUTE IS THE PLANNED ROUTE, to the pixel. An earlier cut rebuilt
|
|
5817
|
+
// the polyline at draw time from the final coordinates, and the rebuild was a
|
|
5818
|
+
// SIMPLER builder than the planner's — it had no escape leg — so a corridor
|
|
5819
|
+
// whose arrival the planner had routed around a node was redrawn straight
|
|
5820
|
+
// through it (`dhcp-client` gained a `thru` that way). The plan carries its
|
|
5821
|
+
// own points, translated once with the scene shift, and nothing recomputes
|
|
5822
|
+
// them: whatever the cost function scored is exactly what the reader sees.
|
|
5823
|
+
const longRoute=e=>{ const q=longPlan.get(e); return q?q.pts.map(z=>z.slice()):null; };
|
|
4909
5824
|
let W=0,Hh=0;
|
|
4910
5825
|
for(const n of lay){ W=Math.max(W,n.x+n.w); Hh=Math.max(Hh,n.y+n.h-y0-20); }
|
|
4911
5826
|
if(W===0){W=480;} if(Hh===0){Hh=280;}
|
|
@@ -4939,6 +5854,230 @@ function renderScene(doc,y0){
|
|
|
4939
5854
|
const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
|
|
4940
5855
|
gsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.9"'+bandEdge(f)+'/>');
|
|
4941
5856
|
}
|
|
5857
|
+
// ── ELEMENT LABELS ARE INK, AND A ROUTE MUST GO ROUND THEM (item 45a) ─────
|
|
5858
|
+
// §14.4's frontier, on the drawing side. `showcase/arp-resolution` ran the
|
|
5859
|
+
// GREEN unicast shaft straight through `rest of the LAN (hosts C, D, ...)`,
|
|
5860
|
+
// and the misreading it invites — that the unicast reaches the rest of the
|
|
5861
|
+
// LAN — is the exact claim the figure exists to deny. The router's obstacle
|
|
5862
|
+
// set was node boxes and group rects: geometry that is DRAWN AS A SHAPE. An
|
|
5863
|
+
// external is never drawn as a shape (`EXTERNAL-EDGE-ENDPOINTS`) and a band NAME is not a shape at
|
|
5864
|
+
// all, so both were invisible to routing while being fully visible on the
|
|
5865
|
+
// page.
|
|
5866
|
+
//
|
|
5867
|
+
// The mechanism is the SMALLEST one consistent with the router that exists:
|
|
5868
|
+
// these two label boxes join the obstacle list `routeAround` already takes,
|
|
5869
|
+
// so a shaft detours around them exactly as it detours around a node. The
|
|
5870
|
+
// alternative — making the LABEL yield — was rejected: an external's label
|
|
5871
|
+
// has one meaningful place (beyond the open end, away from the figure, `EXTERNAL-EDGE-ENDPOINTS`)
|
|
5872
|
+
// and a band's name has one place (the strip it names), so a yielding label
|
|
5873
|
+
// has nowhere to go, and moving it would break the very adjacency that makes
|
|
5874
|
+
// it readable. A node's own label needs nothing here: it lives inside the
|
|
5875
|
+
// node box, which is already an obstacle.
|
|
5876
|
+
//
|
|
5877
|
+
// An external's label is NOT an obstacle to its OWN edge — that edge must
|
|
5878
|
+
// reach the anchor the label names.
|
|
5879
|
+
const extLbl=n=>{
|
|
5880
|
+
const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
|
|
5881
|
+
const bw=lblPx(n.label), bl=String(n.label).split('\n').length, bh=13*bl;
|
|
5882
|
+
let ox,oy;
|
|
5883
|
+
if(Math.abs(bdx)>=Math.abs(bdy)){ ox=bdx>=0?cx+10:cx-10-bw; oy=cy+3.5-13*bl/2-1.5; }
|
|
5884
|
+
else { ox=cx-bw/2; oy=(bdy>=0?cy+17:cy-10)-13*bl/2-1.5; }
|
|
5885
|
+
return {x:ox,y:oy,w:bw,h:bh};
|
|
5886
|
+
};
|
|
5887
|
+
const extObs=[];
|
|
5888
|
+
for(const n of nodes) if(n.boundary&&n.label) extObs.push(Object.assign(extLbl(n),{id:n.id}));
|
|
5889
|
+
const nameObs=[];
|
|
5890
|
+
for(const k in gBox){ const g=doc.groups.find(z=>z.id===k);
|
|
5891
|
+
if(!g||!g.label) continue;
|
|
5892
|
+
const B=gBox[k];
|
|
5893
|
+
nameObs.push({x:B.x0+10, y:B.yA+16-11.5*0.85, w:cwMax(g.label)*6.5*11.5/11, h:11.5*1.1});
|
|
5894
|
+
}
|
|
5895
|
+
// The obstacle list a given edge must respect: every band name, plus every
|
|
5896
|
+
// external label except the ones this edge itself terminates at.
|
|
5897
|
+
// A label's ink is its glyphs plus the clearance that keeps a line from
|
|
5898
|
+
// READING as struck through it. 4 px on every side — the same number the
|
|
5899
|
+
// legibility floor uses for a label's association margin — so a shaft that
|
|
5900
|
+
// grazes a name at 3 px is detoured rather than tolerated.
|
|
5901
|
+
const LBL_PAD=4;
|
|
5902
|
+
const padded=o=>({x:o.x-LBL_PAD,y:o.y-LBL_PAD,w:o.w+2*LBL_PAD,h:o.h+2*LBL_PAD});
|
|
5903
|
+
const lblObs=e=>extObs.filter(o=>o.id!==e.a&&o.id!==e.b).map(padded)
|
|
5904
|
+
.concat(nameObs.map(padded));
|
|
5905
|
+
// ── A BUNDLE RING IS ORIENTED BY ITS MEMBERS (item 43) ───────────────────
|
|
5906
|
+
// Until now the ring was derived from the member links' MIDPOINTS and
|
|
5907
|
+
// nothing else: `rx = max(46, x-spread + 38)`, `ry = max(26, y-spread + 22)`.
|
|
5908
|
+
// Two facts about that formula are the defect. It is DIRECTION-BLIND — the
|
|
5909
|
+
// axes are the canvas's, never the links' — and its floors are large enough
|
|
5910
|
+
// that a bundle of two short legs draws a near-circle whatever the legs do.
|
|
5911
|
+
// Measured on `patterns/topology-b`: rx 46 / ry 42.7, aspect
|
|
5912
|
+
// 1.07, sitting on two legs that fan VERTICALLY, with the caption lying
|
|
5913
|
+
// across the waist and on top of the `p3` endpoint label.
|
|
5914
|
+
//
|
|
5915
|
+
// The drawing convention for a link bundle is a loop THROUGH which the
|
|
5916
|
+
// links run: narrow along the links, long across them. So the ring is now
|
|
5917
|
+
// derived from the members' own frame.
|
|
5918
|
+
//
|
|
5919
|
+
// 1. DIRECTION. The mean UNDIRECTED direction of the members, by the
|
|
5920
|
+
// doubled-angle circular mean — doubling is what makes `a--b` and
|
|
5921
|
+
// `b--a` the same direction, so a bundle does not change shape when an
|
|
5922
|
+
// author writes a member the other way round (conformance 394).
|
|
5923
|
+
// 2. THE BAND. The ring sits at the MIDPOINT OF THE MEMBERS' SHARED RUN
|
|
5924
|
+
// along that direction — the stretch of the corridor every member is
|
|
5925
|
+
// actually in. That is the placement rule because it is the only band
|
|
5926
|
+
// at which "the spread of the members" is a fact about all of them: a
|
|
5927
|
+
// fanning set (the reference topology's ECMP pair, which leaves two
|
|
5928
|
+
// different spines and arrives at one leaf) has a different spread at
|
|
5929
|
+
// every station, and the shared run is the interval over which the
|
|
5930
|
+
// question is even well posed. Members with no shared run at all fall
|
|
5931
|
+
// back to the mean of their midpoints, which is the old centre.
|
|
5932
|
+
// 3. THE AXES. MINOR along the mean direction, sized to clear the strokes;
|
|
5933
|
+
// MAJOR across it, the members' spread at the band plus padding. A ring
|
|
5934
|
+
// needs a long axis to read as a ring, and it takes it from the spread
|
|
5935
|
+
// when the members fan (`rA >= rL`) and from its own RUN when they do
|
|
5936
|
+
// not — a single-member bundle (`reference/topology`'s multi-home link)
|
|
5937
|
+
// has zero spread, and the shape that hugs one link is an ellipse lying
|
|
5938
|
+
// ALONG it, never a circle straddling it.
|
|
5939
|
+
// 4. NO NODE CONTACT. The ring is shrunk along its minor axis until it
|
|
5940
|
+
// clears every node box, and if it cannot it reverts to the pre-0.4
|
|
5941
|
+
// geometry rather than inventing a shape (no corpus figure does).
|
|
5942
|
+
//
|
|
5943
|
+
// The caption is NOT placed here — it is registered with the label pass
|
|
5944
|
+
// below, so it can see the endpoint labels and the edges it has to avoid.
|
|
5945
|
+
const rnd3=v=>Math.round(v*1000)/1000; // ring coordinates, printed short
|
|
5946
|
+
const RING_ALONG=15, // semi-minor: the ring's body along the links
|
|
5947
|
+
RING_ACROSS=20, // clearance beyond the outermost member
|
|
5948
|
+
RING_LONG=34, // the long axis a ring needs to read as a ring
|
|
5949
|
+
RING_FAN=2, // below this spread the members are one line
|
|
5950
|
+
RING_FLOOR=7, // how thin the minor axis may be squeezed
|
|
5951
|
+
RING_SOLO=4; // a ONE-member bundle: the caption's stand-off
|
|
5952
|
+
// ── A ONE-MEMBER BUNDLE IS ITS CAPTION (item 53) ─────────────────────────
|
|
5953
|
+
// `bundle mh "multi-home" l1--l2` is legal and means something real (a
|
|
5954
|
+
// one-link LAG, an Ethernet Segment with a single member), so the
|
|
5955
|
+
// declaration is not an error and is not dropped. But a ring exists to
|
|
5956
|
+
// UNITE lines, and around ONE line it unites nothing: it is ink that adds
|
|
5957
|
+
// no fact, and on the reference topology it also bought the seam a 104 px
|
|
5958
|
+
// corridor demand (2*RX_MIN + clearance) for a shape nobody had to see.
|
|
5959
|
+
//
|
|
5960
|
+
// So a single-member bundle DRAWS NO ELLIPSE. What it draws is its
|
|
5961
|
+
// CAPTION, bundle-styled (the trunk's own stroke, the ring caption's type)
|
|
5962
|
+
// and placed BY THE LINK — which is exactly the statement the construct
|
|
5963
|
+
// makes: this link is the bundle, and here is its name. The frame below is
|
|
5964
|
+
// therefore kept as a PLACEMENT frame and not as a drawing: the caption
|
|
5965
|
+
// pass already sites a caption around a ring's rim, so a ring squeezed to
|
|
5966
|
+
// `RING_SOLO` across gives the same pass a candidate family hugging the
|
|
5967
|
+
// link. Nothing else in the pass changes.
|
|
5968
|
+
//
|
|
5969
|
+
// AN EMPTY CAPTION ON A ONE-MEMBER BUNDLE THEREFORE DRAWS NOTHING AT ALL.
|
|
5970
|
+
// That is a real consequence and it is not hidden: the reference figure
|
|
5971
|
+
// keeps `bundle unlabelled "" s2--l2` — it is the form demonstrator for
|
|
5972
|
+
// the empty label — and states in a comment that this form is now
|
|
5973
|
+
// MODEL-ONLY. The model still carries the trunk, `read` still reports it,
|
|
5974
|
+
// and the drawing says nothing because there is nothing a ring around one
|
|
5975
|
+
// unnamed line could say.
|
|
5976
|
+
// Does the ring's disc meet an axis-aligned rect? Both are mapped into the
|
|
5977
|
+
// frame where the ring is the unit circle; the rect becomes a convex quad,
|
|
5978
|
+
// and the test is "is the quad within 1 of the origin".
|
|
5979
|
+
const ringHitsRect=(R,RL,RA,b)=>{
|
|
5980
|
+
const q=[[b.x,b.y],[b.x+b.w,b.y],[b.x+b.w,b.y+b.h],[b.x,b.y+b.h]].map(p=>{
|
|
5981
|
+
const dx=p[0]-R.cx, dy=p[1]-R.cy;
|
|
5982
|
+
return [(R.ux*dx+R.uy*dy)/RL, (R.vx*dx+R.vy*dy)/RA];
|
|
5983
|
+
});
|
|
5984
|
+
let inside=false;
|
|
5985
|
+
for(let i=0,j=3;i<4;j=i++)
|
|
5986
|
+
if((q[i][1]>0)!==(q[j][1]>0) &&
|
|
5987
|
+
0 < (q[j][0]-q[i][0])*(0-q[i][1])/(q[j][1]-q[i][1])+q[i][0]) inside=!inside;
|
|
5988
|
+
if(inside) return true;
|
|
5989
|
+
for(let i=0,j=3;i<4;j=i++){
|
|
5990
|
+
const vx=q[j][0]-q[i][0], vy=q[j][1]-q[i][1], L2=vx*vx+vy*vy;
|
|
5991
|
+
const t=L2?Math.max(0,Math.min(1,-(q[i][0]*vx+q[i][1]*vy)/L2)):0;
|
|
5992
|
+
if(Math.hypot(q[i][0]+t*vx, q[i][1]+t*vy)<1) return true;
|
|
5993
|
+
}
|
|
5994
|
+
return false;
|
|
5995
|
+
};
|
|
5996
|
+
const ringOf=t=>{
|
|
5997
|
+
const segs=[];
|
|
5998
|
+
for(const [a,b] of t.pairs){
|
|
5999
|
+
const A=byId[a], B=byId[b]; if(!A||!B) continue;
|
|
6000
|
+
const p=borderPoint(A,B.x+B.w/2,B.y+B.h/2), q=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
|
|
6001
|
+
if(Math.hypot(q[0]-p[0],q[1]-p[1])>1e-9) segs.push([p,q]);
|
|
6002
|
+
}
|
|
6003
|
+
if(!segs.length) return null;
|
|
6004
|
+
// (1) mean undirected direction
|
|
6005
|
+
let c2=0,s2=0;
|
|
6006
|
+
for(const [p,q] of segs){
|
|
6007
|
+
const L=Math.hypot(q[0]-p[0],q[1]-p[1]);
|
|
6008
|
+
const c=(q[0]-p[0])/L, s=(q[1]-p[1])/L;
|
|
6009
|
+
c2+=c*c-s*s; s2+=2*c*s;
|
|
6010
|
+
}
|
|
6011
|
+
const th=Math.hypot(c2,s2)<1e-9
|
|
6012
|
+
? Math.atan2(segs[0][1][1]-segs[0][0][1], segs[0][1][0]-segs[0][0][0])
|
|
6013
|
+
: 0.5*Math.atan2(s2,c2);
|
|
6014
|
+
const ux=Math.cos(th), uy=Math.sin(th), vx=-uy, vy=ux;
|
|
6015
|
+
// (2) the band: the midpoint of the shared run
|
|
6016
|
+
const iv=segs.map(([p,q])=>{
|
|
6017
|
+
const a=ux*p[0]+uy*p[1], b=ux*q[0]+uy*q[1];
|
|
6018
|
+
return a<=b?[a,b,p,q]:[b,a,q,p];
|
|
6019
|
+
});
|
|
6020
|
+
const lo=Math.max(...iv.map(z=>z[0])), hi=Math.min(...iv.map(z=>z[1]));
|
|
6021
|
+
const s=lo<hi ? (lo+hi)/2 : iv.reduce((x,z)=>x+(z[0]+z[1])/2,0)/iv.length;
|
|
6022
|
+
const P=iv.map(z=>{
|
|
6023
|
+
const f=Math.min(1,Math.max(0,(s-z[0])/((z[1]-z[0])||1)));
|
|
6024
|
+
return [z[2][0]+(z[3][0]-z[2][0])*f, z[2][1]+(z[3][1]-z[2][1])*f];
|
|
6025
|
+
});
|
|
6026
|
+
const cx=P.reduce((a,p)=>a+p[0],0)/P.length, cy=P.reduce((a,p)=>a+p[1],0)/P.length;
|
|
6027
|
+
const spread=Math.max(...P.map(p=>Math.abs(vx*(p[0]-cx)+vy*(p[1]-cy))));
|
|
6028
|
+
const runHalf=Math.min(...iv.map(z=>Math.min(s-z[0],z[1]-s)));
|
|
6029
|
+
// (3) axes. Two shapes, and which one is drawn is decided by whether the
|
|
6030
|
+
// members FAN at all. A set that fans is CROSSED by the ring: thin along
|
|
6031
|
+
// the links, long enough across them to take the whole spread with room
|
|
6032
|
+
// to spare, so each member passes through a part of the rim where the
|
|
6033
|
+
// ring is still visibly open. A set that does not fan — one link, or
|
|
6034
|
+
// members lying on top of each other — has no spread to take, and the
|
|
6035
|
+
// shape that says "this link is the bundle" is an ellipse lying ALONG it.
|
|
6036
|
+
let rA, rL;
|
|
6037
|
+
if(spread<RING_FAN){ rL=Math.max(RING_ALONG,Math.min(RING_LONG,Math.max(0,runHalf))); rA=RING_ACROSS; }
|
|
6038
|
+
else { rL=RING_ALONG; rA=Math.max(spread+RING_ACROSS,RING_LONG); }
|
|
6039
|
+
const R={cx,cy,ux,uy,vx,vy,th};
|
|
6040
|
+
// (0) ONE MEMBER (item 53): no ellipse is drawn, so this is a caption
|
|
6041
|
+
// frame and not a shape. It is squeezed across to `RING_SOLO` so the
|
|
6042
|
+
// caption pass's rim stations sit beside the link rather than a ring's
|
|
6043
|
+
// radius away from it, and the node-clearance step below is skipped —
|
|
6044
|
+
// there is no ink to keep out of a box.
|
|
6045
|
+
if(segs.length===1){
|
|
6046
|
+
R.solo=true; R.rL=rL; R.rA=RING_SOLO;
|
|
6047
|
+
R.hw=Math.hypot(rL*ux, RING_SOLO*vx); R.hh=Math.hypot(rL*uy, RING_SOLO*vy);
|
|
6048
|
+
return R;
|
|
6049
|
+
}
|
|
6050
|
+
// (4) out of every node box
|
|
6051
|
+
const boxes=nodes.filter(n=>!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}));
|
|
6052
|
+
if(boxes.some(b=>ringHitsRect(R,rL,rA,b))){
|
|
6053
|
+
let ok=false;
|
|
6054
|
+
for(let k=1;k<=24&&!ok;k++){
|
|
6055
|
+
const RL=rL-(rL-RING_FLOOR)*k/24;
|
|
6056
|
+
if(!boxes.some(b=>ringHitsRect(R,RL,rA,b))){ rL=RL; ok=true; }
|
|
6057
|
+
}
|
|
6058
|
+
// No orientation of this ring clears the drawing. Rather than publish a
|
|
6059
|
+
// ring lying over a node, revert to the pre-0.4 derivation, which is at
|
|
6060
|
+
// least the shape every earlier artifact recorded. No corpus figure
|
|
6061
|
+
// takes this branch; it exists so that a hostile geometry degrades to
|
|
6062
|
+
// the old defect instead of a new one.
|
|
6063
|
+
if(!ok){
|
|
6064
|
+
const M=segs.map(([p,q])=>[(p[0]+q[0])/2,(p[1]+q[1])/2]);
|
|
6065
|
+
const mx=M.reduce((a,m)=>a+m[0],0)/M.length, my=M.reduce((a,m)=>a+m[1],0)/M.length;
|
|
6066
|
+
const lx=Math.max(46,Math.max(...M.map(m=>Math.abs(m[0]-mx)))+38);
|
|
6067
|
+
const ly=Math.max(26,Math.max(...M.map(m=>Math.abs(m[1]-my)))+22);
|
|
6068
|
+
return {cx:mx, cy:my, ux:1, uy:0, vx:0, vy:1, th:0, legacy:true,
|
|
6069
|
+
rL:lx, rA:ly, hw:lx, hh:ly};
|
|
6070
|
+
}
|
|
6071
|
+
}
|
|
6072
|
+
R.rL=rL; R.rA=rA;
|
|
6073
|
+
// the axis-aligned box the caption is placed beside and the canvas grows to
|
|
6074
|
+
R.hw=Math.hypot(rL*ux, rA*vx); R.hh=Math.hypot(rL*uy, rA*vy);
|
|
6075
|
+
return R;
|
|
6076
|
+
};
|
|
6077
|
+
const rings=new Map();
|
|
6078
|
+
for(const t of doc.trunks||[]){ const R=ringOf(t); if(R) rings.set(t,R); }
|
|
6079
|
+
// filled by the label pass below, consumed by the ring drawing further down
|
|
6080
|
+
const ringLbl=new Map(), ringLeads=[];
|
|
4942
6081
|
// edges (sorted by plane z, then doc order)
|
|
4943
6082
|
const edges=zsort(doc.edges);
|
|
4944
6083
|
const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
|
|
@@ -4959,6 +6098,12 @@ function renderScene(doc,y0){
|
|
|
4959
6098
|
// (direction: from→tip). Geometry matches #arr marker (viewBox 0 0 10 10,
|
|
4960
6099
|
// refX=9, refY=5, markerWidth=7, markerHeight=7, markerUnits=strokeWidth=1.6):
|
|
4961
6100
|
// arm = 9*(7/10)*1.6 ≈ 10.08 px, half-width = 5*(7/10)*1.6 ≈ 5.6 px.
|
|
6101
|
+
// Which edge is currently drawing, and which `lblsvg` slots its arrowheads
|
|
6102
|
+
// took. Item 55 recolours an edge AFTER the label pass has run, and an
|
|
6103
|
+
// arrowhead in a different colour from its own shaft is not a discrimination
|
|
6104
|
+
// aid, it is a bug. Recorded rather than inferred: the arrowhead carries no
|
|
6105
|
+
// id of its own.
|
|
6106
|
+
let arrowOwner=null; const arrowIdx=new Map();
|
|
4962
6107
|
const arrowTri=(tip,from,col)=>{
|
|
4963
6108
|
const dx=tip[0]-from[0], dy=tip[1]-from[1], L=Math.hypot(dx,dy)||1;
|
|
4964
6109
|
const ux=dx/L, uy=dy/L; // unit vector from→tip
|
|
@@ -4970,6 +6115,9 @@ function renderScene(doc,y0){
|
|
|
4970
6115
|
arrowBox.push({x:Math.min(tip[0],lx,rx), y:Math.min(tip[1],ly,ry),
|
|
4971
6116
|
w:Math.max(tip[0],lx,rx)-Math.min(tip[0],lx,rx),
|
|
4972
6117
|
h:Math.max(tip[1],ly,ry)-Math.min(tip[1],ly,ry)});
|
|
6118
|
+
if(arrowOwner){ let a=arrowIdx.get(arrowOwner);
|
|
6119
|
+
if(!a){ a=[]; arrowIdx.set(arrowOwner,a); }
|
|
6120
|
+
a.push(lblsvg.length-1); }
|
|
4973
6121
|
};
|
|
4974
6122
|
// back-edge side channel: beyond the occupied lanes (nodes AND group boxes)
|
|
4975
6123
|
let occR=0, occB=0;
|
|
@@ -5021,6 +6169,9 @@ function renderScene(doc,y0){
|
|
|
5021
6169
|
// figure where every convergence is a trunk, or none is, is one drawing
|
|
5022
6170
|
// either way. There is deliberately no per-edge escape.
|
|
5023
6171
|
const busRoute=new Map();
|
|
6172
|
+
// Where each RETURN-BUS member's own leg meets the shared trunk, collected as
|
|
6173
|
+
// the members draw so the last one can dot the joins (item 46).
|
|
6174
|
+
const busJoins=new Map();
|
|
5024
6175
|
{
|
|
5025
6176
|
const RAIL_GAP=22, RAIL_CLEAR=12, RAIL_ROOM=30;
|
|
5026
6177
|
const fLo=n=>horiz?n.x:n.y, fHi=n=>horiz?n.x+n.w:n.y+n.h;
|
|
@@ -5058,7 +6209,7 @@ function renderScene(doc,y0){
|
|
|
5058
6209
|
for(const e of edges){
|
|
5059
6210
|
if(!byId[e.a]||!byId[e.b]||e.a===e.b) continue;
|
|
5060
6211
|
if(busMem.has(e)) continue;
|
|
5061
|
-
if(
|
|
6212
|
+
if(chBack(e)&&!pinned(e.a)&&!pinned(e.b)) continue; // channel routes: not reconstructible here
|
|
5062
6213
|
others.push(incumbent(e));
|
|
5063
6214
|
}
|
|
5064
6215
|
const xseg=(a,b,c,d)=>{
|
|
@@ -5092,6 +6243,51 @@ function renderScene(doc,y0){
|
|
|
5092
6243
|
};
|
|
5093
6244
|
const built=[];
|
|
5094
6245
|
let figureOK=busGroups.length>0;
|
|
6246
|
+
// ── TWO TRUNK FORMS, AND WHY THERE HAD TO BE A SECOND (item 60) ────────
|
|
6247
|
+
// FORM 1, THE CROSS RAIL, is the construct as first built: the sources
|
|
6248
|
+
// spread across the cross axis, each drops onto a shared rail just short
|
|
6249
|
+
// of the target, and the rail carries them in. It assumes the sources are
|
|
6250
|
+
// BESIDE one another. When they are not — when the figure is a ladder and
|
|
6251
|
+
// the sources sit one above the other in a single column — the "drop"
|
|
6252
|
+
// from the far source runs straight down the column and through every
|
|
6253
|
+
// source between it and the target, so the form fails its own pierce test
|
|
6254
|
+
// and the figure declined. `statechart/bfd-session` is exactly that shape:
|
|
6255
|
+
// three purple dashed `admin disable` transitions, DOWN/INIT/UP -> ADMINDOWN,
|
|
6256
|
+
// one class and one meaning, drawn as three separate lines in a few pixels
|
|
6257
|
+
// of shared column with the one sentence written three times.
|
|
6258
|
+
//
|
|
6259
|
+
// FORM 2, THE COLUMN TRUNK, is the same statement rotated: each source
|
|
6260
|
+
// leaves on its cross-facing border, runs the short way out to a trunk
|
|
6261
|
+
// standing clear of the column, and the trunk runs ALONG the flow axis
|
|
6262
|
+
// into the target's side. One trunk, one stroke, one arrowhead, junction
|
|
6263
|
+
// dots at the interior joins, and item 54's per-leg labels at each origin
|
|
6264
|
+
// block edge — the same drawing `patterns/state-b` makes with its four
|
|
6265
|
+
// `reset` returns, which is the pattern the review named.
|
|
6266
|
+
//
|
|
6267
|
+
// Both forms face the same three tests, in the same order, and a form
|
|
6268
|
+
// that fails any of them is not offered: every leg clears every node and
|
|
6269
|
+
// band it does not touch; the form crosses no more of the figure than the
|
|
6270
|
+
// routes it replaces; and — clause 3 of the figure-level style rule above
|
|
6271
|
+
// — if NO form passes for ANY eligible group, the whole figure declines.
|
|
6272
|
+
// The rail is offered first and ties go to it, so no figure that draws a
|
|
6273
|
+
// rail today starts drawing a column instead.
|
|
6274
|
+
const TRUNK_GAP=24;
|
|
6275
|
+
// WHERE ON THE BORDER A BUS LEG LEAVES, AND WHY IT IS THREE EIGHTHS.
|
|
6276
|
+
// Not the centre: every other route in and out of a box is anchored there,
|
|
6277
|
+
// so a leg on the centre-line lies along whatever that border already
|
|
6278
|
+
// sends the same way — measured on `bfd-session`, the three legs ran
|
|
6279
|
+
// exactly down the three red teardown routes, coinc 0 -> 6 and F5 1 -> 8.
|
|
6280
|
+
// Not a quarter either, and that is the same lesson one step further in:
|
|
6281
|
+
// the side-channel plan spreads its own arrivals at k/(m+1) of a box's
|
|
6282
|
+
// extent, so a box with three of them anchors at 1/4, 1/2 and 3/4 and a
|
|
6283
|
+
// leg at 1/4 lands ON one — coinc 0 -> 2 (bfd's DOWN, 107 px of shared
|
|
6284
|
+
// ink), also measured. 3/8 sits BETWEEN the plan's own stations, and it
|
|
6285
|
+
// measures best of everything tried: leg fractions 1/8, 1/5, 1/4, 3/10,
|
|
6286
|
+
// 3/8 and 2/5 give score 38, 20, 22, 16, 16, 16 on bfd, with 3/8 the
|
|
6287
|
+
// middle of the three that tie. Toward the target, because that is the
|
|
6288
|
+
// way the bus is going.
|
|
6289
|
+
const BUS_OFF=3/8;
|
|
6290
|
+
const crossLo=n=>horiz?n.y:n.x, crossHi=n=>horiz?n.y+n.h:n.x+n.w;
|
|
5095
6291
|
for(const mem of busGroups){
|
|
5096
6292
|
if(!figureOK) break;
|
|
5097
6293
|
const T=byId[mem[0].b], src=mem.map(e=>byId[e.a]);
|
|
@@ -5102,37 +6298,120 @@ function renderScene(doc,y0){
|
|
|
5102
6298
|
const railF=dir>0
|
|
5103
6299
|
? Math.min(fLo(T)-RAIL_CLEAR, Math.max(fLo(T)-RAIL_GAP, Math.max(...src.map(fHi))+RAIL_CLEAR))
|
|
5104
6300
|
: Math.max(fHi(T)+RAIL_CLEAR, Math.min(fHi(T)+RAIL_GAP, Math.min(...src.map(fLo))-RAIL_CLEAR));
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
6301
|
+
// The trunk's ENTRY is off the target's centre-line for the same reason
|
|
6302
|
+
// its legs are off their sources' (see the leg comment below): on
|
|
6303
|
+
// `bfd-session` the entry ran back along the `admin enable` return
|
|
6304
|
+
// leaving ADMINDOWN's own border centre, 92 px of shared ink and coinc 4.
|
|
6305
|
+
// It enters a quarter of the box's flow extent on the side the bus comes
|
|
6306
|
+
// FROM, which is the side a reader traces it back along.
|
|
6307
|
+
const tc=cC(T), ftc0=(fLo(T)+fHi(T))/2;
|
|
6308
|
+
const forms=[];
|
|
6309
|
+
// FORM 1 — the cross rail. `u` is a member's position ALONG the trunk,
|
|
6310
|
+
// which is the cross axis here and the flow axis in form 2; everything
|
|
6311
|
+
// downstream (dots, lead) is written in `u` so it serves both.
|
|
6312
|
+
{
|
|
6313
|
+
const cand=[]; let ok=true;
|
|
6314
|
+
for(const e of mem){
|
|
6315
|
+
const s=byId[e.a], cs=cC(s);
|
|
6316
|
+
const j=P(railF,cs), h=P(railF,tc);
|
|
6317
|
+
const pts=Math.abs(cs-tc)<0.5
|
|
6318
|
+
? [borderPoint(s,h[0],h[1]), h, borderPoint(T,h[0],h[1])]
|
|
6319
|
+
: [borderPoint(s,j[0],j[1]), j, h, borderPoint(T,h[0],h[1])];
|
|
6320
|
+
const obs=obsFor(s,T);
|
|
6321
|
+
for(let i=0;i+1<pts.length;i++) if(segHitsObs(pts[i],pts[i+1],obs)) ok=false;
|
|
6322
|
+
if(!ok) break;
|
|
6323
|
+
cand.push({e,u:cs,jp:j,pts});
|
|
6324
|
+
}
|
|
6325
|
+
if(ok) forms.push({cand,uT:tc,tp:P(railF,tc)});
|
|
6326
|
+
}
|
|
6327
|
+
// FORM 2 — the column trunk. The trunk stands one TRUNK_GAP clear of
|
|
6328
|
+
// every box the run passes, on whichever side is cleaner; a band it
|
|
6329
|
+
// cannot clear is caught by the same pierce test as a node.
|
|
6330
|
+
{
|
|
6331
|
+
const ftc=ftc0-dir*(fHi(T)-fLo(T))*BUS_OFF;
|
|
6332
|
+
const runLo=Math.min(...src.map(fLo).concat([fLo(T)])),
|
|
6333
|
+
runHi=Math.max(...src.map(fHi).concat([fHi(T)]));
|
|
6334
|
+
const thru=nodes.filter(n=>!n.boundary&&fHi(n)>runLo+0.5&&fLo(n)<runHi-0.5);
|
|
6335
|
+
// WHERE THE TRUNK STANDS, AND WHY "CLEAR OF THE BOXES" IS NOT ENOUGH.
|
|
6336
|
+
// The first build put the trunk one gap outside the widest BOX in the
|
|
6337
|
+
// run, and on `bfd-session` that is x=149 — straight through the
|
|
6338
|
+
// waypoint detour the green DOWN -> UP advance edge takes at x=193.
|
|
6339
|
+
// The bus crossed 3 where the three separate lines crossed 1, the
|
|
6340
|
+
// never-worse test refused it, and the refusal was right: a trunk
|
|
6341
|
+
// drawn across the figure's existing routes is not a tidier drawing.
|
|
6342
|
+
// So each side offers TWO stations — one just outside the boxes, one
|
|
6343
|
+
// outside every route the figure already draws across the same span —
|
|
6344
|
+
// and the tight one is offered first so a figure only pays the extra
|
|
6345
|
+
// width when the crossings say it must.
|
|
6346
|
+
const oX=[];
|
|
6347
|
+
for(const o of others) for(let i=0;i+1<o.length;i++){
|
|
6348
|
+
const a=o[i], b=o[i+1], af=horiz?a[0]:a[1], bf=horiz?b[0]:b[1];
|
|
6349
|
+
if(Math.max(af,bf)<runLo-0.5||Math.min(af,bf)>runHi+0.5) continue;
|
|
6350
|
+
oX.push(horiz?a[1]:a[0], horiz?b[1]:b[0]);
|
|
6351
|
+
}
|
|
6352
|
+
const stations=[];
|
|
6353
|
+
if(thru.length){
|
|
6354
|
+
const nHi=Math.max(...thru.map(crossHi)), nLo=Math.min(...thru.map(crossLo));
|
|
6355
|
+
stations.push(nHi+TRUNK_GAP, nLo-TRUNK_GAP);
|
|
6356
|
+
if(oX.length){
|
|
6357
|
+
const clearHi=Math.max(nHi,...oX)+TRUNK_GAP, clearLo=Math.min(nLo,...oX)-TRUNK_GAP;
|
|
6358
|
+
if(clearHi>nHi+TRUNK_GAP+0.5) stations.splice(1,0,clearHi);
|
|
6359
|
+
if(clearLo<nLo-TRUNK_GAP-0.5) stations.push(clearLo);
|
|
6360
|
+
}
|
|
6361
|
+
}
|
|
6362
|
+
for(const trunkC of stations){
|
|
6363
|
+
if(trunkC<TRUNK_GAP) continue; // off the left/top margin
|
|
6364
|
+
const cand=[]; let ok=true;
|
|
6365
|
+
for(const e of mem){
|
|
6366
|
+
// THE LEG DOES NOT LEAVE ON THE CENTRE-LINE. Every other route in
|
|
6367
|
+
// and out of a box is anchored on its border centre, so a bus leg
|
|
6368
|
+
// that leaves there lies along whatever that box already sends the
|
|
6369
|
+
// same way: on `bfd-session` the three legs ran EXACTLY down the
|
|
6370
|
+
// three red teardown routes leaving the same borders (coinc 0 -> 6,
|
|
6371
|
+
// F5 1 -> 8, both measured). The leg therefore leaves a quarter of
|
|
6372
|
+
// the box's flow extent TOWARD the target — the direction it is
|
|
6373
|
+
// going anyway — which frees the centre-line for the traffic that
|
|
6374
|
+
// has nowhere else to be.
|
|
6375
|
+
const s=byId[e.a], fsc=(fLo(s)+fHi(s))/2+dir*(fHi(s)-fLo(s))*BUS_OFF;
|
|
6376
|
+
const j=P(fsc,trunkC), h=P(ftc,trunkC);
|
|
6377
|
+
// The leg leaves the border AT its own flow position, not at the
|
|
6378
|
+
// point a ray from the box centre happens to cross: a bus is
|
|
6379
|
+
// axis-aligned by construction (see the style decision above), and
|
|
6380
|
+
// `borderPoint` would start the leg on the centre ray and slant it.
|
|
6381
|
+
const ex0=P(fsc, trunkC>cC(s)?crossHi(s):crossLo(s));
|
|
6382
|
+
const en0=P(ftc, trunkC>cC(T)?crossHi(T):crossLo(T));
|
|
6383
|
+
const pts=[ex0, j, h, en0];
|
|
6384
|
+
const obs=obsFor(s,T);
|
|
6385
|
+
for(let i=0;i+1<pts.length;i++) if(segHitsObs(pts[i],pts[i+1],obs)) ok=false;
|
|
6386
|
+
if(!ok) break;
|
|
6387
|
+
cand.push({e,u:fsc,jp:j,pts});
|
|
6388
|
+
}
|
|
6389
|
+
if(ok) forms.push({cand,uT:ftc,tp:P(ftc,trunkC)});
|
|
6390
|
+
}
|
|
5117
6391
|
}
|
|
5118
|
-
|
|
5119
|
-
const bpts=cand.map(c=>c.pts), ipts=mem.map(incumbent);
|
|
5120
|
-
const bc=crossCount(bpts)+pierceCount(bpts,mem);
|
|
6392
|
+
const ipts=mem.map(incumbent);
|
|
5121
6393
|
const ic=crossCount(ipts)+pierceCount(ipts,mem);
|
|
5122
|
-
|
|
5123
|
-
|
|
6394
|
+
let pick=null, pickC=Infinity;
|
|
6395
|
+
for(const F of forms){
|
|
6396
|
+
const bpts=F.cand.map(c=>c.pts);
|
|
6397
|
+
const bc=crossCount(bpts)+pierceCount(bpts,mem);
|
|
6398
|
+
if(bc>ic) continue; // not beaten: keep the incumbents
|
|
6399
|
+
if(bc<pickC-0.5){ pickC=bc; pick=F; }
|
|
5124
6400
|
}
|
|
5125
|
-
|
|
6401
|
+
if(!pick){ figureOK=false; break; }
|
|
6402
|
+
const cand=pick.cand, uT=pick.uT;
|
|
6403
|
+
// junction dots mark the interior joins only: the two ends of the trunk
|
|
5126
6404
|
// are corners, not junctions, and a dot on a corner is wrong.
|
|
5127
|
-
const
|
|
5128
|
-
const
|
|
6405
|
+
const us=cand.map(c=>c.u).concat([uT]);
|
|
6406
|
+
const uLo=Math.min(...us), uHi=Math.max(...us);
|
|
5129
6407
|
const dots=[];
|
|
5130
|
-
for(const c of cand) if(c.
|
|
5131
|
-
if(
|
|
5132
|
-
|
|
5133
|
-
//
|
|
6408
|
+
for(const c of cand) if(c.u>uLo+0.5&&c.u<uHi-0.5) dots.push(c.jp);
|
|
6409
|
+
if(uT>uLo+0.5&&uT<uHi-0.5&&
|
|
6410
|
+
!dots.some(d=>Math.hypot(d[0]-pick.tp[0],d[1]-pick.tp[1])<0.5)) dots.push(pick.tp);
|
|
6411
|
+
// one arrowhead for the whole bus, and the member whose trunk run is
|
|
6412
|
+
// longest leads it; document order breaks the tie.
|
|
5134
6413
|
let lead=cand[0], best=-1;
|
|
5135
|
-
for(const c of cand){ const d=Math.abs(c.
|
|
6414
|
+
for(const c of cand){ const d=Math.abs(c.u-uT); if(d>best+0.5){ best=d; lead=c; } }
|
|
5136
6415
|
built.push({T,cand,dots,lead});
|
|
5137
6416
|
}
|
|
5138
6417
|
// Nothing to undo when the figure declines: the bus is a routing pass and
|
|
@@ -5142,6 +6421,7 @@ function renderScene(doc,y0){
|
|
|
5142
6421
|
dots:i===g.cand.length-1?g.dots:null, arrow:i===g.cand.length-1}));
|
|
5143
6422
|
}
|
|
5144
6423
|
for(const e of edges){
|
|
6424
|
+
arrowOwner=e;
|
|
5145
6425
|
const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
|
|
5146
6426
|
// an edge is pure stroke: `stroke=` and `fill=` name the same channel
|
|
5147
6427
|
// (stroke= wins), `text=` colours the [tail]/[mid]/[head] labels.
|
|
@@ -5159,31 +6439,99 @@ function renderScene(doc,y0){
|
|
|
5159
6439
|
const wantsStart=e.op==='<->'||e.op==='<-', wantsEnd=e.op==='<->'||e.op==='->';
|
|
5160
6440
|
const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
|
|
5161
6441
|
const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
|
|
5162
|
-
|
|
6442
|
+
// ENDPOINT LABELS ANCHOR BY DISTANCE, NOT BY FRACTION (R? 0.4,
|
|
6443
|
+
// engine-backlog item 42). A `[tail]`/`[head]` label exists to say WHICH
|
|
6444
|
+
// END is called that; a placement at a FRACTION of the run says it only
|
|
6445
|
+
// when the run is long. On `patterns/topology-a`'s 70 px edge both `p1`s
|
|
6446
|
+
// landed mid-span, 23 px apart at the same y — two identical strings that
|
|
6447
|
+
// named neither end. So the request no longer carries a preferred
|
|
6448
|
+
// PARAMETER: it carries its OWN endpoint (`p`), the inward direction
|
|
6449
|
+
// (`q`), the OTHER end of the whole edge (`other`) and the edge's total
|
|
6450
|
+
// run (`tot`). The placement pass turns those into a fixed distance,
|
|
6451
|
+
// clamped to half the edge, and refuses any candidate whose box centre is
|
|
6452
|
+
// not strictly nearer its own endpoint than the other one.
|
|
6453
|
+
const runLen=pp=>{let s=0;for(let i=0;i+1<pp.length;i++)s+=Math.hypot(pp[i+1][0]-pp[i][0],pp[i+1][1]-pp[i][1]);return s;};
|
|
6454
|
+
// ── PORT MARKERS ARE THE TOPOLOGY CONVENTION (item 57) ────────────────
|
|
6455
|
+
// On a network diagram the interface name is written INSIDE the device
|
|
6456
|
+
// box, at the point the link crosses its border. That is not decoration:
|
|
6457
|
+
// it is what makes the name unambiguous, because a port belongs to a
|
|
6458
|
+
// DEVICE and the box is the device. `patterns/topology-a` is the case —
|
|
6459
|
+
// six links, twelve `p*` labels, every one of them floating in the seam
|
|
6460
|
+
// between two tiers where the reader has to decide by eye which box each
|
|
6461
|
+
// belongs to, and Access Node 1's two are the pair that cannot be
|
|
6462
|
+
// decided at all.
|
|
6463
|
+
//
|
|
6464
|
+
// So in the `topology` genre an endpoint label carries its OWN NODE, and
|
|
6465
|
+
// the placement pass below anchors it at the BORDER CROSSING, just inside
|
|
6466
|
+
// the box, instead of 16 px out along the shaft. Everything else about
|
|
6467
|
+
// the request is unchanged — same kind, same font, same collision
|
|
6468
|
+
// scoring, same `other`-end affinity filter — so the batch-2 machinery
|
|
6469
|
+
// does the work and only the candidate family is new.
|
|
6470
|
+
//
|
|
6471
|
+
// The owner is read from the GEOMETRY rather than passed in: `p` is the
|
|
6472
|
+
// point on a border and the owner is the box that border belongs to.
|
|
6473
|
+
// Every `seg()` call passes the tail end with A's border point and the
|
|
6474
|
+
// head end with B's, so this is the same answer with no call site to
|
|
6475
|
+
// keep in step.
|
|
6476
|
+
const portOwner=p=>{
|
|
6477
|
+
if(doc.genre!=='topology'||!A||!B||A.boundary||B.boundary) return null;
|
|
6478
|
+
const d=(N)=>Math.hypot(Math.max(N.x-p[0],0,p[0]-(N.x+N.w)),
|
|
6479
|
+
Math.max(N.y-p[1],0,p[1]-(N.y+N.h)));
|
|
6480
|
+
return A===B ? A : (d(A)<=d(B)?A:B);
|
|
6481
|
+
};
|
|
6482
|
+
const seg=(p,q,lbl,fs,other,tot)=>reqLabel({p,q,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end',
|
|
6483
|
+
other,tot,port:portOwner(p)});
|
|
5163
6484
|
const bus=busRoute.get(e);
|
|
5164
6485
|
if(bus){
|
|
5165
6486
|
const pts=bus.pts;
|
|
5166
6487
|
// data-bus is written LAST so every reader that keys on the
|
|
5167
6488
|
// `d=… fill=none stroke=… stroke-width=1.6` prefix is unaffected.
|
|
5168
|
-
|
|
6489
|
+
// ONE TRUNK, ONE DASH (item 60). The shared trunk is stroked once per
|
|
6490
|
+
// member — that is the convention `data-bus` records — and each member's
|
|
6491
|
+
// dash pattern starts at ITS OWN path start, so three members whose legs
|
|
6492
|
+
// are different lengths lay three differently-phased dash patterns over
|
|
6493
|
+
// the same trunk and the gaps fill in: `bfd-session`'s admin trunk drew
|
|
6494
|
+
// SOLID purple under a legend that says the class is dashed, which is
|
|
6495
|
+
// the drawing contradicting the key. Every member's path ENDS at the
|
|
6496
|
+
// same point (the target's border), so aligning the phase there aligns
|
|
6497
|
+
// it along the whole shared run: the offset is whatever brings the
|
|
6498
|
+
// pattern to a period boundary at the path's end.
|
|
6499
|
+
const per=e.style==='dashed'?10:(e.style==='dotted'?6:0);
|
|
6500
|
+
const doff=per?' stroke-dashoffset="'+(((per-(runLen(pts)%per))%per).toFixed(2))+'"':'';
|
|
6501
|
+
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+doff+' data-bus="'+esc(bus.bus)+'"/>');
|
|
5169
6502
|
noteSegs(e,pts);
|
|
5170
6503
|
for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+4-y0-20); }
|
|
5171
6504
|
if(bus.dots) for(const d of bus.dots)
|
|
5172
6505
|
lblsvg.push('<circle cx="'+d[0]+'" cy="'+d[1]+'" r="3" fill="'+col+'" stroke="none"/>');
|
|
5173
|
-
//
|
|
5174
|
-
//
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
6506
|
+
// ── A MERGE-BUS LABELS AT EACH MEMBER'S OWN BLOCK EDGE (item 54) ───
|
|
6507
|
+
// A merged bus used to carry ONE label, on the shared trunk, because
|
|
6508
|
+
// that is what "they all say the same thing" seemed to mean. It reads
|
|
6509
|
+
// wrong. The trunk is the part of the drawing that belongs to no
|
|
6510
|
+
// member, and a word out there names the RAIL; what a reader wants to
|
|
6511
|
+
// know at each exit is what LEAVING THIS BLOCK means, and the place
|
|
6512
|
+
// they look for it is the connection point.
|
|
6513
|
+
//
|
|
6514
|
+
// So the text moves to the LEG and the trunk carries none: every
|
|
6515
|
+
// member with a label registers it as an ENDPOINT-kind request on its
|
|
6516
|
+
// own first segment, which anchors it 16 px from the source border —
|
|
6517
|
+
// the same distance and the same machinery a port label uses. On
|
|
6518
|
+
// `patterns/state-b` that is four small `reset` texts at the four
|
|
6519
|
+
// exits and one unlabelled trunk.
|
|
6520
|
+
//
|
|
6521
|
+
// THE REPETITION IS THE POINT, NOT A DEFECT. The admission rule for a
|
|
6522
|
+
// bus is that every member says the SAME thing (one label, one stroke,
|
|
6523
|
+
// one dash), so per-leg labelling necessarily repeats that one word
|
|
6524
|
+
// once per exit. That is the direction: the merge is about the INK of
|
|
6525
|
+
// the route, not about suppressing the statement at each origin.
|
|
6526
|
+
// the trunk is drawn once by every member; the arrowhead is drawn ONCE
|
|
6527
|
+
// for the bus, which is the whole point of merging its ink.
|
|
6528
|
+
if(e.mid)
|
|
6529
|
+
reqLabel({p:pts[0],q:pts[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,
|
|
6530
|
+
kind:'end',other:pts[pts.length-1],tot:runLen(pts)});
|
|
5183
6531
|
if(bus.arrow&&wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
|
|
5184
6532
|
continue;
|
|
5185
6533
|
}
|
|
5186
|
-
if(
|
|
6534
|
+
if(chBack(e)&&!pinned(e.a)&&!pinned(e.b)){
|
|
5187
6535
|
// ── ROUTING-CHANGE ARCHITECTURE NOTE (`SELF-EDGE-DRAWING`/`EDGE-BEND-RETENTION`) ──────────
|
|
5188
6536
|
// Edge labels are DEFERRED: every label is registered against its
|
|
5189
6537
|
// FINAL segment geometry (reqLabel/lblReq above) and placed by ONE
|
|
@@ -5251,8 +6599,8 @@ function renderScene(doc,y0){
|
|
|
5251
6599
|
// is a placement a reader still reads as belonging to the loop.
|
|
5252
6600
|
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,
|
|
5253
6601
|
ts:[0.5,0.2,0.8,-0.7,1.7,-1.4,2.4],tw:10});
|
|
5254
|
-
if(e.tail) seg(sp[0],sp[1],
|
|
5255
|
-
if(e.head) seg(sp[3],sp[2],
|
|
6602
|
+
if(e.tail) seg(sp[0],sp[1],e.tail,10,sp[3],runLen(sp));
|
|
6603
|
+
if(e.head) seg(sp[3],sp[2],e.head,10,sp[0],runLen(sp));
|
|
5256
6604
|
if(wantsStart) arrowTri(sp[0],sp[1],col);
|
|
5257
6605
|
if(wantsEnd) arrowTri(sp[sp.length-1],sp[sp.length-2],col);
|
|
5258
6606
|
continue;
|
|
@@ -5282,11 +6630,7 @@ function renderScene(doc,y0){
|
|
|
5282
6630
|
// shorter than the label puts the box back on top of the source box
|
|
5283
6631
|
// whatever parameter is chosen (bfd-session's "Detect expired, Echo
|
|
5284
6632
|
// failed" is 169 px wide and a fixed 64 px stub buried it in INIT).
|
|
5285
|
-
const srcStub=
|
|
5286
|
-
const a=pp[0], b=pp[1], L=Math.hypot(b[0]-a[0],b[1]-a[1])||1;
|
|
5287
|
-
const k=Math.min(1,Math.max(64,wpx+24)/L);
|
|
5288
|
-
return [a,[a[0]+(b[0]-a[0])*k, a[1]+(b[1]-a[1])*k]];
|
|
5289
|
-
};
|
|
6633
|
+
const srcStub=srcStubOf;
|
|
5290
6634
|
if(horiz){ // channel runs below the lanes
|
|
5291
6635
|
const chY=occB+28+P.slot; // labels ride ON the channel
|
|
5292
6636
|
const colR=r=>Math.max(...lane(r).map(n=>n.x+n.w));
|
|
@@ -5300,7 +6644,7 @@ function renderScene(doc,y0){
|
|
|
5300
6644
|
const gx=colR(B.rank)+10+ring*7;
|
|
5301
6645
|
pts.push([gx,chY],[gx,B.y+B.h*P.ef],[outSide(B,'r'),B.y+B.h*P.ef]);
|
|
5302
6646
|
} else pts.push([tx,chY],[tx,outSide(B,'b')]);
|
|
5303
|
-
if(e.mid){
|
|
6647
|
+
if(e.mid&&!P.bus){
|
|
5304
6648
|
const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[1]===chY);
|
|
5305
6649
|
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});
|
|
5306
6650
|
}
|
|
@@ -5308,7 +6652,7 @@ function renderScene(doc,y0){
|
|
|
5308
6652
|
const sx=A.x+A.w/2;
|
|
5309
6653
|
const gy=occB+14+ring*12, chX=occR+28+P.slot, topY=chTop-14-ring*12;
|
|
5310
6654
|
pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
|
|
5311
|
-
if(e.mid){
|
|
6655
|
+
if(e.mid&&!P.bus){
|
|
5312
6656
|
const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
|
|
5313
6657
|
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});
|
|
5314
6658
|
}
|
|
@@ -5325,18 +6669,45 @@ function renderScene(doc,y0){
|
|
|
5325
6669
|
const gy=laneB(B.rank)+10+ring*7;
|
|
5326
6670
|
pts.push([chX,gy],[B.x+B.w*P.ef,gy],[B.x+B.w*P.ef,outSide(B,'b')]);
|
|
5327
6671
|
} else pts.push([chX,ty],[outSide(B,'r'),ty]);
|
|
5328
|
-
if(e.mid){
|
|
6672
|
+
if(e.mid&&!P.bus){
|
|
5329
6673
|
const ss=srcStub(pts,lblPx(e.mid)), c1=pts.findIndex(p=>p[0]===chX);
|
|
5330
6674
|
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});
|
|
5331
6675
|
}
|
|
5332
6676
|
}
|
|
6677
|
+
// A return-bus member's own leg is pts[0]->pts[1] on every form the plan
|
|
6678
|
+
// builds above; pts[1] is therefore where it joins the shared trunk. It is
|
|
6679
|
+
// recorded HERE, before the detour pass can splice points in front of it.
|
|
6680
|
+
if(P.bus&&pts.length>1){
|
|
6681
|
+
if(!busJoins.has(P.bus)) busJoins.set(P.bus,[]);
|
|
6682
|
+
busJoins.get(P.bus).push(pts[1]);
|
|
6683
|
+
}
|
|
5333
6684
|
// non-incident nodes are obstacles for the channel runs too: a run
|
|
5334
6685
|
// that would cut through a sibling (e.g. a pinned node parked on the
|
|
5335
6686
|
// escape lane) detours around it instead of drawing across it. When
|
|
5336
6687
|
// spanning the following corner point gives a shorter total run than
|
|
5337
6688
|
// detour + remaining leg (a detour "spike"), the corner is dropped.
|
|
5338
|
-
const obsN=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}))
|
|
6689
|
+
const obsN=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}))
|
|
6690
|
+
.concat(lblObs(e));
|
|
5339
6691
|
const plen=pp=>{let s=0;for(let k=0;k+1<pp.length;k++)s+=Math.hypot(pp[k+1][0]-pp[k][0],pp[k+1][1]-pp[k][1]);return s;};
|
|
6692
|
+
// A SHORTCUT THAT BUYS A CROSSING IS NOT A SHORTCUT (item 45a). The
|
|
6693
|
+
// spike-prune below trades a planned channel corner for a straight span
|
|
6694
|
+
// whenever the span is shorter. It measures LENGTH and nothing else, so
|
|
6695
|
+
// on `showcase/arp-resolution` — where the element-label obstacle above
|
|
6696
|
+
// is what first pushed the shaft off the label — it happily replaced the
|
|
6697
|
+
// orthogonal channel corner with a long diagonal straight across the
|
|
6698
|
+
// broadcast arrow. Ink went down, the reader's job got harder. The prune
|
|
6699
|
+
// therefore also asks whether the span crosses a line already on the
|
|
6700
|
+
// canvas; `edgeSegs` holds exactly those, in draw order.
|
|
6701
|
+
const xseg=(a1,a2,b1,b2)=>{
|
|
6702
|
+
const d=(p1,p2,p3)=>(p2[0]-p1[0])*(p3[1]-p1[1])-(p2[1]-p1[1])*(p3[0]-p1[0]);
|
|
6703
|
+
const d1=d(a1,a2,b1), d2=d(a1,a2,b2), d3=d(b1,b2,a1), d4=d(b1,b2,a2);
|
|
6704
|
+
return ((d1>0)!==(d2>0))&&((d3>0)!==(d4>0));
|
|
6705
|
+
};
|
|
6706
|
+
const spanCuts=pp=>{
|
|
6707
|
+
for(let k=0;k+1<pp.length;k++)
|
|
6708
|
+
for(const g of edgeSegs) if(g.e!==e&&xseg(pp[k],pp[k+1],g.p,g.q)) return true;
|
|
6709
|
+
return false;
|
|
6710
|
+
};
|
|
5340
6711
|
for(let i=0;i+1<pts.length;i++){
|
|
5341
6712
|
const d=routeAround(pts[i],pts[i+1],obsN);
|
|
5342
6713
|
if(!d) continue;
|
|
@@ -5344,7 +6715,7 @@ function renderScene(doc,y0){
|
|
|
5344
6715
|
if(i+2<pts.length){
|
|
5345
6716
|
const span=segHitsObs(pts[i],pts[i+2],obsN)
|
|
5346
6717
|
?routeAround(pts[i],pts[i+2],obsN):[pts[i],pts[i+2]];
|
|
5347
|
-
if(span&&plen(span)<plen(d)+Math.hypot(pts[i+2][0]-pts[i+1][0],pts[i+2][1]-pts[i+1][1])-1e-6){
|
|
6718
|
+
if(span&&!spanCuts(span)&&plen(span)<plen(d)+Math.hypot(pts[i+2][0]-pts[i+1][0],pts[i+2][1]-pts[i+1][1])-1e-6){
|
|
5348
6719
|
ins=span.slice(1,-1); drop=1;
|
|
5349
6720
|
}
|
|
5350
6721
|
}
|
|
@@ -5352,12 +6723,89 @@ function renderScene(doc,y0){
|
|
|
5352
6723
|
i+=ins.length;
|
|
5353
6724
|
}
|
|
5354
6725
|
for(const p of pts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
|
|
5355
|
-
|
|
6726
|
+
// RETURN BUS (item 46): the collapsed members draw the same trunk, so the
|
|
6727
|
+
// path carries `data-bus` for exactly the reason the forward bus does —
|
|
6728
|
+
// it is what lets a reader (and `layout-lint`'s coincident term) tell a
|
|
6729
|
+
// deliberate shared trunk from two lines hidden under each other. Written
|
|
6730
|
+
// LAST, after stroke-width, so no existing reader breaks.
|
|
6731
|
+
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash
|
|
6732
|
+
+(P.bus?' data-bus="'+esc(P.bus)+'"':'')+'/>');
|
|
5356
6733
|
noteSegs(e,pts);
|
|
5357
|
-
if(
|
|
5358
|
-
|
|
5359
|
-
|
|
5360
|
-
|
|
6734
|
+
if(P.busTail){
|
|
6735
|
+
// JUNCTION DOTS. Every member's OWN leg ends where the shared run
|
|
6736
|
+
// begins — pts[1] on every route form the channel plan builds (the drop
|
|
6737
|
+
// to the rail, the reach out to the trunk) — so the joins are collected
|
|
6738
|
+
// as the members draw and the last one dots them. The join FARTHEST
|
|
6739
|
+
// from the arrival is the rail's own end: a corner, not a junction, and
|
|
6740
|
+
// the forward bus does not dot its corners either.
|
|
6741
|
+
const jx=(busJoins.get(P.bus)||[]).slice(), tip=pts[pts.length-1];
|
|
6742
|
+
if(jx.length>1){
|
|
6743
|
+
let far=0;
|
|
6744
|
+
for(let i2=1;i2<jx.length;i2++)
|
|
6745
|
+
if(Math.hypot(jx[i2][0]-tip[0],jx[i2][1]-tip[1])>Math.hypot(jx[far][0]-tip[0],jx[far][1]-tip[1])) far=i2;
|
|
6746
|
+
jx.splice(far,1);
|
|
6747
|
+
}
|
|
6748
|
+
for(const d of jx)
|
|
6749
|
+
lblsvg.push('<circle cx="'+d[0]+'" cy="'+d[1]+'" r="3" fill="'+col+'" stroke="none"/>');
|
|
6750
|
+
}
|
|
6751
|
+
// ITEM 54: the label rides the member's OWN leg, at its origin block's
|
|
6752
|
+
// edge, and the shared trunk carries none. See the forward bus above
|
|
6753
|
+
// for why — the trunk belongs to no member, and the connection point
|
|
6754
|
+
// is where a branch's meaning is read.
|
|
6755
|
+
if(e.mid&&P.bus)
|
|
6756
|
+
reqLabel({p:pts[0],q:pts[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,
|
|
6757
|
+
kind:'end',other:pts[pts.length-1],tot:runLen(pts)});
|
|
6758
|
+
if(e.tail) seg(pts[0],pts[1],e.tail,10,pts[pts.length-1],runLen(pts));
|
|
6759
|
+
if(e.head) seg(pts[pts.length-1],pts[pts.length-2],e.head,10,pts[0],runLen(pts));
|
|
6760
|
+
if(wantsStart&&(!P.bus||P.busTail)) arrowTri(pts[0],pts[1],col);
|
|
6761
|
+
if(wantsEnd&&(!P.bus||P.busTail)) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
|
|
6762
|
+
continue;
|
|
6763
|
+
}
|
|
6764
|
+
// ── corridor route (items 41/46): side chosen by occupancy, lane by
|
|
6765
|
+
// distance from the shared target. Same detour and label discipline as
|
|
6766
|
+
// the channel routes — a corridor is a channel the figure chose.
|
|
6767
|
+
const lrPts=longRoute(e);
|
|
6768
|
+
if(lrPts){
|
|
6769
|
+
for(const p of lrPts){ W=Math.max(W,p[0]+4); Hh=Math.max(Hh,p[1]+16-y0-20); }
|
|
6770
|
+
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(lrPts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
6771
|
+
noteSegs(e,lrPts);
|
|
6772
|
+
if(e.mid){
|
|
6773
|
+
// WHICH LEG CARRIES THE LABEL, and it is the OPPOSITE of the channel
|
|
6774
|
+
// router's answer. A channel run is the figure's outermost lane, so a
|
|
6775
|
+
// label out there says nothing about which line it names and the stub
|
|
6776
|
+
// wins. A CORRIDOR was chosen precisely because that margin is free —
|
|
6777
|
+
// that is the whole selection rule — so its long run is the clear ink
|
|
6778
|
+
// and the stub is the squeezed end, wedged between a source and the
|
|
6779
|
+
// figure's edge. Measured on dhcp-client: stub-first put
|
|
6780
|
+
// "server refuses / NAK -> drop IP" into the RENEWING/REBINDING corner
|
|
6781
|
+
// and took `lblcol` 2 -> 5; run-first leaves it on the open margin. The
|
|
6782
|
+
// stub stays as the ALTERNATE, so a corridor whose run is busy can
|
|
6783
|
+
// still fall back to it.
|
|
6784
|
+
const ss=srcStubOf(lrPts,lblPx(e.mid));
|
|
6785
|
+
let bi=0,bl=-1;
|
|
6786
|
+
for(let i=0;i+1<lrPts.length;i++){
|
|
6787
|
+
const dxx=lrPts[i+1][0]-lrPts[i][0], dyy=lrPts[i+1][1]-lrPts[i][1];
|
|
6788
|
+
if(Math.abs(dxx)>0.5&&Math.abs(dyy)>0.5) continue; // axis-aligned runs only
|
|
6789
|
+
const l=Math.hypot(dxx,dyy);
|
|
6790
|
+
if(l>bl){ bl=l; bi=i; }
|
|
6791
|
+
}
|
|
6792
|
+
// ...WITH ONE EXCEPTION, AND IT IS THE FLOWCHART CONVENTION (`FLOWCHART-ROLE-KEYWORDS`).
|
|
6793
|
+
// A short branch marker leaving a decision — `yes`, `no` — names that
|
|
6794
|
+
// BRANCH, and it reads as the branch's name only next to the decision.
|
|
6795
|
+
// The placement pass already knows this (its `branch` test), and it is
|
|
6796
|
+
// reachable only from the FIRST segment, so a corridor whose label is a
|
|
6797
|
+
// branch marker hands the stub back the primary carrier: flowchart-b's
|
|
6798
|
+
// left-margin `no` otherwise sat halfway down the figure, level with the
|
|
6799
|
+
// OTHER `no`, naming neither.
|
|
6800
|
+
const branch=A.role==='decision'&&String(e.mid).length<=3&&!String(e.mid).includes('\n');
|
|
6801
|
+
if(bl<0||branch) reqLabel({p:ss[0],q:ss[1],alt:bl<0?null:[lrPts[bi],lrPts[bi+1]],
|
|
6802
|
+
text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
|
|
6803
|
+
else reqLabel({p:lrPts[bi],q:lrPts[bi+1],alt:[ss[0],ss[1]],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
|
|
6804
|
+
}
|
|
6805
|
+
if(e.tail) seg(lrPts[0],lrPts[1],e.tail,10,lrPts[lrPts.length-1],runLen(lrPts));
|
|
6806
|
+
if(e.head) seg(lrPts[lrPts.length-1],lrPts[lrPts.length-2],e.head,10,lrPts[0],runLen(lrPts));
|
|
6807
|
+
if(wantsStart) arrowTri(lrPts[0],lrPts[1],col);
|
|
6808
|
+
if(wantsEnd) arrowTri(lrPts[lrPts.length-1],lrPts[lrPts.length-2],col);
|
|
5361
6809
|
continue;
|
|
5362
6810
|
}
|
|
5363
6811
|
const chain=chains.get(e);
|
|
@@ -5451,8 +6899,8 @@ function renderScene(doc,y0){
|
|
|
5451
6899
|
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
5452
6900
|
noteSegs(e,pts);
|
|
5453
6901
|
if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
|
|
5454
|
-
if(e.tail) seg(p0,pts[1],
|
|
5455
|
-
if(e.head) seg(p1,pts[pts.length-2],
|
|
6902
|
+
if(e.tail) seg(p0,pts[1],e.tail,10,p1,runLen(pts));
|
|
6903
|
+
if(e.head) seg(p1,pts[pts.length-2],e.head,10,p0,runLen(pts));
|
|
5456
6904
|
if(wantsStart) arrowTri(pts[0],pts[1],col);
|
|
5457
6905
|
if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
|
|
5458
6906
|
continue;
|
|
@@ -5467,7 +6915,8 @@ function renderScene(doc,y0){
|
|
|
5467
6915
|
// (routeAround). Obstacle-free edges keep the plain line unchanged.
|
|
5468
6916
|
let route=null;
|
|
5469
6917
|
if(A!==B){
|
|
5470
|
-
const obs=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}))
|
|
6918
|
+
const obs=nodes.filter(n=>n!==A&&n!==B&&!n.boundary).map(n=>({x:n.x,y:n.y,w:n.w,h:n.h}))
|
|
6919
|
+
.concat(lblObs(e));
|
|
5471
6920
|
for(const k in gBox){
|
|
5472
6921
|
const b=gBox[k];
|
|
5473
6922
|
const inG=(px,py)=>px>b.x0&&px<b.x1&&py>b.yA&&py<b.yB;
|
|
@@ -5491,8 +6940,8 @@ function renderScene(doc,y0){
|
|
|
5491
6940
|
}
|
|
5492
6941
|
reqLabel({p:route[bi],q:route[bi+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:bi===0});
|
|
5493
6942
|
}
|
|
5494
|
-
if(e.tail) seg(route[0],route[1],
|
|
5495
|
-
if(e.head) seg(route[route.length-1],route[route.length-2],
|
|
6943
|
+
if(e.tail) seg(route[0],route[1],e.tail,10,route[route.length-1],runLen(route));
|
|
6944
|
+
if(e.head) seg(route[route.length-1],route[route.length-2],e.head,10,route[0],runLen(route));
|
|
5496
6945
|
if(wantsStart) arrowTri(route[0],route[1],col);
|
|
5497
6946
|
if(wantsEnd) arrowTri(route[route.length-1],route[route.length-2],col);
|
|
5498
6947
|
for(const pP of route){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+4-y0-20); }
|
|
@@ -5503,8 +6952,8 @@ function renderScene(doc,y0){
|
|
|
5503
6952
|
if(e.mid)
|
|
5504
6953
|
reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
|
|
5505
6954
|
// endpoint labels at the tail/head positions (three-position model, `EDGE-LABEL-PLACEMENT`)
|
|
5506
|
-
if(e.tail) seg([x1,yy1],[x2,yy2],
|
|
5507
|
-
if(e.head) seg([
|
|
6955
|
+
if(e.tail) seg([x1,yy1],[x2,yy2],e.tail,10,[x2,yy2],runLen([[x1,yy1],[x2,yy2]]));
|
|
6956
|
+
if(e.head) seg([x2,yy2],[x1,yy1],e.head,10,[x1,yy1],runLen([[x1,yy1],[x2,yy2]]));
|
|
5508
6957
|
if(wantsStart) arrowTri([x1,yy1],[x2,yy2],col);
|
|
5509
6958
|
if(wantsEnd) arrowTri([x2,yy2],[x1,yy1],col);
|
|
5510
6959
|
}
|
|
@@ -5539,6 +6988,9 @@ function renderScene(doc,y0){
|
|
|
5539
6988
|
else { ox=cx-bw/2; oy=(bdy>=0?cy+17:cy-10)-13*bl/2-1.5; }
|
|
5540
6989
|
obst.push({x:ox,y:oy,w:bw,h:bh,n:null});
|
|
5541
6990
|
}
|
|
6991
|
+
// gap between two boxes, 0 when they touch or overlap (item 43's tie-break)
|
|
6992
|
+
const boxGap=(a,b)=>Math.hypot(Math.max(0,Math.max(a.x-(b.x+b.w), b.x-(a.x+a.w))),
|
|
6993
|
+
Math.max(0,Math.max(a.y-(b.y+b.h), b.y-(a.y+a.h))));
|
|
5542
6994
|
const ovl=(a,b)=>{
|
|
5543
6995
|
const ix=Math.min(a.x+a.w,b.x+b.w)-Math.max(a.x,b.x);
|
|
5544
6996
|
const iy=Math.min(a.y+a.h,b.y+b.h)-Math.max(a.y,b.y);
|
|
@@ -5558,7 +7010,56 @@ function renderScene(doc,y0){
|
|
|
5558
7010
|
}
|
|
5559
7011
|
return t1>t0;
|
|
5560
7012
|
};
|
|
7013
|
+
// distance from a segment to a label box, measured to the box CENTRE —
|
|
7014
|
+
// the same point F5 measures from, so the scorer and the gate agree.
|
|
7015
|
+
const segBoxD=(p,q,b)=>{
|
|
7016
|
+
const cx=b.x+b.w/2, cy=b.y+b.h/2;
|
|
7017
|
+
const vx=q[0]-p[0], vy=q[1]-p[1], L2=vx*vx+vy*vy;
|
|
7018
|
+
const t=L2?Math.max(0,Math.min(1,((cx-p[0])*vx+(cy-p[1])*vy)/L2)):0;
|
|
7019
|
+
return Math.hypot(cx-(p[0]+t*vx), cy-(p[1]+t*vy));
|
|
7020
|
+
};
|
|
7021
|
+
// STANDOFF (item 59): the shortest distance from a carrying segment to the
|
|
7022
|
+
// label's own box — 0 when the line enters it. This is the distance a
|
|
7023
|
+
// reader crosses to get from the word to the line, and it is the quantity
|
|
7024
|
+
// the cap below is written in. It is NOT `segBoxD`, which measures to the
|
|
7025
|
+
// box CENTRE: a 117 px label beside a near-vertical shaft has its centre
|
|
7026
|
+
// 57 px away and its nearest edge 6 px away, and only the second number
|
|
7027
|
+
// says whether the two touch on the page.
|
|
7028
|
+
const segBoxGap=(p,q,b)=>{
|
|
7029
|
+
if(segHit(p,q,b)) return 0;
|
|
7030
|
+
const ptRect=(x,y)=>Math.hypot(Math.max(b.x-x,0,x-(b.x+b.w)),
|
|
7031
|
+
Math.max(b.y-y,0,y-(b.y+b.h)));
|
|
7032
|
+
const segPt=(x,y)=>{
|
|
7033
|
+
const vx=q[0]-p[0], vy=q[1]-p[1], L2=vx*vx+vy*vy;
|
|
7034
|
+
const t=L2?Math.max(0,Math.min(1,((x-p[0])*vx+(y-p[1])*vy)/L2)):0;
|
|
7035
|
+
return Math.hypot(x-(p[0]+t*vx), y-(p[1]+t*vy));
|
|
7036
|
+
};
|
|
7037
|
+
let d=Math.min(ptRect(p[0],p[1]), ptRect(q[0],q[1]));
|
|
7038
|
+
for(const c of [[b.x,b.y],[b.x+b.w,b.y],[b.x,b.y+b.h],[b.x+b.w,b.y+b.h]])
|
|
7039
|
+
d=Math.min(d,segPt(c[0],c[1]));
|
|
7040
|
+
return d;
|
|
7041
|
+
};
|
|
7042
|
+
// THE CAP, AND WHERE THE NUMBER COMES FROM (item 59). Measured over the
|
|
7043
|
+
// 137 mid-labels the corpus places: min 0.3 px, median
|
|
7044
|
+
// 3.0, p75 5.3, p90 6.0, p95 6.0 — the base side offset is 6 px and nine
|
|
7045
|
+
// labels in ten sit at it or inside it. Above that the DRAWN corpus uses
|
|
7046
|
+
// 7.9, 11.0, 17.4 and 17.4 px (all four on `statechart/bfd-session`), and
|
|
7047
|
+
// one figure reaches 32.6 — `layout-compare/srl-evpn-irb-auto`, which is
|
|
7048
|
+
// refused at geometry time and never published.
|
|
7049
|
+
//
|
|
7050
|
+
// 18 px, and the corpus chose it. Caps of 8 and of 12 were both built and
|
|
7051
|
+
// measured: each one pulls bfd's two 17.4 px labels ("rx Down" and
|
|
7052
|
+
// "rx Init, rx Up") in to 6.0 px, where their own self-loop then draws
|
|
7053
|
+
// through them — lblcol 2 -> 4 on that figure, in both runs. That relief
|
|
7054
|
+
// is EARNED, so the cap has to sit above it; 18 is three times the base
|
|
7055
|
+
// offset, just clear of the largest standoff the corpus can justify, and
|
|
7056
|
+
// it still refuses a float half again as far. Past the cap a mid-label is
|
|
7057
|
+
// not simply placed further out — it takes a LEADER (below), so the
|
|
7058
|
+
// association is drawn rather than left to proximity.
|
|
7059
|
+
const STANDOFF_CAP=18;
|
|
5561
7060
|
const CLAMP=t=>Math.max(0.06,Math.min(0.94,t));
|
|
7061
|
+
// Endpoint-label anchor distance (item 42). See the `kind==='end'` branch.
|
|
7062
|
+
const END_D=16;
|
|
5562
7063
|
// SLOPE CLEARANCE (`cl`): "3 px above the line" clears the line only where
|
|
5563
7064
|
// the box touches it. The offsets are axis-aligned while the segment is
|
|
5564
7065
|
// not, so on a diagonal the line keeps climbing across the box's WIDTH and
|
|
@@ -5570,27 +7071,111 @@ function renderScene(doc,y0){
|
|
|
5570
7071
|
// below: a label 7 px further out to stop being struck is worth it, a
|
|
5571
7072
|
// 90 px shove for a long label on a 45 degree line is not, and the scorer
|
|
5572
7073
|
// decides which case it is holding.
|
|
5573
|
-
const cand=(r,t,side,cl)=>{
|
|
7074
|
+
const cand=(r,t,side,cl,ep)=>{
|
|
5574
7075
|
const lines=String(r.text).split('\n'), n=lines.length;
|
|
5575
7076
|
const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
|
|
5576
7077
|
const lh=r.fs*1.3, h=(n-1)*lh+r.fs*1.1;
|
|
5577
7078
|
const up=(n-1)*lh/2+r.fs*0.85; // baseline y = box top + up
|
|
5578
7079
|
const mx=r.p[0]+(r.q[0]-r.p[0])*t, my=r.p[1]+(r.q[1]-r.p[1])*t;
|
|
5579
7080
|
const sdx=Math.abs(r.q[0]-r.p[0]), sdy=Math.abs(r.q[1]-r.p[1]);
|
|
5580
|
-
let ex=0;
|
|
7081
|
+
let ex=ep||0; // extra perpendicular push (endpoint labels' escape)
|
|
5581
7082
|
if(cl){
|
|
5582
|
-
if(side==='above'||side==='below') ex
|
|
5583
|
-
else if(side==='right'||side==='left') ex
|
|
7083
|
+
if(side==='above'||side==='below') ex+=sdx>1e-9?Math.min(1,sdy/sdx)*w/2:0;
|
|
7084
|
+
else if(side==='right'||side==='left') ex+=sdy>1e-9?Math.min(1,sdx/sdy)*h/2:0;
|
|
5584
7085
|
}
|
|
7086
|
+
// FLUSH THE INK AGAINST THE SHAFT (item 59). `w` is an ESTIMATE — 6.5 px
|
|
7087
|
+
// per character at font-size 11 — and it runs about a sixth wide of what
|
|
7088
|
+
// the browser actually sets. That slack has to go somewhere, and until
|
|
7089
|
+
// this release it went on whichever side the anchor left it: a `left`
|
|
7090
|
+
// label was anchored `start` at the box's LEFT edge, so every pixel of
|
|
7091
|
+
// over-estimate opened up between the last glyph and the line the label
|
|
7092
|
+
// names. Measured on `showcase/tcp-state-machine`: both
|
|
7093
|
+
// "CLOSE / delete TCB" boxes hug their purple shaft at the placement's
|
|
7094
|
+
// own 6 px, and the TEXT stops 23 px short of it — which is the distance
|
|
7095
|
+
// the maintainer's review calls a drift the tone pairing cannot rescue.
|
|
7096
|
+
// A side label is therefore anchored on the edge that FACES its shaft:
|
|
7097
|
+
// `end` on the left, `start` on the right, so the ragged edge falls away
|
|
7098
|
+
// from the line and the estimate's slack can never be read as standoff.
|
|
7099
|
+
// The BOX is unchanged, so nothing in the collision scoring moves.
|
|
5585
7100
|
let bx,by,x,anchor=n>1?'middle':'start';
|
|
5586
7101
|
if(side==='on') { bx=mx-w/2; by=my-4-up; anchor='middle'; }
|
|
5587
7102
|
else if(side==='above') { bx=mx-w/2; by=my-3-h-ex; anchor='middle'; }
|
|
5588
7103
|
else if(side==='below') { bx=mx-w/2; by=my+3+ex; anchor='middle'; }
|
|
5589
|
-
else if(side==='right') { bx=mx+6+ex; by=my-h/2; }
|
|
5590
|
-
else { bx=mx-6-w-ex; by=my-h/2; }
|
|
5591
|
-
x=anchor==='middle'?bx+w/2:bx;
|
|
7104
|
+
else if(side==='right') { bx=mx+6+ex; by=my-h/2; anchor='start'; }
|
|
7105
|
+
else { bx=mx-6-w-ex; by=my-h/2; anchor='end'; }
|
|
7106
|
+
x=anchor==='middle'?bx+w/2:anchor==='end'?bx+w:bx;
|
|
5592
7107
|
return {x,y:by+up,anchor,t,side,ex,box:{x:bx,y:by,w,h}};
|
|
5593
7108
|
};
|
|
7109
|
+
// ── PORT-MARKER CANDIDATES (item 57) ──────────────────────────────────
|
|
7110
|
+
// The 16 px own-port anchor of item 42 becomes a BORDER-CROSSING anchor:
|
|
7111
|
+
// the box sits just inside the node, on the side the link crosses, with
|
|
7112
|
+
// its along-border position free so two ports on one border spread
|
|
7113
|
+
// instead of stacking. `PORT_IN` is the gap from the border line to the
|
|
7114
|
+
// marker (enough that the box does not sit ON the stroke); `PORT_END` is
|
|
7115
|
+
// how near a corner a marker may be written; the offsets are one marker
|
|
7116
|
+
// width apart, which is exactly the pitch two ports need to be two.
|
|
7117
|
+
const PORT_IN=2, PORT_END=4, PORT_HUG=25, PORT_OUT=60;
|
|
7118
|
+
// The node's own drawn label, computed by the SAME formulas the node pass
|
|
7119
|
+
// emits it with (shrink-to-fit included), so the obstacle and the drawing
|
|
7120
|
+
// cannot disagree about where a name is.
|
|
7121
|
+
const nodeInk=n=>{
|
|
7122
|
+
if(!n||!n.label) return null;
|
|
7123
|
+
const nl=String(n.label).split('\n');
|
|
7124
|
+
const need=Math.max(...nl.map(cw))*CH;
|
|
7125
|
+
const avail=2*inscribedHalfW(shapeAxes(n),nl.length*8)-16;
|
|
7126
|
+
const fs=(n.rigid&&need>avail)?Math.max(8,FONT*avail/need):FONT;
|
|
7127
|
+
const lw=need*fs/FONT, lh=nl.length*fs*1.15;
|
|
7128
|
+
return {x:n.x+n.w/2-lw/2, y:n.y+n.h/2-lh/2, w:lw, h:lh};
|
|
7129
|
+
};
|
|
7130
|
+
// A label's box is its LINE box: full leading above and below. Its INK is
|
|
7131
|
+
// the glyph band inside that, and the two questions are different (see the
|
|
7132
|
+
// own-name term in the scorer).
|
|
7133
|
+
const inkBox=(b,fs)=>({x:b.x, y:b.y+fs*0.14, w:b.w, h:Math.max(1,b.h-fs*0.28)});
|
|
7134
|
+
const portCands=r=>{
|
|
7135
|
+
const N=r.port, P=r.p, out=[];
|
|
7136
|
+
if(!N) return out;
|
|
7137
|
+
const lines=String(r.text).split('\n'), nL=lines.length;
|
|
7138
|
+
const w=Math.max(...lines.map(cw))*6.5*r.fs/11;
|
|
7139
|
+
const lh=r.fs*1.3, h=(nL-1)*lh+r.fs*1.1, up=(nL-1)*lh/2+r.fs*0.85;
|
|
7140
|
+
// Which border did the link cross? The nearest side of the box.
|
|
7141
|
+
const dl=Math.abs(P[0]-N.x), dr=Math.abs(P[0]-(N.x+N.w)),
|
|
7142
|
+
dt=Math.abs(P[1]-N.y), db=Math.abs(P[1]-(N.y+N.h));
|
|
7143
|
+
const m=Math.min(dl,dr,dt,db);
|
|
7144
|
+
const side=m===dt?'top':m===db?'bottom':m===dl?'left':'right';
|
|
7145
|
+
const vert=(side==='top'||side==='bottom');
|
|
7146
|
+
// ROOM. A marker that cannot be written between the two corners of the
|
|
7147
|
+
// side it crosses has no inside form at all; the ordinary outside
|
|
7148
|
+
// family is then the only one offered, which is the stated fallback.
|
|
7149
|
+
const span=vert?N.w:N.h, need=(vert?w:h)+2*PORT_END;
|
|
7150
|
+
if(span<need) return out;
|
|
7151
|
+
const lo=(vert?N.x:N.y)+PORT_END, hi=(vert?N.x+N.w:N.y+N.h)-PORT_END;
|
|
7152
|
+
const pitch=(vert?w:h)+4;
|
|
7153
|
+
const u0=vert?P[0]:P[1];
|
|
7154
|
+
// TWO RINGS OF CANDIDATES, AND BOTH ARE AT THE CROSSING.
|
|
7155
|
+
// in = inside the border, the convention proper;
|
|
7156
|
+
// out = the mirror image, just OUTSIDE the same border.
|
|
7157
|
+
// The second exists because a small box genuinely runs out of room —
|
|
7158
|
+
// `reference/topology`'s L1 is 42 x 36 and holds one port name, not two
|
|
7159
|
+
// — and the alternative fallback (the ordinary 16 px-along-the-shaft
|
|
7160
|
+
// family) puts the second name out in the seam BETWEEN the two shafts
|
|
7161
|
+
// arriving at that box, which measured F5 margin 0.47 px: a label that
|
|
7162
|
+
// has stopped saying which of two lines it names. Hugging the border it
|
|
7163
|
+
// could not get inside keeps it on its own port, which is the fact it
|
|
7164
|
+
// exists to state. It is surcharged, so it is taken only after inside
|
|
7165
|
+
// has failed.
|
|
7166
|
+
for(const inside of [true,false]) for(const k of [0,1,-1,2,-2]){
|
|
7167
|
+
const u=Math.max(lo+(vert?w:h)/2, Math.min(hi-(vert?w:h)/2, u0+k*pitch));
|
|
7168
|
+
let bx,by;
|
|
7169
|
+
if(side==='bottom'){ bx=u-w/2; by=inside?N.y+N.h-PORT_IN-h:N.y+N.h+PORT_IN; }
|
|
7170
|
+
else if(side==='top'){ bx=u-w/2; by=inside?N.y+PORT_IN:N.y-PORT_IN-h; }
|
|
7171
|
+
else if(side==='left'){ bx=inside?N.x+PORT_IN:N.x-PORT_IN-w; by=u-h/2; }
|
|
7172
|
+
else { bx=inside?N.x+N.w-PORT_IN-w:N.x+N.w+PORT_IN; by=u-h/2; }
|
|
7173
|
+
out.push({x:bx+w/2, y:by+up, anchor:'middle', t:undefined, side,
|
|
7174
|
+
ex:Math.abs(k)*pitch, inPort:inside, hug:!inside,
|
|
7175
|
+
box:{x:bx,y:by,w,h}});
|
|
7176
|
+
}
|
|
7177
|
+
return out;
|
|
7178
|
+
};
|
|
5594
7179
|
const placed=[];
|
|
5595
7180
|
// A request may name a SECOND carrying segment (`alt`). Back edges do: the
|
|
5596
7181
|
// stub leaving the source is the preferred carrier because it says which
|
|
@@ -5601,17 +7186,36 @@ function renderScene(doc,y0){
|
|
|
5601
7186
|
// flat surcharge so it is taken only when the stub really has nowhere.
|
|
5602
7187
|
if(lblReq.length) for(const r0 of lblReq){
|
|
5603
7188
|
const carriers=[[r0.p,r0.q]].concat(r0.alt?[r0.alt]:[]);
|
|
5604
|
-
let best=null,bestS=Infinity
|
|
7189
|
+
let best=null,bestS=Infinity, anyBest=null,anyS=Infinity, offBest=null,offS=Infinity,
|
|
7190
|
+
farBest=null,farS=Infinity;
|
|
5605
7191
|
for(let ci=0;ci<carriers.length;ci++){
|
|
5606
7192
|
const r=ci?Object.assign({},r0,{p:carriers[ci][0],q:carriers[ci][1]}):r0;
|
|
5607
7193
|
const dx=r.q[0]-r.p[0], dy=r.q[1]-r.p[1];
|
|
5608
7194
|
const across=Math.abs(dx)>=Math.abs(dy);
|
|
5609
|
-
let sides, ts, tPref, apWant=null;
|
|
7195
|
+
let sides, ts, tPref, apWant=null, tScale=r.tw||70;
|
|
5610
7196
|
if(r.kind==='end'){
|
|
5611
|
-
//
|
|
7197
|
+
// DISTANCE, NOT FRACTION (item 42). `END_D` is 16 px, chosen from the
|
|
7198
|
+
// instrument rather than from taste: F5's own filter (2) treats a
|
|
7199
|
+
// label within 18 px of a node border as one the reader ties to the
|
|
7200
|
+
// NODE and not to an edge-edge ambiguity, and that is precisely the
|
|
7201
|
+
// reading a port label wants. Anchoring at 16 px puts every endpoint
|
|
7202
|
+
// label inside that reading on every edge long enough to hold it.
|
|
7203
|
+
// The clamp is half the edge's whole run AND half the carrying
|
|
7204
|
+
// segment: a label may never reach the middle of the thing it slides
|
|
7205
|
+
// along, because the middle names no end. Below the clamp the label
|
|
7206
|
+
// sits as near its port as the geometry allows.
|
|
7207
|
+
const Lseg=Math.hypot(dx,dy)||1;
|
|
7208
|
+
const cap=Math.min(0.5*(r.tot||Lseg), 0.5*Lseg);
|
|
7209
|
+
const d0=Math.min(END_D,cap);
|
|
7210
|
+
// The push set is in PIXELS along the edge, outward-biased: a label
|
|
7211
|
+
// driven off its anchor by a collision walks INTO the span (it can
|
|
7212
|
+
// never walk off the end), and it stops at the clamp.
|
|
7213
|
+
const dset=[0,5,-4,10,-8,16,24].map(k=>d0+k).filter(d=>d>=2&&d<=cap+1e-9);
|
|
7214
|
+
if(!dset.length) dset.push(Math.max(1,cap));
|
|
7215
|
+
tPref=d0/Lseg;
|
|
7216
|
+
ts=dset.map(d=>d/Lseg);
|
|
7217
|
+
tScale=1.2*Lseg; // the pull back to the anchor is priced in PIXELS
|
|
5612
7218
|
sides=['on'].concat(across?['above','below']:['right','left']);
|
|
5613
|
-
tPref=r.t0;
|
|
5614
|
-
ts=[r.t0,r.t0-0.06,r.t0+0.06,r.t0-0.12,r.t0+0.12].map(CLAMP);
|
|
5615
7219
|
} else {
|
|
5616
7220
|
sides=across?['above','below']:['right','left'];
|
|
5617
7221
|
// ANTI-PARALLEL PAIRS: the label belongs on the OUTSIDE of its own
|
|
@@ -5650,17 +7254,86 @@ function renderScene(doc,y0){
|
|
|
5650
7254
|
tPref=branch?0.22:0.5;
|
|
5651
7255
|
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]);
|
|
5652
7256
|
}
|
|
5653
|
-
|
|
5654
|
-
|
|
7257
|
+
// The perpendicular escape (item 42). When every in-line position is
|
|
7258
|
+
// blocked, an endpoint label must still be able to say which end it
|
|
7259
|
+
// names, and the only remaining freedom is ACROSS the line. 9 px is one
|
|
7260
|
+
// text line-height: enough to clear a co-located neighbour, priced per
|
|
7261
|
+
// pixel below like every other displacement, so it is taken only when
|
|
7262
|
+
// the in-line positions really have nowhere.
|
|
7263
|
+
const eps=r.kind==='end'?[0,9]:[0];
|
|
7264
|
+
// ── THE CANDIDATE FAMILY ──────────────────────────────────────────
|
|
7265
|
+
// Normally: sides x parameters x slope-clearance x escape, as above.
|
|
7266
|
+
// A PORT MARKER (item 57) adds a family in FRONT of it — positions
|
|
7267
|
+
// just inside its own node's border at the crossing — and keeps the
|
|
7268
|
+
// ordinary family behind, surcharged. That is the fallback the
|
|
7269
|
+
// direction asks for, and it is a PRICE rather than a test, so a
|
|
7270
|
+
// marker is pushed back outside exactly when inside is genuinely
|
|
7271
|
+
// blocked (a box too narrow to hold the text, or the node's own name
|
|
7272
|
+
// already there) and never merely because outside happens to be tidy.
|
|
7273
|
+
const cs=[];
|
|
7274
|
+
if(r.port) for(const c of portCands(r)) cs.push({c,si:0});
|
|
7275
|
+
for(let si=0;si<sides.length;si++) for(const t of ts) for(const cl of [0,1]) for(const ep of eps)
|
|
7276
|
+
cs.push({c:cand(r,t,sides[si],cl,ep),si,out:!!r.port});
|
|
7277
|
+
for(const CS of cs){
|
|
7278
|
+
const c=CS.c, si=CS.si, t=(c.t===undefined?tPref:c.t);
|
|
7279
|
+
c.car=[r.p,r.q]; // the carrier this candidate rides (item 59)
|
|
7280
|
+
// ENDPOINT AFFINITY IS A HARD FILTER, NOT A PRICE. A tail label whose
|
|
7281
|
+
// box centre is nearer the head than the tail has stopped stating the
|
|
7282
|
+
// one fact it exists to state, and no amount of collision relief buys
|
|
7283
|
+
// that back. Candidates that fail are dropped; `bestAny` keeps the
|
|
7284
|
+
// least-bad one so a figure with no legal position still draws.
|
|
7285
|
+
let owns=true;
|
|
7286
|
+
if(r.kind==='end'&&r.other){
|
|
7287
|
+
const ccx=c.box.x+c.box.w/2, ccy=c.box.y+c.box.h/2;
|
|
7288
|
+
owns=Math.hypot(ccx-r.p[0],ccy-r.p[1])<Math.hypot(ccx-r.other[0],ccy-r.other[1]);
|
|
7289
|
+
}
|
|
5655
7290
|
let s=0;
|
|
5656
7291
|
for(const b of placed) s+=3*ovl(c.box,b);
|
|
5657
|
-
|
|
7292
|
+
// A PORT MARKER IS SUPPOSED TO BE INSIDE ITS OWN BOX (item 57), so
|
|
7293
|
+
// that one node is not charged for it. Every other box still is, at
|
|
7294
|
+
// the usual weights — a marker that has slid out of its own device
|
|
7295
|
+
// and into a neighbour is the defect this convention exists to end.
|
|
7296
|
+
for(const o of obst){ if(c.inPort&&o.n===r.port) continue;
|
|
7297
|
+
s+=(o.n===r.A||o.n===r.B?6:2.4)*ovl(c.box,o); }
|
|
7298
|
+
// ...but the node's own NAME is ink, and a port written across it is
|
|
7299
|
+
// the same misreading `band` captions were fixed for (item 49).
|
|
7300
|
+
//
|
|
7301
|
+
// MEASURED INK TO INK, not line box to line box. Everywhere else in
|
|
7302
|
+
// this pass a label is its generous LINE box, which is right when the
|
|
7303
|
+
// question is "is there room to put this somewhere". Here the two
|
|
7304
|
+
// boxes are already inside one 36 px node and the question is whether
|
|
7305
|
+
// the GLYPHS touch: a 13 px name's line box is 15 px tall and its
|
|
7306
|
+
// letters are about 9, so the line boxes overlap by a pixel or two in
|
|
7307
|
+
// every ordinary topology node and the marker was priced out of a box
|
|
7308
|
+
// it fits in perfectly well. `topology-a` measured 4 of 12 markers
|
|
7309
|
+
// inside on line boxes and 12 of 12 on ink.
|
|
7310
|
+
if(c.inPort){ const li=nodeInk(r.port);
|
|
7311
|
+
if(li) s+=6*ovl(inkBox(c.box,r.fs), {x:li.x,y:li.y+li.h*0.09,w:li.w,h:li.h*0.82}); }
|
|
7312
|
+
// the marker leaves its box only under duress, and leaves its PORT
|
|
7313
|
+
// only after that (PORT_HUG < PORT_OUT)
|
|
7314
|
+
s+=CS.out?PORT_OUT:(c.hug?PORT_HUG:0);
|
|
5658
7315
|
for(const a of arrowBox) s+=4*ovl(c.box,a);
|
|
5659
7316
|
// The label's OWN edge is charged like any other. It used to be exempt
|
|
5660
7317
|
// (`g.e!==r.e`), which made a label lying across the line it names FREE
|
|
5661
7318
|
// — and that is the single commonest way a label stops saying which
|
|
5662
7319
|
// line it belongs to, so the exemption was paying for the defect.
|
|
5663
7320
|
for(const g of edgeSegs) if(segHit(g.p,g.q,c.box)) s+=26;
|
|
7321
|
+
// THE PERPENDICULAR STEP STAYS CHEAP, AND THAT IS A MEASURED RESULT
|
|
7322
|
+
// (item 59). The direction was "relief slides along the shaft first,
|
|
7323
|
+
// steps away only within a cap", and the obvious way to write the
|
|
7324
|
+
// first half is to make the step dearer than the slide — the slide is
|
|
7325
|
+
// priced in parameter units, `tScale`/L per pixel, which is 0.35/px on
|
|
7326
|
+
// a 200 px run, exactly what the step costs. Tripling the step to
|
|
7327
|
+
// 0.9/px was built and measured, and the corpus refused it:
|
|
7328
|
+
// `showcase/tcp-state-machine` went lblcol 0 -> 4 and
|
|
7329
|
+
// `patterns/erd-a` 0 -> 1. The reason is that a mid-label's ENTIRE
|
|
7330
|
+
// perpendicular displacement is slope clearance — the smallest step
|
|
7331
|
+
// that gets the box off its own diagonal shaft — and a strike costs
|
|
7332
|
+
// 26 in this scorer, so at 0.9/px any clearance over 29 px is outbid
|
|
7333
|
+
// by lying across the line the label names. Pricing the step above
|
|
7334
|
+
// the strike buys strikes. So the along-first half of item 59 is
|
|
7335
|
+
// carried by the CAP below (a step can never exceed it) and by the
|
|
7336
|
+
// along-shaft candidate family, not by this coefficient.
|
|
5664
7337
|
s+=0.35*c.ex; // price of the slope-clearance displacement
|
|
5665
7338
|
s+=ci*30; // price of leaving the preferred carrier
|
|
5666
7339
|
// Two identical texts sitting side by side is the defect in its purest
|
|
@@ -5676,16 +7349,203 @@ function renderScene(doc,y0){
|
|
|
5676
7349
|
// offers parameters outside [0,1] states its own weight so its escape
|
|
5677
7350
|
// positions cost what they are worth in pixels rather than being
|
|
5678
7351
|
// priced out by the length of the thing they slide along.
|
|
5679
|
-
s+=
|
|
5680
|
-
|
|
5681
|
-
|
|
7352
|
+
s+=tScale*Math.abs(t-tPref)+si*10;
|
|
7353
|
+
// ASSOCIATION MARGIN, IN THE SCORER AT LAST (F5, §14).
|
|
7354
|
+
// The legibility floor asks how much NEARER a label is to the line it
|
|
7355
|
+
// names than to the next line, and until now that question was only
|
|
7356
|
+
// ever asked after the fact, by the lint. Two positions can both be
|
|
7357
|
+
// collision-free and one of them still sit in the gap BETWEEN two
|
|
7358
|
+
// lines, where the reader has to guess; nothing in the score saw the
|
|
7359
|
+
// difference and the side-order term then decided by index.
|
|
7360
|
+
// Measured on `showcase/arp-resolution` (item 58's acceptance
|
|
7361
|
+
// figure): `1: heard, ignored` scored 0 above its line and 0 below
|
|
7362
|
+
// it, took `above` by index, and parked in the 9.4 px gap between the
|
|
7363
|
+
// broadcast line and the reciprocal pair — which then left the ARP
|
|
7364
|
+
// REPLY's own 221 px label no clean position on a 194 px run, and it
|
|
7365
|
+
// drew struck. With the margin priced, that label takes the open
|
|
7366
|
+
// paper below and the reply's label follows it: `lblcol` 1 -> 0.
|
|
7367
|
+
// The bonus is capped so it can break a tie between two legal
|
|
7368
|
+
// positions and nothing more — it can never outrank an overlap,
|
|
7369
|
+
// which is an AREA and unbounded.
|
|
7370
|
+
{ let own=Infinity, oth=Infinity;
|
|
7371
|
+
const perEdge=new Map();
|
|
7372
|
+
for(const g of edgeSegs){
|
|
7373
|
+
const d=segBoxD(g.p,g.q,c.box);
|
|
7374
|
+
if(g.e===r.e){ if(d<own) own=d; } else if(d<oth) oth=d;
|
|
7375
|
+
const cur=perEdge.get(g.e);
|
|
7376
|
+
if(cur===undefined||d<cur) perEdge.set(g.e,d);
|
|
7377
|
+
}
|
|
7378
|
+
if(own<Infinity&&oth<Infinity) s-=0.4*Math.min(45,Math.max(0,oth-own));
|
|
7379
|
+
// ...AND THE SCORER ALSO ASKS THE GATE'S OWN QUESTION (item 60).
|
|
7380
|
+
// The bonus above rewards a position that is NEARER ITS OWN line
|
|
7381
|
+
// than to the next one, and it is clamped at zero — so between two
|
|
7382
|
+
// positions that are BOTH nearer a foreign line, it says nothing,
|
|
7383
|
+
// and the figure falls back to the side-order index. The legibility
|
|
7384
|
+
// floor's F5 does not ask about ownership at that point: it asks
|
|
7385
|
+
// whether the two NEAREST lines are within M of each other, because
|
|
7386
|
+
// that is the reading where a human cannot tell which line the word
|
|
7387
|
+
// belongs to at all. Measured on `statechart/bfd-session` once the
|
|
7388
|
+
// admin transitions merged into one trunk (item 60): the green
|
|
7389
|
+
// "rx Init, rx Up" label sat 40.0 px from INIT's self-loop and
|
|
7390
|
+
// 40.0 px from UP's — margin 0.0, a coin toss — while sliding along
|
|
7391
|
+
// its own shaft to t=0.2 makes those 27 px and 53 px. Nothing in the
|
|
7392
|
+
// score could see the difference. The penalty is a CONSTANT, in the
|
|
7393
|
+
// band the identical-text and wrong-side terms already use, so it
|
|
7394
|
+
// can break the choice between two collision-free positions and can
|
|
7395
|
+
// never outbid an overlap.
|
|
7396
|
+
if(perEdge.size>1){
|
|
7397
|
+
const ds=[...perEdge.values()].sort((a,b)=>a-b);
|
|
7398
|
+
if(ds[1]-ds[0]<4) s+=26;
|
|
7399
|
+
} }
|
|
7400
|
+
// OFF THE CANVAS IS NOT A BAD PLACEMENT, IT IS NO PLACEMENT.
|
|
7401
|
+
// The canvas grows right and down and has no mechanism here to grow
|
|
7402
|
+
// up or left, so a box past those two margins is CLIPPED AWAY and the
|
|
7403
|
+
// text the author wrote is never seen. This was a score term worth
|
|
7404
|
+
// 400, and the note pass already records why a constant cannot do the
|
|
7405
|
+
// job: every other penalty is an AREA and grows without bound, so a
|
|
7406
|
+
// crowded right-hand alternative outbids the constant and the label
|
|
7407
|
+
// goes over the edge. Measured on `statechart/bfd-session` at
|
|
7408
|
+
// 0.4: "rx AdminDown / Detect expired, Echo failed" was
|
|
7409
|
+
// emitted at x = -88.6 and simply did not appear.
|
|
7410
|
+
// So it is a FILTER with its own fallback: an off-canvas candidate is
|
|
7411
|
+
// kept only as the last resort, behind every position that is on the
|
|
7412
|
+
// page at all, and behind the affinity filter for the same reason.
|
|
7413
|
+
// The LEFT margin only, which is the margin this pass has always
|
|
7414
|
+
// judged. The top is left alone deliberately: the two corridor labels
|
|
7415
|
+
// `statechart/dhcp-client` parks above its top rank sit at the canvas
|
|
7416
|
+
// origin BY DESIGN (the return rows run above the figure and the
|
|
7417
|
+
// scene is shifted down to make room), and filtering on `y` pushed
|
|
7418
|
+
// them into one band and cost an F5.
|
|
7419
|
+
const offCanvas=(c.box.x<2);
|
|
7420
|
+
if(offCanvas){ if(s<offS-1e-9){ offS=s; offBest=c; } continue; }
|
|
7421
|
+
// THE CAP IS A FILTER, NOT A PRICE (item 59), for the reason the
|
|
7422
|
+
// off-canvas term above records: every other penalty here is an AREA
|
|
7423
|
+
// and grows without bound, so a constant surcharge on distance is
|
|
7424
|
+
// always outbid by a crowded near position and the label goes for a
|
|
7425
|
+
// walk. A mid-label further from its own shaft than STANDOFF_CAP is
|
|
7426
|
+
// therefore held back behind every position that is inside the cap,
|
|
7427
|
+
// and if one of them wins anyway — the geometry really had nothing
|
|
7428
|
+
// closer — it is drawn with a LEADER back to the shaft. Never a free
|
|
7429
|
+
// float. Endpoint labels are exempt: their distance is governed by
|
|
7430
|
+
// the 16 px anchor and the affinity filter, both of item 42's.
|
|
7431
|
+
if(r.kind!=='end'&&segBoxGap(c.car[0],c.car[1],c.box)>STANDOFF_CAP){
|
|
7432
|
+
if(s<farS-1e-9){ farS=s; farBest=c; } continue; }
|
|
7433
|
+
if(s<anyS-1e-9){ anyS=s; anyBest=c; }
|
|
7434
|
+
if(owns&&s<bestS-1e-9){ bestS=s; best=c; }
|
|
5682
7435
|
}
|
|
5683
7436
|
}
|
|
7437
|
+
if(!best) best=anyBest||farBest||offBest; // no legal position — least bad
|
|
7438
|
+
r0.win=best; // kept for item 55's recolour pass
|
|
5684
7439
|
lblsvg[r0.idx]=textEl(best.x,best.y,r0.fs,best.anchor,r0.col,r0.text,r0.halo);
|
|
7440
|
+
// THE LEADER (item 59). A mid-label that had to be placed past the cap
|
|
7441
|
+
// states its line explicitly: a hairline from the nearest point of its
|
|
7442
|
+
// box to the nearest point of its own carrying segment, in the label's
|
|
7443
|
+
// own colour. It is drawn UNDER nothing and over nothing — it is 1 px,
|
|
7444
|
+
// it starts at the box and it stops at the shaft — so it adds no ink
|
|
7445
|
+
// anywhere the reader is not already looking for the association.
|
|
7446
|
+
if(r0.kind!=='end'&&best.car&&segBoxGap(best.car[0],best.car[1],best.box)>STANDOFF_CAP){
|
|
7447
|
+
const b=best.box, cx=b.x+b.w/2, cy=b.y+b.h/2;
|
|
7448
|
+
const P0=best.car[0], Q0=best.car[1];
|
|
7449
|
+
const vx=Q0[0]-P0[0], vy=Q0[1]-P0[1], L2=vx*vx+vy*vy;
|
|
7450
|
+
const t=L2?Math.max(0,Math.min(1,((cx-P0[0])*vx+(cy-P0[1])*vy)/L2)):0;
|
|
7451
|
+
const fx=P0[0]+t*vx, fy=P0[1]+t*vy;
|
|
7452
|
+
// the box's own boundary point on the ray toward that foot
|
|
7453
|
+
const dx=fx-cx, dy=fy-cy, ax=Math.abs(dx)||1e-9, ay=Math.abs(dy)||1e-9;
|
|
7454
|
+
const k=Math.min((b.w/2+2)/ax,(b.h/2+2)/ay);
|
|
7455
|
+
r0.leadIdx=lblsvg.length;
|
|
7456
|
+
lblsvg.push('<line x1="'+(cx+dx*k)+'" y1="'+(cy+dy*k)+'" x2="'+fx+'" y2="'+fy+
|
|
7457
|
+
'" stroke="'+r0.col+'" stroke-width="1" opacity="0.6"/>');
|
|
7458
|
+
}
|
|
5685
7459
|
placed.push(Object.assign({text:r0.text},best.box));
|
|
5686
7460
|
W=Math.max(W, best.box.x+best.box.w+4);
|
|
5687
7461
|
Hh=Math.max(Hh, best.box.y+best.box.h+4-y0-20);
|
|
5688
7462
|
}
|
|
7463
|
+
// ── A BUNDLE CAPTION SITS BESIDE ITS RING (item 43) ────────────────────
|
|
7464
|
+
// It used to be written at the ring's CENTRE, which is the one place on a
|
|
7465
|
+
// bundle ring that is guaranteed to be occupied: the waist is where the
|
|
7466
|
+
// member links cross. On `reference/topology` the `ECMP uplink set`
|
|
7467
|
+
// caption lay across three edge segments; on `patterns/topology-b`
|
|
7468
|
+
// `Link Bundle 1` lay on top of the `p3` endpoint label.
|
|
7469
|
+
//
|
|
7470
|
+
// So it is placed like every other free label in this pass, against the
|
|
7471
|
+
// same `placed` / `obst` / `arrowBox` / `edgeSegs` evidence, and it enters
|
|
7472
|
+
// AFTER the edge labels: a port label names one end of one link and has
|
|
7473
|
+
// one place to be, while a caption names a ring and has a whole rim to
|
|
7474
|
+
// choose from, so the caption is the one that yields. (Notes still enter
|
|
7475
|
+
// last — nothing yields to a note.)
|
|
7476
|
+
//
|
|
7477
|
+
// The candidate set is twelve stations around the RIM, in the ring's own
|
|
7478
|
+
// frame, so the family follows the ring's orientation instead of the
|
|
7479
|
+
// canvas's. `40*|cos phi|` prices the two stations at the ring's ENDS: the
|
|
7480
|
+
// ends are where the member links leave and where their nodes are, so a
|
|
7481
|
+
// caption there is reaching across the very links the ring encircles. The
|
|
7482
|
+
// flanks — across the members — are free, and between the two the
|
|
7483
|
+
// evidence decides which is clearer. A second tier, 30 px out with a short
|
|
7484
|
+
// dashed leader, exists for the figure where no flank is clear; it is
|
|
7485
|
+
// priced high enough that it is taken only when every rim station is
|
|
7486
|
+
// genuinely occupied.
|
|
7487
|
+
for(const t of doc.trunks||[]){
|
|
7488
|
+
const R=rings.get(t);
|
|
7489
|
+
if(!R||t.label===null||t.label===undefined||t.label==='') continue;
|
|
7490
|
+
const lines=String(t.label).split('\n'), nL=lines.length;
|
|
7491
|
+
const w=Math.max(...lines.map(cw))*6.5*11.5/11, lh=11.5*1.3;
|
|
7492
|
+
const h=(nL-1)*lh+11.5*1.1, up=(nL-1)*lh/2+11.5*0.85;
|
|
7493
|
+
let best=null, bestS=Infinity;
|
|
7494
|
+
for(const tier of [0,1]){
|
|
7495
|
+
const gap=tier?30:7;
|
|
7496
|
+
for(let k=0;k<12;k++){
|
|
7497
|
+
const ph=k*Math.PI/6, cp=Math.cos(ph), sp=Math.sin(ph);
|
|
7498
|
+
const dx=R.ux*cp+R.vx*sp, dy=R.uy*cp+R.vy*sp;
|
|
7499
|
+
const rim=1/Math.hypot(cp/R.rL, sp/R.rA);
|
|
7500
|
+
const out=rim+gap+Math.abs(dx)*w/2+Math.abs(dy)*h/2;
|
|
7501
|
+
const cb={x:R.cx+dx*out-w/2, y:R.cy+dy*out-h/2, w, h};
|
|
7502
|
+
let sc=0;
|
|
7503
|
+
for(const b of placed) sc+=3*ovl(cb,b);
|
|
7504
|
+
for(const o of obst) sc+=2.4*ovl(cb,o);
|
|
7505
|
+
// a group's NAME is ink too (item 45), and a caption written over it
|
|
7506
|
+
// reads as that band's second line
|
|
7507
|
+
for(const o of nameObs) sc+=2.4*ovl(cb,o);
|
|
7508
|
+
for(const a of arrowBox) sc+=4*ovl(cb,a);
|
|
7509
|
+
for(const g of edgeSegs) if(segHit(g.p,g.q,cb)) sc+=26;
|
|
7510
|
+
// A caption lying across ANOTHER bundle's ring has stopped saying
|
|
7511
|
+
// which bundle it names — F5's mechanism again, between two rings
|
|
7512
|
+
// this time. It is charged like a strikethrough because that is
|
|
7513
|
+
// what it is, and measured against the ring's OUTLINE rather than
|
|
7514
|
+
// its bounding box: a tilted ring's box is mostly empty paper, and
|
|
7515
|
+
// charging the box drove the reference figure's caption off a clear
|
|
7516
|
+
// corner and onto an endpoint label. Its OWN ring is never charged
|
|
7517
|
+
// — being beside that one is the whole point.
|
|
7518
|
+
for(const [t2,R2] of rings)
|
|
7519
|
+
if(t2!==t&&!R2.solo&&ringHitsRect(R2,R2.rL,R2.rA,cb)) sc+=26;
|
|
7520
|
+
sc+=40*Math.abs(cp); // the ring's ENDS belong to the links
|
|
7521
|
+
// THE CLEARER FLANK, when two flanks are equally legal. Both of
|
|
7522
|
+
// `patterns/topology-b`'s across-stations score exactly 0 — the
|
|
7523
|
+
// caption fits above the ring and below it — and "first index wins"
|
|
7524
|
+
// then chose by the loop's order rather than by the drawing. This
|
|
7525
|
+
// term is the tie-break and only the tie-break: a bonus of at most
|
|
7526
|
+
// 2, for the candidate with the most white around it, capped so it
|
|
7527
|
+
// can never outrank a single overlap, let alone a strikethrough.
|
|
7528
|
+
let clr=40;
|
|
7529
|
+
for(const b2 of placed) clr=Math.min(clr,boxGap(cb,b2));
|
|
7530
|
+
for(const o of obst) clr=Math.min(clr,boxGap(cb,o));
|
|
7531
|
+
for(const o of nameObs) clr=Math.min(clr,boxGap(cb,o));
|
|
7532
|
+
sc-=0.05*Math.max(0,clr);
|
|
7533
|
+
sc+=tier*400; // the leader is a last resort
|
|
7534
|
+
if(cb.x<2||cb.y<y0) sc+=400; // off the top or left margin
|
|
7535
|
+
if(sc<bestS-1e-9){ bestS=sc; best={cb,tier,dx,dy,rim,gap}; }
|
|
7536
|
+
}
|
|
7537
|
+
}
|
|
7538
|
+
ringLbl.set(t,best);
|
|
7539
|
+
if(best.tier){
|
|
7540
|
+
const p=[R.cx+best.dx*best.rim, R.cy+best.dy*best.rim];
|
|
7541
|
+
const q=[R.cx+best.dx*(best.rim+best.gap), R.cy+best.dy*(best.rim+best.gap)];
|
|
7542
|
+
ringLeads.push([p,q,t]);
|
|
7543
|
+
}
|
|
7544
|
+
placed.push({text:t.label, x:best.cb.x, y:best.cb.y, w, h});
|
|
7545
|
+
best.tx=best.cb.x+w/2; best.ty=best.cb.y+up;
|
|
7546
|
+
W=Math.max(W, best.cb.x+w+4);
|
|
7547
|
+
Hh=Math.max(Hh, best.cb.y+h+4-y0-20);
|
|
7548
|
+
}
|
|
5689
7549
|
// ── note placement (`DRAWN-ANNOTATION-FORM`) — the SAME pass, entered LAST ────────────────
|
|
5690
7550
|
// Four rules, and they are in the spec rather than only here because two
|
|
5691
7551
|
// engines have to agree on them:
|
|
@@ -5721,11 +7581,12 @@ function renderScene(doc,y0){
|
|
|
5721
7581
|
// metaclass the spelling is borrowed from (RULE 4.1).
|
|
5722
7582
|
const noteCarriers=[];
|
|
5723
7583
|
for(const n of nodes) if(n.note!==undefined&&n.note!==null&&!n.boundary)
|
|
5724
|
-
noteCarriers.push({line:n.line,text:n.note,kind:'node',rect:{x:n.x,y:n.y,w:n.w,h:n.h},n
|
|
7584
|
+
noteCarriers.push({line:n.line,text:n.note,kind:'node',ref:n.id,rect:{x:n.x,y:n.y,w:n.w,h:n.h},n,
|
|
7585
|
+
lbl:n.label});
|
|
5725
7586
|
for(const g of doc.groups) if(g.note!==undefined&&g.note!==null){
|
|
5726
7587
|
const B=gBox[g.id]; if(!B) continue;
|
|
5727
|
-
noteCarriers.push({line:g.line,text:g.note,kind:'group',
|
|
5728
|
-
rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}});
|
|
7588
|
+
noteCarriers.push({line:g.line,text:g.note,kind:'group',ref:g.id,
|
|
7589
|
+
rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA},lbl:g.label});
|
|
5729
7590
|
}
|
|
5730
7591
|
for(const e of doc.edges) if(e.note!==undefined&&e.note!==null){
|
|
5731
7592
|
// An edge is a polyline, not a rect. Its carrier POINT is the midpoint of
|
|
@@ -5737,11 +7598,148 @@ function renderScene(doc,y0){
|
|
|
5737
7598
|
const segs=edgeSegs.filter(g=>g.e===e); if(!segs.length) continue;
|
|
5738
7599
|
const m=segs[Math.floor((segs.length-1)/2)];
|
|
5739
7600
|
const cx=(m.p[0]+m.q[0])/2, cy=(m.p[1]+m.q[1])/2;
|
|
5740
|
-
noteCarriers.push({line:e.line,text:e.note,kind:'edge',rect:{x:cx,y:cy,w:0,h:0},e});
|
|
7601
|
+
noteCarriers.push({line:e.line,text:e.note,kind:'edge',ref:e.line,rect:{x:cx,y:cy,w:0,h:0},e});
|
|
5741
7602
|
}
|
|
5742
7603
|
noteCarriers.sort((a,b)=>a.line-b.line);
|
|
7604
|
+
// ── ADJACENCY MUST BE DISCRIMINABLE (item 40) ─────────────────────────
|
|
7605
|
+
// "Adjacency where it can, a leader only when adjacency fails" was always
|
|
7606
|
+
// the rule; what was missing is that the failure test never asked whether
|
|
7607
|
+
// the adjacency SAYS ANYTHING. `annotated-datapath` — the `note=`
|
|
7608
|
+
// demonstrator itself — put the `mac` group note and the `pause` node note
|
|
7609
|
+
// at the same y, 3 px apart, one continuous strip that reads as a caption
|
|
7610
|
+
// row and binds neither box. Each was near-adjacent to the OTHER's target.
|
|
7611
|
+
//
|
|
7612
|
+
// This is F5's mechanism, a THIRD time (edge-label/edge = F5,
|
|
7613
|
+
// endpoint-label/endpoint = item 42, note/element = here): nearest versus
|
|
7614
|
+
// SECOND-nearest. A note is adjacent to its target only when
|
|
7615
|
+
// (a) it sits substantially OVER its target's span on the axis across
|
|
7616
|
+
// the side it takes — beside means beside THIS thing, and a box that
|
|
7617
|
+
// overhangs its target is already reaching for a neighbour; and
|
|
7618
|
+
// (b) its own target is the NEAREST annotatable element, by at least
|
|
7619
|
+
// NOTE_M, to the second-nearest.
|
|
7620
|
+
// `annotatable` is the set of things a note can be written ON — nodes,
|
|
7621
|
+
// groups, connectors — because those are exactly the readings a reader can
|
|
7622
|
+
// mistake it for. When both tests pass the note simply sits there, as
|
|
7623
|
+
// before. When either fails, adjacency has FAILED and the candidate is not
|
|
7624
|
+
// offered at all, so the pass falls through to the far tiers, which is
|
|
7625
|
+
// where the dashed leader already lives. No new drawing machinery.
|
|
7626
|
+
//
|
|
7627
|
+
// NOTE_M = 12 px, not F5's 4: a note is a BOX tens of pixels tall, and the
|
|
7628
|
+
// eye ties it to a neighbour long before the gap closes to a hair. 12 px
|
|
7629
|
+
// is one note line-height (NOTE_FS * 1.35 rounded) — below one line of the
|
|
7630
|
+
// note's own text, two elements are not told apart.
|
|
7631
|
+
const NOTE_M=12;
|
|
7632
|
+
const noteLeads=[];
|
|
7633
|
+
const annot=[];
|
|
7634
|
+
for(const n of nodes) if(!n.boundary) annot.push({k:'node',id:n.id,rect:{x:n.x,y:n.y,w:n.w,h:n.h}});
|
|
7635
|
+
for(const g of doc.groups){ const B=gBox[g.id];
|
|
7636
|
+
if(B) annot.push({k:'group',id:g.id,rect:{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}}); }
|
|
7637
|
+
const rdist=(px,py,r)=>Math.hypot(Math.max(r.x-px,0,px-(r.x+r.w)),Math.max(r.y-py,0,py-(r.y+r.h)));
|
|
7638
|
+
const ptSegD=(px,py,p,q)=>{
|
|
7639
|
+
const vx=q[0]-p[0], vy=q[1]-p[1], L2=vx*vx+vy*vy;
|
|
7640
|
+
const t=L2?Math.max(0,Math.min(1,((px-p[0])*vx+(py-p[1])*vy)/L2)):0;
|
|
7641
|
+
return Math.hypot(px-(p[0]+t*vx), py-(p[1]+t*vy));
|
|
7642
|
+
};
|
|
7643
|
+
// A connector is annotatable too, and it is measured as the polyline it is.
|
|
7644
|
+
const edgeDist=(px,py,e)=>{ let d=Infinity;
|
|
7645
|
+
for(const g of edgeSegs) if(g.e===e)
|
|
7646
|
+
d=Math.min(d,ptSegD(px,py,g.p,g.q));
|
|
7647
|
+
return d; };
|
|
7648
|
+
const noteEdges=[];
|
|
7649
|
+
for(const e of doc.edges) if(edgeSegs.some(g=>g.e===e)) noteEdges.push(e);
|
|
7650
|
+
// Does this candidate box read as belonging to THIS carrier and no other?
|
|
7651
|
+
//
|
|
7652
|
+
// The test is on SPANS, not on centre-to-centre distance, because that is
|
|
7653
|
+
// how the eye reads a note: it looks back along the side the box sits on
|
|
7654
|
+
// and takes the first thing it lands on. So a competitor is any
|
|
7655
|
+
// annotatable element whose PROJECTION across that side overlaps the note
|
|
7656
|
+
// — the reading the filed case describes exactly, the `pause` note's left
|
|
7657
|
+
// edge reaching "under the `mac` band's horizontal projection" — and the
|
|
7658
|
+
// margin is measured along the side, own gap versus second-nearest gap.
|
|
7659
|
+
const spanOf=(r,across)=>across?[r.y,r.y+r.h]:[r.x,r.x+r.w];
|
|
7660
|
+
const gapTo=(cb,r,side)=>side==='below'?cb.y-(r.y+r.h)
|
|
7661
|
+
:side==='above'?r.y-(cb.y+cb.h)
|
|
7662
|
+
:side==='right'?cb.x-(r.x+r.w)
|
|
7663
|
+
:r.x-(cb.x+cb.w);
|
|
7664
|
+
const discriminable=(cb,c,side)=>{
|
|
7665
|
+
const across=(side==='left'||side==='right');
|
|
7666
|
+
// (a) the note sits substantially OVER its target's span. `beside` means
|
|
7667
|
+
// beside THIS thing; a box that hangs off its target is already halfway
|
|
7668
|
+
// to the neighbour. The denominator is the SMALLER of the two spans, so
|
|
7669
|
+
// a wide note fully covering a narrow target passes and a note sliding
|
|
7670
|
+
// off the end of a wide target fails.
|
|
7671
|
+
const [nA,nB]=spanOf(cb,across), [tA,tB]=spanOf(c.rect,across);
|
|
7672
|
+
const denom=Math.min(nB-nA,tB-tA);
|
|
7673
|
+
if(denom>1&&(Math.min(nB,tB)-Math.max(nA,tA))/denom<0.6) return false;
|
|
7674
|
+
// (b) nearest versus second-nearest, along the side, among the elements
|
|
7675
|
+
// whose projection the note actually lies across.
|
|
7676
|
+
const claim=r=>{ const [rA,rB]=spanOf(r,across);
|
|
7677
|
+
return Math.min(nB,rB)>Math.max(nA,rA); };
|
|
7678
|
+
let own=gapTo(cb,c.rect,side), second=Infinity;
|
|
7679
|
+
if(own<0) return false; // the note overlaps its own target
|
|
7680
|
+
for(const a of annot){
|
|
7681
|
+
if(c.kind===a.k&&c.ref===a.id) continue;
|
|
7682
|
+
if(!claim(a.rect)) continue;
|
|
7683
|
+
const g=gapTo(cb,a.rect,side);
|
|
7684
|
+
if(g>=-1) second=Math.min(second,g);
|
|
7685
|
+
}
|
|
7686
|
+
for(const e of noteEdges){
|
|
7687
|
+
if(c.kind==='edge'&&c.e===e) continue;
|
|
7688
|
+
for(const g0 of edgeSegs){ if(g0.e!==e) continue;
|
|
7689
|
+
const r={x:Math.min(g0.p[0],g0.q[0]),y:Math.min(g0.p[1],g0.q[1]),
|
|
7690
|
+
w:Math.abs(g0.q[0]-g0.p[0]),h:Math.abs(g0.q[1]-g0.p[1])};
|
|
7691
|
+
if(!claim(r)) continue;
|
|
7692
|
+
const g=gapTo(cb,r,side);
|
|
7693
|
+
if(g>=-1) second=Math.min(second,g);
|
|
7694
|
+
}
|
|
7695
|
+
}
|
|
7696
|
+
return own+NOTE_M<=second;
|
|
7697
|
+
};
|
|
7698
|
+
// ── AN ADJACENCY NOTE NAMES ITS TARGET; A LEADER NOTE DOES NOT (item 56b) ──
|
|
7699
|
+
// A note sited by ADJACENCY carries its whole association in a gap: the
|
|
7700
|
+
// box is beside a thing, and the reader has to guess which thing. The
|
|
7701
|
+
// drawing already knows the answer — `data-note-for` has carried the
|
|
7702
|
+
// resolved target since item 40b — so the note says it, in words: the
|
|
7703
|
+
// target's LABEL, bold, then a colon, then the note's own sentence
|
|
7704
|
+
// ("MAC layer: Runs on the recovered RX clock…"). It is derived, never
|
|
7705
|
+
// authored: `DOMAIN-CONVENTION-DIRECTIVES` keeps the drawing convention with the engine.
|
|
7706
|
+
//
|
|
7707
|
+
// A note with a DRAWN LEADER does not take the prefix, and that is the
|
|
7708
|
+
// whole of the split. The leader IS the association — a line from the box
|
|
7709
|
+
// to the thing — and repeating it in the text is ink for a fact the
|
|
7710
|
+
// drawing already states unambiguously.
|
|
7711
|
+
//
|
|
7712
|
+
// ONLY A LABELLED NODE OR GROUP can be named this way. A connector has no
|
|
7713
|
+
// id and no name (`data-note-for` carries its source LINE, which is not a
|
|
7714
|
+
// thing a reader can see), and the figure-level note on `title` names the
|
|
7715
|
+
// FIGURE — there is exactly one and it needs no naming.
|
|
7716
|
+
//
|
|
7717
|
+
// ORDER, because the two decisions are circular (the prefix changes the
|
|
7718
|
+
// box, the box changes whether adjacency succeeds): the note is sited
|
|
7719
|
+
// WITH the prefix first, since adjacency is the preferred outcome and the
|
|
7720
|
+
// prefixed box is the one that has to fit. If that siting takes a leader,
|
|
7721
|
+
// the note is re-sited WITHOUT the prefix, and THAT placement stands —
|
|
7722
|
+
// there is no third pass. It terminates by construction and it is
|
|
7723
|
+
// deterministic, which is what `RENDERING-DETERMINISM` needs of it.
|
|
7724
|
+
const notePrefix=c=>((c.kind==='node'||c.kind==='group')&&c.lbl&&String(c.lbl).trim()
|
|
7725
|
+
?String(c.lbl).replace(/\n/g,' ')+':':null);
|
|
5743
7726
|
for(const c of noteCarriers){
|
|
5744
|
-
const
|
|
7727
|
+
const pfx=notePrefix(c);
|
|
7728
|
+
let box=noteBox(c.text,pfx), sited=siteNote(c,box);
|
|
7729
|
+
if(pfx&&sited.best&&sited.best.far){ box=noteBox(c.text,null); sited=siteNote(c,box); }
|
|
7730
|
+
let best=sited.best;
|
|
7731
|
+
// Only if EVERY candidate was off-canvas: take the least-bad one and
|
|
7732
|
+
// clamp it on. It may overlap something; visible and overlapping is
|
|
7733
|
+
// recoverable by an author, invisible is not.
|
|
7734
|
+
if(!best){ best=sited.fallback; best.x=Math.max(0,best.x); best.y=Math.max(y0,best.y); }
|
|
7735
|
+
lblsvg.push(noteSvg(best.x,best.y,box,c));
|
|
7736
|
+
if(best.far){ lblsvg.push(noteLeader(best,box,c.rect));
|
|
7737
|
+
noteLeads.push(noteLeaderPts(best,box,c.rect)); }
|
|
7738
|
+
placed.push({x:best.x,y:best.y,w:box.w,h:box.h});
|
|
7739
|
+
W=Math.max(W, best.x+box.w+4);
|
|
7740
|
+
Hh=Math.max(Hh, best.y+box.h+4-y0-20);
|
|
7741
|
+
}
|
|
7742
|
+
function siteNote(c,box){
|
|
5745
7743
|
// Adjacency first, then the same four sides pushed out far enough that a
|
|
5746
7744
|
// leader is legible. `si` orders the sides; `far` is what decides the
|
|
5747
7745
|
// leader, and it costs enough that adjacency wins every time adjacency is
|
|
@@ -5775,6 +7773,11 @@ function renderScene(doc,y0){
|
|
|
5775
7773
|
by=side==='below'?c.rect.y+c.rect.h+gap:c.rect.y-gap-box.h;
|
|
5776
7774
|
}
|
|
5777
7775
|
const cb={x:bx,y:by,w:box.w,h:box.h};
|
|
7776
|
+
// ADJACENCY THAT SAYS NOTHING IS NOT ADJACENCY (item 40). Tier 0 is
|
|
7777
|
+
// the no-leader tier; a candidate there must actually bind the note
|
|
7778
|
+
// to its carrier. If it does not, it is withdrawn and the far tiers
|
|
7779
|
+
// — which draw the dashed leader — are what remain.
|
|
7780
|
+
if(!far&&!discriminable(cb,c,side)) continue;
|
|
5778
7781
|
let s=0;
|
|
5779
7782
|
// A NOTE YIELDS; NOTHING YIELDS TO A NOTE. `placed` holds the edge
|
|
5780
7783
|
// labels and the notes already sited, and its weight is the HIGHEST
|
|
@@ -5787,6 +7790,7 @@ function renderScene(doc,y0){
|
|
|
5787
7790
|
for(const o of obst) s+=6*ovl(cb,o);
|
|
5788
7791
|
for(const a of arrowBox) s+=8*ovl(cb,a);
|
|
5789
7792
|
for(const g of edgeSegs) if(segHit(g.p,g.q,cb)) s+=26;
|
|
7793
|
+
for(const g of noteLeads) if(segHit(g[0],g[1],cb)) s+=26;
|
|
5790
7794
|
for(const g of doc.groups){ const B=gBox[g.id];
|
|
5791
7795
|
if(B) s+=1.2*ovl(cb,{x:B.x0,y:B.yA,w:B.x1-B.x0,h:B.yB-B.yA}); }
|
|
5792
7796
|
s+=si*12;
|
|
@@ -5816,19 +7820,176 @@ function renderScene(doc,y0){
|
|
|
5816
7820
|
if(s<bestS-1e-9){ bestS=s; best={x:bx,y:by,far,side}; }
|
|
5817
7821
|
}
|
|
5818
7822
|
}
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
7823
|
+
return {best,fallback};
|
|
7824
|
+
}
|
|
7825
|
+
// ── TONE PAIRING FOR CROWDED UNCLASSED EDGES (item 55) ────────────────
|
|
7826
|
+
// Where several plain lines cross the middle of a figure, every one of
|
|
7827
|
+
// them is `#555` and every one of their labels is `#555`, so the reader
|
|
7828
|
+
// pairs word to line by tracing. `statechart/dhcp-client` is the case:
|
|
7829
|
+
// five long diagonals through one region, five two-line labels sitting
|
|
7830
|
+
// among them.
|
|
7831
|
+
//
|
|
7832
|
+
// The engine assigns those lines DISTINCT TONES, and each label takes its
|
|
7833
|
+
// own line's tone. That is all the tones do.
|
|
7834
|
+
//
|
|
7835
|
+
// THE `PRESENTATION-AS-MEANING-CARRIER` BOUNDARY, and it is the whole reason the palette looks like
|
|
7836
|
+
// this. `PRESENTATION-AS-MEANING-CARRIER` says presentation is never the sole carrier of meaning: a
|
|
7837
|
+
// reader must never have to read a colour to learn a fact. These tones
|
|
7838
|
+
// carry NO fact. They are not derived from anything the author wrote,
|
|
7839
|
+
// they earn NO legend entry, no `class` and no `stroke=` names them, and
|
|
7840
|
+
// a reader who goes looking for what "the olive one" means finds nothing
|
|
7841
|
+
// stated anywhere — because there is nothing. They are DISCRIMINATION,
|
|
7842
|
+
// the same job a fan-out offset does when two messages share a node pair,
|
|
7843
|
+
// and the palette is what keeps them readable as that: four MUTED hues,
|
|
7844
|
+
// none of them a signal colour and none of them one of the plane or
|
|
7845
|
+
// class colours a legend can name. A classed edge is never touched, because there
|
|
7846
|
+
// the colour DOES carry the legend's meaning and a second scheme on top
|
|
7847
|
+
// of it would be two claims about one stroke.
|
|
7848
|
+
//
|
|
7849
|
+
// ELIGIBILITY. An edge takes a tone only if it (a) joins no class,
|
|
7850
|
+
// (b) sets no `stroke=` of its own — both would be an authored choice —
|
|
7851
|
+
// (c) draws a `mid` label, since a line with nothing to pair needs no
|
|
7852
|
+
// pairing, and (d) is not a merge-bus member, whose whole construct is
|
|
7853
|
+
// that several edges are ONE line.
|
|
7854
|
+
//
|
|
7855
|
+
// CROWDING is measured on the FINISHED drawing, after every label is
|
|
7856
|
+
// sited: an eligible edge is crowded when its label box comes within
|
|
7857
|
+
// TONE_PROX of another edge's shaft or another edge's label. 30 px is the
|
|
7858
|
+
// threshold — a little over two label line-heights, the distance at which
|
|
7859
|
+
// a reader stops being able to sweep from word to line without crossing
|
|
7860
|
+
// something else.
|
|
7861
|
+
//
|
|
7862
|
+
// ASSIGNMENT is a greedy pass in DOCUMENT ORDER, and it asks about the
|
|
7863
|
+
// EYE rather than about the array: "not the same as its neighbour" was
|
|
7864
|
+
// never the requirement — the palette this replaced had two entries 8.7
|
|
7865
|
+
// apart in CIE L*a*b*, which is different and still not TELLABLE apart at
|
|
7866
|
+
// a glance, and that was the whole job. Every crowded edge takes a tone
|
|
7867
|
+
// clearly apart from every neighbour already assigned, and among those
|
|
7868
|
+
// the least-spent one, so a figure uses its whole palette instead of
|
|
7869
|
+
// ping-ponging between two extremes (see the choice below). Ordering by
|
|
7870
|
+
// source line and breaking ties by palette index makes it deterministic:
|
|
7871
|
+
// same source, same tones.
|
|
7872
|
+
{
|
|
7873
|
+
const TONE_PROX=30;
|
|
7874
|
+
// The palette (item 61). Four muted hues at essentially ONE lightness —
|
|
7875
|
+
// L* 38.6, 39.7, 38.9, 40.9 — so they differ by HUE and nothing else,
|
|
7876
|
+
// and no tone can be read as heavier or more important than another:
|
|
7877
|
+
// slate blue #3f5b8c warm brown #7a5638
|
|
7878
|
+
// olive drab #55611f steel #206a72
|
|
7879
|
+
// Pairwise separation in CIE L*a*b* is 28.6 to 68.1, against the 8.7 of
|
|
7880
|
+
// the four slate-greys this replaced (a just-noticeable difference is
|
|
7881
|
+
// about 2.3, so those two were "different" and not tellable apart).
|
|
7882
|
+
// Because a label takes its line's tone and a label is TEXT, every one
|
|
7883
|
+
// is legible as type on white: 6.80, 6.53, 6.74 and 6.24 to 1, all above
|
|
7884
|
+
// the 4.5:1 floor for body text. A tone that discriminates by being too
|
|
7885
|
+
// faint to read has traded one defect for a worse one. None of them is
|
|
7886
|
+
// near a plane or class colour, and none is near the #555 default an
|
|
7887
|
+
// untoned line keeps: the nearest approach is warm brown at 26.
|
|
7888
|
+
const TONES=['#3f5b8c','#7a5638','#55611f','#206a72'];
|
|
7889
|
+
const busMem=new Set();
|
|
7890
|
+
for(const [e2] of busRoute) busMem.add(e2);
|
|
7891
|
+
for(const [e2,P2] of chPlan) if(P2.bus) busMem.add(e2);
|
|
7892
|
+
const lblOf=new Map();
|
|
7893
|
+
for(const q of lblReq) if(q.kind==='mid'&&q.win&&!lblOf.has(q.e)) lblOf.set(q.e,q.win.box);
|
|
7894
|
+
const elig=[];
|
|
7895
|
+
for(const e2 of edges){
|
|
7896
|
+
if(e2.stroke!==undefined||busMem.has(e2)) continue;
|
|
7897
|
+
const cl=e2.cls; if(cl!==undefined&&cl!==null&&(!Array.isArray(cl)||cl.length)) continue;
|
|
7898
|
+
if(!lblOf.has(e2)) continue;
|
|
7899
|
+
elig.push(e2);
|
|
7900
|
+
}
|
|
7901
|
+
const segD=(b,e2)=>{ let d=Infinity;
|
|
7902
|
+
for(const g of edgeSegs) if(g.e===e2) d=Math.min(d,segBoxD(g.p,g.q,b));
|
|
7903
|
+
return d; };
|
|
7904
|
+
const crowded=new Set(), near=new Map();
|
|
7905
|
+
for(const e2 of elig){
|
|
7906
|
+
const b=lblOf.get(e2);
|
|
7907
|
+
for(const f of edges){
|
|
7908
|
+
if(f===e2) continue;
|
|
7909
|
+
const hit=segD(b,f)<TONE_PROX ||
|
|
7910
|
+
(lblOf.has(f)&&boxGap(b,lblOf.get(f))<TONE_PROX);
|
|
7911
|
+
if(!hit) continue;
|
|
7912
|
+
crowded.add(e2);
|
|
7913
|
+
if(elig.indexOf(f)>=0){
|
|
7914
|
+
if(!near.has(e2)) near.set(e2,new Set()); near.get(e2).add(f);
|
|
7915
|
+
if(!near.has(f)) near.set(f,new Set()); near.get(f).add(e2);
|
|
7916
|
+
}
|
|
7917
|
+
}
|
|
7918
|
+
}
|
|
7919
|
+
// DISTANCE IS MEASURED IN THE EYE, NOT IN THE ARRAY (item 61). The old
|
|
7920
|
+
// palette was ordered by darkness, so "furthest position in the list"
|
|
7921
|
+
// was a fair proxy for "most tellable apart". A palette that separates
|
|
7922
|
+
// by HUE has no such order — steel is not "between" slate blue and
|
|
7923
|
+
// olive — so the assignment asks the question directly, in CIE L*a*b*,
|
|
7924
|
+
// where a difference of about 2.3 is the just-noticeable one and these
|
|
7925
|
+
// four sit 28.6 to 68.1 apart.
|
|
7926
|
+
const tLab=h=>{
|
|
7927
|
+
const v=[1,3,5].map(i=>parseInt(h.slice(i,i+2),16)/255)
|
|
7928
|
+
.map(c=>c<=0.03928?c/12.92:Math.pow((c+0.055)/1.055,2.4));
|
|
7929
|
+
let X=(0.4124*v[0]+0.3576*v[1]+0.1805*v[2])/0.95047,
|
|
7930
|
+
Y=(0.2126*v[0]+0.7152*v[1]+0.0722*v[2]),
|
|
7931
|
+
Z=(0.0193*v[0]+0.1192*v[1]+0.9505*v[2])/1.08883;
|
|
7932
|
+
const f=t=>t>0.008856?Math.cbrt(t):(7.787*t+16/116);
|
|
7933
|
+
const fx=f(X), fy=f(Y), fz=f(Z);
|
|
7934
|
+
return [116*fy-16, 500*(fx-fy), 200*(fy-fz)];
|
|
7935
|
+
};
|
|
7936
|
+
const LAB=TONES.map(tLab);
|
|
7937
|
+
const dE=(i,j)=>Math.hypot(LAB[i][0]-LAB[j][0],LAB[i][1]-LAB[j][1],LAB[i][2]-LAB[j][2]);
|
|
7938
|
+
const tone=new Map(), used=TONES.map(()=>0);
|
|
7939
|
+
for(const e2 of elig.slice().sort((p,q)=>p.line-q.line)){
|
|
7940
|
+
if(!crowded.has(e2)) continue;
|
|
7941
|
+
const nb=[];
|
|
7942
|
+
for(const f of (near.get(e2)||[])) if(tone.has(f)) nb.push(TONES.indexOf(tone.get(f)));
|
|
7943
|
+
// ENOUGH IS ENOUGH, AND THEN SPREAD. Taking the FURTHEST tone from
|
|
7944
|
+
// each neighbour sounds right and collapses the palette: with one
|
|
7945
|
+
// assigned neighbour the furthest is always the same antipode, so a
|
|
7946
|
+
// figure whose crowding graph is a set of pairs paints itself in two
|
|
7947
|
+
// hues and the other two are never seen (dhcp-client, measured: 2 of
|
|
7948
|
+
// 4). Every pair in this palette is at least 28.6 apart — an order of
|
|
7949
|
+
// magnitude over the ~2.3 just-noticeable difference — so "furthest"
|
|
7950
|
+
// buys nothing a reader can use, while a THIRD identity in a crowded
|
|
7951
|
+
// corner buys a great deal. So: any tone clearly apart from every
|
|
7952
|
+
// neighbour (>= TONE_SEP) is admissible, and among the admissible the
|
|
7953
|
+
// LEAST-SPENT one wins; ties to the lowest index, so the same source
|
|
7954
|
+
// always paints the same figure. If nothing clears the bar — a future
|
|
7955
|
+
// palette with closer entries, or more crowding than tones — it falls
|
|
7956
|
+
// back to the plain furthest-from-the-neighbours choice.
|
|
7957
|
+
const TONE_SEP=25;
|
|
7958
|
+
let pick=-1, bestU=-1;
|
|
7959
|
+
for(let i=0;i<TONES.length;i++){
|
|
7960
|
+
const d=nb.length?Math.min(...nb.map(j=>dE(i,j))):Infinity;
|
|
7961
|
+
if(d<TONE_SEP) continue;
|
|
7962
|
+
const u=used[i]; // how many lines already wear it
|
|
7963
|
+
if(pick<0||u<bestU){ bestU=u; pick=i; }
|
|
7964
|
+
}
|
|
7965
|
+
if(pick<0){ let bestD=-1; pick=0;
|
|
7966
|
+
for(let i=0;i<TONES.length;i++){
|
|
7967
|
+
const d=nb.length?Math.min(...nb.map(j=>dE(i,j))):Infinity;
|
|
7968
|
+
if(d>bestD+1e-9){ bestD=d; pick=i; }
|
|
7969
|
+
} }
|
|
7970
|
+
tone.set(e2, TONES[pick]);
|
|
7971
|
+
used[pick]++;
|
|
7972
|
+
}
|
|
7973
|
+
if(tone.size>1) for(const [e2,col2] of tone){
|
|
7974
|
+
const key='data-edge="'+e2.line+'"';
|
|
7975
|
+
for(let i=0;i<esvg.length;i++)
|
|
7976
|
+
if(esvg[i].indexOf(key)>=0) esvg[i]=esvg[i].replace('stroke="#555"','stroke="'+col2+'"');
|
|
7977
|
+
for(const i of (arrowIdx.get(e2)||[]))
|
|
7978
|
+
lblsvg[i]=lblsvg[i].replace('fill="#555"','fill="'+col2+'"');
|
|
7979
|
+
for(const q of lblReq) if(q.e===e2&&q.win){
|
|
7980
|
+
lblsvg[q.idx]=textEl(q.win.x,q.win.y,q.fs,q.win.anchor,col2,q.text,q.halo);
|
|
7981
|
+
// a standoff leader (item 59) is part of the label, so it takes the
|
|
7982
|
+
// label's tone with it — otherwise the one line drawn to state the
|
|
7983
|
+
// association would be the one line still painted the old colour.
|
|
7984
|
+
if(q.leadIdx!==undefined)
|
|
7985
|
+
lblsvg[q.leadIdx]=lblsvg[q.leadIdx].replace('stroke="'+q.col+'"','stroke="'+col2+'"');
|
|
7986
|
+
}
|
|
7987
|
+
}
|
|
5828
7988
|
}
|
|
5829
7989
|
}
|
|
5830
7990
|
// nodes on top (each wrapped in a draggable, identifiable group)
|
|
5831
7991
|
const nsvg=[];
|
|
7992
|
+
const nodeLbl=new Map(); // node id -> its drawn label's box (item 49)
|
|
5832
7993
|
for(const n of nodes){
|
|
5833
7994
|
if(n.boundary){
|
|
5834
7995
|
// never drawn as a shape (`EXTERNAL-EDGE-ENDPOINTS`): the edge already ended open at the
|
|
@@ -5886,32 +8047,43 @@ function renderScene(doc,y0){
|
|
|
5886
8047
|
const avail=2*inscribedHalfW(shapeAxes(n),nl.length*8)-16;
|
|
5887
8048
|
if(n.rigid && need>avail) fs=Math.max(8, FONT*avail/need);
|
|
5888
8049
|
nsvg.push(textEl(n.x+n.w/2, n.y+n.h/2+fs*0.35, fs, 'middle', txt, n.label));
|
|
8050
|
+
// The node's own label as INK, for whoever else wants to write inside this
|
|
8051
|
+
// box — today the zone-band captions (item 49). Recorded here rather than
|
|
8052
|
+
// recomputed there, because `fs` is the shrink-to-fit size and only this
|
|
8053
|
+
// scope knows it.
|
|
8054
|
+
{ const lw=need*fs/FONT, lh=nl.length*fs*1.15;
|
|
8055
|
+
nodeLbl.set(n.id,{x0:n.x+n.w/2-lw/2, x1:n.x+n.w/2+lw/2,
|
|
8056
|
+
y0:n.y+n.h/2-lh/2, y1:n.y+n.h/2+lh/2}); }
|
|
5889
8057
|
nsvg.push('</g>');
|
|
5890
8058
|
}
|
|
5891
8059
|
// trunk rings (semantic LAG/ES bundles): the ellipse is DERIVED from the
|
|
5892
|
-
// member links
|
|
8060
|
+
// member links — drag a node and the ring follows. Its geometry is computed
|
|
8061
|
+
// above (`ringOf`, item 43) because the caption has to be placed by the
|
|
8062
|
+
// label pass, which runs before this one; here the ring is only drawn.
|
|
8063
|
+
// `rL` is the semi-axis ALONG the members and `rA` the one ACROSS them, so
|
|
8064
|
+
// the SVG's rx/ry are the ring's own axes rotated onto the canvas.
|
|
5893
8065
|
const tsvg=[];
|
|
5894
8066
|
for(const t of zsort(doc.trunks)){
|
|
5895
|
-
const
|
|
5896
|
-
for(const [a,b] of t.pairs){
|
|
5897
|
-
const A=byId[a], B=byId[b]; if(!A||!B) continue;
|
|
5898
|
-
const [x1,yy1]=borderPoint(A,B.x+B.w/2,B.y+B.h/2);
|
|
5899
|
-
const [x2,yy2]=borderPoint(B,A.x+A.w/2,A.y+A.h/2);
|
|
5900
|
-
mids.push([(x1+x2)/2,(yy1+yy2)/2]);
|
|
5901
|
-
}
|
|
5902
|
-
if(!mids.length) continue;
|
|
5903
|
-
const cx=mids.reduce((s,m)=>s+m[0],0)/mids.length;
|
|
5904
|
-
const cy=mids.reduce((s,m)=>s+m[1],0)/mids.length;
|
|
5905
|
-
const rx=Math.max(46, Math.max(...mids.map(m=>Math.abs(m[0]-cx)))+38);
|
|
5906
|
-
const ry=Math.max(26, Math.max(...mids.map(m=>Math.abs(m[1]-cy)))+22);
|
|
8067
|
+
const R=rings.get(t); if(!R) continue;
|
|
5907
8068
|
// 0.1 (§8.4): the ring has no interior — `stroke=` is the one
|
|
5908
8069
|
// name for its channel; `fill=` on a bundle is now a line error, so the
|
|
5909
8070
|
// model field is always absent and the old `t.fill` fallback is gone.
|
|
5910
8071
|
const col=t.stroke||'#64748b';
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
8072
|
+
const deg=R.th*180/Math.PI;
|
|
8073
|
+
const rot=Math.abs(deg)<0.05?'':' transform="rotate('+rnd3(deg)+' '+rnd3(R.cx)+' '+rnd3(R.cy)+')"';
|
|
8074
|
+
// ONE MEMBER = NO ELLIPSE (item 53). The caption below is the whole
|
|
8075
|
+
// drawing, and an unlabelled one-member bundle draws nothing.
|
|
8076
|
+
if(!R.solo){
|
|
8077
|
+
tsvg.push('<ellipse cx="'+rnd3(R.cx)+'" cy="'+rnd3(R.cy)+'" rx="'+rnd3(R.rL)+'" ry="'+rnd3(R.rA)+'" fill="transparent" stroke="'+(t.stroke||col)+'"'+dashOf(t.style,'6 4')+' stroke-width="1.6"'+rot+'/>');
|
|
8078
|
+
W=Math.max(W,R.cx+R.hw); Hh=Math.max(Hh,R.cy+R.hh-y0-20);
|
|
8079
|
+
}
|
|
8080
|
+
const L=ringLbl.get(t);
|
|
8081
|
+
if(L) tsvg.push(textEl(rnd3(L.tx), rnd3(L.ty), 11.5, 'middle', col, t.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
5914
8082
|
}
|
|
8083
|
+
// the caption's leader, when no rim station was clear (item 43)
|
|
8084
|
+
for(const [p,q,t] of ringLeads)
|
|
8085
|
+
tsvg.push('<line x1="'+rnd3(p[0])+'" y1="'+rnd3(p[1])+'" x2="'+rnd3(q[0])+'" y2="'+rnd3(q[1])
|
|
8086
|
+
+'" stroke="'+(t.stroke||'#64748b')+'" stroke-width="1" stroke-dasharray="3 3"/>');
|
|
5915
8087
|
// threshold markers + labels (top layer). The target is a GROUP box or,
|
|
5916
8088
|
// a single NODE box — the same two scopes `band` has always
|
|
5917
8089
|
// taken (§2.6, `AUTHORING-INTENT-OVER-RENDERING`); a node's box is spelled in the gBox coordinate shape.
|
|
@@ -5936,17 +8108,84 @@ function renderScene(doc,y0){
|
|
|
5936
8108
|
// band already owns an area, so the name needs no gutter and the canvas box
|
|
5937
8109
|
// does not grow — W and Hh are untouched, which is why adding the label moves
|
|
5938
8110
|
// no existing geometry in any figure.
|
|
8111
|
+
// ── A BAND CAPTION CLEARS THE NAME OF THE THING IT IS DRAWN INSIDE ──────
|
|
8112
|
+
// (item 49.) Centred-in-the-band is right when the band is the
|
|
8113
|
+
// only ink in that area and wrong when the band is drawn INSIDE a node,
|
|
8114
|
+
// because the node's own label is already centred there: `block-experimental`
|
|
8115
|
+
// put "Drained", "Q2" and "Headroom" on one baseline 1.6 px apart and the
|
|
8116
|
+
// reader got `DrainedQ2Headroom`. This is item 44/45's finding at the
|
|
8117
|
+
// intra-node scale — ink-blind placement — so the answer is theirs: the node
|
|
8118
|
+
// label, and every caption already placed on the same node, are OBSTACLES.
|
|
8119
|
+
//
|
|
8120
|
+
// The caption keeps its band-centred position unless that box comes within
|
|
8121
|
+
// 4 px of one (F6's margin, and F6 is the axis that would charge it). It
|
|
8122
|
+
// then slides by the SMALLEST amount that opens the gap — along the band's
|
|
8123
|
+
// OWN long axis first, which keeps it inside the band it names, then across
|
|
8124
|
+
// — staying within the host node. Only when the interior can hold no such
|
|
8125
|
+
// position does it move OUTSIDE, just beyond the band's outer edge, which is
|
|
8126
|
+
// where a threshold's caption already goes. A group- or region-scope band is
|
|
8127
|
+
// untouched: there is no centred label in the middle of a group box, and
|
|
8128
|
+
// every such figure in the corpus is byte-identical.
|
|
8129
|
+
const bandBoxes=new Map();
|
|
5939
8130
|
for(const f of zsort(doc.bands)){
|
|
5940
8131
|
const bn=byId[f.target];
|
|
5941
8132
|
const B=gBox[f.target] || (bn ? {x0:bn.x, x1:bn.x+bn.w, yA:bn.y, yB:bn.y+bn.h} : null);
|
|
5942
8133
|
if(!B) continue;
|
|
5943
8134
|
const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
|
|
5944
|
-
|
|
8135
|
+
let tx=bx+bw/2, ty=by+bh/2+4, anchor='middle';
|
|
8136
|
+
const lw=cwMax(f.label)*6.5, M=4;
|
|
8137
|
+
const obst=[];
|
|
8138
|
+
if(bn){
|
|
8139
|
+
const nl=nodeLbl.get(bn.id); if(nl) obst.push(nl);
|
|
8140
|
+
for(const o of (bandBoxes.get(bn.id)||[])) obst.push(o);
|
|
8141
|
+
}
|
|
8142
|
+
const boxAt=(x,y)=>({x0:x-lw/2, x1:x+lw/2, y0:y-4-7.5, y1:y-4+3.5});
|
|
8143
|
+
const clear=b=>!obst.some(o=>b.x0<o.x1+M&&b.x1>o.x0-M&&b.y0<o.y1+M&&b.y1>o.y0-M);
|
|
8144
|
+
if(obst.length && !clear(boxAt(tx,ty))){
|
|
8145
|
+
// long axis first: a vertical slab slides up/down, a horizontal one
|
|
8146
|
+
// left/right. Inside the host node either way.
|
|
8147
|
+
const vert=(f.dir==='left'||f.dir==='right');
|
|
8148
|
+
const dirs=vert?[[0,-1],[0,1],[-1,0],[1,0]]:[[-1,0],[1,0],[0,-1],[0,1]];
|
|
8149
|
+
let done=false;
|
|
8150
|
+
for(let d=1; d<=160 && !done; d+=1){
|
|
8151
|
+
for(const [ux,uy] of dirs){
|
|
8152
|
+
const x=tx+ux*d, y=ty+uy*d, b=boxAt(x,y);
|
|
8153
|
+
// containment is asked of the axis being MOVED ALONG only. A
|
|
8154
|
+
// caption is routinely wider than the slab it names (a 30% band on
|
|
8155
|
+
// an 84 px node against a 45 px word), and it always was — sliding
|
|
8156
|
+
// it vertically must not be refused for an overhang the centred
|
|
8157
|
+
// placement already had.
|
|
8158
|
+
if(uy&&(b.y0<B.yA+1||b.y1>B.yB-1)) continue;
|
|
8159
|
+
if(ux&&(b.x0<B.x0+1||b.x1>B.x1-1)) continue;
|
|
8160
|
+
if(clear(b)){ tx=x; ty=y; done=true; break; }
|
|
8161
|
+
}
|
|
8162
|
+
}
|
|
8163
|
+
if(!done){
|
|
8164
|
+
// SIDE PLACEMENT: the interior cannot hold both, so the caption
|
|
8165
|
+
// leaves the node on the band's own outer side and keeps its
|
|
8166
|
+
// adjacency from outside.
|
|
8167
|
+
// `extend=` names the direction the band GROWS, so its outer side is
|
|
8168
|
+
// the edge it grew FROM: an `extend=right` band starts at the left
|
|
8169
|
+
// edge and leaves by the left.
|
|
8170
|
+
if(f.dir==='right') { anchor='end'; tx=B.x0-6; ty=by+bh/2+4; }
|
|
8171
|
+
else if(f.dir==='left') { anchor='start'; tx=B.x1+6; ty=by+bh/2+4; }
|
|
8172
|
+
else if(f.dir==='up') { anchor='middle'; tx=bx+bw/2; ty=B.yB+13; }
|
|
8173
|
+
else { anchor='middle'; tx=bx+bw/2; ty=B.yA-6; }
|
|
8174
|
+
W=Math.max(W, anchor==='end'?tx:tx+(anchor==='start'?lw:lw/2));
|
|
8175
|
+
Hh=Math.max(Hh, ty+4-y0-20);
|
|
8176
|
+
}
|
|
8177
|
+
}
|
|
8178
|
+
if(bn){
|
|
8179
|
+
const b=boxAt(anchor==='middle'?tx:(anchor==='end'?tx-lw/2:tx+lw/2), ty);
|
|
8180
|
+
if(!bandBoxes.has(bn.id)) bandBoxes.set(bn.id,[]);
|
|
8181
|
+
bandBoxes.get(bn.id).push(b);
|
|
8182
|
+
}
|
|
8183
|
+
tsvg.push(textEl(tx, ty, 11, anchor, labelInk(f.fill,'#334155'), f.label,
|
|
5945
8184
|
' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
5946
8185
|
}
|
|
5947
8186
|
const yEnd=y0+20+Hh+10;
|
|
5948
8187
|
return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
|
|
5949
|
-
meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}, errs:gErrs};
|
|
8188
|
+
meta:{W:W, top:y0+20+chShift+tShift, Hh:Hh, left:bShift+lShift}, errs:gErrs};
|
|
5950
8189
|
}
|
|
5951
8190
|
// borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
|
|
5952
8191
|
// It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
|
|
@@ -6119,7 +8358,7 @@ function edgeRuns(v, p, n, span, ownAt){
|
|
|
6119
8358
|
// `lifeline` declaration order is the COLUMN axis, left to right. Both axes
|
|
6120
8359
|
// are declaration-ordered, and that is why this genre has no `flow` and no
|
|
6121
8360
|
// `rank`: a key that reordered the drawing would make the picture disagree
|
|
6122
|
-
// with the text (`SEQUENCE-
|
|
8361
|
+
// with the text (`SEQUENCE-GENRE-VOCABULARY`). `fragment` and `operand` lines are
|
|
6123
8362
|
// DECLARATIONS and carry no time position of their own; a container's drawn
|
|
6124
8363
|
// extent is the span of its members' positions. Implementation: every
|
|
6125
8364
|
// element carries its source line number, so the row order is recovered by
|
|
@@ -7057,6 +9296,25 @@ function shade(hex,f){
|
|
|
7057
9296
|
return '#'+c(((v>>16)&255)*f)+c(((v>>8)&255)*f)+c((v&255)*f);
|
|
7058
9297
|
}
|
|
7059
9298
|
const CHART_PALETTE=['#3b82f6','#22c55e','#f59e0b','#ef4444','#a855f7','#14b8a6','#eab308','#64748b'];
|
|
9299
|
+
// Does an axis-aligned box overlap a CONVEX polygon? Separating-axis test:
|
|
9300
|
+
// two convex shapes miss each other iff some edge normal of either separates
|
|
9301
|
+
// them. The bar faces this is asked about are quads by construction (three
|
|
9302
|
+
// per bar), so the test is exact and needs no clipping library.
|
|
9303
|
+
function chartQuadHitsBox(poly,box){
|
|
9304
|
+
const bx=[[box.x0,box.y0],[box.x1,box.y0],[box.x1,box.y1],[box.x0,box.y1]];
|
|
9305
|
+
const shapes=[poly,bx];
|
|
9306
|
+
for(const sh of shapes){
|
|
9307
|
+
for(let i=0;i<sh.length;i++){
|
|
9308
|
+
const a=sh[i], c=sh[(i+1)%sh.length];
|
|
9309
|
+
const nx=-(c[1]-a[1]), ny=(c[0]-a[0]);
|
|
9310
|
+
let a0=Infinity,a1=-Infinity,b0=Infinity,b1=-Infinity;
|
|
9311
|
+
for(const q of poly){ const d=q[0]*nx+q[1]*ny; if(d<a0)a0=d; if(d>a1)a1=d; }
|
|
9312
|
+
for(const q of bx){ const d=q[0]*nx+q[1]*ny; if(d<b0)b0=d; if(d>b1)b1=d; }
|
|
9313
|
+
if(a1<b0||b1<a0) return false;
|
|
9314
|
+
}
|
|
9315
|
+
}
|
|
9316
|
+
return true;
|
|
9317
|
+
}
|
|
7060
9318
|
function renderChart(b,y0,doc){
|
|
7061
9319
|
const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
|
|
7062
9320
|
const rows=t.rows.map(r=>r.cells.slice(1).map(c=>parseFloat(c.v)||0));
|
|
@@ -7081,41 +9339,114 @@ function renderChart(b,y0,doc){
|
|
|
7081
9339
|
// gutter costs only the width it actually uses (0 when no label overhangs,
|
|
7082
9340
|
// so every chart whose labels already fitted is byte-unchanged).
|
|
7083
9341
|
const rLabPx=l=>cwMax(l)*6.5*10/11; // textEl draws these at font-size 10
|
|
7084
|
-
const
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
|
|
7088
|
-
|
|
9342
|
+
const oy=y0+18+ZS*zmax+6;
|
|
9343
|
+
// EVERYTHING BELOW IS COMPUTED IN A FRAME WHOSE x IS RELATIVE TO THE FLOOR
|
|
9344
|
+
// ORIGIN `ox`, because the gutter that fixes `ox` is not known until the
|
|
9345
|
+
// labels have been placed, and label placement is invariant to it: a nudge
|
|
9346
|
+
// is a shift relative to the bars, and the bars shift with the origin.
|
|
9347
|
+
const PR=(r,c,z)=>[(c-r)*W2, oy+(c+r)*H2-z*ZS];
|
|
9348
|
+
const svg=[], lsvg=[];
|
|
7089
9349
|
svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bar3d</text>');
|
|
7090
|
-
//
|
|
7091
|
-
const
|
|
7092
|
-
svg.push('<polygon points="'+F.map(p=>p.join(',')).join(' ')+'" fill="#f6f5ef" stroke="#d6d4cc"/>');
|
|
7093
|
-
// bars, far to near
|
|
7094
|
-
const order=[];
|
|
9350
|
+
// bars, far to near — polygons kept for the label occlusion test below
|
|
9351
|
+
const order=[], faces=[];
|
|
7095
9352
|
for(let r=0;r<R;r++)for(let c=0;c<C;c++)order.push([r,c]);
|
|
7096
9353
|
order.sort((a,b2)=>(a[0]+a[1])-(b2[0]+b2[1]));
|
|
9354
|
+
const bars=[];
|
|
7097
9355
|
for(const [r,c] of order){
|
|
7098
9356
|
const h=rows[r][c]; if(h<=0) continue;
|
|
7099
9357
|
const col=CHART_PALETTE[c%CHART_PALETTE.length];
|
|
7100
9358
|
const i0=r+(1-BAR)/2, i1=r+(1+BAR)/2, j0=c+(1-BAR)/2, j1=c+(1+BAR)/2;
|
|
7101
|
-
const A=
|
|
7102
|
-
const B0=
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
//
|
|
7108
|
-
|
|
7109
|
-
|
|
9359
|
+
const A=PR(i0,j0,h),Bp=PR(i1,j0,h),Cp=PR(i1,j1,h),D=PR(i0,j1,h);
|
|
9360
|
+
const B0=PR(i1,j0,0),C0=PR(i1,j1,0),D0=PR(i0,j1,0);
|
|
9361
|
+
bars.push([Bp,Cp,C0,B0],[Cp,D,D0,C0],[A,Bp,Cp,D]);
|
|
9362
|
+
faces.push([[Bp,Cp,C0,B0],shade(col,0.72)],[[Cp,D,D0,C0],shade(col,0.55)],[[A,Bp,Cp,D],col]);
|
|
9363
|
+
}
|
|
9364
|
+
// AXIS LABELS ARE PLACED LAST AND PAINTED LAST (backlog 48).
|
|
9365
|
+
// They used to be emitted in WORLD space at the floor's edge and then left
|
|
9366
|
+
// where they landed, which put them inside the projection's own footprint:
|
|
9367
|
+
// a tall bar rises up-and-left across exactly the row line whose name sits
|
|
9368
|
+
// out there, so the categories a tall bar celebrates were the ones it hid
|
|
9369
|
+
// (`telemetry-export`: "Sampler" on the 12000 bar; `queue-heatmap`: P0, P1
|
|
9370
|
+
// and P2 on the bar field, and the Q captions under the bar feet). Paint
|
|
9371
|
+
// order alone does not fix it — dark grey on a saturated fill is unread
|
|
9372
|
+
// whichever is on top — so the label LEANS OUT along its own grid line,
|
|
9373
|
+
// the one direction that keeps it a label OF that row or column: a row
|
|
9374
|
+
// name travels outward at constant r (decreasing c), a column name outward
|
|
9375
|
+
// at constant c (increasing r), each stepping 2 px until its box clears
|
|
9376
|
+
// every bar face. Leaning out is bounded (40 px); the halo is what carries
|
|
9377
|
+
// the rare label that cannot fully clear. The nudge changes no bar, no
|
|
9378
|
+
// floor and no ruler — only where a name sits on the line it already
|
|
9379
|
+
// belonged to.
|
|
9380
|
+
const HALO=' paint-order="stroke" stroke="#fff" stroke-width="3"';
|
|
9381
|
+
const LEAN=2, LEAN_MAX=96, CEIL=y0+18;
|
|
9382
|
+
function leanBox(px,py,wpx,anchor){
|
|
9383
|
+
return {x0:anchor==='end'?px-wpx:px, x1:anchor==='end'?px:px+wpx, y0:py-8, y1:py+2.5};
|
|
9384
|
+
}
|
|
9385
|
+
function leanOut(x,y,wpx,anchor,dx,dy){
|
|
9386
|
+
const n=Math.hypot(dx,dy), ux=dx/n, uy=dy/n;
|
|
9387
|
+
let last=[x,y,leanBox(x,y,wpx,anchor),false];
|
|
9388
|
+
for(let k=0;k<=LEAN_MAX/LEAN;k++){
|
|
9389
|
+
const px=x+ux*k*LEAN, py=y+uy*k*LEAN;
|
|
9390
|
+
const box=leanBox(px,py,wpx,anchor);
|
|
9391
|
+
// never lean up past the section's own caption line: a name that
|
|
9392
|
+
// escapes the bars by leaving the chart is not a fix.
|
|
9393
|
+
if(box.y0<CEIL) break;
|
|
9394
|
+
last=[px,py,box];
|
|
9395
|
+
// 3 px of air, so "clear" reads as clear rather than as a name
|
|
9396
|
+
// ruled off by the bar it is standing against.
|
|
9397
|
+
const pad={x0:box.x0-3,x1:box.x1+3,y0:box.y0-2,y1:box.y1+2};
|
|
9398
|
+
let hit=false;
|
|
9399
|
+
for(const q of bars) if(chartQuadHitsBox(q,pad)){ hit=true; break; }
|
|
9400
|
+
// an axis label already placed is ink too: the column captions of
|
|
9401
|
+
// `telemetry-export` are 20 px apart in x and 10 px in y, so two long
|
|
9402
|
+
// ones ("Rate (pps)", "Drops/min") overlap by a line's descender unless
|
|
9403
|
+
// each leans clear of the last.
|
|
9404
|
+
if(!hit) for(const o of laid)
|
|
9405
|
+
if(pad.x0<o.x1&&pad.x1>o.x0&&pad.y0<o.y1&&pad.y1>o.y0){ hit=true; break; }
|
|
9406
|
+
if(!hit) return [px,py,box,true];
|
|
9407
|
+
}
|
|
9408
|
+
return [last[0],last[1],last[2],false];
|
|
9409
|
+
}
|
|
9410
|
+
let overhang=0, farRight=-Infinity, farDown=-Infinity;
|
|
9411
|
+
const placed=[], laid=[];
|
|
9412
|
+
rLab.forEach((l,r)=>{
|
|
9413
|
+
const p=PR(r+0.5,-0.15,0);
|
|
9414
|
+
const [x,y,box,ok]=leanOut(p[0]-4,p[1]+10,rLabPx(l),'end',-W2,-H2);
|
|
9415
|
+
placed.push([x,y,'end',l,ok]); laid.push(box);
|
|
9416
|
+
overhang=Math.max(overhang,-box.x0); farRight=Math.max(farRight,box.x1); farDown=Math.max(farDown,box.y1);
|
|
9417
|
+
});
|
|
9418
|
+
cLab.forEach((l,c)=>{
|
|
9419
|
+
const p=PR(R+0.15,c+0.5,0);
|
|
9420
|
+
const [x,y,box,ok]=leanOut(p[0]+4,p[1]+10,rLabPx(l),'start',-W2,H2);
|
|
9421
|
+
placed.push([x,y,'start',l,ok]); laid.push(box);
|
|
9422
|
+
overhang=Math.max(overhang,-box.x0); farRight=Math.max(farRight,box.x1); farDown=Math.max(farDown,box.y1);
|
|
9423
|
+
});
|
|
9424
|
+
const ox=Math.max(R*W2+8, overhang+4);
|
|
9425
|
+
for(const [poly,fill] of faces)
|
|
9426
|
+
svg.push('<polygon points="'+poly.map(p=>(p[0]+ox)+','+p[1]).join(' ')+'" fill="'+fill+'"/>');
|
|
9427
|
+
// THE HALO IS THE LAST RESORT, NOT THE HABIT. A label that found clear air
|
|
9428
|
+
// is drawn plainly: `tools/layout-lint.js` tells an EDGE label from every
|
|
9429
|
+
// other label by the halo twin (two <text> at one point), so haloing a chart
|
|
9430
|
+
// axis caption enrols it in F5's denominator, and an axis whose denominator
|
|
9431
|
+
// silently absorbs five non-edge labels is measuring something other than
|
|
9432
|
+
// what it prints. Only a caption the lean could not clear keeps the halo,
|
|
9433
|
+
// which is the one case where it is carrying the legibility.
|
|
9434
|
+
for(const [x,y,anchor,l,ok] of placed)
|
|
9435
|
+
lsvg.push(textEl(x+ox,y,10,anchor,'#6f6e69',l,ok?'':HALO));
|
|
9436
|
+
// floor grid edges — drawn UNDER the bars, so it is spliced in ahead of them
|
|
9437
|
+
const F=[PR(0,0,0),PR(R,0,0),PR(R,C,0),PR(0,C,0)];
|
|
9438
|
+
svg.splice(1,0,'<polygon points="'+F.map(p=>(p[0]+ox)+','+p[1]).join(' ')+'" fill="#f6f5ef" stroke="#d6d4cc"/>');
|
|
7110
9439
|
// z ruler at the right-back corner
|
|
7111
|
-
const zr=
|
|
9440
|
+
const zr=[PR(0,C,0)[0]+ox, PR(0,C,0)[1]];
|
|
7112
9441
|
svg.push('<line x1="'+(zr[0]+14)+'" y1="'+zr[1]+'" x2="'+(zr[0]+14)+'" y2="'+(zr[1]-zmax*ZS)+'" stroke="#8a8880"/>');
|
|
7113
9442
|
for(const z of [0, Math.round(zmax/2), Math.round(zmax)]){
|
|
7114
9443
|
svg.push('<line x1="'+(zr[0]+11)+'" y1="'+(zr[1]-z*ZS)+'" x2="'+(zr[0]+17)+'" y2="'+(zr[1]-z*ZS)+'" stroke="#8a8880"/>');
|
|
7115
9444
|
svg.push(textEl(zr[0]+21, zr[1]-z*ZS+3.5, 9.5, 'start', '#6f6e69', String(z)));
|
|
7116
9445
|
}
|
|
7117
|
-
const
|
|
7118
|
-
|
|
9446
|
+
const near=[PR(R,C,0)[0]+ox, PR(R,C,0)[1]];
|
|
9447
|
+
const w=Math.max(near[0]+70, farRight+ox+4);
|
|
9448
|
+
const hgt=Math.max(near[1]+24, farDown+6)-y0;
|
|
9449
|
+
return {svg:svg.join('')+lsvg.join(''), y:y0+hgt, w:w};
|
|
7119
9450
|
}
|
|
7120
9451
|
|
|
7121
9452
|
// ---- timing ----
|
|
@@ -7571,7 +9902,7 @@ let lastSVG='', lastMeta=null, lastPad={x:0,y:0}, lastDoc=null;
|
|
|
7571
9902
|
// SPELLINGS, not per-genre legality (the parser owns legality), so the
|
|
7572
9903
|
// per-genre withdrawals of `SCENE-KEYWORD-MEMBERSHIP` change nothing here — `threshold`, `band` and
|
|
7573
9904
|
// `bundle` are still content lines in the genres that still declare them.
|
|
7574
|
-
// `SEQUENCE-
|
|
9905
|
+
// `SEQUENCE-GENRE-VOCABULARY`: `lifeline` `message` `fragment` `operand` join the
|
|
7575
9906
|
// list, because under `sequence` they ARE the content zone — the genre has no
|
|
7576
9907
|
// `node`/`edge` spelling at all. Omitting them did not produce a line error;
|
|
7577
9908
|
// it produced a WRONG FIGURE, which under this genre is worse. `lastContentLineIdx`
|
|
@@ -7585,7 +9916,7 @@ const CONTENT_KW=/^(figdown|title|node|state|lifeline|message|fragment|operand|g
|
|
|
7585
9916
|
// `statechart`, so an id it declares must count as USED here or the GUI
|
|
7586
9917
|
// hands out a colliding id. The list is spellings, not per-genre legality —
|
|
7587
9918
|
// the parser owns legality.
|
|
7588
|
-
// `SEQUENCE-
|
|
9919
|
+
// `SEQUENCE-GENRE-VOCABULARY`: `lifeline` `fragment` `operand` are the three
|
|
7589
9920
|
// id-DECLARING keywords of `sequence` (its `state` REFERENCES a lifeline in
|
|
7590
9921
|
// slot 1 and declares nothing, so it must stay out of this list under either
|
|
7591
9922
|
// genre — it is already here for `statechart`, where slot 1 does declare, and
|
|
@@ -7654,7 +9985,7 @@ function guiVersion(lines){
|
|
|
7654
9985
|
// `flowline` into a `figdown 0.1 flowchart` document would make the editor
|
|
7655
9986
|
// author a line error.
|
|
7656
9987
|
const guiConnKw=lines=>connectorKwAt(guiGenre(lines), guiVersion(lines))||'edge';
|
|
7657
|
-
// `SEQUENCE-
|
|
9988
|
+
// `SEQUENCE-GENRE-VOCABULARY`: "a line that DECLARES a node" is a per-DOCUMENT question,
|
|
7658
9989
|
// not a per-language one, because two genres may SHARE a spelling for two
|
|
7659
9990
|
// different grammars. `state` is the case: under `statechart` it declares a
|
|
7660
9991
|
// node, under `sequence` it declares an OCCURRENCE that references a lifeline.
|
|
@@ -7668,7 +9999,7 @@ const guiConnKw=lines=>connectorKwAt(guiGenre(lines), guiVersion(lines))||'edge'
|
|
|
7668
9999
|
// So the pattern is built from the DOCUMENT'S OWN node word, the same registry
|
|
7669
10000
|
// read `guiNodeKw` already makes for what to write.
|
|
7670
10001
|
const nodeLineReAt=lines=>new RegExp('^\\s*'+guiNodeKw(lines)+'\\s+');
|
|
7671
|
-
// `SEQUENCE-
|
|
10002
|
+
// `SEQUENCE-GENRE-VOCABULARY`: ONE alternation, built from the engine's own
|
|
7672
10003
|
// `NODE_SPELLINGS`, for every GUI pattern that has to find "the line that
|
|
7673
10004
|
// declares this node". Three of them were spelling `node|state` by hand and so
|
|
7674
10005
|
// went blind the moment a genre added a fourth word: under `sequence` a
|
|
@@ -7676,7 +10007,7 @@ const nodeLineReAt=lines=>new RegExp('^\\s*'+guiNodeKw(lines)+'\\s+');
|
|
|
7676
10007
|
// because none of the three could find its line. A per-genre word list written
|
|
7677
10008
|
// out by hand is the defect; this is the single source.
|
|
7678
10009
|
const NODE_KW_ALT=[...NODE_SPELLINGS].join('|');
|
|
7679
|
-
// `SEQUENCE-
|
|
10010
|
+
// `SEQUENCE-GENRE-VOCABULARY`: DOES THIS GENRE HAVE A COORDINATE FOR A PIN TO SET?
|
|
7680
10011
|
// `pin` is genre-free vocabulary (`LAYOUT-ZONE-NAMESPACE`, `LAYOUT_KW`), so it PARSES under every
|
|
7681
10012
|
// genre — but it only MEANS anything where a scene lays the figure out. A
|
|
7682
10013
|
// ladder's two axes are both declaration-ordered (draft §7), which is the same
|
|
@@ -7703,7 +10034,7 @@ function ensureFlowDirective(lines){
|
|
|
7703
10034
|
if(m){ genre=m[1]; break; }
|
|
7704
10035
|
}
|
|
7705
10036
|
// pure typed genres have no scene layout axis
|
|
7706
|
-
// `SEQUENCE-
|
|
10037
|
+
// `SEQUENCE-GENRE-VOCABULARY`: `sequence` joins them, and for the opposite
|
|
7707
10038
|
// reason — it HAS two axes and the genre already fixes both. Columns run in
|
|
7708
10039
|
// `lifeline` declaration order and time runs down the page in the order the
|
|
7709
10040
|
// occurrence lines are written (draft §7), so `flow` is not a keyword of
|
|
@@ -8047,7 +10378,7 @@ function installDirectEdit(){
|
|
|
8047
10378
|
ev.preventDefault();
|
|
8048
10379
|
const id=g.dataset.node, x0=+g.dataset.x, y0=+g.dataset.y, start=toModel(ev);
|
|
8049
10380
|
let dx=0,dy=0,moved=false;
|
|
8050
|
-
// `SEQUENCE-
|
|
10381
|
+
// `SEQUENCE-GENRE-VOCABULARY`: THE DRAG IS NOT OFFERED WHERE A PIN SETS NOTHING.
|
|
8051
10382
|
// The pointer handler stays — a lifeline head must still be CLICKABLE, to
|
|
8052
10383
|
// select it and to arm a link — but under a declaration-ordered genre the
|
|
8053
10384
|
// pointer never becomes a drag: no ghost translate (so nothing to spring
|
|
@@ -8089,7 +10420,7 @@ function installDirectEdit(){
|
|
|
8089
10420
|
g.addEventListener('dblclick',ev=>{
|
|
8090
10421
|
ev.preventDefault();
|
|
8091
10422
|
const id=g.dataset.node;
|
|
8092
|
-
// `SEQUENCE-
|
|
10423
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the CURRENT label is looked up through the genre's own
|
|
8093
10424
|
// collection — under `sequence` it is `doc.lifelines`, so asking
|
|
8094
10425
|
// `lastDoc.nodes` offered an empty prompt for a lifeline that has a
|
|
8095
10426
|
// label, and accepting it silently blanked the head.
|
|
@@ -8101,7 +10432,7 @@ function installDirectEdit(){
|
|
|
8101
10432
|
});
|
|
8102
10433
|
}
|
|
8103
10434
|
let selectedId=null, linkArm=null;
|
|
8104
|
-
// `SEQUENCE-
|
|
10435
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the DOCUMENT'S node word, not the union — see
|
|
8105
10436
|
// `nodeLineReAt`. Every caller here is answering "which line declares the
|
|
8106
10437
|
// element the user clicked", and under `sequence` the union answered with a
|
|
8107
10438
|
// `state` occurrence line whenever one was written above the declaration.
|
|
@@ -8128,7 +10459,7 @@ function deleteNode(id){
|
|
|
8128
10459
|
for(const p of d.paths||[]) if(p.a===id||p.b===id) drop.add(p.line);
|
|
8129
10460
|
for(const g of d.thresholds||[]) if(g.target===id) drop.add(g.line);
|
|
8130
10461
|
for(const b of d.bands||[]) if(b.target===id) drop.add(b.line);
|
|
8131
|
-
// `SEQUENCE-
|
|
10462
|
+
// `SEQUENCE-GENRE-VOCABULARY`: `sequence` CASCADES, exactly as `statechart`
|
|
8132
10463
|
// does — deleting a `state` there drops every `transition` touching it,
|
|
8133
10464
|
// because those live in `doc.edges` and the loop above already sees them.
|
|
8134
10465
|
// A sequence figure's connectors do NOT: a `message` is its own model row
|
|
@@ -8203,7 +10534,7 @@ function moveNodeLine(id,dir){ // dir +1 = later line = painted on top
|
|
|
8203
10534
|
const lines=$('src').value.split('\n');
|
|
8204
10535
|
const idx=nodeLineIdx(lines,id); if(idx<0) return;
|
|
8205
10536
|
const isNode=l=>nodeLineReAt(lines).test(l);
|
|
8206
|
-
// `SEQUENCE-
|
|
10537
|
+
// `SEQUENCE-GENRE-VOCABULARY`: what the move MEANS is the genre's, not the button's.
|
|
8207
10538
|
// Where a scene lays the figure out, line order is paint order. Where both
|
|
8208
10539
|
// axes are declaration-ordered (draft §7), moving a `lifeline` line moves its
|
|
8209
10540
|
// COLUMN — the same keystroke, a different fact, and the status line may not
|
|
@@ -8220,7 +10551,7 @@ function moveNodeLine(id,dir){ // dir +1 = later line = painted on top
|
|
|
8220
10551
|
}
|
|
8221
10552
|
function select(id){
|
|
8222
10553
|
selectedId=id;
|
|
8223
|
-
// `SEQUENCE-
|
|
10554
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the enablement test asks the GENRE'S collection, not
|
|
8224
10555
|
// `doc.nodes`. Fill / Delete / Raise / Lower all act on the node LINE, and
|
|
8225
10556
|
// the patterns that find it have known every genre's spelling since
|
|
8226
10557
|
// 0.4, so every one of them already worked on a `lifeline` — this
|
|
@@ -8252,7 +10583,7 @@ function drawResizeHandle(){
|
|
|
8252
10583
|
const svg=$('out').querySelector('svg'); if(!svg) return;
|
|
8253
10584
|
svg.querySelectorAll('.rsz').forEach(x=>x.remove());
|
|
8254
10585
|
if(!selectedId) return;
|
|
8255
|
-
// `SEQUENCE-
|
|
10586
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the corner handle writes `pin <id> width= height=`, so
|
|
8256
10587
|
// it is the SECOND gesture that would author a line the ladder does not
|
|
8257
10588
|
// read — and it only became reachable now that a lifeline can be selected.
|
|
8258
10589
|
// Same test as the drag: where a pin sets nothing, the handle is not drawn.
|
|
@@ -8458,7 +10789,7 @@ function insertNodeLine(){
|
|
|
8458
10789
|
const nnum=nid.slice(1);
|
|
8459
10790
|
const last=lastContentLineIdx(lines);
|
|
8460
10791
|
const kk=$('newkind').value;
|
|
8461
|
-
// `SEQUENCE-
|
|
10792
|
+
// `SEQUENCE-GENRE-VOCABULARY`: the kind picker only gets to speak if the genre's
|
|
8462
10793
|
// node directive TAKES `shape=`. A `lifeline` does not — a participant column
|
|
8463
10794
|
// has one drawn form and the option names nothing on it — so `+ Node` with
|
|
8464
10795
|
// the picker off `box` was writing `lifeline n1 "Node 1" shape=rounded`,
|