umlimator 0.0.0-stage → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2870 @@
1
+ /*! umlimator: embeddable, layered architecture maps. MIT, see LICENSE.
2
+ *
3
+ * import { render, layout, mountPage } from './umlimator.js';
4
+ * const map = render(element, model, { on: { select: e => ... } }); // map.update(model), map.destroy()
5
+ *
6
+ * Derived from the /visual page template of super-board by Eric Tech (MIT, THIRD_PARTY_NOTICES.md), whose section
7
+ * layout adapts BuilderIO/skills (MIT) and whose diagram style, router approach and viewer UX (camera with reading
8
+ * depths, minimap, node focus, finder, export) adapt tt-a1i/archify (MIT). See THIRD_PARTY_NOTICES.md.
9
+ * One file, no dependencies, no build step, no network calls; docs/module-api.md describes the API.
10
+ */
11
+ const STYLES = `
12
+ /* diagrams */
13
+ figure.dia { margin: 0 0 18px; padding: 18px 18px 14px; overflow: hidden; }
14
+ @media (min-width: 1300px) { #diagram { position: relative; left: 50%; transform: translateX(-50%); width: min(1640px, calc(100vw - 64px)); } }
15
+ figure.dia figcaption { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 18px; margin-bottom: 10px; }
16
+ figure.dia .ft { font-weight: 600; font-size: 16px; }
17
+ figure.dia .fc { color: var(--um-muted); font-size: 14px; margin-top: 10px; }
18
+ .canvas { position: relative; overflow: hidden; border-radius: 10px; border: 1px solid var(--um-border); background-color: var(--um-bg-2); touch-action: none; cursor: grab; outline: none;
19
+ background-image: radial-gradient(circle, var(--um-dot) 1.1px, transparent 1.4px), linear-gradient(var(--um-gridline) 1px, transparent 1px), linear-gradient(90deg, var(--um-gridline) 1px, transparent 1px); }
20
+ .canvas:focus-visible { box-shadow: 0 0 0 2px var(--um-cyan); }
21
+ .canvas.grabbing, .stage.grabbing { cursor: grabbing; }
22
+ .canvas svg { display: block; position: absolute; inset: 0; }
23
+ .canvas.full { position: fixed; inset: 12px; z-index: 60; border-radius: 14px; box-shadow: var(--um-shadow); height: auto !important; }
24
+ body.noscroll { overflow: hidden; }
25
+ .cam-hint { position: absolute; left: 10px; bottom: 8px; font: 500 11px var(--um-mono); color: var(--um-dim); pointer-events: none; opacity: 0; transition: opacity .2s; }
26
+ .canvas:hover .cam-hint { opacity: 1; }
27
+ .ctl { position: absolute; right: 10px; bottom: 10px; display: flex; align-items: center; gap: 2px; padding: 3px; border-radius: 11px; background: var(--um-chrome); border: 1px solid var(--um-border-2); box-shadow: var(--um-shadow); backdrop-filter: blur(10px); z-index: 4; }
28
+ .ctl button { min-width: 30px; height: 28px; border: 0; background: none; color: var(--um-text); font: 600 13px var(--um-mono); border-radius: 8px; cursor: pointer; padding: 0 7px; }
29
+ .ctl button:hover:not(:disabled) { background: var(--um-panel-2); color: var(--um-cyan); }
30
+ .ctl button:disabled { opacity: .35; cursor: default; }
31
+ .ctl .pct { min-width: 54px; font-weight: 500; color: var(--um-muted); }
32
+ .ctl .sep { width: 1px; height: 18px; background: var(--um-border-2); margin: 0 3px; }
33
+ .ctl .depth { font: 700 10px var(--um-mono); letter-spacing: .12em; color: var(--um-muted); padding: 0 8px; }
34
+ .mini-ctl { opacity: .0; transition: opacity .2s; } .canvas:hover .mini-ctl, .canvas:focus-within .mini-ctl, .canvas.full .mini-ctl { opacity: 1; }
35
+ @media (hover: none) { .mini-ctl { opacity: 1; } }
36
+ .legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font: 600 11px var(--um-mono); color: var(--um-muted); align-items: center; }
37
+ .legend i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; border: 1.5px solid; margin-right: 6px; vertical-align: -1px; }
38
+ svg .n-mask { fill: var(--um-mask); }
39
+ svg .n-body { stroke-width: 1.5; transition: stroke-width .15s, filter .15s; }
40
+ svg .n-label { fill: var(--um-text); font: 600 13px var(--um-mono); letter-spacing: -.01em; }
41
+ svg .n-sub { fill: var(--um-muted); font: 10px var(--um-mono); }
42
+ svg .e-label { fill: var(--um-muted); font: 600 10px var(--um-mono); }
43
+ svg .e-lbg { fill: var(--um-bg-2); stroke: var(--um-border); }
44
+ svg .g-label { font: 700 10px var(--um-mono); letter-spacing: .12em; text-transform: uppercase; }
45
+ svg .badge-tx { font: 700 8.5px var(--um-mono); letter-spacing: .08em; }
46
+ /* a line's weight (#44) scales what is drawn for it: --um-ew is 1, 1.75 or 2.5 */
47
+ :host { --um-ew: 1; } svg .wt-2 { --um-ew: 1.75; } svg .wt-3 { --um-ew: 2.5; }
48
+ svg .edge { fill: none; stroke-width: calc(1.6px * var(--um-ew, 1)); stroke-linecap: round; stroke-linejoin: round; transition: stroke-width .15s, opacity .2s; }
49
+ svg .halo { fill: none; stroke: var(--um-bg-2); stroke-width: calc(6px + 1.8px * (var(--um-ew, 1) - 1)); }
50
+ svg .hitline { fill: none; stroke: transparent; stroke-width: calc(12px + 1.8px * (var(--um-ew, 1) - 1)); }
51
+ svg .flowline { fill: none; stroke-width: calc(2.4px * var(--um-ew, 1)); stroke-linecap: round; stroke-dasharray: 10 70; opacity: 0; pointer-events: none; }
52
+ .flowing svg .flowline { opacity: .9; animation: flowdash 2.4s linear infinite; }
53
+ .flowing svg .edge { opacity: .8; }
54
+ @keyframes flowdash { to { stroke-dashoffset: -160; } }
55
+ /* intake badges and the view tour: a ticket dot rides the loop while flow is on */
56
+ svg .n-logo { fill: var(--um-logo-tile); stroke: var(--um-border-2); stroke-width: .8; } svg .n-logo.dark { fill: var(--um-logo-tile-dark); }
57
+ .ph .big.logo { background: var(--um-logo-tile) !important; box-shadow: inset 0 0 0 1px var(--um-border-2); } .ph .big.logo.dark { background: var(--um-logo-tile-dark) !important; }
58
+ svg .intake-chip { fill: var(--um-logo-tile); stroke: var(--um-border-2); stroke-width: 1.2; }
59
+ svg .intake-tx { fill: var(--um-muted); font: 600 10.5px var(--um-sans); }
60
+ svg .intake-chip.dashed { fill: var(--um-panel); stroke: var(--um-w-feeds); stroke-dasharray: 3 3; }
61
+ svg .intake-mark { fill: var(--um-intake-mark); font: 700 13px var(--um-sans); }
62
+ svg .intake-chip.dashed + .intake-mark { fill: var(--um-w-feeds); }
63
+ svg .tour, svg .tour-glow { display: none; pointer-events: none; }
64
+ .flowing svg .tour, .flowing svg .tour-glow { display: inline; }
65
+ svg .tour-glow { fill: none; stroke-width: 7; stroke-linecap: round; filter: blur(2.5px); }
66
+ svg .tour-dot { fill: var(--um-w-feeds); stroke: var(--um-bg-2); stroke-width: 1.5; filter: drop-shadow(0 0 6px var(--um-w-feeds)); }
67
+ svg .tour-packet { fill: var(--um-w-feeds); }
68
+ .flowing svg .node.tour-stop { transform-box: fill-box; transform-origin: center; animation: tour-pulse 8s ease-in-out infinite; }
69
+ @keyframes tour-pulse { 0%, 9%, 100% { transform: none; filter: none; } 4% { transform: scale(1.04); filter: drop-shadow(0 0 10px color-mix(in srgb, var(--um-w-feeds) 60%, transparent)); } }
70
+ .flowing svg .edge-g.back .edge { animation: march 1.1s linear infinite; }
71
+ @keyframes march { to { stroke-dashoffset: -22; } }
72
+ @media (prefers-reduced-motion: reduce) { svg .tour, svg .tour-glow { display: none !important; } .flowing svg .node.tour-stop, .flowing svg .edge-g.back .edge { animation: none; } }
73
+ @media (prefers-reduced-motion: reduce) { .flowing svg .flowline { animation: none; opacity: 0; } .flowing svg .edge { opacity: 1; } }
74
+ .ctl button.on { color: var(--um-cyan); background: var(--um-cyan-f); }
75
+ svg .flow { stroke-dasharray: 6 6; animation: flow 1.2s linear infinite; }
76
+ @keyframes flow { to { stroke-dashoffset: -12; } }
77
+ @media (prefers-reduced-motion: reduce) { svg .flow { animation: none; } }
78
+ /* reading depths (archify): far out = shapes + names; closer = subs, verbs, edge labels */
79
+ .depth-read { transition: opacity .18s; }
80
+ [data-depth="map"] .depth-read { opacity: 0; pointer-events: none; }
81
+ /* typed node styles (map) */
82
+ svg .node { cursor: pointer; outline: none; }
83
+ svg .node .n-ring { fill: none; stroke-width: 1.2; stroke-opacity: .38; }
84
+ svg .node.pub .n-body { stroke-width: 2.6; }
85
+ svg .node.pub .n-label { font-weight: 700; }
86
+ svg .node.priv .n-body { stroke-width: 1; stroke-opacity: .7; fill-opacity: .5; }
87
+ svg .node.priv .n-label { fill: var(--um-muted); font-weight: 500; font-size: 11.5px !important; }
88
+ svg .node.priv .n-sub { display: none; }
89
+ svg .node.priv .n-sigil { opacity: .7; }
90
+ svg .node .n-stack { fill: var(--um-mask); stroke-width: 1; stroke-opacity: .45; }
91
+ svg .node .ini-tx { font: 700 7.5px var(--um-mono); }
92
+ svg .node .tab-tx, svg .family .tab-tx { font: 800 8px var(--um-mono); letter-spacing: .14em; fill: var(--um-mask); text-transform: uppercase; } svg .family .pub-tab { cursor: pointer; outline: none; }
93
+ /* focus targets for lines and a card's tabs (#43): outside the cards, never hit by the pointer, a ring when focused by key */
94
+ svg .um-key { outline: none; pointer-events: none; } svg .key-ring { fill: none; stroke: none; }
95
+ svg .line-key:focus-visible .key-ring { stroke: var(--um-cyan); stroke-opacity: .55; stroke-width: calc(7.8px + 1.8px * (var(--um-ew, 1) - 1)); stroke-linecap: round; stroke-linejoin: round; }
96
+ svg .tab-key:focus-visible .key-ring, svg .chip-key:focus-visible .key-ring, svg .family .pub-tab:focus-visible rect { stroke: var(--um-cyan); stroke-width: 2; }
97
+ svg .node .verb-chip { fill: var(--um-mask); stroke-width: .9; stroke-opacity: .6; }
98
+ svg .node .verb-tx { font: 600 9.5px var(--um-mono); }
99
+ svg .node.subject .n-body { stroke-dasharray: none; filter: drop-shadow(0 0 10px color-mix(in srgb, currentColor 0%, transparent)); }
100
+ svg .node:hover .n-body, svg .node:focus-visible .n-body { stroke-width: 3; }
101
+ svg .node.priv:hover .n-body { stroke-opacity: 1; }
102
+ svg .node.priv:hover .n-label { fill: var(--um-text); }
103
+ svg .node:focus-visible .n-mask { stroke: var(--um-cyan); stroke-width: 3; }
104
+ svg .edge-g.hov .edge, svg .edge-g.hot .edge { stroke-width: calc(2.6px * var(--um-ew, 1)); }
105
+ .focusing svg .node:not(.sel):not(.nb) { opacity: .28; }
106
+ .focusing svg .edge-g:not(.hot) { opacity: .15; }
107
+ .focusing svg .e-lab:not(.hot) { opacity: .15; }
108
+ svg .node, svg .edge-g, svg .e-lab { transition: opacity .2s; }
109
+ svg .node.sel .n-body { stroke-width: 3.2; }
110
+ svg .node.sel .n-mask { stroke: var(--um-accent); stroke-width: 2; }
111
+ svg .node.sel .n-body { animation: select-glow .65s ease-out; filter: drop-shadow(0 0 5px color-mix(in srgb, var(--um-accent) 40%, transparent)); }
112
+ svg .node.nb .n-body { stroke-width: 2.6; stroke-opacity: 1; }
113
+ svg .n-port { fill: var(--um-panel); stroke-width: 1.5; pointer-events: none; transition: fill .2s, stroke-width .2s; }
114
+ svg .node.sel .n-port, svg .node.nb .n-port.hot { fill: var(--um-accent); stroke: var(--um-accent); stroke-width: 2.2; }
115
+ :host .focusing svg .edge-g.hot .flowline { opacity: .95; stroke-width: calc(3.4px * var(--um-ew, 1)); stroke-dasharray: 8 13; animation: selected-flow 1.1s linear infinite; }
116
+ :host .focusing svg .edge-g.hot .edge { stroke-width: calc(2.8px * var(--um-ew, 1)); }
117
+ @keyframes selected-flow { to { stroke-dashoffset: -42; } }
118
+ @keyframes select-glow { from { filter: drop-shadow(0 0 12px color-mix(in srgb, var(--um-accent) 70%, transparent)); stroke-width: 5; } }
119
+ svg .node.main-step .n-body { stroke-width: 2.2; }
120
+ svg .node.main-step .n-label { font-weight: 800; }
121
+ @media (prefers-reduced-motion: reduce) {
122
+ svg .node, svg .edge-g, svg .e-lab, svg .n-port { transition: none; }
123
+ svg .node.sel .n-body, svg .node.flash .n-mask { animation: none; }
124
+ :host .focusing svg .edge-g.hot .flowline { animation: none; opacity: .8; }
125
+ .panel { animation: none !important; }
126
+ }
127
+ svg .node.flash .n-mask { animation: flash 1.3s ease-out; }
128
+ @keyframes flash { 0%, 40% { stroke: var(--um-amber); stroke-width: 8; } 100% { stroke-width: 0; } }
129
+
130
+
131
+ /* map: rounded wires with pill labels, lanes, step badges, author name tags */
132
+ :host svg .e-lbg { fill: var(--um-panel); stroke: var(--um-border-2); }
133
+ :host svg .e-label { fill: var(--um-text); font: 600 10.5px var(--um-sans); }
134
+ :host svg .edge { stroke-width: calc(1.8px * var(--um-ew, 1)); }
135
+ :host svg .w-state .edge { stroke-width: calc(1.4px * var(--um-ew, 1)); }
136
+ :host .flowing svg .flowline { opacity: .45; }
137
+ :host .flowing svg .edge { opacity: 1; }
138
+ :host .flowing svg .w-state .flowline { opacity: 0; }
139
+ svg .e-lab.ctag .e-lbg { fill: var(--um-accent-f); stroke: color-mix(in srgb, var(--um-accent) 45%, transparent); } svg .e-lab.ctag .e-label { fill: var(--um-accent); }
140
+ svg .e-lab.w-state .e-label { fill: var(--um-muted); font-weight: 500; }
141
+ svg .e-sub { fill: var(--um-muted); font: 500 9.5px var(--um-sans); }
142
+ .lgi i.w { width: 18px; height: 0; border: 0; border-top: 2px solid; border-radius: 0; }
143
+ .lgi i.w.dash { border-top-style: dashed; }
144
+ svg .e-step { fill: var(--um-accent); } svg .e-step-tx { fill: var(--um-accent-fg); font: 700 9.5px var(--um-sans); }
145
+ svg .lane-box { fill: var(--um-lane); stroke: var(--um-border); }
146
+ svg .lane-num { fill: var(--um-accent); } svg .lane-num-tx { fill: var(--um-accent-fg); font: 700 11px var(--um-sans); }
147
+ svg .lane-t { fill: var(--um-text); font: 700 11px var(--um-sans); } svg .lane-c { fill: var(--um-muted); font: 500 11px var(--um-sans); }
148
+ svg .lane-arrow { fill: var(--um-dim); font: 600 16px var(--um-sans); }
149
+ svg .stp { fill: var(--um-text); } svg .stp-tx { fill: var(--um-bg); font: 700 9.5px var(--um-sans); }
150
+ svg .ntag-bg { fill: var(--um-panel); stroke: var(--um-border-2); }
151
+ svg .ntag-tx { fill: var(--um-muted); font: 600 10px var(--um-sans); }
152
+ svg .ntag-ring { fill: none; stroke-width: 1.6; }
153
+ svg .ini-tx2 { font: 700 9px var(--um-sans); }
154
+ :host svg .n-label, :host svg .e-label { font-family: var(--um-sans); }
155
+ :host svg .n-sub, :host svg .verb-tx { font-family: var(--um-sans); }
156
+ :host svg .n-label { font-weight: 650; letter-spacing: 0; }
157
+ .stage.paused svg, .stage.paused svg * { animation-play-state: paused !important; }
158
+ :host { font: 15px/1.6 var(--um-sans); color: var(--um-text); -webkit-font-smoothing: antialiased; }
159
+ .stage { position: absolute; inset: 0; overflow: hidden; touch-action: none; cursor: grab; outline: none; background: var(--um-stage-bg); }
160
+ .stage svg.map-svg { position: absolute; inset: 0; display: block; transition: opacity .13s ease; }
161
+ .stage.fading svg.map-svg { opacity: 0; }
162
+ /* controls + minimap, bottom right */
163
+ .mctl { position: absolute; right: 16px; bottom: 40px; z-index: 3; display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
164
+ .minimap { width: 200px; height: 112px; border-radius: 10px; background: var(--um-panel); border: 1px solid var(--um-border-2); box-shadow: var(--um-shadow); overflow: hidden; cursor: crosshair; padding: 6px; transition: opacity .2s; }
165
+ .minimap.all { opacity: .6; } .minimap:hover { opacity: 1; }
166
+ .minimap svg { width: 100%; height: 100%; display: block; }
167
+ .minimap .vp { fill: color-mix(in srgb, var(--um-accent) 10%, transparent); stroke: var(--um-accent); }
168
+ .mctl .ctl { position: static; border-radius: 12px; padding: 4px; background: var(--um-panel); border-color: var(--um-border-2); }
169
+ .mctl .ctl button.on { color: var(--um-accent); background: color-mix(in srgb, var(--um-accent) 14%, transparent); }
170
+ .mctl .ctl button:hover:not(:disabled) { color: var(--um-accent); }
171
+ .empty-view { position: absolute; left: 50%; top: 45%; transform: translate(-50%, -50%); text-align: center; width: min(380px, 86%); z-index: 2; }
172
+ .empty-view .ico { font-size: 28px; color: var(--um-dim); }
173
+ .empty-view h4 { margin: 6px 0 4px; font: 650 16px var(--um-sans); }
174
+ .empty-view p { margin: 0 0 12px; color: var(--um-muted); font-size: 13px; }
175
+ svg .family-box { fill: var(--um-panel); fill-opacity: .45; stroke-width: 1.5; }
176
+ svg .family-title { font: 650 11px var(--um-mono); }
177
+ svg .family.sel .family-box { stroke: var(--um-accent); stroke-width: 2; }
178
+ svg .edited-dot { fill: var(--um-edited); stroke: var(--um-mask); stroke-width: 1.5; }
179
+ .um-rename { position: absolute; z-index: 5; box-sizing: border-box; font-family: var(--um-sans); font-weight: 600; color: var(--um-text); background: var(--um-panel); border: 1.5px solid var(--um-accent); border-radius: 6px; padding: 0 6px; outline: none; box-shadow: var(--um-shadow); }
180
+ svg .um-ghost { opacity: .6; pointer-events: none; } svg .um-ghost.um-pending { opacity: .3; }
181
+ svg .family.um-target .family-box { stroke: var(--um-accent); stroke-width: 2.5; stroke-dasharray: 6 4; }
182
+ svg .um-handle circle { fill: var(--um-accent); stroke: var(--um-mask); stroke-width: 2; cursor: crosshair; }
183
+ .um-live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
184
+ .um-still *, .um-still *::before, .um-still *::after { transition: none !important; animation: none !important; }
185
+ svg .um-link { fill: none; stroke: var(--um-accent); stroke-width: 2; stroke-dasharray: 5 4; pointer-events: none; }
186
+ @media (max-width: 820px) { .minimap { display: none; } .mctl { bottom: 14px; right: 12px; } }
187
+ ` + /* each tone's tint (--um-<tone>-f), mixed at the map from the tone and panel in effect there: mixed on :root (as the theme
188
+ file does, for page chrome) a tint keeps the tone it was mixed from, whatever an ancestor of the map sets later */
189
+ `:host { ${Object.entries({ accent: 12, cyan: 9, green: 9, violet: 9, amber: 10, rose: 8, orange: 9, slate: 8, fuchsia: 8, 'k-public': 8, 'k-lane': 8,
190
+ 'k-ext': 7, 'k-verb': 7, 'k-script': 5, 'k-hook': 12, 'k-board': 9 }).map(([t, p]) => `--um-${t}-f: color-mix(in srgb, var(--um-${t}) ${p}%, var(--um-panel));`).join(' ')} }`;
191
+
192
+ const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
193
+ const inline = s => esc(s)
194
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
195
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
196
+ .replace(/==([^=]+)==/g, '<mark>$1</mark>')
197
+ .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
198
+ const md = s => String(s ?? '').trim().split(/\n{2,}/).map(b => /^\s*[-*] /.test(b)
199
+ ? '<ul>' + b.split(/\n/).map(l => '<li>' + inline(l.replace(/^\s*[-*] /, '')) + '</li>').join('') + '</ul>'
200
+ : '<p>' + inline(b).replace(/\n/g, '<br>') + '</p>').join('');
201
+ const h = (html) => { const t = document.createElement('template'); t.innerHTML = html.trim(); return t.content.firstElementChild; };
202
+ /* ================= diagram engine (shared by document diagrams and the map) =================
203
+ Style adapted from tt-a1i/archify (MIT): semantic palette, opaque mask under a translucent
204
+ fill, per-kind sigils, grid canvas, orthogonal rounded routes, arrow markers. */
205
+ const KIND = {
206
+ frontend: ['cyan', 'Client / UI', 'people and screens'], backend: ['green', 'Service / API', 'runs the logic'], database: ['violet', 'Data store', 'keeps data'],
207
+ cloud: ['amber', 'Infra / Cloud', 'hosts, observability'], security: ['rose', 'Auth / Security', 'checks and holds secrets'], queue: ['orange', 'Queue / Event', 'hands work on'],
208
+ external: ['slate', 'External', 'outside the system'], file: ['slate', 'File / Doc', 'a document or config'], ai: ['fuchsia', 'Agent / AI', 'an agent or model'],
209
+ // map-mode kinds (skill / architecture maps)
210
+ 'm-public': ['k-public', 'Public skill', 'you run it'], 'm-lane': ['k-lane', 'Board lane', 'the board starts it'],
211
+ 'm-ext': ['k-ext', 'External skill', 'from another pack'], 'm-verb': ['k-verb', 'Impeccable verb', 'a design command'],
212
+ 'm-script': ['k-script', 'Script', 'code a skill runs'], 'm-hook': ['k-hook', 'Hook / guard', 'fires on its own'],
213
+ 'm-board': ['k-board', 'State', 'board, config, ledgers'],
214
+ 'm-step': ['k-public', 'Process step', 'open for implementation details'],
215
+ };
216
+ const MAPK = { /* umlimator */ public: 'm-public', lane: 'm-lane', script: 'm-script', 'skill-dep': 'm-ext', dep: 'm-ext', verb: 'm-verb', step: 'm-step', hook: 'm-hook', guard: 'm-hook', board: 'm-board', state: 'm-board' };
217
+ const ALIAS = { client: 'frontend', ui: 'frontend', web: 'frontend', page: 'frontend', browser: 'frontend', app: 'frontend',
218
+ api: 'backend', service: 'backend', server: 'backend', function: 'backend', endpoint: 'backend', route: 'backend', script: 'backend', cli: 'backend', worker: 'backend', module: 'backend',
219
+ db: 'database', store: 'database', storage: 'database', cache: 'database', table: 'database', data: 'database',
220
+ infra: 'cloud', cdn: 'cloud', deploy: 'cloud', host: 'cloud', ci: 'cloud',
221
+ auth: 'security', secret: 'security', policy: 'security', guard: 'security', hook: 'security',
222
+ bus: 'queue', event: 'queue', stream: 'queue', webhook: 'queue', cron: 'queue', job: 'queue',
223
+ user: 'external', human: 'external', thirdparty: 'external', vendor: 'external',
224
+ doc: 'file', config: 'file', skill: 'file', md: 'file', template: 'file', output: 'file',
225
+ agent: 'ai', llm: 'ai', model: 'ai' };
226
+ const kindOf = (k, map) => { k = String(k || '').toLowerCase(); if (map && MAPK[k]) return MAPK[k]; return KIND[k] ? k : (ALIAS[k] || (map ? 'm-script' : 'backend')); };
227
+ const SIGIL = {
228
+ frontend: '<rect x="2" y="3" width="12" height="8.5" rx="1.2"/><path d="M5.5 14h5M8 11.5V14"/>',
229
+ backend: '<rect x="2.5" y="2.5" width="11" height="4.5" rx="1"/><rect x="2.5" y="9" width="11" height="4.5" rx="1"/><path d="M5 4.75h.01M5 11.25h.01"/>',
230
+ database: '<ellipse cx="8" cy="4" rx="5" ry="2"/><path d="M3 4v8c0 1.1 2.2 2 5 2s5-.9 5-2V4M3 8c0 1.1 2.2 2 5 2s5-.9 5-2"/>',
231
+ cloud: '<path d="M4.3 12.5h7.3a2.4 2.4 0 0 0 .2-4.8 4 4 0 0 0-7.5-1.3A3.1 3.1 0 0 0 4.3 12.5Z"/>',
232
+ security: '<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/><path d="m5.8 8 1.5 1.5 3-3"/>',
233
+ queue: '<path d="M2 5h8M2 8h11M2 11h8"/><path d="m11 3.5 2 1.5-2 1.5M11 9.5l2 1.5-2 1.5"/>',
234
+ external: '<rect x="2.5" y="5" width="8.5" height="8.5" rx="1.5"/><path d="M8 2.5h5.5V8M13.5 2.5 7.5 8.5"/>',
235
+ file: '<path d="M4 2h5l3 3v9H4z"/><path d="M9 2v3h3M6 8.5h4M6 11h4"/>',
236
+ ai: '<path d="M8 2l1.4 4.6L14 8l-4.6 1.4L8 14l-1.4-4.6L2 8l4.6-1.4z"/>',
237
+ 'm-public': '<rect x="1.8" y="2.5" width="12.4" height="11" rx="1.8"/><path d="m4.6 6.3 2 1.8-2 1.8M8.4 10.4h3"/>',
238
+ 'm-lane': '<rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M2 6.2h12M2 9.8h12"/><path d="m9.5 3.4 1.2.95-1.2.95"/>',
239
+ 'm-script': '<path d="M4 2h5l3 3v9H4z"/><path d="M9 2v3h3"/><path d="m7 8.3-1.5 1.6L7 11.5M9 8.3l1.5 1.6L9 11.5"/>',
240
+ 'm-ext': '<path d="M6.6 9.4 9.4 6.6"/><path d="m7.2 4.6 1-1a2.5 2.5 0 0 1 3.5 3.5l-1 1M8.8 11.4l-1 1a2.5 2.5 0 0 1-3.5-3.5l1-1"/>',
241
+ 'm-hook': '<path d="M8 2.2 13 4v3.5c0 3.1-1.8 5.4-5 6.5-3.2-1.1-5-3.4-5-6.5V4Z"/><path d="m5.8 8 1.5 1.5 3-3"/>',
242
+ 'm-verb': '<path d="M3 13 9.5 6.5M8 3l.7 1.6L10.3 5.3 8.7 6 8 7.6 7.3 6 5.7 5.3 7.3 4.6zM12 8.5l.45 1.05L13.5 10l-1.05.45L12 11.5l-.45-1.05L10.5 10l1.05-.45z"/>',
243
+ 'm-step': '<path d="M2 8h11M9 4l4 4-4 4"/>',
244
+ 'm-board': '<rect x="2" y="2.5" width="12" height="11" rx="1.5"/><path d="M6 2.5v11M10 2.5v11"/><path d="M3.4 5.2h1.2M7.4 5.2h1.2M7.4 7.6h1.2M11.4 5.2h1.2"/>',
245
+ };
246
+ const CHG = { added: ['green', 'NEW'], new: ['green', 'NEW'], modified: ['amber', 'CHANGED'], changed: ['amber', 'CHANGED'], removed: ['rose', 'REMOVED'], planned: ['cyan', 'PLANNED'] };
247
+ const NW = 168, CG0 = 84, RG0 = 44;
248
+ const pillW = (label, sub, max = 440) => Math.max(sansW(fit(label, 10.5, max), 10.5, .92), sub ? sansW(fit(sub, 9.5, max), 9.5, .92) : 0) + 18; // a line's label, and its sublabel under it (#44)
249
+ // Text width. Without a measurer: a monospace estimate (font-independent, so layouts are the same on every machine;
250
+ // the bench and layout() in Node use it). render() sets measureText to the host's own font while it lays out a view.
251
+ let measureText = null;
252
+ const textW = (s, px) => measureText ? measureText(String(s ?? ''), px) : String(s ?? '').length * px * 0.6;
253
+ const sansW = (s, px, k) => measureText ? measureText(String(s ?? ''), px) : textW(s, px) * k; // k: the estimate's sans correction
254
+ let ctx2d; // the 2D context text is measured on, probed once per module: jsdom has none, and says so on every try
255
+ function hostMeasure(el) { // widths in the font the host gives the map (--um-sans), memoised; null without a 2D canvas (jsdom)
256
+ if (ctx2d === undefined) ctx2d = document.createElement('canvas').getContext('2d');
257
+ const c = ctx2d; if (!c) return null;
258
+ const cs = getComputedStyle(el), family = cs.getPropertyValue('--um-sans').trim() || cs.fontFamily || 'sans-serif', memo = new Map();
259
+ return (s, px) => { const k = px + '\u0000' + s; let w = memo.get(k); if (w === undefined) { c.font = `600 ${px}px ${family}`; w = c.measureText(s).width; memo.set(k, w); } return w; };
260
+ }
261
+ function wrap2(s, px, max) { const w = String(s ?? '').split(' '); let best = null;
262
+ for (let i = 1; i < w.length; i++) { const a = w.slice(0, i).join(' '), b = w.slice(i).join(' '), m = Math.max(textW(a, px), textW(b, px)); if (!best || m < best[2]) best = [a, b, m]; }
263
+ if (best && Math.max(textW(best[0], px), textW(best[1], px)) > max) { let i = 1; while (i < w.length - 1 && textW(w.slice(0, i + 1).join(' '), px) <= max) i++; best = [w.slice(0, i).join(' '), w.slice(i).join(' ')]; }
264
+ return best ? [fit(best[0], px, max, true), fit(best[1], px, max, true)] : null; }
265
+ function clauseFit(s, px, max) { s = String(s ?? ''); if (textW(s, px) <= max) return s; for (const sep of ['; ', ', ', ' (']) { const q = s.split(sep); let o = q[0]; if (textW(o, px) > max) continue; for (const x of q.slice(1)) { if (textW(o + sep + x, px) > max) break; o += sep + x; } return o; } return fit(s, px, max, true); }
266
+ function fit(s, px, max, words) { s = String(s ?? ''); if (textW(s, px) <= max) return s; // words: cut between words where it can (a card's texts)
267
+ let k; if (measureText) { k = s.length; while (k > 3 && textW(s.slice(0, k) + '…', px) > max) k--; } else k = Math.max(Math.floor(max / (px * 0.6)) - 1, 3);
268
+ const w = words ? s.slice(0, k + 1).replace(/\S*$/, '').replace(/[\s,;:]+$/, '') : '';
269
+ return (w || s.slice(0, k)) + '…'; }
270
+ function roundPath(pts, r = 9) {
271
+ let d = `M ${pts[0][0]} ${pts[0][1]}`;
272
+ for (let i = 1; i < pts.length - 1; i++) {
273
+ const [px, py] = pts[i - 1], [x, y] = pts[i], [nx, ny] = pts[i + 1];
274
+ const l1 = Math.hypot(x - px, y - py), l2 = Math.hypot(nx - x, ny - y), rr = Math.min(r, l1 / 2, l2 / 2);
275
+ if (rr < 1) { d += ` L ${x} ${y}`; continue; }
276
+ const ax = x - (x - px) / l1 * rr, ay = y - (y - py) / l1 * rr, bx = x + (nx - x) / l2 * rr, by = y + (ny - y) / l2 * rr;
277
+ d += ` L ${ax} ${ay} Q ${x} ${y} ${bx} ${by}`;
278
+ }
279
+ const last = pts[pts.length - 1]; return d + ` L ${last[0]} ${last[1]}`;
280
+ }
281
+ const hit = (a, b, pad = 0) => a.x < b.x + b.w + pad && b.x < a.x + a.w + pad && a.y < b.y + b.h + pad && b.y < a.y + a.h + pad;
282
+ const warned = new Set(), warn = m => { if (!warned.has(m)) { warned.add(m); console.warn('umlimator: ' + m); } }; // each warning once
283
+
284
+ /* Layered auto-layout for views that carry no col/row: longest-path layers (cycles broken by DFS),
285
+ barycentre ordering sweeps, integer rows so row gaps stay free as routing channels. */
286
+ /* Lines cross as little as the order of the cards in a column allows (#53): a few barycentre sweeps (each card goes where
287
+ its neighbours in the next or previous column lie, its place now breaking ties), and the best order found is kept only
288
+ when it crosses less than the order given. A line that skips columns has a point in each column it passes (Sugiyama's
289
+ dummy nodes), so it orders the cards it runs between too. cols: columns of ids in reading order, changed in place;
290
+ links: [a, b] with b in a later column. */
291
+ function untangle(cols, links) {
292
+ const colOf = {}; cols.forEach((c, k) => c.forEach(id => { colOf[id] = k; }));
293
+ const segs = [], extra = new Set(), want = {};
294
+ links.forEach(([a, b], n) => {
295
+ const ka = colOf[a], kb = colOf[b]; if (ka == null || kb == null || kb <= ka) return;
296
+ let prev = a; const ra = cols[ka].indexOf(a), rb = cols[kb].indexOf(b);
297
+ for (let k = ka + 1; k < kb; k++) { // a point first goes where the straight line between its ends would be
298
+ const p = '\u0001' + n + '.' + k; extra.add(p); colOf[p] = k; want[p] = ra + (rb - ra) * (k - ka) / (kb - ka);
299
+ segs.push([prev, p]); prev = p; }
300
+ segs.push([prev, b]);
301
+ });
302
+ if (extra.size) cols.forEach((c, k) => { const ps = [...extra].filter(p => colOf[p] === k);
303
+ if (ps.length) cols[k] = [...c.map((id, i) => [id, i, 0]), ...ps.map(p => [p, want[p], 1])].sort((x, y) => x[1] - y[1] || x[2] - y[2]).map(x => x[0]); });
304
+ const ins = {}, outs = {}; segs.forEach(([a, b]) => { (outs[a] ||= []).push(b); (ins[b] ||= []).push(a); });
305
+ const crossings = () => { const at = {}; let n = 0; cols.forEach(c => c.forEach((id, i) => { at[id] = i; }));
306
+ for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) { const [a, b] = segs[i], [c, d] = segs[j];
307
+ if (colOf[a] === colOf[c] && (at[a] - at[c]) * (at[b] - at[d]) < 0) n++; }
308
+ return n; };
309
+ let best = segs.length > 1 ? crossings() : 0, keep = cols.map(c => c.slice()); // the order given, unless one crosses less
310
+ for (let it = 0; it < 8 && best; it++) {
311
+ const down = it % 2 === 0, seq = cols.map((_, k) => k).filter(k => down ? k > 0 : k < cols.length - 1);
312
+ if (!down) seq.reverse();
313
+ for (const k of seq) { const at = Object.fromEntries(cols[down ? k - 1 : k + 1].map((id, i) => [id, i]));
314
+ const key = (id, i) => { const nb = ((down ? ins : outs)[id] || []).filter(x => at[x] != null).map(x => at[x]);
315
+ return nb.length ? nb.reduce((x, y) => x + y, 0) / nb.length : i; };
316
+ cols[k] = cols[k].map((id, i) => [id, key(id, i), i]).sort((p, q) => p[1] - q[1] || p[2] - q[2]).map(x => x[0]); }
317
+ const n = crossings(); if (n < best) { best = n; keep = cols.map(c => c.slice()); }
318
+ }
319
+ keep.forEach((c, k) => { cols[k] = c.filter(id => !extra.has(id)); });
320
+ }
321
+ /* A card no line comes into goes just before its nearest callee, not in the first column: its lines get shorter. Returns
322
+ the ranks with it moved, or null when none moves (bestFlow keeps them when they route better). */
323
+ function pullSources(ids, links, rank) {
324
+ const into = new Set(links.map(([, b]) => b)); let moved = false; const out = { ...rank };
325
+ ids.forEach(i => { const to = links.filter(([a]) => a === i).map(([, b]) => rank[b]).filter(r => r != null);
326
+ if (to.length && !into.has(i) && Math.min(...to) - 1 > rank[i]) { out[i] = Math.min(...to) - 1; moved = true; } });
327
+ return moved ? out : null;
328
+ }
329
+ /* Rows for ordered columns: each card level with the cards its lines come from (the median row), then with those they go
330
+ to, then again from the left, as near as the order allows with its height between it and the next (least squares: pool
331
+ adjacent violators); on half rows from 0. A line between cards on one row runs straight, past fewer cards. h: an item's
332
+ height in rows (a box in a view with boxes); a link [a, b, oa, ob] leaves a at row oa inside it and reaches b at row
333
+ ob (a box's card), else at its middle. */
334
+ function rowsFor(cols, links, h = () => 1) {
335
+ const row = {}, colOf = {}; cols.forEach((c, k) => { let r = 0; c.forEach(id => { row[id] = r; r += h(id); colOf[id] = k; }); });
336
+ const ins = {}, outs = {}, at = (id, o) => o ?? (h(id) - 1) / 2; // where a link meets an item, in rows from its top
337
+ links.forEach(([a, b, oa, ob]) => { if (colOf[a] == null || colOf[b] == null || colOf[b] <= colOf[a]) return;
338
+ (outs[a] ||= []).push([b, at(b, ob), at(a, oa)]); (ins[b] ||= []).push([a, at(a, oa), at(b, ob)]); });
339
+ const median = xs => { const s = xs.slice().sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
340
+ const fit = (list, want) => { const blocks = []; let off = 0;
341
+ list.forEach(id => { blocks.push({ ids: [id], sum: want[id] - off, n: 1 }); off += h(id);
342
+ while (blocks.length > 1 && blocks.at(-2).sum / blocks.at(-2).n > blocks.at(-1).sum / blocks.at(-1).n) { const b = blocks.pop(), a = blocks.at(-1); a.ids.push(...b.ids); a.sum += b.sum; a.n += b.n; } });
343
+ off = 0; blocks.forEach(b => b.ids.forEach(id => { row[id] = b.sum / b.n + off; off += h(id); })); };
344
+ for (const [from, to, step] of [[1, cols.length, 1], [cols.length - 2, -1, -1], [1, cols.length, 1]])
345
+ for (let k = from; k !== to; k += step) { const want = {};
346
+ cols[k].forEach(id => { const nb = (step > 0 ? ins : outs)[id] || []; want[id] = nb.length ? median(nb.map(([x, ox, o]) => row[x] + ox - o)) : row[id]; });
347
+ fit(cols[k], want); }
348
+ const low = Math.min(...Object.values(row));
349
+ cols.forEach(c => c.forEach((id, i) => { row[id] = Math.round((row[id] - low) * 2) / 2; if (i) row[id] = Math.max(row[id], row[c[i - 1]] + h(c[i - 1])); }));
350
+ return row;
351
+ }
352
+
353
+ /* A view about one node: the node sits in the middle, callers to its left, callees to its right,
354
+ each further ring one column out on the side its neighbours are on. */
355
+ function hubLayout(ids, edges, hub) {
356
+ const set = new Set(ids), es = edges.filter(e => set.has(e.from) && set.has(e.to) && e.from !== e.to);
357
+ const nb = {}; ids.forEach(i => { nb[i] = new Set(); }); es.forEach(e => { nb[e.from].add(e.to); nb[e.to].add(e.from); });
358
+ if (!nb[hub] || !nb[hub].size) return null;
359
+ const dist = { [hub]: 0 }, q = [hub];
360
+ while (q.length) { const u = q.shift(); nb[u].forEach(v => { if (dist[v] == null) { dist[v] = dist[u] + 1; q.push(v); } }); }
361
+ const ring = [...nb[hub]], callers = ring.filter(i => es.some(e => e.from === i && e.to === hub) && !es.some(e => e.from === hub && e.to === i));
362
+ const callees = ring.filter(i => !callers.includes(i));
363
+ const side = { [hub]: 0 }, capCol = 7;
364
+ callers.forEach(i => { side[i] = -1; });
365
+ const leaves = callees.filter(i => [...nb[i]].every(v => v === hub || dist[v] === 1)).reverse();
366
+ const deep = callees.filter(i => !leaves.includes(i));
367
+ let right = [...deep, ...leaves.slice().reverse()];
368
+ let left = callers.length;
369
+ while (right.length > capCol && left < right.length - 1) { const mv = leaves.find(i => right.includes(i)) || right[right.length - 1]; right = right.filter(i => i !== mv); side[mv] = -1; left++; }
370
+ right.forEach(i => { side[i] = 1; });
371
+ ids.filter(i => dist[i] > 1).sort((a, b) => dist[a] - dist[b]).forEach(i => {
372
+ const s = [...nb[i]].filter(v => side[v] != null && dist[v] < dist[i]).reduce((a, v) => a + Math.sign(side[v]), 0);
373
+ side[i] = (s < 0 ? -1 : 1) * dist[i];
374
+ });
375
+ ids.filter(i => dist[i] === 1).forEach(i => { side[i] = Math.sign(side[i]) || 1; });
376
+ const cols = {}; ids.filter(i => side[i] != null).forEach(i => (cols[side[i]] ||= []).push(i));
377
+ const keys = Object.keys(cols).map(Number).sort((a, b) => Math.abs(a) - Math.abs(b));
378
+ const rowOf = {}; rowOf[hub] = 0;
379
+ keys.forEach(c => { // order by the rows of inner neighbours (barycentre)
380
+ const inner = cols[c].map(i => { const ps = [...nb[i]].filter(v => rowOf[v] != null); return [i, ps.length ? ps.reduce((a, v) => a + rowOf[v], 0) / ps.length : 0]; });
381
+ inner.sort((a, b) => a[1] - b[1]);
382
+ inner.forEach(([i], k) => { rowOf[i] = k - (inner.length - 1) / 2; });
383
+ });
384
+ const pos = {}, minC = Math.min(...keys);
385
+ let maxR = 0; keys.forEach(c => { maxR = Math.max(maxR, cols[c].length); });
386
+ keys.forEach(c => cols[c].forEach(i => { pos[i] = [c - minC, Math.round(rowOf[i] + (maxR - 1) / 2)]; }));
387
+ // resolve rounding collisions inside a column
388
+ keys.forEach(c => { const used = new Set(); cols[c].sort((a, b) => pos[a][1] - pos[b][1]).forEach(i => { while (used.has(pos[i][1])) pos[i][1]++; used.add(pos[i][1]); }); });
389
+ const loose = ids.filter(i => side[i] == null);
390
+ if (loose.length) { const base = Math.max(...Object.values(pos).map(p => p[1])) + 1, per = Math.max(2, keys.length);
391
+ loose.forEach((i, j) => { pos[i] = [j % per, base + Math.floor(j / per)]; }); }
392
+ return pos;
393
+ }
394
+
395
+ function autoLayout(ids, edges, hub) {
396
+ if (hub) { const hp = hubLayout(ids, edges, hub); if (hp) return hp; }
397
+ ids = [...ids];
398
+ const out = {}, inn = {}; ids.forEach(i => { out[i] = []; inn[i] = []; });
399
+ const es = edges.filter(e => out[e.from] && out[e.to] && e.from !== e.to);
400
+ const state = {}, back = new Set();
401
+ const dfs = u => { state[u] = 1; es.filter(e => e.from === u).forEach(e => { if (state[e.to] === 1) back.add(e); else if (!state[e.to]) dfs(e.to); }); state[u] = 2; };
402
+ const roots = ids.filter(i => !es.some(e => e.to === i));
403
+ [hub, ...roots, ...ids].filter(i => i && out[i]).forEach(i => { if (!state[i]) dfs(i); });
404
+ es.filter(e => !back.has(e)).forEach(e => { out[e.from].push(e.to); inn[e.to].push(e.from); });
405
+ const layer = {}, linked = ids.filter(i => out[i].length || inn[i].length);
406
+ const lay = (i, seen = new Set()) => { if (layer[i] != null) return layer[i]; if (seen.has(i)) return 0; seen.add(i);
407
+ return (layer[i] = inn[i].length ? Math.max(...inn[i].map(p => lay(p, seen) + 1)) : 0); };
408
+ if (hub && out[hub] && out[hub].length) { // a view about one node: it leads, its direct callees form the first ring
409
+ layer[hub] = 0; out[hub].forEach(c => { layer[c] = 1; });
410
+ inn[hub].forEach(p => { if (layer[p] == null) layer[p] = 1; });
411
+ const lay2 = (i, seen = new Set()) => { if (layer[i] != null) return layer[i]; if (seen.has(i)) return 2; seen.add(i);
412
+ const ps = inn[i].map(p => lay2(p, seen) + 1); return (layer[i] = Math.max(2, ...ps)); };
413
+ linked.forEach(i => lay2(i));
414
+ }
415
+ linked.forEach(i => lay(i));
416
+ // pull sinks right next to their callers instead of leaving them at layer 0
417
+ if (!hub) linked.forEach(i => { if (!inn[i].length && out[i].length) layer[i] = Math.max(0, Math.min(...out[i].map(c => layer[c])) - 1); });
418
+ const loose = ids.filter(i => !out[i].length && !inn[i].length);
419
+ let maxL = linked.length ? Math.max(...linked.map(i => layer[i])) : -1;
420
+ const below = loose.length && linked.length && (maxL + 2) * 290 > 1.5 * 114 * Math.max(1, ...Object.values(linked.reduce((a, i) => { a[layer[i]] = (a[layer[i]] || 0) + 1; return a; }, {})));
421
+ if (below) ids = ids.filter(i => !loose.includes(i));
422
+ if (loose.length && !below) { // isolated nodes: a compact block of extra columns
423
+ const per = Math.max(3, Math.ceil(Math.sqrt(loose.length * 1.6)));
424
+ loose.forEach((i, j) => { layer[i] = maxL + 1 + Math.floor(j / per); });
425
+ maxL = Math.max(...ids.map(i => layer[i]));
426
+ }
427
+ const cols = Array.from({ length: maxL + 1 }, () => []);
428
+ ids.forEach(i => cols[layer[i]].push(i));
429
+ // split very tall columns so the map stays landscape
430
+ const cap = Math.max(5, Math.ceil(Math.sqrt(ids.length) * 1.4));
431
+ const pos = {};
432
+ for (let sweep = 0; sweep < 6; sweep++) {
433
+ cols.forEach((col, c) => {
434
+ const ref = sweep % 2 ? cols[c + 1] : cols[c - 1]; if (!ref) return;
435
+ const idx = Object.fromEntries(ref.map((n, k) => [n, k]));
436
+ const bc = n => { const nb = (sweep % 2 ? out[n] : inn[n]).filter(x => idx[x] != null); return nb.length ? nb.reduce((a, x) => a + idx[x], 0) / nb.length : null; };
437
+ const keyed = col.map((n, k) => [n, bc(n) ?? k]);
438
+ keyed.sort((a, b) => a[1] - b[1]); cols[c] = keyed.map(x => x[0]);
439
+ });
440
+ }
441
+ let cx = 0; const tallest = Math.min(cap, Math.max(...cols.map(c => c.length)));
442
+ cols.forEach(col => {
443
+ const chunks = []; for (let k = 0; k < col.length; k += cap) chunks.push(col.slice(k, k + cap));
444
+ chunks.forEach(ch => { const off = Math.floor((tallest - ch.length) / 2); ch.forEach((n, r) => { pos[n] = [cx, r + off]; }); cx++; });
445
+ });
446
+ if (below) { // a wide graph keeps its loose nodes in a tidy row underneath
447
+ const per = Math.max(2, cx), base = Math.max(...Object.values(pos).map(p => p[1])) + 1;
448
+ loose.forEach((n, j) => { pos[n] = [j % per + Math.max(0, Math.floor((per - Math.min(per, loose.length)) / 2)), base + Math.floor(j / per)]; });
449
+ }
450
+ return pos;
451
+ }
452
+
453
+ /* wire kinds on maps: colour says what the line means; state reads/writes stay light */
454
+ const WIRES = [['calls', 'calls', 'runs or invokes it'], ['feeds', 'feeds', 'hands work on'], ['triggers', 'triggers', 'suggests, offers, fires'], ['state', 'state', 'reads or writes a file, board, config'], ['guard', 'guard', 'a hook steps in']];
455
+ // a node's badges: `badges` (strings or {text, tone}), else the single `badge`
456
+ const badgesOf = n => (Array.isArray(n.badges) && n.badges.length ? n.badges : n.badge ? [n.badge] : [])
457
+ .map(b => b && typeof b === 'object' ? { text: String(b.text ?? ''), tone: b.tone } : { text: String(b ?? '') }).filter(b => b.text);
458
+ const toneOf = b => { if (b.tone) return 'var(--um-' + String(b.tone).replace(/[^\w-]/g, '') + ')'; const w = b.text.toUpperCase().split('·')[0].trim();
459
+ return 'var(--um-' + (['BUILT', 'DECIDED'].includes(w) ? 'green' : ['PARTIAL', 'OPEN', 'OPT-IN'].includes(w) ? 'amber' : w === 'NOT BUILT' ? 'rose'
460
+ : ['PROPOSED', 'AGREED', 'TARGET'].includes(w) ? 'accent' : 'slate') + ')'; };
461
+ // the tabs on a card's top border, left to right: its badges, else what its kind says (public, external, lane)
462
+ const mapTabs = (n, c) => { const tw = t => t.toUpperCase().length * 6.4 + 10, b = badgesOf(n); // the text as given: the sheet upper-cases it (#43)
463
+ if (b.length) return b.map((x, i) => ({ text: x.text, fill: toneOf(x), badge: i, w: tw(x.text) }));
464
+ const t = n.k === 'm-public' ? 'PUBLIC' : n.k === 'm-ext' ? 'EXTERNAL' : n.k === 'm-lane' ? 'LANE' : ''; return t ? [{ text: t, fill: c, w: tw(t) }] : []; };
465
+ const mapNodeW = n => {
466
+ const px = n.emphasis ? 15 : 13;
467
+ const sub = n.sub || n.sublabel || '';
468
+ return Math.min(n.emphasis ? 205 : 386, Math.max(n.emphasis ? 146 : 138,
469
+ Math.ceil(textW(n.label, px) + 70), badgesOf(n).length ? Math.ceil(badgesOf(n).reduce((a, b) => a + b.text.length * 6.4 + 10, 0) + (badgesOf(n).length - 1) * 4 + 120) : 0,
470
+ Math.min(386, Math.ceil(textW(sub, 10) + 70))));
471
+ };
472
+ /* Geometry: node boxes, ports, orthogonal routes, label slots. A view with lines the router found no route for is laid
473
+ out once more, with room where those lines need it (#40); a view whose lines all route keeps its geometry. */
474
+ function layoutGraph(dg, map) {
475
+ const g = layoutPass(dg, map, null);
476
+ if (!g.room) return g;
477
+ const g2 = layoutPass(dg, map, g.room, true); g2.ms.probe = g.ms.probe; return g2;
478
+ }
479
+ function layoutPass(dg, map, room, last) { // room: { x: { col: px }, y: { row: px } }; last: lay out with it, find no more
480
+ const __ms = { t: performance.now() }, t0 = __ms.t;
481
+ const CG = dg.gap ? dg.gap : map ? (dg.mainSteps && !dg.containers ? 38 : 66) : CG0, RG = dg.rgap ? dg.rgap : map ? 44 : RG0; // family borders also need clearance between columns
482
+ const NH = dg.nodeH || 64, pitch = NH + RG;
483
+ const nodes = (dg.nodes || []).map((n, i) => {
484
+ const k = kindOf(n.kind, map);
485
+ const w = n.w || (map ? mapNodeW(n) : NW);
486
+ return { ...n, k, col: n.col ?? i, row: n.row ?? 0, w, h: n.h || NH };
487
+ });
488
+ const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
489
+ const cols = dg.abs ? [] : [...new Set(nodes.map(n => n.col))].sort((a, b) => a - b);
490
+ const colX = {}, colW = {}; let x = 0;
491
+ // a card with no line, in a row under every card that has one, sits in a row of its own (#46): it never widens a column
492
+ const ends = new Set((dg.edges || []).filter(e => e.from !== e.to).flatMap(e => [e.from, e.to])), low = Math.max(-Infinity, ...nodes.filter(n => ends.has(n.id)).map(n => n.row));
493
+ if (low === -Infinity) ends.clear(); // no lines at all: no such row
494
+ const boxed = new Set((dg.containers || []).flatMap(f => f.nodeIds || [])); // a box's row keeps to the box's columns
495
+ nodes.forEach(n => { n.aside = map && !dg.abs && !ends.has(n.id) && n.row > low && !boxed.has(n.id); });
496
+ cols.forEach(c => { const ns = nodes.filter(n => n.col === c && !n.aside); if (ns.length) colW[c] = Math.max(...ns.map(n => n.w)); });
497
+ // a labelled line between neighbouring columns gets a gap wide enough for its label and 27 px of line on each side
498
+ const gapAfter = {}, colOf = {}, boxOf = id => ((dg.containers || []).filter(f => (f.nodeIds || []).includes(id)).pop() || {}).id; if (map && !dg.abs) { nodes.forEach(n => colOf[n.id] = n.col);
499
+ // labelled lines that leave (or reach) one card side by side run 16 px apart, too close for their pills to stack,
500
+ // so the pills sit next to each other: the gap fits them all, with 6 px between pills
501
+ const sum = {};
502
+ // a line to a container ends on its box's border, beside the box's nearest column of cards
503
+ const inBox = (b, seen = new Set()) => { const c = !seen.has(b) && seen.add(b) && (dg.containers || []).find(f => f.id === b);
504
+ return c ? [...(c.nodeIds || []), ...(dg.containers || []).filter(k => k.parent === b).flatMap(k => inBox(k.id, seen))] : []; };
505
+ const near = (b, other) => { const cs = inBox(b).map(i => colOf[i]).filter(c => c != null); if (other == null || !cs.length) return null;
506
+ const lo = Math.min(...cs), hi = Math.max(...cs); return other < lo ? lo : other > hi ? hi : null; };
507
+ (dg.edges || []).forEach(e => { const p = colOf[e.from] ?? near(e.from, colOf[e.to]), q = colOf[e.to] ?? near(e.to, colOf[e.from]); if (p == null || q == null || !e.label || Math.abs(p - q) !== 1) return;
508
+ const c = Math.min(p, q), w = pillW(e.label, e.sublabel) + (e.step != null ? 20 : 0), box = boxOf(e.from) !== boxOf(e.to) || !(e.from in colOf) || !(e.to in colOf) ? 48 : 0;
509
+ gapAfter[c] = Math.max(gapAfter[c] || 0, w + 54 + box);
510
+ [e.from, e.to].forEach(id => { const k = c + '|' + id, t = sum[k] ||= { w: -6, box: 0 }; t.w += w + 6; t.box = Math.max(t.box, box); gapAfter[c] = Math.max(gapAfter[c], t.w + 54 + t.box); }); }); }
511
+ const extraX = { ...(room || {}).x }, extraY = { ...(room || {}).y }; // more room after a column (row): for lines with no route, and where a box would touch another box (below)
512
+ const gap = c => Math.max(CG, gapAfter[c] || 0) + (extraX[c] || 0);
513
+ const cx = c => Number.isInteger(c) ? colX[c] : colX[Math.floor(c)] + (c % 1) * ((colW[Math.floor(c)] || NW) + gap(Math.floor(c)));
514
+ const rowY = r => Object.keys(extraY).reduce((y, k) => +k < r ? y + extraY[k] : y, r * pitch);
515
+ const place = () => {
516
+ x = 0; for (let c = Math.floor(cols[0] ?? 0); c <= Math.ceil(cols[cols.length - 1] ?? 0); c++) { colX[c] = x; x += (colW[c] || NW) + gap(c); }
517
+ nodes.forEach(n => { n.x = cx(n.col) + ((colW[n.col] || n.w) - n.w) / 2; n.y = rowY(n.row); if (map && n.k === 'm-script') { n.h = NH - 22; n.y += 11; } });
518
+ let prev = null; // that row in column order: each card at its column, or right of the one before when that one is wider
519
+ nodes.filter(n => n.aside).sort((a, b) => a.row - b.row || a.col - b.col).forEach(n => { if (prev && prev.row === n.row) n.x = Math.max(n.x, prev.x + prev.w + CG); prev = n; });
520
+ };
521
+ if (dg.abs) nodes.forEach(n => { n.x = n.ax; n.y = n.ay; if (map && n.k === 'm-script') { n.h -= 22; n.y += 11; } });
522
+ else place();
523
+ // Family frames enclose ordinary cards, so clicks, ports and focus keep their existing owners.
524
+ // Children contribute their entire frame to their parent's bounds, rather than just their cards.
525
+ const familyDefs = dg.containers || [], tagged = n => map && (n.author || n.k === 'm-ext'); // tagged: a name tag hangs under the card
526
+ const framesNow = () => { const cache = {};
527
+ const frameOf = (f, trail = new Set()) => {
528
+ if (cache[f.id]) return cache[f.id];
529
+ if (trail.has(f.id)) return null;
530
+ const next = new Set([...trail, f.id]);
531
+ const children = familyDefs.filter(c => c.parent === f.id).map(c => frameOf(c, next)).filter(Boolean);
532
+ const members = (f.nodeIds || []).map(id => byId[id]).filter(Boolean);
533
+ const rects = [...members.map(n => ({x:n.x - 36,y:n.y - 14,w:n.w + 72,h:n.h + 44 + (tagged(n) ? 14 : 0)})), ...children];
534
+ if (!rects.length) return null;
535
+ const p = 12, x = Math.min(...rects.map(r => r.x)) - p, y = Math.min(...rects.map(r => r.y)) - p - 24;
536
+ return cache[f.id] = {...f, x, y, w:Math.max(...rects.map(r => r.x+r.w)) + p - x, h:Math.max(...rects.map(r => r.y+r.h)) + p - y};
537
+ };
538
+ const out = familyDefs.map(f => frameOf(f)).filter(Boolean);
539
+ if (map) out.forEach(f => { // the box fits its title and its badges, which are tabs on its top border, at the right
540
+ const tabs = badgesOf(f).map((b, i) => ({ text: b.text, fill: toneOf(b), badge: i, w: b.text.length * 6.4 + 10 })), all = tabs.reduce((a, t) => a + t.w + 4, -4);
541
+ f.w = Math.max(f.w, String(f.label || f.id).length * 6.7 + 32 + (tabs.length ? all + 8 : 0));
542
+ let tx = f.x + f.w - 12 - all; f.tabs = tabs.map(t => { const o = { ...t, x: tx }; tx += t.w + 4; return o; }); });
543
+ return out;
544
+ };
545
+ // Boxes keep apart: a column (row) gap widens wherever a box would come within 32 px of another box, or of a card outside
546
+ // it, so their borders never touch and a line can run between them. Frames are measured again until nothing is too close.
547
+ if (map && !dg.abs && familyDefs.length) for (let pass = 0, BOXGAP = 32; pass < 4; pass++) {
548
+ const within = (a, b) => a.x >= b.x && a.y >= b.y && a.x + a.w <= b.x + b.w && a.y + a.h <= b.y + b.h;
549
+ const span = ns => ns.length ? { c0: Math.min(...ns.map(n => n.col)), c1: Math.max(...ns.map(n => n.col)), r0: Math.min(...ns.map(n => n.row)), r1: Math.max(...ns.map(n => n.row)) } : null;
550
+ const fr = framesNow().map(f => ({ ...f, s: span(nodes.filter(n => within(n, f))) })).filter(f => f.s);
551
+ const cards = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h + (tagged(n) ? 14 : 0), s: span([n]) }));
552
+ const wantX = {}, wantY = {}, want = (o, k, v) => { if (v > 0) o[k] = Math.max(o[k] || 0, Math.ceil(v)); };
553
+ fr.forEach((A, i) => [...fr.slice(i + 1), ...cards].forEach(B => {
554
+ if (within(A, B) || within(B, A) || !hit(A, B, BOXGAP)) return;
555
+ const a = A.s, b = B.s;
556
+ if (a.c1 < b.c0 || b.c1 < a.c0) { const [L, R] = a.c1 < b.c0 ? [A, B] : [B, A]; want(wantX, Math.floor(L.s.c1), L.x + L.w + BOXGAP - R.x); }
557
+ else if (a.r1 < b.r0 || b.r1 < a.r0) { const [T, U] = a.r1 < b.r0 ? [A, B] : [B, A]; want(wantY, Math.floor(T.s.r1), T.y + T.h + BOXGAP - U.y); }
558
+ }));
559
+ if (!Object.keys(wantX).length && !Object.keys(wantY).length) break;
560
+ Object.entries(wantX).forEach(([k, v]) => { extraX[k] = (extraX[k] || 0) + v; }); Object.entries(wantY).forEach(([k, v]) => { extraY[k] = (extraY[k] || 0) + v; });
561
+ place();
562
+ }
563
+ // decorations hanging on a node's top/bottom border (kind tab, step badge, ⤢ badge, author name tag):
564
+ // ports keep off them, routes and labels treat the name tag as an obstacle
565
+ if (map) nodes.forEach(n => {
566
+ n.decoT = []; n.decoB = [];
567
+ let tx = n.x + 12; mapTabs(n).forEach(t => { n.decoT.push([tx - 6, t.w + 12]); tx += t.w + 4; });
568
+ if (n.step) n.decoT.push([n.x - 10, 24]);
569
+ if (n.kids || n.opens) n.decoT.push([n.x + n.w - 46, 40]);
570
+ if (n.author || n.k === 'm-ext') {
571
+ const A = (dg.authors || {})[n.author] || {}, max = n.w - 40;
572
+ let t = n.author ? '@' + n.author : 'unknown';
573
+ while (t.length > 4 && sansW(t, 10, .92) + 42 > max) t = t.slice(0, -2) + '…';
574
+ const w = Math.ceil(sansW(t, 10, .92) + 42);
575
+ n.tag = { x: n.x + n.w - 12 - w, y: n.y + n.h - 9, w, h: 22, text: t, img: A.avatar || '', name: A.name || '' };
576
+ n.decoB.push([n.tag.x - 2, n.tag.w + 4]);
577
+ }
578
+ });
579
+ const tagRects = nodes.filter(n => n.tag).map(n => ({ x: n.tag.x - 2, y: n.tag.y - 2, w: n.tag.w + 4, h: n.tag.h + 6 }));
580
+ const families = framesNow();
581
+ families.forEach(f => (f.tabs || []).forEach(t => tagRects.push({ x: t.x - 2, y: f.y - 10, w: t.w + 4, h: 19 }))); // lines and labels keep off a box's tabs
582
+ // intake sources: brand badges stacked left of the node they feed, each with its own short feeds line
583
+ const intake = map ? (dg.intake || []).filter(b => byId[b.to]).map((b, i, all) => {
584
+ const n = byId[b.to], mine = all.filter(o => o.to === b.to), k = mine.indexOf(b), off = k - (mine.length - 1) / 2;
585
+ const cx = n.x - 72, cy = n.y + n.h / 2 + off * 34, py = n.y + n.h / 2 + off * Math.min(16, (n.h - 20) / Math.max(1, mine.length - 1));
586
+ const ex = n.x - 38 + Math.abs(off) * 6; // nested elbows: an outer badge turns nearer the card, so lines never cross
587
+ return { ...b, cx, cy, r: 14, pts: simplify([[cx + 15, cy], [ex, cy], [ex, py], [n.x, py]]) };
588
+ }) : [];
589
+ intake.forEach(b => tagRects.push({ x: b.cx - b.r - 4, y: b.cy - b.r - 4, w: 2 * b.r + 8, h: 2 * b.r + 8 }));
590
+ /* ---- routing: one orthogonal route per edge, no shared trunks ----
591
+ Ported in approach from archify's router (MIT): renderers/shared/route-quality.mjs
592
+ shortestOrthogonalGridRoute + renderers/architecture/routing.mjs. Each edge gets its own
593
+ port, spread along the node side in the order its far end lies; then a sparse orthogonal
594
+ visibility grid (node edges ± clearance, endpoint stubs, gap centre lines, earlier routes
595
+ ± separation) is searched with Dijkstra, carrying the incoming direction so turns cost a
596
+ bend penalty. Earlier routes may never be overlapped and are crossed only when no clean
597
+ route exists; earlier labels are obstacles, so later routes keep clear of them. */
598
+ // an end may be a container: the line ends on its box's border, whose title the ports keep clear of (not a box and
599
+ // something inside it: that has no line)
600
+ const boxEnd = map ? Object.fromEntries(families.map(f => [f.id, { id: f.id, label: f.label || f.id, box: true, x: f.x, y: f.y, w: f.w, h: f.h,
601
+ decoT: [[f.x + 8, Math.min(f.w - 16, textW(f.label || f.id, 11) + 16)], ...(f.tabs || []).map(t => [t.x - 6, t.w + 12])], decoB: [] }])) : {};
602
+ const end = id => byId[id] || boxEnd[id], holds = (b, n) => b.box && n !== b && n.x >= b.x && n.y >= b.y && n.x + n.w <= b.x + b.w && n.y + n.h <= b.y + b.h;
603
+ const edges = (dg.edges || []).filter(e => { const s = end(e.from), t = end(e.to); if (!s || !t || e.from === e.to) return false;
604
+ if (holds(s, t) || holds(t, s)) { warn(`no line for edge ${e.id ?? e.from + '->' + e.to}: it joins the box "${(holds(s, t) ? s : t).id}" and something inside it`); return false; }
605
+ return true; }).map(e => ({ ...e, s: end(e.from), t: end(e.to) }));
606
+ const segsOf = pts => pts.slice(0, -1).map((a, i) => [a, pts[i + 1]]);
607
+ const segHitsRect = (a, b, r) => { const x1 = Math.min(a[0], b[0]), x2 = Math.max(a[0], b[0]), y1 = Math.min(a[1], b[1]), y2 = Math.max(a[1], b[1]); return x2 > r.x && x1 < r.x + r.w && y2 > r.y && y1 < r.y + r.h; };
608
+ const through = (pts, e) => nodes.find(n => n !== e.s && n !== e.t && segsOf(pts).some(([a, b]) => segHitsRect(a, b, { x: n.x + 2, y: n.y + 2, w: n.w - 4, h: n.h - 4 }))); // a card it does not join
609
+ // fixed edges arrive with their route already drawn (the hub comb); the router works around them
610
+ // a loop back out of a family runs under the frames: out the source's right side, down past every
611
+ // frame and card it would cut through, left along the bottom, and up into the target's bottom
612
+ const inFam = n => families.filter(f => n.x >= f.x && n.x + n.w <= f.x + f.w && n.y >= f.y && n.y + n.h <= f.y + f.h);
613
+ edges.filter(e => map && e.back && !e.fixed && (inFam(e.s).length || inFam(e.t).length)).forEach((e, k) => {
614
+ // the source's other outgoing wires keep its middle port; the loop leaves lower down
615
+ const { s, t } = e, sy = s.y + s.h / 2 + (edges.some(o => o !== e && o.from === s.id && !o.back) ? s.h / 4 : 0);
616
+ const xr = Math.max(s.x + s.w + 22, ...inFam(s).map(f => f.x + f.w + 14)) + k * 10;
617
+ const tx = t.tag ? Math.max(t.x + 12, (t.x + t.tag.x - 2) / 2) : t.x + t.w / 2, ty = t.y + t.h + (t.kids || t.opens ? 8 : 0);
618
+ const span = nodes.filter(n => n.x < xr && n.x + n.w > tx), yb = Math.max(...[...inFam(s), ...inFam(t)].map(f => f.y + f.h), ...span.map(n => n.y + n.h + (n.tag ? 22 : 8))) + 24 + k * 10;
619
+ const pts = [[s.x + s.w, sy], [xr, sy], [xr, yb], [tx, yb], [tx, ty]];
620
+ if (!through(pts, e)) { e.fixed = pts; e.under = true; } // one that would cut through a card is left to the router
621
+ });
622
+ const free = edges.filter(e => !e.fixed);
623
+ edges.filter(e => e.fixed).forEach(e => { e.pts = e.fixed; e.p1 = e.pts[0]; e.p2 = e.pts[e.pts.length - 1]; e.fs = 'R'; e.ts = e.under ? 'B' : 'L'; e.stubs = []; e.how = e.under ? 'under' : 'comb'; });
624
+ const sides = {};
625
+ const pushSide = (n, side, e, other) => ((sides[n.id + '|' + side] ||= []).push({ e, other, n }));
626
+ free.forEach(e => {
627
+ const { s, t } = e, sc = s.x + s.w / 2, tc = t.x + t.w / 2, sy = s.y + s.h / 2, ty = t.y + t.h / 2;
628
+ const gapX = tc > sc ? t.x - (s.x + s.w) : s.x - (t.x + t.w), gapY = ty >= sy ? t.y - (s.y + s.h) : s.y - (t.y + t.h);
629
+ if (s.box || t.box ? gapX >= gapY : gapX > 24) { e.fs = tc > sc ? 'R' : 'L'; e.ts = tc > sc ? 'L' : 'R'; } // a box: the sides facing across the wider gap
630
+ else { e.fs = ty >= sy ? 'B' : 'T'; e.ts = ty >= sy ? 'T' : 'B'; }
631
+ const SIDE = { left: 'L', right: 'R', top: 'T', bottom: 'B' }; if (SIDE[e.fromSide]) e.fs = SIDE[e.fromSide]; if (SIDE[e.toSide]) e.ts = SIDE[e.toSide];
632
+ });
633
+ // a side holds only as many ports as fit 14px apart; the steepest extras move to the top/bottom side
634
+ const sideSpan = (n, side) => { // longest stretch of a top/bottom border free of decorations
635
+ const decos = side === 'T' ? n.decoT : side === 'B' ? n.decoB : null;
636
+ if (!decos || !decos.length) return null;
637
+ let spans = [[n.x + 8, n.x + n.w - 8]];
638
+ decos.forEach(([dx, dw]) => { spans = spans.flatMap(([a, b]) => { const c = dx - 8, d = dx + dw + 8; if (d <= a || c >= b) return [[a, b]]; return [[a, c], [d, b]].filter(([p, q]) => q - p > 0); }); });
639
+ const best = spans.sort((p, q) => (q[1] - q[0]) - (p[1] - p[0]))[0];
640
+ return best && best[1] - best[0] >= 24 ? best : null;
641
+ };
642
+ const PORT = 14, cap = (n, side) => { const sp = sideSpan(n, side); return Math.max(1, Math.floor(((side === 'L' || side === 'R') ? n.h : sp ? sp[1] - sp[0] : n.w) * 0.84 / PORT)); };
643
+ for (let pass = 0; pass < 2; pass++) nodes.forEach(n => {
644
+ ['R', 'L'].forEach(side => {
645
+ const mine = free.filter(e => (e.s === n && e.fs === side) || (e.t === n && e.ts === side));
646
+ const over = mine.length - cap(n, side); if (over <= 0) return;
647
+ const far = e => { const o = e.s === n ? e.t : e.s; return (o.y + o.h / 2) - (n.y + n.h / 2); };
648
+ mine.sort((a, b) => Math.abs(far(b)) - Math.abs(far(a))).slice(0, over).forEach(e => {
649
+ const ns = far(e) < 0 ? 'T' : 'B';
650
+ if (e.s === n) e.fs = ns; else e.ts = ns;
651
+ });
652
+ });
653
+ });
654
+ free.forEach(e => { pushSide(e.s, e.fs, e, e.t); pushSide(e.t, e.ts, e, e.s); });
655
+ const portAt = (it, side, p) => { if (it.n === it.e.s && it.e.fs === side) it.e.p1 = p; else it.e.p2 = p; };
656
+ Object.entries(sides).sort(([, a], [, b]) => !!a[0].n.box - !!b[0].n.box).forEach(([key, list]) => { // cards first: a box's port lines up with them
657
+ const side = key.split('|').pop(), vertical = side === 'L' || side === 'R';
658
+ const n = list[0].n;
659
+ if (n.box) { // each port in line with its line's other end where the border allows (a straight line): below the title on
660
+ // a side, off the title, tabs and corners on top or bottom; in that order, 16 (26) px apart
661
+ const want = it => { const q = it.e.s === n ? it.e.p2 : it.e.p1, o = it.other; return q ? q[vertical ? 1 : 0] : vertical ? o.y + o.h / 2 : o.x + o.w / 2; };
662
+ const sp = vertical ? [n.y + 40, n.y + n.h - 16] : sideSpan(n, side) || [n.x, n.x + n.w];
663
+ let lo = vertical ? sp[0] : Math.max(sp[0], n.x + 20), hi = vertical ? sp[1] : Math.min(sp[1], n.x + n.w - 20); if (hi < lo) lo = hi = (lo + hi) / 2;
664
+ const ws = list.map(want), order = list.map((_, i) => i).sort((a, b) => ws[a] - ws[b]), gap = Math.min(vertical ? 16 : 26, (hi - lo) * 0.84 / list.length);
665
+ const v = order.map(i => Math.min(hi, Math.max(lo, ws[i])));
666
+ for (let k = 1; k < v.length; k++) v[k] = Math.max(v[k], v[k - 1] + gap);
667
+ for (let k = v.length - 1; k >= 0; k--) v[k] = Math.min(v[k], k === v.length - 1 ? hi : v[k + 1] - gap);
668
+ order.forEach((i, k) => portAt(list[i], side, vertical ? [side === 'R' ? n.x + n.w : n.x, v[k]] : [v[k], side === 'T' ? n.y : n.y + n.h]));
669
+ return;
670
+ }
671
+ // order ports by where the far end lies, so neighbouring routes do not cross at the node: on a left or right side by its
672
+ // y; on a top or bottom side by its angle from the side, swept from the side's left end (#49). By x, then a y tie-break
673
+ // in one direction, cards in one column on the left of a bottom side came in the wrong order, and their lines crossed.
674
+ const mx = n.x + n.w / 2, my = n.y + n.h / 2, turn = it => { let a = Math.atan2(it.other.y + it.other.h / 2 - my, it.other.x + it.other.w / 2 - mx) - (side === 'T' ? -Math.PI / 2 : Math.PI / 2);
675
+ while (a <= -Math.PI) a += 2 * Math.PI; while (a > Math.PI) a -= 2 * Math.PI; return side === 'T' ? a : -a; };
676
+ list.sort((a, b) => vertical ? (a.other.y - b.other.y) || ((side === 'R' ? -1 : 1) * (a.other.x - b.other.x)) : turn(a) - turn(b));
677
+ const sp = vertical ? null : sideSpan(n, side);
678
+ const len = vertical ? n.h : sp ? sp[1] - sp[0] : n.w, mid = sp ? (sp[0] + sp[1]) / 2 : n.x + n.w / 2;
679
+ const gap = Math.min(vertical ? 16 : 26, (len * 0.84) / Math.max(1, list.length)), cy = n.y + n.h / 2;
680
+ list.forEach((it, i) => {
681
+ const off = (i - (list.length - 1) / 2) * gap;
682
+ const p = side === 'R' ? [n.x + n.w, cy + off] : side === 'L' ? [n.x, cy + off]
683
+ : side === 'T' ? [mid + off, n.y] : [mid + off, n.y + n.h];
684
+ portAt(it, side, p);
685
+ });
686
+ });
687
+ const C = map ? 16 : 12, STUB = dg.mainSteps ? C : C + 6, SEP = 10, BEND = map ? 60 : 26, CROSS = 900; // maps: 16 px clear of other nodes, few bends
688
+ const OUT = { R: [1, 0], L: [-1, 0], T: [0, -1], B: [0, 1] };
689
+ const nodeRects = nodes.map(n => ({ x: n.x, y: n.y, w: n.w, h: n.h }));
690
+ const gapLinesX = [], gapLinesY = [];
691
+ if (dg.abs) {
692
+ (dg.lanes || []).forEach(l => gapLinesX.push(l.x - (dg.laneGap || 32) / 2, l.x + 8, l.x + l.w - 8, l.x + l.w + (dg.laneGap || 32) / 2));
693
+ nodes.forEach(n => gapLinesY.push(n.y - RG / 2, n.y + n.h + RG / 2));
694
+ } else {
695
+ Object.keys(colX).map(Number).forEach(c => gapLinesX.push(colX[c] - CG / 2, colX[c] + (colW[c] || NW) + CG / 2));
696
+ [...new Set(nodes.map(n => n.y))].forEach(y => gapLinesY.push(y - RG / 2, y + NH + RG / 2));
697
+ }
698
+ const routed = [], reserved = [];
699
+ const inRect = (p, r) => p[0] > r.x && p[0] < r.x + r.w && p[1] > r.y && p[1] < r.y + r.h;
700
+ const colinear = (a, b, c, d) => {
701
+ if (a[0] === b[0] && c[0] === d[0] && Math.abs(a[0] - c[0]) < SEP - 1) return Math.min(Math.max(a[1], b[1]), Math.max(c[1], d[1])) - Math.max(Math.min(a[1], b[1]), Math.min(c[1], d[1])) > 0;
702
+ if (a[1] === b[1] && c[1] === d[1] && Math.abs(a[1] - c[1]) < SEP - 1) return Math.min(Math.max(a[0], b[0]), Math.max(c[0], d[0])) - Math.max(Math.min(a[0], b[0]), Math.min(c[0], d[0])) > 0;
703
+ return false;
704
+ };
705
+ const crosses = (p, q, r, s) => { // perpendicular interior crossing
706
+ const pv = p[0] === q[0], rv = r[0] === s[0]; if (pv === rv) return false;
707
+ const [v, hz] = pv ? [[p, q], [r, s]] : [[r, s], [p, q]];
708
+ return hz[0][1] > Math.min(v[0][1], v[1][1]) && hz[0][1] < Math.max(v[0][1], v[1][1]) && v[0][0] > Math.min(hz[0][0], hz[1][0]) && v[0][0] < Math.max(hz[0][0], hz[1][0]); };
709
+ const onSegInterior = (p, a, b) => (a[0] === b[0] && Math.abs(p[0] - a[0]) < 0.5 && p[1] > Math.min(a[1], b[1]) && p[1] < Math.max(a[1], b[1]))
710
+ || (a[1] === b[1] && Math.abs(p[1] - a[1]) < 0.5 && p[0] > Math.min(a[0], b[0]) && p[0] < Math.max(a[0], b[0]));
711
+ function gridRoute(e, allowCross, win) {
712
+ const st = [e.p1[0] + OUT[e.fs][0] * STUB, e.p1[1] + OUT[e.fs][1] * STUB];
713
+ const en = [e.p2[0] + OUT[e.ts][0] * STUB, e.p2[1] + OUT[e.ts][1] * STUB];
714
+ // ends that face each other closer than two stubs (a card 32 px beside a box) leave the grid no room: the plain
715
+ // elbow between them is the route, when it keeps off cards and tags and does not run along or across another line
716
+ if (OUT[e.fs][0] === -OUT[e.ts][0] && OUT[e.fs][1] === -OUT[e.ts][1] && (en[0] - st[0]) * OUT[e.fs][0] + (en[1] - st[1]) * OUT[e.fs][1] < 0) {
717
+ const p = fallback(e), lines = routed.flatMap(r => segsOf(r.pts));
718
+ if (segsOf(p).every(([a, b]) => ![...nodeRects, ...tagRects].some(r => segHitsRect(a, b, r)) && !lines.some(([c, d]) => colinear(a, b, c, d) || (!allowCross && crosses(a, b, c, d))))) return p;
719
+ }
720
+ const W = win ? { x: Math.min(st[0], en[0]) - win, y: Math.min(st[1], en[1]) - win, x2: Math.max(st[0], en[0]) + win, y2: Math.max(st[1], en[1]) + win } : null;
721
+ const any = allowCross === 2; // the last resort: only cards and tags stand in the way; crossing or running along a line, or a label, costs
722
+ const obs = [...nodeRects.map(r => ({ x: r.x - C, y: r.y - C, w: r.w + 2 * C, h: r.h + 2 * C })), ...tagRects, ...(any ? [] : reserved.map(r => ({ x: r.x - 3, y: r.y - 3, w: r.w + 6, h: r.h + 6 })))];
723
+ const avoid = [...routed.flatMap(r => segsOf(r.pts)), ...free.filter(o => o !== e && !routed.includes(o)).flatMap(o => o.stubs || [])];
724
+ const xs = new Set([st[0], en[0], ...gapLinesX]), ys = new Set([st[1], en[1], ...gapLinesY]);
725
+ obs.forEach(r => { xs.add(r.x - 1); xs.add(r.x + r.w + 1); ys.add(r.y - 1); ys.add(r.y + r.h + 1); });
726
+ avoid.forEach(([a, b]) => { if (a[0] === b[0]) { xs.add(a[0]); xs.add(a[0] - SEP); xs.add(a[0] + SEP); } else { ys.add(a[1]); ys.add(a[1] - SEP); ys.add(a[1] + SEP); } });
727
+ const keep = new Set([st[0], en[0], st[1], en[1]]);
728
+ avoid.forEach(([a, b]) => { if (a[0] === b[0]) keep.add(a[0]); else keep.add(a[1]); }); // a route's own line is never merged away
729
+ const co = vals => vals.filter(v => !W || keep.has(v) || true).sort((a, b) => a - b).filter((v, i, s) => i === 0 || keep.has(v) || v - s[i - 1] >= 5);
730
+ if (map) families.forEach(f => { xs.add(f.x - 16); xs.add(f.x + f.w + 16); ys.add(f.y - 16); ys.add(f.y + f.h + 16); });
731
+ let X = co([...xs]), Y = co([...ys]);
732
+ if (map) { const far = (v, bs) => keep.has(v) || bs.every(u => Math.abs(v - u) >= 8);
733
+ X = X.filter(v => far(v, families.flatMap(f => [f.x, f.x + f.w]))); Y = Y.filter(v => far(v, families.flatMap(f => [f.y, f.y + f.h]))); }
734
+ if (W) { X = X.filter(v => keep.has(v) || (v >= W.x && v <= W.x2)); Y = Y.filter(v => keep.has(v) || (v >= W.y && v <= W.y2)); }
735
+ const nx = X.length, ny = Y.length, N = nx * ny;
736
+ // an end that lies in an obstacle (or, without crossings, on an earlier line) fails the search: say so before building the grid
737
+ const blockedAt = p => obs.some(r => inRect(p, r)) || (!allowCross && avoid.some(([a, b]) => onSegInterior(p, a, b)));
738
+ if (blockedAt(st) || blockedAt(en)) return null;
739
+ const ok = new Uint8Array(N), crossAt = new Uint8Array(N);
740
+ // each obstacle and earlier line marks only the cells and moves near it (found by binary search, then tested
741
+ // exactly as inRect, segHitsRect, onSegInterior and colinear do); every cell testing every obstacle and line
742
+ // made one route cost O(cells x obstacles)
743
+ const from = (A, v) => { let a = 0, b = A.length; while (a < b) { const m = (a + b) >> 1; if (A[m] < v) a = m + 1; else b = m; } return a; };
744
+ const inObs = new Uint8Array(N), onAv = new Uint8Array(N), blockH = new Uint8Array(N), blockV = new Uint8Array(N), alongH = any ? new Uint8Array(N) : blockH, alongV = any ? new Uint8Array(N) : blockV;
745
+ for (const r of obs) {
746
+ const i0 = from(X, r.x - 1), j0 = from(Y, r.y - 1), qx = r.x + .5, qy = r.y + .5, qx2 = qx + (r.w - 1), qy2 = qy + (r.h - 1);
747
+ for (let i = i0; i < nx && X[i] <= r.x + r.w + 1; i++) for (let j = j0; j < ny && Y[j] <= r.y + r.h + 1; j++)
748
+ if (X[i] > r.x && X[i] < r.x + r.w && Y[j] > r.y && Y[j] < r.y + r.h) inObs[i * ny + j] = 1;
749
+ const lx = Math.min(qx, qx2), hx = Math.max(qx, qx2), ly = Math.min(qy, qy2), hy = Math.max(qy, qy2);
750
+ for (let i = Math.max(0, from(X, lx - 1) - 1); i < nx - 1 && X[i] <= hx + 1; i++) for (let j = from(Y, ly - 1); j < ny && Y[j] <= hy + 1; j++)
751
+ if (X[i + 1] > qx && X[i] < qx2 && Y[j] > qy && Y[j] < qy2) blockH[i * ny + j] = 1;
752
+ for (let i = from(X, lx - 1); i < nx && X[i] <= hx + 1; i++) for (let j = Math.max(0, from(Y, ly - 1) - 1); j < ny - 1 && Y[j] <= hy + 1; j++)
753
+ if (X[i] > qx && X[i] < qx2 && Y[j + 1] > qy && Y[j] < qy2) blockV[i * ny + j] = 1;
754
+ }
755
+ for (const [c, d] of avoid) {
756
+ const x1 = Math.min(c[0], d[0]), x2 = Math.max(c[0], d[0]), y1 = Math.min(c[1], d[1]), y2 = Math.max(c[1], d[1]);
757
+ if (c[0] === d[0]) { // vertical: cells on its interior; vertical moves alongside it
758
+ for (let i = from(X, x1 - 1); i < nx && X[i] <= x2 + 1; i++) if (Math.abs(X[i] - c[0]) < 0.5) for (let j = from(Y, y1 - 1); j < ny && Y[j] <= y2 + 1; j++)
759
+ if (Y[j] > y1 && Y[j] < y2) onAv[i * ny + j] = 1;
760
+ for (let i = from(X, x1 - SEP - 1); i < nx && X[i] <= x2 + SEP + 1; i++) if (Math.abs(X[i] - c[0]) < SEP - 1) for (let j = Math.max(0, from(Y, y1 - 1) - 1); j < ny - 1 && Y[j] <= y2 + 1; j++)
761
+ if (Math.min(Y[j + 1], y2) - Math.max(Y[j], y1) > 0) alongV[i * ny + j] = 1;
762
+ }
763
+ if (c[1] === d[1]) { // horizontal: the same, across
764
+ for (let j = from(Y, y1 - 1); j < ny && Y[j] <= y2 + 1; j++) if (Math.abs(Y[j] - c[1]) < 0.5) for (let i = from(X, x1 - 1); i < nx && X[i] <= x2 + 1; i++)
765
+ if (X[i] > x1 && X[i] < x2) onAv[i * ny + j] = 1;
766
+ for (let j = from(Y, y1 - SEP - 1); j < ny && Y[j] <= y2 + SEP + 1; j++) if (Math.abs(Y[j] - c[1]) < SEP - 1) for (let i = Math.max(0, from(X, x1 - 1) - 1); i < nx - 1 && X[i] <= x2 + 1; i++)
767
+ if (Math.min(X[i + 1], x2) - Math.max(X[i], x1) > 0) alongH[i * ny + j] = 1;
768
+ }
769
+ }
770
+ for (let k = 0; k < N; k++) if (!inObs[k] && (!onAv[k] || allowCross)) { ok[k] = 1; crossAt[k] = onAv[k]; }
771
+ if (any) for (const r of reserved) for (let i = from(X, r.x - 3); i < nx && X[i] < r.x + r.w + 3; i++) for (let j = from(Y, r.y - 3); j < ny && Y[j] < r.y + r.h + 3; j++) crossAt[i * ny + j] = 1;
772
+ const ix = v => X.indexOf(v), iy = v => Y.indexOf(v);
773
+ const s0 = ix(st[0]) * ny + iy(st[1]), t0 = ix(en[0]) * ny + iy(en[1]);
774
+ if (!ok[s0] || !ok[t0]) return null;
775
+ const passable = (i1, j1, i2, j2) => !(i1 !== i2 ? blockH[Math.min(i1, i2) * ny + j1] : blockV[i1 * ny + Math.min(j1, j2)]);
776
+ // a target shut in a small pocket that does not hold the start cannot be reached: flood out from it (moves work
777
+ // both ways) and give up at once, instead of letting the search visit every cell it can reach
778
+ { const seen = new Uint8Array(N), q = [t0]; seen[t0] = 1;
779
+ for (let h = 0; h < q.length && q.length < 4000; h++) { const c = q[h], i = Math.floor(c / ny), j = c % ny;
780
+ if (c === s0) break;
781
+ for (const [i2, j2] of [[i + 1, j], [i - 1, j], [i, j + 1], [i, j - 1]]) { if (i2 < 0 || j2 < 0 || i2 >= nx || j2 >= ny) continue;
782
+ const c2 = i2 * ny + j2; if (!seen[c2] && ok[c2] && passable(i, j, i2, j2)) { seen[c2] = 1; q.push(c2); } } }
783
+ if (q.length < 4000 && !seen[s0]) return null; }
784
+ // Dijkstra over (cell, incoming direction)
785
+ const DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1]], dist = new Float64Array(N * 4).fill(Infinity), prev = new Int32Array(N * 4).fill(-1);
786
+ // a binary heap in typed arrays, with the same sift steps as before, so equal keys pop in the same order
787
+ let hd = new Float64Array(256), hk = new Int32Array(256), hn = 0, pd = 0, pk = 0;
788
+ const hswap = (a, b) => { const d = hd[a], k = hk[a]; hd[a] = hd[b]; hk[a] = hk[b]; hd[b] = d; hk[b] = k; };
789
+ const hpush = (k, d) => { if (hn === hd.length) { const d2 = new Float64Array(hn * 2), k2 = new Int32Array(hn * 2); d2.set(hd); k2.set(hk); hd = d2; hk = k2; }
790
+ let i = hn++; hd[i] = d; hk[i] = k; while (i) { const p = (i - 1) >> 1; if (hd[p] <= hd[i]) break; hswap(p, i); i = p; } };
791
+ const hpop = () => { pd = hd[0]; pk = hk[0]; hn--; if (hn) { hd[0] = hd[hn]; hk[0] = hk[hn]; let i = 0; for (;;) { const l = 2 * i + 1, r = l + 1; let m = i;
792
+ if (l < hn && hd[l] < hd[m]) m = l; if (r < hn && hd[r] < hd[m]) m = r; if (m === i) break; hswap(m, i); i = m; } } };
793
+ const d0 = DIRS.findIndex(d => d[0] === OUT[e.fs][0] && d[1] === OUT[e.fs][1]);
794
+ const dEnd = DIRS.findIndex(d => d[0] === -OUT[e.ts][0] && d[1] === -OUT[e.ts][1]);
795
+ // A*: the heap is keyed by distance plus the straight distance left, which never overestimates (every move costs
796
+ // at least its length), so each route keeps its cost; the search stops visiting cells far from the target
797
+ const hx = Float64Array.from(X, x => Math.abs(x - en[0])), hy = Float64Array.from(Y, y => Math.abs(y - en[1])), DI = [1, -1, 0, 0], DJ = [0, 0, 1, -1];
798
+ dist[s0 * 4 + d0] = 0; hpush(s0 * 4 + d0, hx[(s0 / ny) | 0] + hy[s0 % ny]);
799
+ let best = -1;
800
+ while (hn) {
801
+ hpop(); const k = pk, cell = k >> 2, dir = k & 3, i = (cell / ny) | 0, j = cell - i * ny;
802
+ if (pd > dist[k] + (hx[i] + hy[j])) continue;
803
+ const d = dist[k];
804
+ if (cell === t0) { best = k; if (dir === dEnd) break; continue; }
805
+ const xi = X[i], yj = Y[j];
806
+ for (let nd = 0; nd < 4; nd++) {
807
+ if ((nd ^ 1) === dir && ((nd >> 1) === (dir >> 1))) continue; // no reversal
808
+ const i2 = i + DI[nd], j2 = j + DJ[nd];
809
+ if (i2 < 0 || j2 < 0 || i2 >= nx || j2 >= ny) continue;
810
+ const c2 = i2 * ny + j2; if (!ok[c2] || (nd < 2 ? blockH[(nd === 0 ? i : i2) * ny + j] : blockV[i * ny + (nd === 2 ? j : j2)])) continue;
811
+ let w = Math.abs(X[i2] - xi) + Math.abs(Y[j2] - yj) + (nd === dir ? 0 : BEND) + (crossAt[c2] ? CROSS : 0);
812
+ if (any && (nd < 2 ? alongH[(nd === 0 ? i : i2) * ny + j] : alongV[i * ny + (nd === 2 ? j : j2)])) w += 2 * CROSS + 3 * (Math.abs(X[i2] - xi) + Math.abs(Y[j2] - yj));
813
+ if (c2 === t0 && nd !== dEnd) w += BEND;
814
+ const k2 = c2 * 4 + nd; if (d + w < dist[k2]) { dist[k2] = d + w; prev[k2] = k; hpush(k2, d + w + (hx[i2] + hy[j2])); }
815
+ }
816
+ }
817
+ if (best < 0) return null;
818
+ const cells = []; for (let k = best; k >= 0; k = prev[k]) { cells.push(k >> 2); if (k === s0 * 4 + d0) break; }
819
+ const pts = [e.p1, ...cells.reverse().map(c => [X[Math.floor(c / ny)], Y[c % ny]]), e.p2];
820
+ return simplify(pts);
821
+ }
822
+ function simplify(pts) {
823
+ const out = [pts[0]];
824
+ for (let i = 1; i < pts.length; i++) {
825
+ const p = pts[i], a = out[out.length - 1];
826
+ if (Math.abs(p[0] - a[0]) < .01 && Math.abs(p[1] - a[1]) < .01) continue;
827
+ if (out.length > 1) { const b = out[out.length - 2]; if ((b[0] === a[0] && a[0] === p[0]) || (b[1] === a[1] && a[1] === p[1])) out.pop(); }
828
+ out.push(p);
829
+ }
830
+ return out;
831
+ }
832
+ function fallback(e) { // last resort: a plain elbow
833
+ const st = [e.p1[0] + OUT[e.fs][0] * STUB, e.p1[1] + OUT[e.fs][1] * STUB], en = [e.p2[0] + OUT[e.ts][0] * STUB, e.p2[1] + OUT[e.ts][1] * STUB];
834
+ const horiz = e.fs === 'R' || e.fs === 'L', mid = horiz ? (st[0] + en[0]) / 2 : (st[1] + en[1]) / 2;
835
+ return simplify(horiz ? [e.p1, st, [mid, st[1]], [mid, en[1]], en, e.p2] : [e.p1, st, [st[0], mid], [en[0], mid], en, e.p2]);
836
+ }
837
+ // labels: on the edge's own segment, in free space — never across another route, node, or label
838
+ const placed = [];
839
+ function placeLabel(e) {
840
+ if (!e.label && e.step == null) return;
841
+ const full = map ? fit(e.label || '', 10.5, e.under ? 320 : 440) : fit((e.step != null ? e.step + ' · ' : '') + (e.label || ''), 10, 150);
842
+ const sub = map && e.label && e.sublabel ? fit(e.sublabel, 9.5, e.under ? 320 : 440) : ''; // a second, smaller line under the label (#44)
843
+ const tries = [full]; if (e.step != null && e.label && !dg.abs) tries.push(map ? '' : String(e.step));
844
+ const segs = segsOf(e.pts).map((s, i) => ({ a: s[0], b: s[1], i, l: Math.hypot(s[1][0] - s[0][0], s[1][1] - s[0][1]), h: s[0][1] === s[1][1] }));
845
+ const order = [...segs].sort((p, q) => (q.h - p.h) * 40 + (q.l - p.l));
846
+ for (const pass of (map ? [0, 1, 2] : [0])) for (const text of tries) { const relax = pass === 2; // 0 strict, 1 strict but may touch a box border, 2 relaxed
847
+ const two = !!(text && sub), pill = map && e.step != null, w = map ? (text ? pillW(text, two && sub) : 0) + (pill ? (text ? 20 : 22) : 0) : textW(text, 10) + 12, hgt = map ? (two ? 32 : 20) : 18;
848
+ for (const sg of order) {
849
+ if (!relax && sg.h && sg.l < w + 10) continue;
850
+ if (!relax && !sg.h && sg.l < hgt + 14) continue;
851
+ for (const f of (relax ? [.5, .32, .68, .2, .8, .1, .9, .42, .58, .26, .74, .05, .95] : [.5, .32, .68, .2, .8])) {
852
+ const lx = sg.a[0] + (sg.b[0] - sg.a[0]) * f, ly = sg.a[1] + (sg.b[1] - sg.a[1]) * f;
853
+ const box = { x: lx - w / 2, y: ly - hgt / 2, w, h: hgt };
854
+ const grow = { x: box.x - 3, y: box.y - 3, w: box.w + 6, h: box.h + 6 };
855
+ if (nodeRects.some(r => hit(relax ? { x: r.x, y: r.y - 10, w: r.w, h: r.h + 10 } : { x: r.x - STUB - 4, y: r.y - STUB - 4, w: r.w + 2 * STUB + 8, h: r.h + 2 * STUB + 8 }, box))) continue; // keep port stubs free
856
+ if (tagRects.some(r => hit(r, grow))) continue;
857
+ if (placed.some(r => (pass === 0 || !r.band) && hit(r, grow))) continue;
858
+ if (!relax && routed.some(r => r !== e && segsOf(r.pts).some(([a, b]) => segHitsRect(a, b, grow)))) continue;
859
+ if (!relax && segs.some(s2 => s2.i !== sg.i && segHitsRect(s2.a, s2.b, box))) continue;
860
+ placed.push(box); reserved.push(grow); e.lbl = { ...box, text, ...(two ? { sub } : {}), step: map ? e.step : null, short: text !== full }; return;
861
+ }
862
+ }
863
+ }
864
+ }
865
+ let stats = {};
866
+ free.forEach(e => { // every port's stub is reserved before routing starts
867
+ const st = [e.p1[0] + OUT[e.fs][0] * STUB, e.p1[1] + OUT[e.fs][1] * STUB], en = [e.p2[0] + OUT[e.ts][0] * STUB, e.p2[1] + OUT[e.ts][1] * STUB];
868
+ e.stubs = [[e.p1, st], [en, e.p2]];
869
+ });
870
+ const runRouting = order => {
871
+ routed.length = 0; reserved.length = 0; placed.length = 0; stats = {};
872
+ (dg.blocks || []).forEach(b => { reserved.push(b); placed.push(b); });
873
+ if (map) families.forEach(f => { const h = { x: f.x + 8, y: f.y + 3, w: Math.min(f.w - 16, textW(f.label || f.id, 11) + 16), h: 22 }; reserved.push(h); placed.push(h);
874
+ placed.push(...[{ x: f.x, y: f.y - 3, w: f.w, h: 6 }, { x: f.x, y: f.y + f.h - 3, w: f.w, h: 6 }, { x: f.x - 3, y: f.y, w: 6, h: f.h }, { x: f.x + f.w - 3, y: f.y, w: 6, h: f.h }].map(r => ({ ...r, band: true }))); }); // lane headers and arrows stay clear
875
+ edges.filter(e => e.fixed).forEach(e => { routed.push(e); if (e.under) { placeLabel(e); stats.under = (stats.under || 0) + 1; return; } if (e.lbl) { placed.push(e.lbl); reserved.push({ x: e.lbl.x - 3, y: e.lbl.y - 3, w: e.lbl.w + 6, h: e.lbl.h + 6 }); } stats.comb = (stats.comb || 0) + 1; });
876
+ order.forEach(e => {
877
+ let how = 'grid'; e.lbl = null; e.pts = gridRoute(e, false, 320) || gridRoute(e, false, 0);
878
+ if (!e.pts) { how = 'cross'; e.pts = gridRoute(e, true, 0); } if (!e.pts) { how = 'fallback'; e.pts = fallback(e); }
879
+ stats[how] = (stats[how] || 0) + 1; e.how = how;
880
+ routed.push(e); placeLabel(e);
881
+ });
882
+ return (stats.cross || 0) + (stats.fallback || 0);
883
+ };
884
+ __ms.place = performance.now() - __ms.t; __ms.t = performance.now();
885
+ let order = [...free].sort((a, b) => (Math.abs(a.p1[0] - a.p2[0]) + Math.abs(a.p1[1] - a.p2[1])) - (Math.abs(b.p1[0] - b.p2[0]) + Math.abs(b.p1[1] - b.p2[1])));
886
+ // lines into one side of a card nest (#49). The router turns a line as late as it can, so the line whose turn must lie
887
+ // nearest the card goes first and each next one takes the track beside it: coming from below, the lowest port first;
888
+ // from above, the highest. Shortest first, the last line found no track left and went round its own column.
889
+ const into = {}; free.forEach(e => (into[e.t.id + '|' + e.ts] ||= []).push(e));
890
+ Object.entries(into).forEach(([k, list]) => { if (list.length < 2) return; const ax = /\|[LR]$/.test(k) ? 1 : 0;
891
+ const nearFirst = e => { const d = e.p1[ax] - e.p2[ax]; return d > 0 ? -e.p2[ax] : d < 0 ? e.p2[ax] - 1e6 : 1e7; };
892
+ const slots = list.map(e => order.indexOf(e)).sort((a, b) => a - b), turns = [...list].sort((a, b) => nearFirst(a) - nearFirst(b));
893
+ slots.forEach((at, i) => { order[at] = turns[i]; }); });
894
+ let bad = runRouting(order);
895
+ // rip-up and reroute: an edge that had to cross goes first next time; keep the best attempt (not when the view is laid
896
+ // out again with room: that layout's tries seldom gained anything, and they cost a third of its time)
897
+ for (let tries = 0; bad && !last && tries < 4; tries++) {
898
+ const stuck = order.filter(e => e.how !== 'grid');
899
+ const next = [...stuck, ...order.filter(e => e.how === 'grid')];
900
+ const snap = order.map(e => [e, e.pts, e.lbl, e.how]), snapStats = stats, snapR = [routed.slice(), reserved.slice(), placed.slice()];
901
+ const b2 = runRouting(next);
902
+ if (b2 < bad) { bad = b2; order = next; }
903
+ else { snap.forEach(([e, p, l, hw]) => { e.pts = p; e.lbl = l; e.how = hw; }); stats = snapStats; routed.splice(0, routed.length, ...snapR[0]); reserved.splice(0, reserved.length, ...snapR[1]); placed.splice(0, placed.length, ...snapR[2]); break; }
904
+ }
905
+ __ms.route = performance.now() - __ms.t; __ms.t = performance.now(); __ms.rounds = 0; __ms.reroutes = 0;
906
+ // Lines left with no route get room (#40). Each one's route around the cards (the last resort's, below) shows where it
907
+ // would run along another line: that column (row) gap widens by two lanes for it, and the view is laid out again.
908
+ if (!last && !dg.quick && map && !dg.abs && free.some(e => e.how === 'fallback')) {
909
+ const need = { x: {}, y: {} }, cs = Object.keys(colX).map(Number), rs = nodes.map(n => n.row), r0 = Math.floor(Math.min(...rs)), r1 = Math.ceil(Math.max(...rs));
910
+ const gapAt = (v, vertical) => { if (vertical) return cs.find(c => colX[c] + (colW[c] || NW) < v && v < colX[c + 1]);
911
+ for (let r = r0; r < r1; r++) if (rowY(r) + NH < v && v < rowY(r + 1)) return r; };
912
+ free.filter(e => e.how === 'fallback').forEach(e => { routed.splice(routed.indexOf(e), 1); const p = gridRoute(e, 2, 320) || gridRoute(e, 2, 0), mine = { x: new Set(), y: new Set() };
913
+ segsOf(p || []).forEach(([a, b]) => { if (!routed.some(o => segsOf(o.pts).some(([c, d]) => colinear(a, b, c, d)))) return;
914
+ const v = a[0] === b[0], g = gapAt(v ? a[0] : a[1], v); if (g != null) mine[v ? 'x' : 'y'].add(g); });
915
+ for (const k of ['x', 'y']) mine[k].forEach(g => { need[k][g] = (need[k][g] || 0) + 2 * SEP; }); });
916
+ if (Object.keys(need.x).length + Object.keys(need.y).length) {
917
+ for (const k of ['x', 'y']) Object.entries((room || {})[k] || {}).forEach(([g, v]) => { need[k][g] = (need[k][g] || 0) + v; });
918
+ return { room: need, ms: { probe: performance.now() - t0 } };
919
+ }
920
+ }
921
+ // port repair: two routes that cross next to a shared node side swap ports, keep it if issues drop
922
+ const issueCount = () => routeAudit({ edges }, '').length;
923
+ const endAt = (e, n, side) => (e.s === n && e.fs === side) ? 'p1' : (e.t === n && e.ts === side) ? 'p2' : null;
924
+ let issues = issueCount();
925
+ // a swap re-routes only the two swapped edges (ripped up, then routed after every other route), not the whole
926
+ // graph; a round goes on after an improvement; and repair spends at most 2 routings per free edge, so its cost
927
+ // grows with the graph instead of with crossings x edges x routing (it stalled on 26 nodes / 44 edges)
928
+ const restub = e => { e.stubs = [[e.p1, [e.p1[0] + OUT[e.fs][0] * STUB, e.p1[1] + OUT[e.fs][1] * STUB]], [[e.p2[0] + OUT[e.ts][0] * STUB, e.p2[1] + OUT[e.ts][1] * STUB], e.p2]]; };
929
+ const ripUp = e => { const i = routed.indexOf(e); if (i >= 0) routed.splice(i, 1); const L = e.lbl; if (!L) return;
930
+ const same = m => r => r.x === L.x - m && r.y === L.y - m && r.w === L.w + 2 * m && r.h === L.h + 2 * m;
931
+ const p = placed.findIndex(same(0)); if (p >= 0) placed.splice(p, 1); const q = reserved.findIndex(same(3)); if (q >= 0) reserved.splice(q, 1); };
932
+ // a swapped edge tries a clean route near its ends, then one that may cross (near its ends, then anywhere); it
933
+ // skips the clean whole-grid search, which mostly fails here and visits every cell when it does
934
+ const routeOne = e => { let how = 'grid'; e.lbl = null; e.pts = gridRoute(e, false, 320);
935
+ if (!e.pts) { how = 'cross'; e.pts = gridRoute(e, true, 320) || gridRoute(e, true, 0); } if (!e.pts) { how = 'fallback'; e.pts = fallback(e); }
936
+ e.how = how; routed.push(e); placeLabel(e); return true; };
937
+ let budget = 2 * free.length, swapped = false;
938
+ for (let round = 0; issues && dg.repair !== false && round < 8 && budget > 0; round++) {
939
+ let fixed = false; __ms.rounds++;
940
+ const segsE = e => e.pts.slice(0, -1).map((p, i) => [p, e.pts[i + 1]]);
941
+ const xing = (a, b) => segsE(a).some(([p, q]) => segsE(b).some(([r, s]) => crosses(p, q, r, s)));
942
+ const pairs = []; edges.forEach((a, i) => edges.slice(i + 1).forEach(b => { if (xing(a, b)) pairs.push([a, b]); }));
943
+ // a crossing pair, or a pair where one shares a node side with the other's crossing partner
944
+ const cand = [], seen = new Set(), add = (a, b) => { const k = a.id + '|' + b.id; if (!seen.has(k)) { seen.add(k); cand.push([a, b]); } }; // each pair once; crossing pairs first
945
+ pairs.forEach(([a, b]) => add(a, b)); pairs.forEach(([a, b]) => free.forEach(o => { if (o !== a && o !== b) { add(a, o); add(b, o); } }));
946
+ outer3: for (const [a, b] of cand.filter(([x, y]) => !x.fixed && !y.fixed)) {
947
+ for (const n of [a.s, a.t]) for (const side of ['L', 'R', 'T', 'B']) {
948
+ const ka = endAt(a, n, side), kb = endAt(b, n, side); if (!ka || !kb) continue;
949
+ if (budget <= 0) break outer3;
950
+ const pa = a[ka], pb = b[kb]; a[ka] = pb; b[kb] = pa; restub(a); restub(b);
951
+ const snap = [a, b].map(e => [e, e.pts, e.lbl, e.how]), snapR = [routed.slice(), reserved.slice(), placed.slice()];
952
+ const ab = new Set([a, b]), was = routeAudit({ edges }, '', ab).length; // issues away from a and b cannot change
953
+ budget -= 2; __ms.reroutes++;
954
+ if (!was) { a[ka] = pa; b[kb] = pb; restub(a); restub(b); continue; } // nothing to gain: the count can only drop by their own issues
955
+ ripUp(a); ripUp(b);
956
+ const now = routeOne(a) && routeOne(b) ? issues - was + routeAudit({ edges }, '', ab).length : Infinity;
957
+ if (now < issues) { issues = now; fixed = swapped = true; if (!issues) break outer3; continue outer3; }
958
+ a[ka] = pa; b[kb] = pb; restub(a); restub(b);
959
+ snap.forEach(([e, p, l, hw]) => { e.pts = p; e.lbl = l; e.how = hw; });
960
+ routed.splice(0, routed.length, ...snapR[0]); reserved.splice(0, reserved.length, ...snapR[1]); placed.splice(0, placed.length, ...snapR[2]);
961
+ }
962
+ }
963
+ if (!fixed) break;
964
+ }
965
+ if (swapped) { ['grid', 'cross', 'fallback'].forEach(k => { delete stats[k]; }); free.forEach(e => { stats[e.how] = (stats[e.how] || 0) + 1; }); }
966
+ // the last resort for a line still with no route: around the cards, across or along other lines if it must (it was a
967
+ // plain elbow through whatever lay in its way, and a card's mask made the line look like a connection to that card)
968
+ if (!dg.quick) free.filter(e => e.how === 'fallback').forEach(e => { ripUp(e); e.pts = gridRoute(e, 2, 320) || gridRoute(e, 2, 0) || e.pts; e.lbl = null; routed.push(e); placeLabel(e); });
969
+ if (!dg.quick) edges.forEach(e => { const n = through(e.pts, e); if (n) warn(`the line for edge ${edgeRefIds(e).join(' / ') || e.from + '->' + e.to} runs through card "${n.id}", which it does not join: no route found around it`); });
970
+ __ms.repair = performance.now() - __ms.t; delete __ms.t;
971
+ const groups = (dg.groups || []).map(g => {
972
+ const ms = (g.nodes || []).map(i => byId[i]).filter(Boolean);
973
+ if (!ms.length) return null;
974
+ const P = 18;
975
+ return { ...g, k: kindOf(g.kind || 'external'), x: Math.min(...ms.map(n => n.x)) - P, y: Math.min(...ms.map(n => n.y)) - P - 16,
976
+ x2: Math.max(...ms.map(n => n.x + n.w)) + P, y2: Math.max(...ms.map(n => n.y + n.h)) + P };
977
+ }).filter(Boolean);
978
+ const xs = [...families.flatMap(f => [f.x,f.x+f.w]), ...(dg.lanes || []).flatMap(l => [l.x, l.x + l.w]), ...nodes.flatMap(n => [n.x - 14, n.x + n.w + 8]), ...groups.flatMap(g => [g.x, g.x2]), ...edges.flatMap(e => e.pts.map(p => p[0])), ...placed.flatMap(p => [p.x, p.x + p.w])];
979
+ const ys = [...families.flatMap(f => [f.y,f.y+f.h]), ...(dg.lanes || []).flatMap(l => [l.y, l.y + l.h]), ...nodes.flatMap(n => [n.y - 14, n.y + n.h + (n.tag ? 18 : 8)]), ...groups.flatMap(g => [g.y, g.y2]), ...edges.flatMap(e => e.pts.map(p => p[1])), ...placed.flatMap(p => [p.y, p.y + p.h])];
980
+ intake.forEach(b => { xs.push(b.cx - b.r - 8 - textW(b.label || '', 10.5), b.cx + b.r); ys.push(b.cy - b.r, b.cy + b.r); });
981
+ const M = 36, minX = Math.min(...xs) - M, minY = Math.min(...ys) - M;
982
+ return { ms: __ms, stats, nodes, edges, subAndChips: !!dg.subAndChips, groups, families, intake, tour: dg.tour || null, byId, map, hub: dg.hub || null, lanes: dg.lanes || [], blocks: dg.blocks || [], bounds: { x: minX, y: minY, w: Math.max(...xs) - minX + M, h: Math.max(...ys) - minY + M } };
983
+ }
984
+
985
+ let svgSeq = 0;
986
+ /* SVG markup. opts: { childCount(id) -> n, subject: id, title } */
987
+ function svgOf(geo, opts = {}) {
988
+ const id = 'g' + (svgSeq++), { nodes, edges, groups, bounds: B, map } = geo;
989
+ const col = k => `var(--um-${KIND[k][0]})`, fill = k => `var(--um-${KIND[k][0]}-f)`;
990
+ const estyle = e => {
991
+ if (map && e.wk && !e.style) return { c: `var(--um-w-${e.wk})`, m: e.wk, dash: e.back ? '6 5' : '', cls: ' w-' + e.wk + (e.back ? ' back' : '') };
992
+ const st = e.style || (e.change === 'added' ? 'added' : e.change === 'removed' ? 'removed' : '');
993
+ if (st === 'added' || st === 'emphasis') return { c: 'var(--um-green)', m: 'g', dash: '', flow: st === 'added' || e.animate };
994
+ if (st === 'removed') return { c: 'var(--um-rose)', m: 'r', dash: '5 5', op: .7 };
995
+ if (st === 'security') return { c: 'var(--um-rose)', m: 'r', dash: '5 5' };
996
+ if (st === 'dashed' || st === 'async') return { c: 'var(--um-violet)', m: 'v', dash: '4 4' };
997
+ return { c: 'var(--um-arrow)', m: 'd', dash: '' };
998
+ };
999
+ const marker = (n, c, k = 1) => `<marker id="${id}-${n}" markerWidth="${10 * k}" markerHeight="${8 * k}" refX="${9 * k}" refY="${4 * k}" orient="auto-start-reverse" markerUnits="userSpaceOnUse"><path d="M0 0 L${10 * k} ${4 * k} L0 ${8 * k} Z" fill="${c}"/></marker>`;
1000
+ const MC = { d: 'var(--um-arrow)', g: 'var(--um-green)', r: 'var(--um-rose)', v: 'var(--um-violet)', h: 'var(--um-cyan)', ...Object.fromEntries(WIRES.map(w => [w[0], `var(--um-w-${w[0]})`])) };
1001
+ const heavy = [...new Set(edges.filter(e => e.weight > 1).map(e => estyle(e).m + '-w' + e.weight))]; // a heavier line's arrowhead grows with it
1002
+ let s = `<svg viewBox="${B.x} ${B.y} ${B.w} ${B.h}" data-w="${B.w}" data-h="${B.h}" role="${map ? 'group' : 'img'}" aria-label="${esc(opts.title || 'Diagram')}" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet">
1003
+ <defs>${marker('d', 'var(--um-arrow)')}${marker('g', 'var(--um-green)')}${marker('r', 'var(--um-rose)')}${marker('v', 'var(--um-violet)')}${marker('h', 'var(--um-cyan)')}${map ? WIRES.map(w => marker(w[0], `var(--um-w-${w[0]})`)).join('') : ''}${heavy.map(m => { const [c, k] = m.split('-w'); return marker(m, MC[c], 1 + (k - 1) * .35); }).join('')}</defs>`;
1004
+ const families = geo.families || [], familyDepth = f => f.parent ? 1 + familyDepth(families.find(p => p.id === f.parent) || {}) : 0;
1005
+ families.slice().sort((a,b) => familyDepth(a)-familyDepth(b)).forEach(f => {
1006
+ s += `<g class="family" data-family="${esc(f.id)}"${f.parent ? ` data-parent="${esc(f.parent)}"` : ''}><rect class="family-box" x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="14" stroke="var(--um-${familyDepth(f) ? 'cyan' : 'border-2'})"/>
1007
+ <text class="family-title" x="${f.x+12}" y="${f.y+18}" fill="var(--um-muted)" data-t="ft" data-o="${esc(f.id)}">${esc(f.label || f.id)}</text>${(f.tabs || []).map(t => `
1008
+ <g class="pub-tab" data-badge="${t.badge}"><rect x="${t.x}" y="${f.y - 8}" width="${t.w}" height="15" rx="4" fill="${t.fill}"/><text x="${t.x + t.w / 2}" y="${f.y + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`).join('')}</g>`;
1009
+ });
1010
+ (geo.lanes || []).forEach((l, i) => {
1011
+ s += `<g class="lane" data-lane="${i}">${l.bare ? '' : `<rect x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="14" class="lane-box"/>`}
1012
+ <circle cx="${l.x + 20}" cy="${l.y + 20}" r="8" class="lane-num"/><text x="${l.x + 20}" y="${l.y + 24}" text-anchor="middle" class="lane-num-tx">${i + 1}</text>
1013
+ <text x="${l.x + 34}" y="${l.y + 24}" class="lane-t" data-t="lt" data-o="lane:${i}">${esc(l.title)}</text>
1014
+ <text x="${l.x + 34 + sansW(l.title, 11, .95) + 8}" y="${l.y + 24}" class="lane-c">${l.ids.length} ${l.ids.length === 1 ? 'part' : 'parts'}</text></g>`;
1015
+ if (!l.bare && i < geo.lanes.length - 1) s += `<text x="${l.x + l.w + (l.gap || 32) / 2}" y="${l.y + 25}" text-anchor="middle" class="lane-arrow">→</text>`;
1016
+ });
1017
+ groups.forEach(g => {
1018
+ s += `<g class="grp"><rect x="${g.x}" y="${g.y}" width="${g.x2 - g.x}" height="${g.y2 - g.y}" rx="12" fill="${fill(g.k)}" fill-opacity=".35" stroke="${col(g.k)}" stroke-opacity=".55" stroke-dasharray="6 4"/>
1019
+ <text x="${g.x + 12}" y="${g.y + 16}" class="g-label" fill="${col(g.k)}">${esc(fit(g.label, 10, g.x2 - g.x - 24))}</text></g>`;
1020
+ });
1021
+ // tour (map): one ticket dot rides the view's loop; each stop pulses and each wire glows as it passes
1022
+ const TOUR = 8, f2 = v => +Math.min(1, Math.max(0, v)).toFixed(4), tour = map && opts.tour !== false && (geo.tour || []).length > 1 ? (() => {
1023
+ const pts = [], legs = {}, at = {}, ends = [];
1024
+ for (let i = 0; i + 1 < geo.tour.length; i++) {
1025
+ const a = geo.tour[i], b = geo.tour[i + 1], k = edges.findIndex(e => (e.from === a && e.to === b) || (e.both && e.from === b && e.to === a));
1026
+ if (k < 0) return null;
1027
+ const p = edges[k].from === a ? edges[k].pts : [...edges[k].pts].reverse();
1028
+ if (pts.length) pts.push([pts[pts.length - 1][0], p[0][1]]); // through the card to the next port
1029
+ const from = pts.length; pts.push(...p); ends.push([k, from, pts.length - 1, b]);
1030
+ }
1031
+ const P = pts.filter((p, i) => !i || p[0] !== pts[i - 1][0] || p[1] !== pts[i - 1][1]), L = [0];
1032
+ pts.slice(1).forEach((p, i) => L.push(L[i] + Math.abs(p[0] - pts[i][0]) + Math.abs(p[1] - pts[i][1])));
1033
+ const tot = L[L.length - 1] || 1; at[geo.tour[0]] = 0;
1034
+ ends.forEach(([k, a, b, to]) => { legs[k] = [L[a] / tot, L[b] / tot]; if (!(to in at)) at[to] = L[b] / tot; });
1035
+ return { d: roundPath(P), legs, at };
1036
+ })() : null;
1037
+ const tourAnim = (vals, times) => `<animate attributeName="opacity" values="${vals}" keyTimes="${times.map(f2).join(';')}" dur="${TOUR}s" repeatCount="indefinite"/>`;
1038
+ s += '<g class="edges">';
1039
+ (geo.intake || []).forEach(b => {
1040
+ s += `<g class="intake"><title>${esc(b.label)} feeds ${esc((geo.byId[b.to] || {}).label || b.to)}</title><path d="${roundPath(b.pts)}" class="edge" stroke="var(--um-w-feeds)" marker-end="url(#${id}-feeds)"/>
1041
+ <circle cx="${b.cx}" cy="${b.cy}" r="${b.r}" class="intake-chip${b.dashed ? ' dashed' : ''}"/>${b.logo ? `<image href="${esc(b.logo)}" x="${b.cx - 9}" y="${b.cy - 9}" width="18" height="18"/>` : b.mark ? `<text x="${b.cx}" y="${b.cy + 4.5}" text-anchor="middle" class="intake-mark">${esc(b.mark)}</text>` : ''}
1042
+ <text x="${b.cx - b.r - 7}" y="${b.cy + 3.5}" text-anchor="end" class="intake-tx">${esc(b.label)}</text>
1043
+ ${tour ? `<circle r="3.2" class="tour tour-packet" opacity="0"><animateMotion path="${roundPath(b.pts)}" keyPoints="0;0;1" keyTimes="0;.86;1" calcMode="linear" dur="${TOUR}s" repeatCount="indefinite"/>${tourAnim('0;0;.8;.8;0', [0, .86, .88, .99, 1])}</circle>` : ''}</g>`;
1044
+ });
1045
+ edges.forEach((e, i) => {
1046
+ const st = estyle(e), d = roundPath(e.pts), mk = st.m + (e.weight > 1 ? '-w' + e.weight : '');
1047
+ if (tour && tour.legs[i]) { const [a, b] = tour.legs[i];
1048
+ s += `<path d="${d}" class="tour-glow" stroke="${st.c}" opacity="0">${tourAnim('0;0;.55;.55;0;0', [0, a, Math.min(a + .01, b), b, b + .03, 1])}</path>`; }
1049
+ s += `<g class="edge-g${st.cls || ''}${e.weight > 1 ? ' wt-' + e.weight : ''}" data-from="${esc(e.from)}" data-to="${esc(e.to)}" data-i="${i}"><path d="${d}" class="halo"/><path d="${d}" class="hitline"/>
1050
+ <path d="${d}" class="edge${st.flow ? ' flow' : ''}" stroke="${st.c}" ${st.dash && !st.flow ? `stroke-dasharray="${st.dash}"` : ''} ${st.op ? `opacity="${st.op}"` : ''} marker-end="url(#${id}-${mk})" ${e.both ? `marker-start="url(#${id}-${mk})"` : ''}/>
1051
+ <path d="${d}" class="flowline" stroke="${st.c}"/>${(e.all || [e]).some(x => x.edited) ? (() => { let m = [e.pts[0], e.pts[1]];
1052
+ e.pts.slice(1).forEach((p, k) => { const a = e.pts[k]; if (Math.abs(p[0] - a[0]) + Math.abs(p[1] - a[1]) > Math.abs(m[1][0] - m[0][0]) + Math.abs(m[1][1] - m[0][1])) m = [a, p]; });
1053
+ return `<circle cx="${(m[0][0] + m[1][0]) / 2}" cy="${(m[0][1] + m[1][1]) / 2}" r="4" class="edited-dot"><title>Edited</title></circle>`; })() : ''}
1054
+ <title>${e.step != null ? 'Step ' + e.step + ': ' : ''}${esc(e.s.label)} ${e.both ? '⇄' : '→'} ${esc(e.t.label)}${e.label ? ': ' + esc(e.label) : ''}${e.label && e.sublabel ? ' (' + esc(e.sublabel) + ')' : ''}${e.when ? '\n' + esc(e.when) : ''}</title></g>`;
1055
+ });
1056
+ s += '</g><g class="nodes">';
1057
+ nodes.forEach(n => {
1058
+ const c = col(n.k), f = fill(n.k), removed = n.change === 'removed', planned = n.change === 'planned';
1059
+ const pub = map && n.k === 'm-public', priv = map && n.k === 'm-script', ext = map && n.k === 'm-ext', verb = map && n.k === 'm-verb', hook = map && n.k === 'm-hook';
1060
+ const rx = verb ? n.h / 2 : map ? 10 : 9;
1061
+ const kids = n.kids != null ? n.kids : opts.childCount ? opts.childCount(n.id) : 0, stacked = !!(kids || n.opens); // opens a view, loaded or not (#47)
1062
+ const subject = opts.subject === n.id;
1063
+ const verbs = map && (n.verbs || []).length ? n.verbs : null;
1064
+ const subTxt = n.noSub ? '' : n.sub || (map && n.origin ? n.origin : '') || (map ? (n.what ? n.what.replace(/[`*]/g, '').split(/(?<=\.)\s/)[0] : KIND[n.k][1]) : '');
1065
+ const third = !!(verbs && subTxt && !priv && geo.subAndChips); // the view's subAndChips: the sub-line, then the chips on a third line (a script card shows no sub-line)
1066
+ const sw = map ? n.w - 70 : n.w - 24; // map cards: text runs from 54 px in (after the mark) to 16 px from the right
1067
+ const sub2 = map && subTxt && !verbs && textW(subTxt, 10) > sw && textW(n.label, n.emphasis ? 15 : 13) <= sw ? wrap2(subTxt, 10, sw) : null;
1068
+ const sub = subTxt && (!verbs || third) ? (sub2 ? sub2.join(' ') : clauseFit(subTxt, 10, sw)) : '';
1069
+ const labelPx = n.emphasis ? 15 : 13;
1070
+ const lw = map ? n.w - 70 : n.w - 72; // the mark (sigil, icon or logo tile) reaches 27 px in; the name keeps 36 px each side
1071
+ const two = map && textW(n.label, labelPx) > lw ? wrap2(n.label, labelPx, lw) : null;
1072
+ const lpx = two ? labelPx : textW(n.label, labelPx) > lw ? (measureText ? Math.max(11, labelPx * lw / textW(n.label, labelPx)) : Math.max(11, lw / (String(n.label).length * 0.6))) : labelPx;
1073
+ const ly = n.y + n.h / 2 + (third ? -14 : sub || verbs ? -3 : 4) - (two ? 8 : 0) - (sub2 ? 6 : 0);
1074
+ // A card is a button named by what it shows, in the order it shows it, so its visible text is a run of whole words of its
1075
+ // name (WCAG 2.5.3, as axe checks it) at every zoom: what always shows comes first, then the sub-line and chips (faded out
1076
+ // when zoomed far out). What is not shown, or not in full, goes in parentheses, which that check leaves out. The card's
1077
+ // texts are apart (whitespace between them), so text-based tools read separate words.
1078
+ const said = [], plain = t => String(t ?? '').replace(/…/g, '').replace(/\s+/g, ' ').trim();
1079
+ const part = (shown, full) => plain(shown) === plain(full) ? plain(full) : `${plain(shown)} (in full: ${plain(full)})`;
1080
+ const tx0 = map ? n.x + 54 : n.x + n.w / 2, anchor = map ? 'start' : 'middle', lines = (t, dy) => `<tspan x="${tx0}">${esc(t[0])}</tspan> <tspan x="${tx0}" dy="${dy}">${esc(t[1])}</tspan>`;
1081
+ const label = two ? two.join(' ') : fit(n.label, lpx, lw, true), subShown = sub && !priv; // a script card hides its sub-line
1082
+ said.push(part(label, n.label) + (subTxt && !subShown ? ` (${subTxt})` : ''));
1083
+ const parts = [`<title>${esc(n.label)}${subTxt ? ' · ' + esc(subTxt) : ''}${n.note ? '\n' + esc(n.note) : ''}${stacked ? '\nDouble-click to open' : ''}</title>`];
1084
+ if (stacked) parts.push(`<rect x="${n.x + 8}" y="${n.y + 8}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/><rect x="${n.x + 4}" y="${n.y + 4}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-stack" stroke="${c}"/>`);
1085
+ if (pub) parts.push(`<rect x="${n.x - 4}" y="${n.y - 4}" width="${n.w + 8}" height="${n.h + 8}" rx="14" class="n-ring" stroke="${c}"/>`);
1086
+ if (hook) { const hx = Math.min(16, n.h / 3), pts = `${n.x + hx},${n.y} ${n.x + n.w - hx},${n.y} ${n.x + n.w},${n.y + n.h / 2} ${n.x + n.w - hx},${n.y + n.h} ${n.x + hx},${n.y + n.h} ${n.x},${n.y + n.h / 2}`;
1087
+ parts.push(`<polygon points="${pts}" class="n-mask"/><polygon points="${pts}" class="n-body" fill="${f}" stroke="${c}" stroke-linejoin="round"/>`); }
1088
+ else parts.push(`<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-mask"/>
1089
+ <rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="${rx}" class="n-body" fill="${f}" stroke="${c}" ${removed || ext || planned ? 'stroke-dasharray="6 4"' : ''} ${removed ? 'opacity=".6"' : ''}/>`);
1090
+ parts.push((() => { const I = (opts.icons || {})[n.icon], x = map ? n.x + 14 : n.x + (verb ? 14 : hook ? 17 : 9), y = map ? n.y + n.h / 2 - 14 : n.y + (priv ? 8 : 11), M = map ? 28 : 16; // map: one 28 px mark, left, centred
1091
+ return I && I.src ? `<rect x="${x - M * 3 / 28}" y="${y - M * 3 / 28}" width="${M * 34 / 28}" height="${M * 34 / 28}" rx="${M / 4}" class="n-logo${I.dark ? ' dark' : ''}"/><image href="${I.src}" x="${x}" y="${y}" width="${M}" height="${M}"/>`
1092
+ : I && I.g ? `<g transform="translate(${x} ${y}) scale(${M / 24})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 24 / M}" stroke-linecap="round" stroke-linejoin="round">${I.g}</g>`
1093
+ : `<g transform="translate(${x} ${y}) scale(${M / 16})" class="n-sigil" fill="none" stroke="${c}" stroke-width="${1.75 * 16 / M}" stroke-linecap="round" stroke-linejoin="round">${SIGIL[n.k]}</g>`; })());
1094
+ parts.push(`<text x="${tx0}" y="${ly}" text-anchor="${anchor}" class="n-label" data-o="${esc(n.id)}" data-t="nl" style="font-size:${lpx.toFixed(1)}px" ${removed ? 'text-decoration="line-through"' : ''}>${two ? lines(two, 15) : esc(label)}</text>`);
1095
+ if (map) { let tx = n.x + 12; mapTabs(n, c).forEach(t => { const tw = t.w; // each badge is its own tab, clickable on its own
1096
+ parts.push(`<g class="pub-tab"${t.badge != null ? ` data-badge="${t.badge}"` : ''}><rect x="${tx}" y="${n.y - 8}" width="${tw}" height="15" rx="4" fill="${t.fill}"/><text x="${tx + tw / 2}" y="${n.y + 3}" text-anchor="middle" class="tab-tx">${esc(t.text)}</text></g>`);
1097
+ said.push(t.badge != null ? badgesOf(n)[t.badge].text : t.text.toLowerCase()); tx += tw + 4; }); }
1098
+ if (map && n.edited) parts.push(`<g class="edited"><title>Edited</title><circle cx="${n.x + n.w - 10}" cy="${n.y + n.h - 10}" r="4" class="edited-dot"/></g>`);
1099
+ if (map && n.step) { const t = String(n.step), sw = Math.max(20, sansW(t, 9.5, .9) + 10);
1100
+ parts.push(`<g class="step"><title>Step ${esc(t)}</title><rect x="${n.x - 10}" y="${n.y - 10}" width="${sw}" height="20" rx="10" class="stp"/><text x="${n.x - 10 + sw / 2}" y="${n.y + 3.5}" text-anchor="middle" class="stp-tx">${esc(t)}</text></g>`);
1101
+ said.push(`(step) ${t}`); }
1102
+ if (n.tag) { // author name tag under the card: a bigger headshot + @handle (avatar inlined by render)
1103
+ const T = n.tag, cxp = T.x + 11, cyp = T.y + 11, cid = 'av-' + id + '-' + String(n.id).replace(/[^\w-]/g, '_');
1104
+ const ini = T.img ? '' : String(n.author || '?').split(/[\s_-]+/).map(w => w[0]).join('').slice(0, 2).toUpperCase();
1105
+ parts.push(`<g class="ntag"><title>${esc(T.name ? 'by ' + T.name + ' (@' + n.author + ')' : n.author ? '@' + n.author : 'author unknown')}</title><rect x="${T.x}" y="${T.y}" width="${T.w}" height="${T.h}" rx="${T.h / 2}" class="ntag-bg"/>`
1106
+ + (T.img ? `<clipPath id="${cid}"><circle cx="${cxp}" cy="${cyp}" r="13"/></clipPath><image href="${T.img}" x="${cxp - 13}" y="${cyp - 13}" width="26" height="26" clip-path="url(#${cid})" preserveAspectRatio="xMidYMid slice"/><circle cx="${cxp}" cy="${cyp}" r="13" class="ntag-ring" stroke="${c}"/>`
1107
+ : `<circle cx="${cxp}" cy="${cyp}" r="13" class="n-mask" stroke="${c}" stroke-width="1.4" stroke-dasharray="2 2"/><text x="${cxp}" y="${cyp + 3}" text-anchor="middle" class="ini-tx2" fill="${c}">${esc(ini)}</text> `)
1108
+ + `<text x="${T.x + 30}" y="${T.y + 14.5}" class="ntag-tx" data-t="nt" data-o="${esc(n.id)}">${esc(T.text)}</text></g>`);
1109
+ said.push(`(by${T.name ? ' ' + T.name : ''}) ${plain([ini, T.text].filter(Boolean).join(' '))}`);
1110
+ }
1111
+ const hint = stacked && opts.openHint !== false;
1112
+ if (hint) { const kx = map ? n.x + n.w - 44 : n.x + n.w - 40, ky = map ? n.y - 8 : n.y + n.h - 11;
1113
+ parts.push(`<g class="open-hint${map ? '' : ' depth-read'}"><title>Open the inner view (or double-click the card)</title><rect x="${kx}" y="${ky}" width="36" height="16" rx="8" class="n-mask" stroke="${c}"/><text x="${kx + 18}" y="${ky + 12}" text-anchor="middle" class="badge-tx" fill="${c}">⤢${kids ? ' ' + kids : ''}</text></g>`);
1114
+ said.push(kids ? `${kids} (cards inside, opens the inner view)` : '(opens the inner view)'); }
1115
+ const ch = CHG[String(n.change || '').toLowerCase()];
1116
+ if (ch) {
1117
+ const bw = ch[1].length * 6 + 12;
1118
+ parts.push(`<rect x="${n.x + n.w - bw - 8}" y="${n.y - 8}" width="${bw}" height="16" rx="8" fill="var(--um-mask)" stroke="var(--um-${ch[0]})"/>
1119
+ <text x="${n.x + n.w - bw / 2 - 8}" y="${n.y + 3}" text-anchor="middle" class="badge-tx" fill="var(--um-${ch[0]})">${ch[1]}</text>`);
1120
+ said.push(ch[1].toLowerCase());
1121
+ }
1122
+ if (sub) { parts.push(`<text x="${tx0}" y="${ly + 16 + (two ? 15 : 0)}" text-anchor="${anchor}" class="n-sub depth-read" data-o="${esc(n.id)}" data-t="ns">${sub2 ? lines(sub2, 12) : esc(sub)}</text>`);
1123
+ if (subShown) said.push(part(sub, subTxt)); }
1124
+ if (verbs) { // verb chips: the node's public surface, at a glance
1125
+ let vx = 0; const chips = [], maxW = third ? sw : n.w - 24; // on a third line they keep to the text's column, clear of the mark
1126
+ for (let i = 0; i < verbs.length; i++) {
1127
+ const wv = textW(verbs[i], 9.5) + 10, rest = verbs.length - i - 1, more = rest ? textW('+' + rest, 9.5) + 10 : 0;
1128
+ if (vx + wv + (rest ? more + 4 : 0) > maxW && i) { chips.push(['+' + (verbs.length - i), textW('+' + (verbs.length - i), 9.5) + 10, true]); break; }
1129
+ chips.push([verbs[i], wv]); vx += wv + 4;
1130
+ }
1131
+ const total = chips.reduce((a, c2) => a + c2[1], 0) + (chips.length - 1) * 4;
1132
+ let px = third ? tx0 : n.x + (n.w - total) / 2; const py = third ? ly + 23 + (two ? 15 : 0) : ly + 7;
1133
+ parts.push(`<g class="verbs depth-read">${chips.map(([t, wv], ci) => { const m = `<rect x="${px}" y="${py}" width="${wv}" height="14" rx="7" class="verb-chip" data-chip="${ci}" stroke="${c}"/><text x="${px + wv / 2}" y="${py + 10}" text-anchor="middle" class="verb-tx" data-chip="${ci}" data-o="${esc(n.id)}" data-t="nv" fill="${c}">${esc(t)}</text>`; px += wv + 4; return m; }).join('\n')}</g>`);
1134
+ chips.forEach(([t, , more], ci) => said.push(more ? `${t} (more: ${verbs.slice(ci).join(', ')})` : t));
1135
+ }
1136
+ if (map) {
1137
+ const ports = new Map();
1138
+ edges.forEach((e, i) => { const point = e.from === n.id ? e.p1 : e.to === n.id ? e.p2 : null; if (!point) return;
1139
+ const key = point.join(','); const port = ports.get(key) || { point, indices: [] }; port.indices.push(i); ports.set(key, port); });
1140
+ ports.forEach(({ point, indices }) => { parts.push(`<circle cx="${point[0]}" cy="${point[1]}" r="3.5" class="n-port" data-edges="${indices.join(',')}" stroke="${c}"/>`); });
1141
+ }
1142
+ const name = said.join(', ') + (n.edited ? ' (edited)' : '') + (stacked && !hint ? ' (opens inner view)' : '');
1143
+ s += `<g class="node${pub ? ' pub' : ''}${priv ? ' priv' : ''}${stacked ? ' has-child' : ''}${subject ? ' subject' : ''}${n.emphasis ? ' main-step' : ''}${planned ? ' planned' : ''}${tour && n.id in tour.at ? ' tour-stop' : ''}"${tour && n.id in tour.at ? ` style="animation-delay:${(tour.at[n.id] * TOUR - .32).toFixed(2)}s"` : ''} data-id="${esc(n.id)}" tabindex="${map ? 0 : -1}" role="${map ? 'button' : 'img'}" aria-label="${esc(name)}">
1144
+ ${parts.join('\n')}
1145
+ </g>`;
1146
+ });
1147
+ if (tour) s += `<g class="tour" aria-hidden="true"><circle r="5.5" class="tour-dot"><animateMotion path="${tour.d}" dur="${TOUR}s" repeatCount="indefinite"/></circle></g>`;
1148
+ if (map) { // each line's focus target: after the cards, so lines follow them in the Tab order (#43), under the labels; the mouse still hits the line
1149
+ s += '</g><g class="keys">';
1150
+ edges.forEach((e, i) => { const said = e.label ? `: ${e.label}${e.sublabel ? ` (${e.sublabel})` : ''}` : '';
1151
+ const name = `${e.step != null ? `Step ${e.step}: ` : ''}Line ${e.both ? `between ${e.s.label} and ${e.t.label}, both ways` : `from ${e.s.label} to ${e.t.label}`}${said}`;
1152
+ s += `<g class="um-key line-key${e.weight > 1 ? ' wt-' + e.weight : ''}" data-i="${i}" tabindex="0" role="button" aria-pressed="false" aria-label="${esc(name)}"><path d="${roundPath(e.pts)}" class="key-ring"/></g>`; });
1153
+ }
1154
+ s += '</g><g class="labels depth-read">';
1155
+ edges.forEach((e, i) => {
1156
+ if (!e.lbl) return;
1157
+ const L = e.lbl;
1158
+ if (map) {
1159
+ const st = L.step != null ? `<circle cx="${L.x + 11}" cy="${L.y + L.h / 2}" r="8" class="e-step"/><text x="${L.x + 11}" y="${L.y + L.h / 2 + 3.3}" text-anchor="middle" class="e-step-tx">${esc(L.step)}</text>` : '';
1160
+ const ax = L.step != null ? L.x + 23 : L.x + L.w / 2, an = L.step != null ? '' : 'text-anchor="middle"';
1161
+ const tx = L.text ? `<text x="${ax}" y="${L.y + 14}" ${an} class="e-label" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.text)}</text>` : '';
1162
+ const sb = L.sub ? `<text x="${ax}" y="${L.y + 26}" ${an} class="e-sub" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.sub)}</text>` : '';
1163
+ s += `<g class="e-lab${L.tag ? ' ctag' : ''}${e.wk ? ' w-' + e.wk : ''}" data-i="${i}"><rect x="${L.x}" y="${L.y}" width="${L.w}" height="${L.h}" rx="${Math.min(L.h / 2, 10)}" class="e-lbg"/>${st}${tx}${sb}</g>`;
1164
+ } else
1165
+ s += `<g class="e-lab" data-i="${i}"><rect x="${L.x}" y="${L.y}" width="${L.w}" height="${L.h}" rx="5" class="e-lbg"/><text x="${L.x + L.w / 2}" y="${L.y + 12.5}" text-anchor="middle" class="e-label" data-t="el" data-o="${esc(e.from)}>${esc(e.to)}">${esc(L.text)}</text></g>`;
1166
+ });
1167
+ return s + '</g></svg>';
1168
+ }
1169
+
1170
+ /* ================= camera: wheel/pinch zoom, drag pan, animated moves =================
1171
+ Ported in spirit from archify's viewer camera (MIT): reading depths by zoom level
1172
+ (map / read / full), fit + 100% presets, keyboard steps. Implemented on the SVG viewBox
1173
+ so text stays crisp at every zoom. */
1174
+ class Camera {
1175
+ constructor(el, svg, o = {}) {
1176
+ this.el = el; this.svg = svg; this.o = o; this.s = { cx: 0, cy: 0, k: 1 }; this.touched = false;
1177
+ this.ptrs = new Map(); this.anim = 0;
1178
+ el.addEventListener('wheel', e => this.onWheel(e), { passive: false });
1179
+ el.addEventListener('pointerdown', e => this.onDown(e));
1180
+ el.addEventListener('pointermove', e => this.onMove(e));
1181
+ ['pointerup', 'pointercancel', 'pointerleave'].forEach(t => el.addEventListener(t, e => this.onUp(e)));
1182
+ el.addEventListener('click', e => { if (this.dragged) { e.stopPropagation(); e.preventDefault(); this.dragged = false; } }, true);
1183
+ el.addEventListener('dblclick', e => { if (e.target.closest('.node')) return; const r = el.getBoundingClientRect(); this.zoomAt(2, e.clientX - r.left, e.clientY - r.top, true); });
1184
+ }
1185
+ setSvg(svg, bounds) { this.svg = svg; this.b = bounds; }
1186
+ size() { return [this.el.clientWidth || 800, this.el.clientHeight || 500]; }
1187
+ fitState(pad = 0.94, sets) { // fits the bounds inside the viewport minus the overlay insets (header pill, hint line); sets: these insets instead
1188
+ const [W, H] = this.size(), b = this.b, opts = [].concat(sets || (this.o.inset ? this.o.inset() : { t: 0, r: 0, b: 0, l: 0 }));
1189
+ const kOf = I => Math.min(Math.max(80, W - I.l - I.r) / b.w, Math.max(80, H - I.t - I.b) / b.h);
1190
+ const I = opts.reduce((a, o) => kOf(o) > kOf(a) + 1e-6 ? o : a); // several inset sets: keep the one that leaves the map largest
1191
+ const aw = Math.max(80, W - I.l - I.r), ah = Math.max(80, H - I.t - I.b);
1192
+ const k = Math.min(Math.min(aw / b.w, ah / b.h) * pad, this.o.maxFit || 1.25);
1193
+ const sx = I.l + aw / 2 - W / 2, sy = I.t + ah / 2 - H / 2;
1194
+ return { cx: b.x + b.w / 2 - sx / k, cy: b.y + b.h / 2 - sy / k, k };
1195
+ }
1196
+ limits() { const f = this.fitState(); return [Math.min(f.k * 0.5, 0.2), this.o.max || 4]; }
1197
+ clamp(s) {
1198
+ const [lo, hi] = this.limits(), b = this.b;
1199
+ const k = Math.min(hi, Math.max(lo, s.k)), [W, H] = this.size(), mx = W / (2 * k) * .5, my = H / (2 * k) * .5;
1200
+ return { k, cx: Math.min(b.x + b.w + mx, Math.max(b.x - mx, s.cx)), cy: Math.min(b.y + b.h + my, Math.max(b.y - my, s.cy)) };
1201
+ }
1202
+ apply() {
1203
+ const [W, H] = this.size(), { cx, cy, k } = this.s;
1204
+ const vx = cx - W / (2 * k), vy = cy - H / (2 * k);
1205
+ this.svg.setAttribute('viewBox', `${vx} ${vy} ${W / k} ${H / k}`);
1206
+ this.svg.setAttribute('width', W); this.svg.setAttribute('height', H);
1207
+ const g = 24 * k, G = g * 5;
1208
+ this.el.style.backgroundSize = `${g}px ${g}px, ${G}px ${G}px, ${G}px ${G}px`;
1209
+ const ox = -vx * k, oy = -vy * k;
1210
+ this.el.style.backgroundPosition = `${ox}px ${oy}px, ${ox}px ${oy}px, ${ox}px ${oy}px`;
1211
+ this.el.dataset.depth = k < (this.o.mapDepth || 0.5) ? 'map' : k < 1.6 ? 'read' : 'full';
1212
+ this.o.onChange && this.o.onChange(this);
1213
+ }
1214
+ stopAnimation() {
1215
+ cancelAnimationFrame(this.anim); this.anim = 0;
1216
+ // Resize/refit interrupts camera motion. Navigation awaits that motion, so cancellation
1217
+ // must settle its promise too; otherwise go() never releases its busy flag.
1218
+ const done = this.animationDone; this.animationDone = null; if (done) done();
1219
+ }
1220
+ set(s) { this.stopAnimation(); this.s = this.clamp({ ...this.s, ...s }); this.apply(); }
1221
+ animate(to, ms = 380) {
1222
+ this.stopAnimation();
1223
+ const from = { ...this.s }, dest = this.clamp({ ...this.s, ...to });
1224
+ if (reducedMotion()) { this.set(dest); return Promise.resolve(); }
1225
+ return new Promise(res => {
1226
+ this.animationDone = res;
1227
+ const t0 = performance.now(), ease = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
1228
+ const lk0 = Math.log(from.k), lk1 = Math.log(dest.k);
1229
+ const step = now => {
1230
+ const t = Math.min(1, (now - t0) / ms), e = ease(t);
1231
+ this.s = { cx: from.cx + (dest.cx - from.cx) * e, cy: from.cy + (dest.cy - from.cy) * e, k: Math.exp(lk0 + (lk1 - lk0) * e) };
1232
+ this.apply();
1233
+ if (t < 1) this.anim = requestAnimationFrame(step); else { this.anim = 0; this.animationDone = null; res(); }
1234
+ };
1235
+ this.anim = requestAnimationFrame(step);
1236
+ });
1237
+ }
1238
+ toWorld(sx, sy) { const [W, H] = this.size(), { cx, cy, k } = this.s; return [cx + (sx - W / 2) / k, cy + (sy - H / 2) / k]; }
1239
+ zoomAt(f, sx, sy, anim) {
1240
+ const [W, H] = this.size(); if (sx == null) { sx = W / 2; sy = H / 2; }
1241
+ const [wx, wy] = this.toWorld(sx, sy), [lo, hi] = this.limits();
1242
+ const k = Math.min(hi, Math.max(lo, this.s.k * f));
1243
+ const to = { k, cx: wx - (sx - W / 2) / k, cy: wy - (sy - H / 2) / k };
1244
+ this.touched = true;
1245
+ return anim ? this.animate(to, 220) : this.set(to);
1246
+ }
1247
+ fit(anim = true) { this.touched = false; return anim ? this.animate(this.fitState()) : this.set(this.fitState()); }
1248
+ actual(anim = true) { this.touched = true; return anim ? this.animate({ k: 1 }) : this.set({ k: 1 }); }
1249
+ pan(dx, dy, anim) { this.touched = true; const to = { cx: this.s.cx - dx / this.s.k, cy: this.s.cy - dy / this.s.k }; return anim ? this.animate(to, 160) : this.set(to); }
1250
+ reveal(r, anim = true) { // pan (no zoom change) until rect r is comfortably inside the viewport
1251
+ const [W, H] = this.size(), { cx, cy, k } = this.s, hw = W / (2 * k), hh = H / (2 * k), m = 60 / k;
1252
+ let nx = cx, ny = cy;
1253
+ if (r.x - m < cx - hw) nx = r.x - m + hw; else if (r.x + r.w + m > cx + hw) nx = r.x + r.w + m - hw;
1254
+ if (r.y - m < cy - hh) ny = r.y - m + hh; else if (r.y + r.h + m > cy + hh) ny = r.y + r.h + m - hh;
1255
+ if (r.w + 2 * m > 2 * hw) nx = r.x + r.w / 2;
1256
+ if (nx !== cx || ny !== cy) return anim ? this.animate({ cx: nx, cy: ny }, 300) : this.set({ cx: nx, cy: ny });
1257
+ return Promise.resolve();
1258
+ }
1259
+ zoomTo(r, fill = 0.9) { const [W, H] = this.size(); return { cx: r.x + r.w / 2, cy: r.y + r.h / 2, k: Math.min(W / r.w, H / r.h) * fill }; }
1260
+ onWheel(e) {
1261
+ const pinch = e.ctrlKey || e.metaKey;
1262
+ if (this.o.wheel === 'ctrl' && !pinch && !this.el.classList.contains('full')) return; // page scroll wins in documents
1263
+ e.preventDefault();
1264
+ const r = this.el.getBoundingClientRect(), sx = e.clientX - r.left, sy = e.clientY - r.top;
1265
+ const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY, dx = e.deltaMode === 1 ? e.deltaX * 16 : e.deltaX;
1266
+ const trackpadPan = !pinch && e.deltaMode === 0 && (Math.abs(dx) > 0 || (Math.abs(dy) < 40 && !Number.isInteger(dy)));
1267
+ if (trackpadPan) return this.pan(-dx, -dy);
1268
+ this.zoomAt(Math.exp(-dy * (pinch ? 0.012 : 0.0022)), sx, sy);
1269
+ }
1270
+ onDown(e) {
1271
+ if (e.button !== 0 && e.pointerType === 'mouse') return;
1272
+ if (e.target.closest('.ctl, .minimap, button, a, input, .um-edit') || (this.o.skip && this.o.skip(e))) return;
1273
+ this.ptrs.set(e.pointerId, [e.clientX, e.clientY]);
1274
+ this.start = { x: e.clientX, y: e.clientY, s: { ...this.s }, dist: this.pinchDist() };
1275
+ this.moved = 0; this.dragged = false;
1276
+ cancelAnimationFrame(this.anim);
1277
+ }
1278
+ pinchDist() { const p = [...this.ptrs.values()]; return p.length > 1 ? Math.hypot(p[0][0] - p[1][0], p[0][1] - p[1][1]) : 0; }
1279
+ onMove(e) {
1280
+ if (!this.ptrs.has(e.pointerId)) return;
1281
+ this.ptrs.set(e.pointerId, [e.clientX, e.clientY]);
1282
+ if (this.ptrs.size > 1) { // pinch
1283
+ const d = this.pinchDist(); if (!this.start.dist) { this.start.dist = d; this.start.s = { ...this.s }; return; }
1284
+ const p = [...this.ptrs.values()], r = this.el.getBoundingClientRect();
1285
+ this.s = { ...this.start.s }; this.zoomAt(d / this.start.dist, (p[0][0] + p[1][0]) / 2 - r.left, (p[0][1] + p[1][1]) / 2 - r.top);
1286
+ this.dragged = true; return;
1287
+ }
1288
+ const dx = e.clientX - this.start.x, dy = e.clientY - this.start.y;
1289
+ this.moved = Math.max(this.moved, Math.hypot(dx, dy));
1290
+ if (this.moved > 4) {
1291
+ if (!this.dragged) { this.dragged = true; this.el.classList.add('grabbing'); try { this.el.setPointerCapture(e.pointerId); } catch (_) {} }
1292
+ this.touched = true;
1293
+ this.set({ cx: this.start.s.cx - dx / this.s.k, cy: this.start.s.cy - dy / this.s.k });
1294
+ }
1295
+ }
1296
+ onUp(e) {
1297
+ if (!this.ptrs.has(e.pointerId)) return;
1298
+ this.ptrs.delete(e.pointerId); this.el.classList.remove('grabbing');
1299
+ if (this.ptrs.size === 1) { const [p] = [...this.ptrs.values()]; this.start = { x: p[0], y: p[1], s: { ...this.s }, dist: 0 }; }
1300
+ if (!this.dragged) this.dragged = false;
1301
+ }
1302
+ }
1303
+
1304
+ function routeAudit(geo, where, only) { // only: a set of edges; counts just the issues that involve one of them
1305
+ const sk = e => only && !only.has(e);
1306
+ const issues = [];
1307
+ // routes: no crossings, no shared runs, no label lying across another route
1308
+ const segs = geo.edges.map(e => e.pts.slice(0, -1).map((a, i) => [a, e.pts[i + 1]]));
1309
+ const name = e => `${e.s.label}→${e.t.label}`;
1310
+ const crossing = ([a, b], [c, d]) => {
1311
+ const av = a[0] === b[0], cv = c[0] === d[0];
1312
+ if (av === cv) { // parallel: overlapping collinear run = shared trunk
1313
+ if (av && Math.abs(a[0] - c[0]) < 1) return Math.min(Math.max(a[1], b[1]), Math.max(c[1], d[1])) - Math.max(Math.min(a[1], b[1]), Math.min(c[1], d[1])) > 1 ? 'share' : null;
1314
+ if (!av && Math.abs(a[1] - c[1]) < 1) return Math.min(Math.max(a[0], b[0]), Math.max(c[0], d[0])) - Math.max(Math.min(a[0], b[0]), Math.min(c[0], d[0])) > 1 ? 'share' : null;
1315
+ return null;
1316
+ }
1317
+ const [v, hz] = av ? [[a, b], [c, d]] : [[c, d], [a, b]];
1318
+ const x = v[0][0], y = hz[0][1];
1319
+ const inV = y > Math.min(v[0][1], v[1][1]) + .5 && y < Math.max(v[0][1], v[1][1]) - .5, inH = x > Math.min(hz[0][0], hz[1][0]) + .5 && x < Math.max(hz[0][0], hz[1][0]) - .5;
1320
+ return inV && inH ? 'cross' : null;
1321
+ };
1322
+ for (let i = 0; i < segs.length; i++) for (let j = i + 1; j < segs.length; j++) {
1323
+ if (geo.edges[i].fixed && geo.edges[j].fixed) continue; // one comb: its branches share the trunk by design
1324
+ if (sk(geo.edges[i]) && sk(geo.edges[j])) continue;
1325
+ let found = null;
1326
+ for (const s1 of segs[i]) { for (const s2 of segs[j]) { found = crossing(s1, s2); if (found) break; } if (found) break; }
1327
+ if (found === 'cross') issues.push(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} cross`);
1328
+ if (found === 'share') issues.push(`${where}: edges ${name(geo.edges[i])} and ${name(geo.edges[j])} share a line`);
1329
+ }
1330
+ geo.edges.forEach((e, i) => { if (!e.lbl) return; const L = { x: e.lbl.x + 1, y: e.lbl.y + 1, w: e.lbl.w - 2, h: e.lbl.h - 2 };
1331
+ geo.edges.forEach((o, j) => { if (j !== i && !(sk(e) && sk(o)) && segs[j].some(([a, b]) => Math.max(a[0], b[0]) > L.x && Math.min(a[0], b[0]) < L.x + L.w && Math.max(a[1], b[1]) > L.y && Math.min(a[1], b[1]) < L.y + L.h)) issues.push(`${where}: label "${e.lbl.text || e.lbl.step}" lies on edge ${name(o)}`); }); });
1332
+ const segHit = (r, i) => segs[i].some(([a, b]) => Math.max(a[0], b[0]) > r.x && Math.min(a[0], b[0]) < r.x + r.w && Math.max(a[1], b[1]) > r.y && Math.min(a[1], b[1]) < r.y + r.h);
1333
+ if (geo.map) {
1334
+ const ex = 12, drawn = new Set(geo.edges.map(e => e.from + '>' + e.to)); // A→B and B→A both drawn: a pair the view keeps apart
1335
+ geo.edges.forEach((e, i) => {
1336
+ // clearance: a route keeps 12+ px from every node it does not connect
1337
+ geo.nodes.forEach(n => { if (n.id === e.from || n.id === e.to) return;
1338
+ if (segHit({ x: n.x - ex + .5, y: n.y - ex + .5, w: n.w + 2 * ex - 1, h: n.h + 2 * ex - 1 }, i)) issues.push(`${where}: edge ${name(e)} passes within ${ex}px of node "${n.label}"`); });
1339
+ // no detours: a route stays under 2.5x the straight distance between its ends
1340
+ const len = e.pts.slice(1).reduce((a, p, k) => a + Math.abs(p[0] - e.pts[k][0]) + Math.abs(p[1] - e.pts[k][1]), 0);
1341
+ const d = Math.hypot(e.pts[e.pts.length - 1][0] - e.pts[0][0], e.pts[e.pts.length - 1][1] - e.pts[0][1]);
1342
+ if (len > 2.5 * Math.max(d, 100)) issues.push(`${where}: edge ${name(e)} takes a detour (${Math.round(len)}px for ${Math.round(d)}px)`);
1343
+ // reading order: every wire runs left to right, except one that closes a loop or answers its pair (keepPairs)
1344
+ if (!e.back && !e.both && !drawn.has(e.to + '>' + e.from) && e.t.x + e.t.w / 2 <= e.s.x + e.s.w / 2) issues.push(`${where}: edge ${name(e)} runs right-to-left`);
1345
+ });
1346
+ if (geo.hub) { // hub views: one visible line into each callee
1347
+ const into = {}; geo.edges.forEach(e => { (into[e.to] ||= []).push(e); });
1348
+ Object.entries(into).forEach(([id, es]) => { es = es.filter(e => !e.back); if (es.some(e => e.from === geo.hub) && es.length > 1) issues.push(`${where}: hub callee "${(geo.byId[id] || {}).label || id}" has ${es.length} incoming lines`); });
1349
+ }
1350
+ }
1351
+ // author name tags hang under their card: no route may run through one
1352
+ (geo.nodes || []).forEach(n => { if (!n.tag) return; const r = { x: n.tag.x + 1, y: n.tag.y + 1, w: n.tag.w - 2, h: n.tag.h - 2 };
1353
+ geo.edges.forEach((o, j) => { if (segHit(r, j)) issues.push(`${where}: name tag "${n.tag.text}" lies on edge ${name(o)}`); }); });
1354
+ // lanes: every part sits inside its own lane, lanes never overlap, no route crosses a lane header
1355
+ const lanes = geo.lanes || [];
1356
+ lanes.forEach((l, i) => {
1357
+ lanes.slice(i + 1).forEach(m => { if (hit(l, m, -1)) issues.push(`${where}: lanes "${l.title}" and "${m.title}" overlap`); });
1358
+ (geo.nodes || []).forEach(n => {
1359
+ if (n.lane === i && (n.x < l.x || n.x + n.w > l.x + l.w || n.y < l.y || n.y + n.h + (n.tag ? 14 : 0) > l.y + l.h)) issues.push(`${where}: lane "${l.title}": node "${n.label}" sticks out`);
1360
+ if (n.lane != null && n.lane !== i && hit(n, l, -1)) issues.push(`${where}: lane "${l.title}": node "${n.label}" of another lane sits in it`);
1361
+ if (n.lane == null && hit(n, l, -1)) issues.push(`${where}: lane "${l.title}": node "${n.label}" belongs to no lane but sits in it`);
1362
+ });
1363
+ });
1364
+ (geo.blocks || []).forEach(b => geo.edges.forEach((o, j) => { if (segHit({ x: b.x + 1, y: b.y + 1, w: b.w - 2, h: b.h - 2 }, j)) issues.push(`${where}: lane "${b.title || ''}": header crossed by edge ${name(o)}`); }));
1365
+ const families = geo.families || [], inside = (a,b) => b.x > a.x && b.y > a.y && b.x+b.w < a.x+a.w && b.y+b.h < a.y+a.h;
1366
+ families.forEach(f => {
1367
+ const parent = families.find(p => p.id === f.parent);
1368
+ if (f.parent && (!parent || !inside(parent,f))) issues.push(`${where}: family "${f.label}" sticks out of its parent`);
1369
+ (f.nodeIds || []).forEach(id => { const n = geo.byId[id]; if (n && !inside(f,n)) issues.push(`${where}: family "${f.label}" does not contain "${n.label}"`); });
1370
+ families.filter(g => g !== f && g.parent === f.parent && g.id > f.id).forEach(g => { if (hit(f,g,-1)) issues.push(`${where}: families "${f.label}" and "${g.label}" overlap`); });
1371
+ geo.edges.forEach((e,i) => { if (segHit({x:f.x+8,y:f.y+3,w:Math.min(f.w-16,textW(f.label || f.id,11)+16),h:22},i)) issues.push(`${where}: family "${f.label}": header crossed by edge ${name(e)}`); });
1372
+ geo.edges.forEach((e,i) => { if (segs[i].some(([a,b]) => { const v = a[0] === b[0], c = v ? a[0] : a[1], p = v ? [a[1], b[1]] : [a[0], b[0]];
1373
+ const lo = Math.max(Math.min(...p), v ? f.y : f.x), hi = Math.min(Math.max(...p), v ? f.y + f.h : f.x + f.w);
1374
+ return hi - lo > 40 && (v ? [f.x, f.x + f.w] : [f.y, f.y + f.h]).some(u => Math.abs(c - u) < 8); })) issues.push(`${where}: edge ${name(e)} runs along the border of family "${f.label}"`); });
1375
+ geo.edges.forEach(e => { const L = e.lbl; if (!L) return; const xin = L.x + L.w > f.x && L.x < f.x + f.w, yin = L.y + L.h > f.y && L.y < f.y + f.h;
1376
+ if ((xin && [f.y, f.y + f.h].some(u => L.y < u && L.y + L.h > u)) || (yin && [f.x, f.x + f.w].some(u => L.x < u && L.x + L.w > u))) issues.push(`${where}: label "${L.text || L.step}" sits on the border of family "${f.label}"`); });
1377
+ });
1378
+ return issues;
1379
+ }
1380
+
1381
+ /* label-overlap audit used by `visual.py check` (world coordinates, real font metrics) */
1382
+ function auditSvg(svg, geo, where) {
1383
+ const issues = [], R = n => ({ x: n.x, y: n.y, w: n.w, h: n.h });
1384
+ const T = [...svg.querySelectorAll('text[data-t]')].map(t => { let b; try { b = t.getBBox(); } catch (_) { return null; } return { t: t.dataset.t, o: t.dataset.o, txt: t.textContent, b: { x: b.x, y: b.y, w: b.width, h: b.height } }; }).filter(x => x && x.b.w);
1385
+ const nodes = geo.nodes;
1386
+ for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) if (hit(R(nodes[i]), R(nodes[j]), -1)) issues.push(`${where}: nodes "${nodes[i].label}" and "${nodes[j].label}" overlap`);
1387
+ T.forEach(a => {
1388
+ if (a.t === 'lt' || a.t === 'ft') {
1389
+ nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: lane "${a.txt}": header covers node "${n.label}"`); });
1390
+ if (a.t === 'ft') { const f = (geo.families || []).find(f => f.id === a.o); if (f && (a.b.x < f.x || a.b.x+a.b.w > f.x+f.w)) issues.push(`${where}: family title "${a.txt}" overflows its box`); }
1391
+ } else if (a.t === 'el') {
1392
+ nodes.forEach(n => { if (hit(a.b, R(n), -1)) issues.push(`${where}: edge label "${a.txt}" covers node "${n.label}"`); });
1393
+ } else {
1394
+ const own = geo.byId[a.o];
1395
+ if (own && (a.b.x < own.x + 1 || a.b.x + a.b.w > own.x + own.w - 1)) issues.push(`${where}: text "${a.txt}" overflows node "${own.label}"`);
1396
+ if (own && geo.map && (a.t === 'nl' || a.t === 'ns') && hit(a.b, { x: own.x + 11, y: own.y + own.h / 2 - 17, w: 34, h: 34 }, 2)) issues.push(`${where}: text "${a.txt}" touches the mark on node "${own.label}"`);
1397
+ nodes.forEach(n => { if (n !== own && hit(a.b, R(n), -1)) issues.push(`${where}: text "${a.txt}" overlaps node "${n.label}"`); });
1398
+ }
1399
+ });
1400
+ issues.push(...routeAudit(geo, where));
1401
+ if (geo.map) geo.edges.forEach(e => { const L = e.lbl; if (!L || !e.label || !e.pts) return; // a label must leave some of its own line visible
1402
+ const c = [L.x + L.w / 2, L.y + L.h / 2], P = e.pts; let s = 0, at = null, half = 0, tot = 0;
1403
+ for (let i = 0; i + 1 < P.length; i++) { const a = P[i], b = P[i + 1], len = Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
1404
+ if (at == null) { const onH = a[1] === b[1] && Math.abs(c[1] - a[1]) < 2 && c[0] >= Math.min(a[0], b[0]) - 1 && c[0] <= Math.max(a[0], b[0]) + 1;
1405
+ const onV = a[0] === b[0] && Math.abs(c[0] - a[0]) < 2 && c[1] >= Math.min(a[1], b[1]) - 1 && c[1] <= Math.max(a[1], b[1]) + 1;
1406
+ if (onH || onV) { at = tot + Math.abs(c[0] - a[0]) + Math.abs(c[1] - a[1]); half = onH ? L.w / 2 : L.h / 2; } }
1407
+ tot += len; }
1408
+ if (at == null) return; const room = Math.min(at - half, tot - at - half);
1409
+ if (room < 16) issues.push(`${where}: label "${e.label}" leaves its line ${Math.max(0, Math.round(room))} px (${e.s.label}→${e.t.label})`); });
1410
+ const els = T.filter(a => a.t === 'el');
1411
+ for (let i = 0; i < els.length; i++) for (let j = i + 1; j < els.length; j++) if (hit(els[i].b, els[j].b, -1)) issues.push(`${where}: edge labels "${els[i].txt}" and "${els[j].txt}" overlap`);
1412
+ return issues;
1413
+ }
1414
+
1415
+ /* Swimlanes for run-order views. A view may set `lanes` ([{title, nodeIds}] or false); otherwise a
1416
+ sequence view is read step by step and each part is filed under the first workflow phase its own
1417
+ label (then the step's label, then its sub line) names. Phases only move forward, so a lane is a
1418
+ contiguous stretch of the run order. Fewer than three lanes, or a lane layout that would need a
1419
+ crossing, falls back to the free graph. */
1420
+ const PHASES = [
1421
+ ['Ask', /\b(grill|ask|interview|question|intake|taste|intent|clarif)/i],
1422
+ ['Plan', /\b(plan|spec\b|scope|claim|triage|ticket|backlog)/i],
1423
+ ['Build', /\b(build|implement|launch|scaffold|develop)/i],
1424
+ ['Check', /\b(check|review|detect|shots?\b|screenshot|audit|test|qa\b|verif|critique|lint|scan|grade)/i],
1425
+ ['Fix', /\b(fix|polish|refine|repair|patch|rework)/i],
1426
+ ['Finish', /\b(pr\b|pull request|merge|ship|publish|release|deploy|human|ledger|manifest|report)/i],
1427
+ ];
1428
+ function phaseOf(texts) {
1429
+ for (const t of texts) {
1430
+ let best = null;
1431
+ PHASES.forEach((p, i) => { const m = p[1].exec(String(t || '')); if (m && (!best || m.index < best[1])) best = [i, m.index]; });
1432
+ if (best) return best[0];
1433
+ }
1434
+ return null;
1435
+ }
1436
+
1437
+ /* ================= map: one model, many views =================
1438
+ Viewer UX ported in spirit from archify (MIT): camera with reading depths, minimap, node focus,
1439
+ node finder, export. Drill-down views, swimlanes, breadcrumbs and deep links are super-board's /visual. */
1440
+
1441
+ // every model edge gets an id for events: its own, else "<from>-><to>#<n>" (n: its place among edges with the same ends)
1442
+ function edgeIds(list) {
1443
+ const seen = {};
1444
+ (list || []).forEach(e => { if (!e || typeof e !== 'object') return; const k = e.from + '->' + e.to, n = seen[k] = (seen[k] ?? -1) + 1; e._id = e.id != null ? String(e.id) : `${k}#${n}`; });
1445
+ }
1446
+
1447
+ /* The model's views and everything that lays one out. Works on its own copy of the model, needs no DOM. */
1448
+ function mapContext(model, opts = {}) {
1449
+ const D = structuredClone(model || {});
1450
+ const M = opts.measure || null, withM = f => (...a) => { const prev = measureText; measureText = M; try { return f(...a); } finally { measureText = prev; } };
1451
+ edgeIds(D.edges); (D.views || []).forEach(v => { if (Array.isArray(v.edges)) edgeIds(v.edges); });
1452
+ const views = (D.views || []).filter(v => v && v.id);
1453
+ const nodes = [...(D.nodes || []), ...views.flatMap(v => v.nodes || [])];
1454
+ const N = Object.fromEntries(nodes.map(n => [n.id, n]));
1455
+ const V = Object.fromEntries(views.map(v => [v.id, v]));
1456
+ const viewOfParent = p => V[p] ? p : (views.find(v => v.id === p || v.node === p) || {}).id; // parent may name a node
1457
+ views.forEach(v => { if (v.parent) v.parent = viewOfParent(v.parent) || null; });
1458
+ const rootId = (D.root && V[D.root]) ? D.root : (views.find(v => !v.parent) || views[0] || {}).id;
1459
+ const nodeIn = (nid, vid) => ({ ...(N[nid] || {}), ...((V[vid] || {}).nodeOverrides || {})[nid] });
1460
+ const childOf = (nid, vid) => { const n = nodeIn(nid, vid); const o = n.view || n.opensView; return o && V[o] ? o : V[nid] ? nid : (views.find(v => v.node === nid) || {}).id; };
1461
+ const opensOf = (nid, vid) => { const n = nodeIn(nid, vid), o = childOf(nid, vid) || n.view || n.opensView; return o != null && String(o) !== vid ? String(o) : null; }; // loaded or not (#47)
1462
+ const subjectOf = v => v.node || (N[v.id] ? v.id : null);
1463
+ const viewsWith = nid => views.filter(v => (v.nodeIds || []).includes(nid));
1464
+ const kidsOf = id => views.filter(x => (x.parent || null) === id);
1465
+ const chain = vid => { const out = []; let c = V[vid]; const seen = new Set(); while (c && !seen.has(c.id)) { seen.add(c.id); out.unshift(c.id); c = V[c.parent]; } return out; };
1466
+ const viewEdges = v => { // an edge's ends are cards of the view or its containers
1467
+ const ids = new Set([...(v.nodeIds || []), ...(v.containers || []).map(c => c && c.id).filter(Boolean)]);
1468
+ const list = Array.isArray(v.edges) ? v.edges : (D.edges || []).filter(e => ids.has(e.from) && ids.has(e.to));
1469
+ return list.map(e => typeof e === 'string' ? (D.edges || []).find(x => x.id === e) : e).filter(e => e && ids.has(e.from) && ids.has(e.to));
1470
+ };
1471
+ /* ---------- reading order: every view runs left → right ----------
1472
+ The trigger sits at the far left; each next step or callee goes one column to the right, branches
1473
+ stack inside a column, state (board, config, repo) sits at the end of its column. A view whose
1474
+ focus node starts three or more things is a hub: its callees go into phase lanes, and one comb of
1475
+ lines feeds them — a single trunk, one split per lane, one short branch per callee with its label
1476
+ just left of the callee. A line that closes a loop is the only one allowed to run right to left. */
1477
+ const HUB_PHASES = [
1478
+ ['Prepare', /\b(grill|ask|interview|plan|spec|scope|shape|sharpen|ladder|docs?\b|reads?\b|design|picks?|init|triage|on-ramp|vocabular|idea|requires|climbs)/i],
1479
+ ['Build', /\b(build|implement|routes?|test|tdd|writes?|fix|diagnos|resolv|fetch|runs?\b|prototype|split|code|finds?|drafts?|bridges?|wires?)/i],
1480
+ ['Check', /\b(review|verif|proves?|audit|critique|judge|coverage|polish|humaniz|hunts?|checks?|grades?)/i],
1481
+ ['Hand off', /\b(pr\b|moves? card|files?\b|board|merges?|gate|closes?|bounces?|ships?|posts?|parks?|ticks|updates|backlog)/i],
1482
+ ];
1483
+ const latestPhase = texts => { for (const t of texts) { let best = null; HUB_PHASES.forEach((p, i) => { if (p[1].test(String(t || ''))) best = i; }); if (best != null) return best; } return null; };
1484
+ const isState = id => (N[id] || {}).kind === 'board';
1485
+ function loopsOf(v) { // edges that close a cycle, found depth-first in run order from the roots
1486
+ const ids = (v.nodeIds || []).filter(i => N[i]), edges = viewEdges(v), back = new Set(), st = {};
1487
+ edges.forEach((e, k) => { if (e.loop) back.add(k); }); // an edge marked `loop: true` is the view's loop back
1488
+ const dfs = u => { st[u] = 1; edges.forEach((e, k) => { if (e.from !== u || e.from === e.to || e.loop) return; if (st[e.to] === 1) back.add(k); else if (!st[e.to]) dfs(e.to); }); st[u] = 2; };
1489
+ [...ids.filter(i => !edges.some(e => e.to === i && e.from !== i && !e.loop)), ...ids].forEach(i => { if (!st[i]) dfs(i); });
1490
+ if (v.render === 'sequence') { // run order: a step back to a part an earlier step already reached loops back (state may be written again later)
1491
+ const first = {}; edges.forEach((e, k) => { if (first[e.from] == null && !edges.some((x, j) => j < k && x.to === e.from)) first[e.from] = -1; if (first[e.to] == null) first[e.to] = k; });
1492
+ edges.forEach((e, k) => { if (first[e.to] < k && !isState(e.to) && e.from !== e.to) back.add(k); });
1493
+ }
1494
+ return { edges, back, keys: new Set(edges.filter((e, k) => back.has(k)).map(e => e.from + '>' + e.to)) };
1495
+ }
1496
+ function deriveLanes(v, hub) { // sequence views: lanes named by workflow phase, in run order
1497
+ if (v.lanes === false) return null;
1498
+ const ids = (v.nodeIds || []).filter(i => N[i]), edges = viewEdges(v);
1499
+ if (Array.isArray(v.lanes)) {
1500
+ const lanes = v.lanes.map(l => ({ title: l.title || l.label || '', ids: (l.nodeIds || []).filter(i => ids.includes(i) && i !== hub) })).filter(l => l.ids.length);
1501
+ return lanes.length ? { lanes, explicit: true } : null;
1502
+ }
1503
+ if (v.render !== 'sequence' || edges.length < 4) return null;
1504
+ const lanes = [], laneOf = {}, pending = [];
1505
+ let curP = -1, named = 0, filed = 0;
1506
+ const file = (id, p) => {
1507
+ filed++; if (p != null) named++;
1508
+ if (p == null) { if (!lanes.length) { pending.push(id); return; } lanes[lanes.length - 1].ids.push(id); laneOf[id] = lanes.length - 1; return; }
1509
+ if (p > curP) { curP = p; lanes.push({ title: PHASES[p][0], ids: [] }); }
1510
+ lanes[lanes.length - 1].ids.push(id); laneOf[id] = lanes.length - 1;
1511
+ };
1512
+ edges.forEach(e => [e.from, e.to].forEach(id => {
1513
+ if (id === hub || laneOf[id] != null || pending.includes(id)) return;
1514
+ const n = N[id];
1515
+ file(id, phaseOf([n.label, id === e.to ? e.label : '', n.sublabel || n.sub]));
1516
+ }));
1517
+ if (lanes.length < 3 || lanes.length > 6 || named < filed * 0.6) return null; // lanes only when most parts name their phase
1518
+ lanes[0].ids.unshift(...pending);
1519
+ ids.forEach(i => { if (i !== hub && laneOf[i] == null && !pending.includes(i)) lanes[lanes.length - 1].ids.push(i); });
1520
+ return { lanes };
1521
+ }
1522
+ function hubPlan(v, hub, lp) {
1523
+ const ids = (v.nodeIds || []).filter(i => N[i] && i !== hub), seq = v.render === 'sequence';
1524
+ const fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
1525
+ const callees = []; lp.edges.forEach(e => { if (e.from === hub && e.to !== hub && !callees.includes(e.to)) callees.push(e.to); });
1526
+ const laneOf = {}; let titles = [], boxed = true;
1527
+ const dl = deriveLanes(v, hub);
1528
+ if (dl) { titles = dl.lanes.map(l => l.title); dl.lanes.forEach((l, i) => l.ids.forEach(id => { laneOf[id] = i; })); }
1529
+ else if (seq) { // run order, three to a column
1530
+ boxed = false;
1531
+ const stepOf = id => lp.edges.findIndex(e => e.from === hub && e.to === id) + 1;
1532
+ callees.forEach((id, k) => { laneOf[id] = Math.floor(k / 3); });
1533
+ for (let i = 0; i * 3 < callees.length; i++) { const grp = callees.slice(i * 3, i * 3 + 3).map(stepOf); titles.push(grp.length > 1 ? `Steps ${grp[0]}–${grp[grp.length - 1]}` : `Step ${grp[0]}`); }
1534
+ } else { // by the kind of work the line names; unnamed ones follow the previous callee
1535
+ let prev = 1;
1536
+ lp.edges.forEach(e => { if (e.from !== hub || laneOf[e.to] != null) return; const p = isState(e.to) ? 3 : latestPhase([condTag(e.label) ? 'routes' : e.label, N[e.to].label]); laneOf[e.to] = p == null ? prev : p; prev = laneOf[e.to]; });
1537
+ titles = HUB_PHASES.map(p => p[0]);
1538
+ }
1539
+ const raise = () => { for (let it = 0; it <= ids.length; it++) fwd.forEach(e => { if (e.from === hub || laneOf[e.from] == null) return; if (laneOf[e.to] == null || laneOf[e.to] < laneOf[e.from]) laneOf[e.to] = laneOf[e.from]; }); };
1540
+ raise();
1541
+ ids.forEach(i => { if (laneOf[i] == null) { const t = fwd.filter(e => e.from === i && laneOf[e.to] != null).map(e => laneOf[e.to]); laneOf[i] = t.length ? Math.min(...t) : 0; } });
1542
+ if (!dl && !seq) { const last = Math.max(...ids.map(i => laneOf[i])); ids.forEach(i => { if (isState(i)) laneOf[i] = Math.max(laneOf[i], last); }); raise(); }
1543
+ const used = [...new Set(ids.map(i => laneOf[i]))].sort((a, b) => a - b);
1544
+ // branches: hub → callee is drawn unless the callee already has another line coming in
1545
+ const branch = new Set(callees.filter(c => !isState(c) && !lp.edges.some(e => e.to === c && e.from !== hub)));
1546
+ const sub = {}; ids.forEach(i => { sub[i] = 0; });
1547
+ for (let it = 0; it <= ids.length; it++) fwd.forEach(e => { if (e.from === hub || e.to === hub) return; if (laneOf[e.from] === laneOf[e.to] && sub[e.to] < sub[e.from] + 1) sub[e.to] = Math.min(3, sub[e.from] + 1); });
1548
+ const seen = id => { const k = lp.edges.findIndex(e => e.to === id); return k < 0 ? 999 + lp.edges.findIndex(e => e.from === id) : k; };
1549
+ const lanes = used.map(u => {
1550
+ const mine = ids.filter(i => laneOf[i] === u), nCols = Math.max(...mine.map(i => sub[i])) + 1;
1551
+ const cols = Array.from({ length: nCols }, (_, j) => mine.filter(i => sub[i] === j)
1552
+ .sort((a, b) => (branch.has(b) - branch.has(a)) || (isState(a) - isState(b)) || (seen(a) - seen(b))));
1553
+ return { title: titles[u] || `Lane ${u + 1}`, ids: mine, cols };
1554
+ });
1555
+ return { lanes, branch, boxed: boxed && lanes.length > 1 };
1556
+ }
1557
+ /* a hub plan made stable against the one before it: a card that was there keeps its lane (by title), column and
1558
+ order; a new card takes its place in the fresh plan, after the cards already in that column */
1559
+ function keepHub(P, Q) {
1560
+ const at = {}; Q.lanes.forEach(L => L.cols.forEach((col, j) => col.forEach((id, r) => { at[id] = { t: L.title, j, r }; })));
1561
+ const titles = Q.lanes.map(L => L.title);
1562
+ P.lanes.forEach((L, i) => { if (titles.includes(L.title)) return; const before = P.lanes.slice(0, i).map(x => x.title).filter(t => titles.includes(t)).pop();
1563
+ titles.splice(before == null ? 0 : titles.indexOf(before) + 1, 0, L.title); });
1564
+ const lanes = titles.map(title => ({ title, ids: [], cols: [] })), lane = Object.fromEntries(lanes.map(L => [L.title, L]));
1565
+ const put = (id, t, j, r) => { const L = lane[t]; L.ids.push(id); (L.cols[j] ||= []).push([r, id]); };
1566
+ P.lanes.forEach(L => L.cols.forEach((col, j) => col.forEach((id, r) => { const a = at[id]; if (a) put(id, a.t, a.j, a.r); else put(id, L.title, j, 1e6 + r); })));
1567
+ lanes.forEach(L => { L.cols = L.cols.filter(c => c && c.length).map(c => c.sort((a, b) => a[0] - b[0]).map(x => x[1])); });
1568
+ const out = lanes.filter(L => L.ids.length);
1569
+ return { ...P, lanes: out, boxed: Q.boxed && out.length > 1 };
1570
+ }
1571
+ const planCache = {};
1572
+ function planOf(vid) {
1573
+ if (vid in planCache) return planCache[vid];
1574
+ const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), lp = loopsOf(v);
1575
+ const outs = id => lp.edges.filter(e => e.from === id && e.to !== id).length;
1576
+ let hub = subjectOf(v); if (hub && !ids.includes(hub)) hub = null;
1577
+ const sources = ids.filter(i => outs(i) && !lp.edges.some(e => e.to === i && e.from !== i));
1578
+ if (!hub && lp.edges.length && outs(lp.edges[0].from) >= 3 && sources.length === 1 && /^(public|lane|verb|external)$/.test(N[lp.edges[0].from]?.kind || '')) hub = lp.edges[0].from; // one trigger only; an overview with several stays a flow
1579
+ if (hub && outs(hub) < 3) hub = null;
1580
+ const steps = {}; if (v.render === 'sequence') lp.edges.forEach((e, i) => { (steps[e.to] ||= []).push(i + 1); });
1581
+ const flow = { flow: true, backKeys: lp.keys, steps, hub: null };
1582
+ if (!hub || v.containers || v.mainSteps || v.render === 'process') return (planCache[vid] = flow);
1583
+ planCache[vid] = { ...hubPlan(v, hub, lp), hub, backKeys: lp.keys, steps };
1584
+ const was = opts.prev && opts.prev[vid]; // after an update: cards keep the lane, column and order they had
1585
+ if (was && was.hub === hub && was.lanes) return (planCache[vid] = keepHub(planCache[vid], was));
1586
+ // a hub plan that cannot route cleanly gives way to the plain left-to-right flow, if that does better
1587
+ const bad = g => routeAudit(g, '').length + ((g.stats.cross || 0) + (g.stats.fallback || 0)) * 5;
1588
+ let hb = bad(buildGeo(vid, undefined, undefined, true));
1589
+ if (hb) { // try other orders inside each lane column before giving up on the hub layout
1590
+ const P0 = planCache[vid]; let evals = 0;
1591
+ for (let pass = 0; pass < 2 && hb && evals < 80; pass++) P0.lanes.forEach(L => L.cols.forEach(col => {
1592
+ for (let a = 0; a < col.length && hb && evals < 80; a++) for (let b = a + 1; b < col.length && hb && evals < 80; b++) {
1593
+ if (P0.branch.has(col[a]) !== P0.branch.has(col[b])) continue;
1594
+ [col[a], col[b]] = [col[b], col[a]]; evals++;
1595
+ const nb = bad(buildGeo(vid, undefined, undefined, true)); if (nb < hb) hb = nb; else [col[a], col[b]] = [col[b], col[a]];
1596
+ }
1597
+ }));
1598
+ }
1599
+ if (hb) { const keep = planCache[vid]; planCache[vid] = flow; const fb = bad(buildGeo(vid, undefined, undefined, true)); if (fb >= hb) planCache[vid] = keep; }
1600
+ return planCache[vid];
1601
+ }
1602
+ /* a layout may place only some cards: they keep their slots, the rest take their auto-layout slot or the next free
1603
+ row below it (a complete layout is used as it is) */
1604
+ function fillLayout(vid, L) {
1605
+ const ids = (V[vid].nodeIds || []).filter(i => N[i]);
1606
+ if (ids.every(i => Array.isArray(L[i]))) return L;
1607
+ const auto = autoPos(vid), out = {};
1608
+ ids.forEach(i => { if (Array.isArray(L[i])) out[i] = L[i]; });
1609
+ ids.forEach(i => { if (out[i]) return; let [c, r] = auto[i] || [0, 0]; while (Object.values(out).some(([c2, r2]) => c2 === c && Math.abs(r2 - r) < 1)) r++; out[i] = [c, r]; }); // auto rows may be half rows: a row apart
1610
+ return out;
1611
+ }
1612
+ /* plain views: longest-path columns from the roots, state at the bottom of its column */
1613
+ /* views with containers: each container is a block in columns of its own, its cards in call order inside it; blocks (and
1614
+ cards in no container) run left to right by the lines between them, and those with no line between them stack in the
1615
+ same columns. A nested container is a block inside its parent's. */
1616
+ function blockPos(vid) {
1617
+ const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), lp = loopsOf(v), boxes = (v.containers || []).filter(c => c && c.id);
1618
+ const fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to), byBox = Object.fromEntries(boxes.map(b => [b.id, b]));
1619
+ const home = Object.fromEntries(ids.map(i => [i, (boxes.filter(b => (b.nodeIds || []).includes(i)).pop() || {}).id ?? null])); // the box a card is drawn in
1620
+ const up = x => ids.includes(x) ? home[x] : byBox[x] && byBox[byBox[x].parent] ? byBox[x].parent : null; // the box a card or box sits in
1621
+ const itemIn = (x, g) => { for (let k = 0; x != null && k < 64; k++, x = up(x)) if (up(x) === g) return x; }; // what in group g holds x
1622
+ const holds = (x, y) => { for (let k = 0; y != null && k < 64; k++, y = up(y)) if (y === x) return true; return false; };
1623
+ const first = x => { const k = lp.edges.findIndex(e => holds(x, e.from) || holds(x, e.to)); return k < 0 ? Infinity : k; };
1624
+ const sink = x => ids.includes(x) && isState(x) && !fwd.some(e => e.from === x); // state that leads nowhere goes last
1625
+ const lined = x => lp.edges.some(e => e.from !== e.to && (holds(x, e.from) || holds(x, e.to)));
1626
+ const block = g => { // a group's cards at [col, row] from its top left, and its size in slots
1627
+ const all = [...ids.filter(i => home[i] === g), ...boxes.filter(b => up(b.id) === g).map(b => b.id)];
1628
+ // cards with no line go in rows of their own under the group's columns, as under a plain view (#46): at the bottom of
1629
+ // their box, or under the view; they stacked in the first column with the sources
1630
+ const loose = all.some(lined) ? all.filter(x => ids.includes(x) && !lined(x)) : [], items = all.filter(x => !loose.includes(x)), size = {}, out = {};
1631
+ items.forEach(x => { size[x] = ids.includes(x) ? { w: 1, h: 1, pos: { [x]: [0, 0] } } : block(x); out[x] = []; });
1632
+ fwd.forEach(e => { const a = itemIn(e.from, g), b = itemIn(e.to, g); if (a !== undefined && b !== undefined && a !== b && !out[a].includes(b)) out[a].push(b); });
1633
+ const st = {}, dag = [], into = new Set(Object.values(out).flat()); // lines that close a loop do not order the blocks
1634
+ const dfs = u => { st[u] = 1; out[u].forEach(w => { if (st[w] === 1) return; dag.push([u, w]); if (!st[w]) dfs(w); }); st[u] = 2; };
1635
+ [...items.filter(x => !into.has(x)), ...items].forEach(x => { if (!st[x]) dfs(x); });
1636
+ const rank = Object.fromEntries(items.map(x => [x, 0]));
1637
+ for (let it = 0; it < items.length; it++) dag.forEach(([a, b]) => { if (rank[b] < rank[a] + 1) rank[b] = rank[a] + 1; });
1638
+ const cols = [], pos = {}; let c0 = 0, h = 0;
1639
+ items.forEach(x => (cols[rank[x]] ||= []).push(x));
1640
+ const main = cols.map(l => (l || []).filter(x => !sink(x)).sort((a, b) => first(a) - first(b)));
1641
+ untangle(main, dag); // in call order, unless another order crosses less (#53)
1642
+ const inside = (x, id) => ids.includes(x) ? 0 : (size[x].pos[id] || [0, null])[1]; // the row of a box's card that a line meets
1643
+ const row = rowsFor(main, fwd.filter(level).map(e => { const a = itemIn(e.from, g), b = itemIn(e.to, g); return a !== undefined && b !== undefined && a !== b ? [a, b, inside(a, e.from), inside(b, e.to)] : null; }).filter(Boolean),
1644
+ x => size[x].h); // each block level with the cards its lines come from and go to
1645
+ main.forEach((l, k) => { cols[k] = [...l, ...(cols[k] || []).filter(sink).sort((a, b) => first(a) - first(b))]; });
1646
+ cols.forEach(list => { let r0 = 0, w = 0;
1647
+ list.forEach(x => { const sz = size[x]; if (row[x] != null) r0 = Math.max(r0, row[x]);
1648
+ Object.entries(sz.pos).forEach(([id, [c, r]]) => { pos[id] = [c0 + c, r0 + r]; }); r0 += sz.h; w = Math.max(w, sz.w); });
1649
+ c0 += w; h = Math.max(h, r0); });
1650
+ const per = Math.max(2, c0); loose.forEach((x, k) => { pos[x] = [k % per, h + Math.floor(k / per)]; });
1651
+ if (loose.length) { c0 = Math.max(c0, Math.min(per, loose.length)); h += Math.ceil(loose.length / per); }
1652
+ return { w: c0, h, pos };
1653
+ };
1654
+ return block(null).pos;
1655
+ }
1656
+ const autoPos = vid => ((V[vid] || {}).containers || []).length ? blockPos(vid) : bestFlow(vid);
1657
+ const level = e => !/^(top|bottom)$/.test(e.fromSide) && !/^(top|bottom)$/.test(e.toSide); // a line into a card's top or bottom asks for no row
1658
+ /* A small plain view is routed a few ways and keeps the one the layout check scores best: cards level with their
1659
+ neighbours, or packed from the top as before, with cards no line comes into beside their callee or not. A cheap
1660
+ version of the CLI's search (optimize), affordable up to 30 cards; a larger view takes the first way. */
1661
+ function bestFlow(vid) {
1662
+ const first = flowPos(vid), n = ((V[vid] || {}).nodeIds || []).length;
1663
+ if (n > 30 || n < 3) return first;
1664
+ const seen = new Set([JSON.stringify(first)]), ways = [first];
1665
+ for (const o of [{ pull: true }, { packed: true }, { pull: true, packed: true }]) { const p = flowPos(vid, o), k = JSON.stringify(p); if (!seen.has(k)) { seen.add(k); ways.push(p); } }
1666
+ if (ways.length < 2) return first;
1667
+ let best = first, score = Infinity;
1668
+ ways.forEach(p => { const g = buildGeo(vid, p), s = scoreGeo(g); if (s < score - 0.5) { score = s; best = p; drawn[vid] = { pos: p, g }; } }); // as it will be drawn: with port repair
1669
+ return best;
1670
+ }
1671
+ const drawn = {}; // the way bestFlow kept, routed already: the view's next buildGeo takes it once instead of routing it again
1672
+ function flowPos(vid, o = {}) {
1673
+ const v = V[vid], lp = loopsOf(v), fwd = lp.edges.filter((e, k) => !lp.back.has(k) && e.from !== e.to);
1674
+ // cards with no line go in rows of their own under the view (#46), as wide as its columns; they stacked with the sources
1675
+ const all = (v.nodeIds || []).filter(i => N[i]), lined = new Set(lp.edges.filter(e => e.from !== e.to).flatMap(e => [e.from, e.to]));
1676
+ const loose = lined.size ? all.filter(i => !lined.has(i)) : [], ids = all.filter(i => !loose.includes(i));
1677
+ const rank = {}; ids.forEach(i => { rank[i] = 0; });
1678
+ for (let it = 0; it <= ids.length; it++) fwd.forEach(e => { if (rank[e.to] < rank[e.from] + 1) rank[e.to] = rank[e.from] + 1; });
1679
+ const seen = id => { const k = lp.edges.findIndex(e => e.to === id || e.from === id); return k < 0 ? 999 : k; };
1680
+ const sink = i => isState(i) && !fwd.some(e => e.from === i); // state that leads nowhere sits along the bottom
1681
+ const pos = {}, links = fwd.map(e => [e.from, e.to]);
1682
+ const order = rk => { const cols = {}; ids.forEach(i => (cols[rk[i]] ||= []).push(i));
1683
+ const ranks = Object.keys(cols).map(Number).sort((a, b) => a - b), mains = ranks.map(c => cols[c].filter(i => !sink(i)).sort((a, b) => seen(a) - seen(b)));
1684
+ untangle(mains, links); return { cols, ranks, mains }; }; // in reading order, unless another order crosses less (#53)
1685
+ const { cols, ranks, mains } = order((o.pull && pullSources(ids, links, rank)) || rank);
1686
+ const row = o.packed ? Object.fromEntries(mains.flatMap(m => m.map((i, r) => [i, r]))) : rowsFor(mains, fwd.filter(level).map(e => [e.from, e.to]));
1687
+ const height = Math.max(0, ...Object.values(row)) + 1;
1688
+ Object.entries(cols).forEach(([c, list]) => {
1689
+ const main = mains[ranks.indexOf(+c)], st = list.filter(sink).sort((a, b) => seen(a) - seen(b));
1690
+ main.forEach(i => { pos[i] = [+c, row[i]]; });
1691
+ st.forEach((i, r) => { pos[i] = [+c, height + r]; }); // state along the bottom, never above the flow
1692
+ });
1693
+ const per = Math.max(2, Object.keys(cols).length), under = Math.max(...Object.values(pos).map(p => p[1])) + 1;
1694
+ loose.forEach((i, k) => { pos[i] = [k % per, under + Math.floor(k / per)]; });
1695
+ return pos;
1696
+ }
1697
+
1698
+ function buildView(vid, opts = {}) {
1699
+ const g = buildGeo(vid), v = V[vid];
1700
+ return { g, markup: svgOf(g, { title: v.title, subject: subjectOf(v), icons: D.icons, ...opts }) };
1701
+ }
1702
+ const layoutCache = {};
1703
+ // the cards a card opens: its inner view's, else the count the host gives for one it has not loaded (opensCount)
1704
+ const kidCount = (nid, vid) => { const c = childOf(nid, vid), k = c && c !== vid ? (V[c].nodeIds || []).length : 0, n = +nodeIn(nid, vid).opensCount;
1705
+ return k || (opensOf(nid, vid) && n > 0 ? Math.floor(n) : 0); };
1706
+ // a line's kind: the first one its edges name (#45); else a hook's line is a guard and a board's is state; else, only where
1707
+ // a legend says what the colours mean (the standalone page, or a view's kindsFromLabels), a guess from the label's words
1708
+ const wireKind = (e, words) => { const t = N[e.to] || {}, f = N[e.from] || {}, l = String(e.label || '');
1709
+ const named = (e.all || [e]).map(x => x.kind).find(k => WIRES.some(w => w[0] === k)); if (named) return named;
1710
+ if (f.kind === 'hook') return 'guard';
1711
+ if (t.kind === 'board' || f.kind === 'board' || words && /\b(reads?|writes? config|saves?|records?|appends?)\b/i.test(l)) return 'state';
1712
+ if (t.kind === 'hook' || words && /\b(trigger|fires|suggests?|offers?|requires|wires)/i.test(l)) return 'triggers';
1713
+ if (words && /\b(feeds?|hands? off|routes?|passes|returns|bounces|then)\b/i.test(l)) return 'feeds';
1714
+ return 'calls'; };
1715
+ function prepEdges(vid, edges, backKeys) {
1716
+ const par = {}; // parallel edges between one pair read as one edge listing every relation
1717
+ const merged = edges.filter(e => { const k = e.from + '>' + e.to + ((V[vid] || {}).keepPairs ? '#' + e.id : ''), m = par[k]; if (!m) { par[k] = e; e.all = [e]; return true; } m.all.push(e); return false; })
1718
+ .map(e => e.all.length < 2 ? e : { ...e, label: (e.label || '') + ' +' + (e.all.length - 1), when: e.all.map(x => (x.step != null ? x.step + '. ' : '') + (x.label || '')).join('\n') });
1719
+ const out = merged.map(e => ({ ...e, back: !(V[vid] || {}).keepPairs && backKeys && backKeys.has(e.from + '>' + e.to) }));
1720
+ const pair = {}; // A→B plus B→A reads as one two-way edge
1721
+ return out.filter(e => { const r = !(V[vid] || {}).keepPairs && pair[e.to + '>' + e.from]; if (r) { r.both = true; r.back = false; r.all = [...(r.all || [r]), ...(e.all || [e])]; if (e.label && e.label !== r.label) r.label = (r.label ? r.label + ' / ' : '') + e.label; if (e.when) r.when = [r.when, e.when].filter(Boolean).join(' · '); return false; } pair[e.from + '>' + e.to] = e; return true; })
1722
+ .map(e => { const all = e.all || [e], subs = [...new Set(all.map(x => x.sublabel).filter(x => x != null && x !== '').map(String))];
1723
+ return { ...e, wk: wireKind(e, opts.kinds || (V[vid] || {}).kindsFromLabels), weight: Math.max(...all.map(x => [2, 3].includes(+x.weight) ? +x.weight : 1)), sublabel: subs.join(' / ') || undefined }; });
1724
+ }
1725
+ const condTag = l => { const m = /^(?:routes|on-ramp:?)\s+(\S+)$/i.exec(String(l || '').trim()); return m ? m[1] : null; }; // "routes bug" → a "bug" tag
1726
+ // quick: a candidate the layout searches below score and may drop, laid out once, as it was before 0.6.0 (no room for lines
1727
+ // with no route, no way round the cards for them, no warning)
1728
+ function buildGeo(vid, override, repair = (V[vid] || {}).repair !== false, quick = false) { // a view's repair: false turns port repair off
1729
+ const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]);
1730
+ const edges = viewEdges(v).map(e => ({ ...e }));
1731
+ if (v.render === 'sequence') edges.forEach((e, i) => { e.step = i + 1; });
1732
+ const P = planCache[vid] || { flow: true, backKeys: loopsOf(v).keys, steps: {} };
1733
+ if (!override && P.hub) return hubGeo(vid, P, edges, repair, quick);
1734
+ const pos = override || (v.layout ? fillLayout(vid, v.layout) : (layoutCache[vid] ||= autoPos(vid)));
1735
+ const ready = drawn[vid]; delete drawn[vid]; if (ready && !override && ready.pos === pos && repair === (v.repair !== false) && !quick) return ready.g;
1736
+ const mainSteps = v.mainSteps || v.render === 'process';
1737
+ const stepIds = v.stepIds || ids;
1738
+ const dg = { nodeH: mainSteps ? 82 : v.subAndChips ? 80 : 64, subAndChips: !!v.subAndChips, mainSteps, containers: v.containers, nodes: ids.map(i => { const n = nodeIn(i, vid), step = mainSteps && stepIds.includes(i); return { ...n, sub: n.sub || n.sublabel, emphasis: !!step || !!n.emphasis, step: step ? stepIds.indexOf(i) + 1 : null, verbs: step ? [] : n.verbs, kids: kidCount(i, vid), opens: !!opensOf(i, vid), col: (pos[i] || [0, 0])[0], row: (pos[i] || [0, 0])[1] }; }),
1739
+ edges: prepEdges(vid, edges, P.backKeys) };
1740
+ dg.authors = D.authors; dg.repair = repair; dg.intake = v.intake; dg.tour = v.tour; dg.gap = v.gap; dg.rgap = v.rgap; dg.quick = quick;
1741
+ return layoutGraph(dg, true);
1742
+ }
1743
+ function hubGeo(vid, P, edges, repair, quick) {
1744
+ const NH = V[vid].subAndChips ? 80 : 64, CG = 108, RG = 44, pitch = NH + RG, hub = P.hub;
1745
+ const all = P.lanes.flatMap(l => l.ids).concat(hub);
1746
+ const widths = Object.fromEntries(all.map(i => [i, mapNodeW(nodeIn(i, vid))]));
1747
+ const nodeW = widths[hub];
1748
+ const stepTxt = id => { const s = (P.steps || {})[id] || []; return s.length > 2 ? s[0] + '+' : s.join('·'); };
1749
+ const nodesOut = [{ ...nodeIn(hub, vid), sub: nodeIn(hub, vid).sub || nodeIn(hub, vid).sublabel, kids: kidCount(hub, vid), opens: !!opensOf(hub, vid), ax: 0, ay: 0, w: nodeW, h: NH }];
1750
+ const lanes = [], blocks = [], spine = [];
1751
+ let x = nodeW + CG, maxRows = 1;
1752
+ const rowAt = {};
1753
+ P.lanes.forEach((L, li) => {
1754
+ const x0 = x; spine[li] = x0 - CG + 28;
1755
+ const SUBG = 54; // columns inside a lane carry no branch labels, so they sit closer
1756
+ L.cols.forEach((col, j) => {
1757
+ const colW = Math.max(...col.map(id => widths[id]));
1758
+ const taken = new Set();
1759
+ col.forEach((id, r) => { // a follow-on part lines up with the part that leads to it
1760
+ let row = r;
1761
+ if (j > 0) { const src = edges.find(e => e.to === id && rowAt[e.from] != null && P.lanes[li].ids.includes(e.from)); row = src ? rowAt[src.from] : r; while (taken.has(row)) row++; }
1762
+ taken.add(row); rowAt[id] = row; maxRows = Math.max(maxRows, row + 1);
1763
+ nodesOut.push({ ...nodeIn(id, vid), sub: nodeIn(id, vid).sub || nodeIn(id, vid).sublabel, kids: kidCount(id, vid), opens: !!opensOf(id, vid), ax: x, ay: row * pitch, w: widths[id], h: NH, lane: li, step: stepTxt(id) });
1764
+ });
1765
+ x += colW + (j < L.cols.length - 1 ? SUBG : CG);
1766
+ });
1767
+ const right = x - CG + 16;
1768
+ const lx = P.boxed ? x0 - CG + 40 : x0 - 12, lw = P.boxed ? right - lx : right - 4 - lx;
1769
+ lanes.push({ title: L.title, ids: L.ids, x: lx, y: -62, w: lw, h: 0, gap: 24, bare: !P.boxed });
1770
+ const hx = P.boxed ? lx : x0 - 12;
1771
+ blocks.push({ x: hx + 12, y: -52, w: Math.min(lw - 24, 34 + sansW(L.title, 11, .95) + 8 + textW('00 parts', 11)), h: 22, title: L.title });
1772
+ if (P.boxed && li < P.lanes.length - 1) blocks.push({ x: lx + lw + 4, y: -48, w: 16, h: 18, title: L.title + ' → next' });
1773
+ });
1774
+ lanes.forEach(l => { l.h = maxRows * pitch - RG + 30 + 62; });
1775
+ // the comb: trunk from the hub, a spine per lane, a branch into each callee with its label at the end
1776
+ const yh = NH / 2, yt = -24, hubR = nodeW, byIdLocal = Object.fromEntries(nodesOut.map(n => [n.id, n]));
1777
+ const comb = [], others = [];
1778
+ const prepared = prepEdges(vid, edges.filter(e => e.from !== hub || P.branch.has(e.to)), P.backKeys);
1779
+ prepared.forEach(e => {
1780
+ if (e.from !== hub) return others.push(e);
1781
+ const c = byIdLocal[e.to]; if (!c) return;
1782
+ const li = c.lane, xs = spine[li], yc = c.ay + NH / 2;
1783
+ let pts = li === 0 ? [[hubR, yh], [xs, yh], [xs, yc], [c.ax, yc]] : [[hubR, yh], [spine[0], yh], [spine[0], yt], [xs, yt], [xs, yc], [c.ax, yc]];
1784
+ pts = pts.filter((p, k) => !k || p[0] !== pts[k - 1][0] || p[1] !== pts[k - 1][1]);
1785
+ pts = pts.filter((p, k) => k === 0 || k === pts.length - 1 || !((pts[k - 1][0] === p[0] && p[0] === pts[k + 1][0]) || (pts[k - 1][1] === p[1] && p[1] === pts[k + 1][1])));
1786
+ const tag = condTag(e.label), avail = c.ax - 26 - (xs + 10);
1787
+ const text = avail >= 44 ? fit(tag || e.label || '', 10.5, Math.max(30, avail - 20) / .92) : '', sub = text && e.sublabel ? fit(e.sublabel, 9.5, Math.max(30, avail - 20) / .92) : '';
1788
+ const w = Math.min(avail, pillW(text, sub)), h = sub ? 32 : 20;
1789
+ e.fixed = pts; e.lbl = text ? { x: c.ax - 26 - w, y: yc - h / 2, w, h, text, ...(sub ? { sub } : {}), step: null, tag: !!tag } : null;
1790
+ comb.push(e);
1791
+ });
1792
+ return layoutGraph({ nodeH: NH, subAndChips: !!V[vid].subAndChips, abs: true, nodes: nodesOut, edges: [...comb, ...others], lanes, blocks, laneGap: 24, repair, hub, tour: V[vid].tour, authors: D.authors, quick }, true);
1793
+ }
1794
+ /* crossing-minimising local search over rows and sides; `visual.py render` bakes the result into the page */
1795
+ function scoreGeo(g) {
1796
+ const bad = routeAudit(g, '').length, len = g.edges.reduce((a, e) => a + e.pts.slice(1).reduce((s, p, i) => s + Math.abs(p[0] - e.pts[i][0]) + Math.abs(p[1] - e.pts[i][1]), 0), 0);
1797
+ const bends = g.edges.reduce((a, e) => a + e.pts.length, 0), drop = g.edges.filter(e => e.label && !e.lbl).length;
1798
+ return bad * 1000 + ((g.stats.cross || 0) + (g.stats.fallback || 0)) * 150 + drop * 40 + len / 15 + bends * 12 + g.bounds.w * g.bounds.h / 4e5;
1799
+ }
1800
+ function optimize(vid, budget = 160) {
1801
+ budget = Math.max(budget, Math.min(400, 20 * (V[vid].nodeIds || []).length));
1802
+ const t0 = performance.now(); let wall = 4000; // per-view time box, extended while problems remain
1803
+ const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]);
1804
+ let pos = JSON.parse(JSON.stringify(autoPos(vid)));
1805
+ const boxed = (v.containers || []).length, homeOf = i => ((v.containers || []).filter(c => (c.nodeIds || []).includes(i)).pop() || {}).id; // blocks stay whole
1806
+ const lined = new Set(viewEdges(v).filter(e => e.from !== e.to).flatMap(e => [e.from, e.to])), loose = i => lined.size && !lined.has(i);
1807
+ let best = scoreGeo(buildGeo(vid, pos, false, true)), evals = 1;
1808
+ const hub = subjectOf(v), hubCol = hub && pos[hub] ? pos[hub][0] : null;
1809
+ const tryPos = cand => { evals++; const s = scoreGeo(buildGeo(vid, cand, false, true)); if (s < best - 0.5) { best = s; pos = cand; return true; } return false; };
1810
+ let improved = true;
1811
+ while (improved && evals < budget * (best >= 1000 ? 2.5 : 1) && performance.now() - t0 < (best >= 1000 ? 12000 : wall)) {
1812
+ improved = false;
1813
+ for (const a of ids) {
1814
+ if (evals >= budget * (best >= 1000 ? 2.5 : 1) || performance.now() - t0 > (best >= 1000 ? 12000 : wall)) break;
1815
+ if (loose(a)) continue; // the row of cards with no line (#46) routes nothing
1816
+ const [ca, ra] = pos[a];
1817
+ const moves = [];
1818
+ // columns are the reading order, so only the order inside a column may change
1819
+ const band = i => isState(i) && !viewEdges(v).some(e => e.from === i);
1820
+ const stateFloor = () => Math.max(-1, ...ids.filter(i => !band(i) && !loose(i)).map(i => pos[i][1]));
1821
+ ids.forEach(b => { if (b !== a && !loose(b) && pos[b][0] === ca && band(a) === band(b) && (!boxed || homeOf(a) === homeOf(b))) moves.push(['swap', b]); });
1822
+ if (!boxed) moves.push(['row', -1], ['row', 1]);
1823
+ for (const [kind, arg] of moves) {
1824
+ if (evals >= budget) break;
1825
+ const c = JSON.parse(JSON.stringify(pos));
1826
+ if (kind === 'swap') { if (a === hub) continue; const t2 = c[a]; c[a] = c[arg]; c[arg] = t2; }
1827
+ else if (kind === 'col') { const nc = ca + arg; let r = ra; while (ids.some(b => b !== a && c[b][0] === nc && Math.abs(c[b][1] - r) < 1)) r++; c[a] = [nc, r]; }
1828
+ else if (kind === 'row') {
1829
+ let r = ra + arg;
1830
+ if (band(a) && r <= stateFloor()) r = stateFloor() + 1;
1831
+ while (ids.some(b => b !== a && c[b][0] === ca && Math.abs(c[b][1] - r) < 1)) r += arg > 0 ? 1 : -1; // rows may be half rows: keep a row apart
1832
+ if (r < 0 || (band(a) && r <= stateFloor())) continue;
1833
+ c[a][1] = r;
1834
+ }
1835
+ else { const nc = 2 * hubCol - ca; const occ = ids.find(b => c[b][0] === nc && c[b][1] === ra); if (occ) c[occ][0] = ca; c[a][0] = nc; }
1836
+ if (Math.min(...ids.map(i => c[i][0])) < 0) { const m = Math.min(...ids.map(i => c[i][0])); ids.forEach(i => { c[i][0] -= m; }); }
1837
+ if (tryPos(c)) { improved = true; break; }
1838
+ }
1839
+ }
1840
+ }
1841
+ return { pos, score: best, evals };
1842
+ }
1843
+ // what a view's picture is drawn from: an update keeps the layout of every view whose key did not change
1844
+ const viewKey = vid => {
1845
+ const v = V[vid], ids = (v.nodeIds || []).filter(i => N[i]), ns = ids.map(i => nodeIn(i, vid));
1846
+ const icons = [...new Set(ns.map(n => n.icon).filter(Boolean))].map(k => (D.icons || {})[k]), who = [...new Set(ns.map(n => n.author).filter(Boolean))].map(k => (D.authors || {})[k]);
1847
+ return JSON.stringify([v, ns, viewEdges(v), ids.map(i => [kidCount(i, vid), opensOf(i, vid)]), icons, who]);
1848
+ };
1849
+ const geoCache = {};
1850
+ return { D, views, nodes, N, V, rootId, nodeIn, childOf, opensOf, subjectOf, viewsWith, kidsOf, chain, viewEdges, isState, planOf: withM(planOf), planCache,
1851
+ layoutCache, geoCache, buildGeo: withM(buildGeo), buildView: withM(buildView), optimize: withM(optimize), flowPos, kidCount, viewKey };
1852
+ }
1853
+
1854
+ const edgeRefIds = e => [...new Set((e.all || [e]).map(x => x._id).filter(Boolean))];
1855
+
1856
+ /* layout(model, viewId): one view's geometry, as the canvas draws it. No DOM. */
1857
+ export function layout(model, viewId, options = {}) {
1858
+ const ctx = mapContext(model, { measure: options.measure });
1859
+ if (!ctx.V[viewId]) throw new Error(`umlimator: no view ${JSON.stringify(viewId)}`);
1860
+ ctx.planOf(viewId);
1861
+ const g = ctx.buildGeo(viewId, undefined, options.repair);
1862
+ g.edges.forEach(e => { e.ids = edgeRefIds(e); });
1863
+ return g;
1864
+ }
1865
+
1866
+ let INSTANT = false; // the page's own hooks (#check, #still, #shot) draw without animation
1867
+ const reducedMotion = () => INSTANT || (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches);
1868
+ let SHEET = null;
1869
+ const sheet = () => { if (!SHEET) { SHEET = new CSSStyleSheet(); SHEET.replaceSync(STYLES); } return SHEET; };
1870
+ const INNER = Symbol('umlimator.inner'); // what mountPage needs from render() beyond the public handle
1871
+
1872
+ /* render(container, model, options) -> handle: the canvas for one view at a time, with drill-down. */
1873
+ /* an SVG file drawn onto a canvas: a PNG Blob, or null when there is nothing to draw */
1874
+ async function pngOf(file) {
1875
+ if (!file) return null;
1876
+ const img = new Image(); img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(file); await img.decode();
1877
+ const size = k => Math.ceil(+(file.match(new RegExp('^<svg[^>]*?\\s' + k + '="([\\d.]+)"')) || [])[1] || 0); // the file's own size, not the rounded one
1878
+ const c = document.createElement('canvas'); c.width = size('width') || img.naturalWidth; c.height = size('height') || img.naturalHeight; c.getContext('2d').drawImage(img, 0, 0, c.width, c.height);
1879
+ return new Promise(r => c.toBlob(r, 'image/png'));
1880
+ }
1881
+
1882
+ export function render(container, model, options = {}) {
1883
+ const P = options._page || {}; // the standalone page's hooks: status line, overlay insets, still and reader drawing
1884
+ if (P.instant) INSTANT = true;
1885
+ const OPS = ['add', 'rename', 'move', 'delete', 'reorder'], ed = options.editable;
1886
+ const E = ed === true ? Object.fromEntries(OPS.map(o => [o, true])) : ed && typeof ed === 'object' ? Object.fromEntries(OPS.map(o => [o, !!ed[o]])) : null;
1887
+ const can = op => !!(E && E[op]);
1888
+ const host = document.createElement('div');
1889
+ host.className = 'umlimator'; host.style.cssText = 'display:block;position:relative;width:100%;height:100%;min-height:0';
1890
+ container.appendChild(host);
1891
+ // text is measured in the host's font (--um-sans); measure: false keeps the font-independent estimate
1892
+ const own = typeof options.measure === 'function' ? options.measure : null;
1893
+ let measure = options.measure === false ? null : own || hostMeasure(host), current = model;
1894
+ let ctx = mapContext(model, { measure, kinds: !!P.kinds }); // kinds: the standalone page guesses line kinds from label words, for its legend
1895
+ const root = host.attachShadow({ mode: 'open', delegatesFocus: true }); // host.focus() lands in the map (on the selected card)
1896
+ root.adoptedStyleSheets = [sheet()];
1897
+ root.innerHTML = `<div class="um-live" role="status" aria-live="polite"></div><div class="stage" tabindex="-1">
1898
+ <div class="mctl">${options.minimap ? '<div class="minimap" title="Overview — click or drag to move"></div>' : ''}${options.controls === false ? '' : `
1899
+ <div class="ctl">
1900
+ <button data-z="up" title="Up one level (Esc)" aria-label="Up one level">↑</button><span class="sep"></span>
1901
+ <button data-z="out" title="Zoom out (−)" aria-label="Zoom out">−</button><button data-z="fit" class="pct" title="Fit to screen (0)">100%</button><button data-z="in" title="Zoom in (+)" aria-label="Zoom in">+</button><span class="sep"></span>
1902
+ <button data-z="one" title="Actual size (1)">1:1</button><button data-z="flow" title="Animate flow direction (F)" aria-label="Animate flow">≋</button>${E && E.add ? '<span class="sep"></span><button data-z="add" title="Add a card (N, or Alt + double-click)" aria-label="Add a card">+</button>' : ''}<span class="depth" title="Reading depth follows zoom">READ</span>
1903
+ </div>`}</div></div>`;
1904
+ const stage = root.querySelector('.stage'), q = s => root.querySelector(s);
1905
+ const subs = {};
1906
+ const emit = (type, e) => { const f = (options.on || {})[type]; if (f) f(e); (subs[type] || []).slice().forEach(fn => fn(e)); };
1907
+ let cam = null, geo = null, svg = null, cur = null, sel = null, more = [], busy = false, paused = false, dead = false;
1908
+ const timers = new Set(), later = (f, ms) => { const t = setTimeout(() => { timers.delete(t); if (!dead) f(); }, ms); timers.add(t); return t; };
1909
+ const sleep = ms => new Promise(r => later(r, reducedMotion() ? 0 : ms));
1910
+ const status = t => P.status && P.status(t);
1911
+ const wide = P.wide || (() => (cam ? cam.size()[0] : stage.clientWidth) > 820);
1912
+
1913
+ const upOf = () => (P.upTarget ? P.upTarget() : (ctx.V[cur] || {}).parent) || null;
1914
+ const nodeRef = id => ({ type: 'node', id, view: cur });
1915
+ const picked = () => sel ? [...more.map(nodeRef), sel] : []; // every selected thing, the latest last (more: other selected cards)
1916
+ const say = t => { const l = root.querySelector('.um-live'); if (l) l.textContent = t; };
1917
+ const nameOf = id => ctx.nodeIn(id, cur).label || ((geo.families || []).find(f => f.id === id) || {}).label || id;
1918
+ const emitSel = () => {
1919
+ say(!sel ? 'Nothing selected' : sel.type === 'node' ? nameOf(sel.id) + (more.length ? `, ${more.length + 1} cards selected` : ', selected')
1920
+ : sel.type === 'edge' ? (e => e ? `Line from ${nameOf(e.from)} to ${nameOf(e.to)}, selected` : 'Line selected')(geo.edges[edgeIndex(sel)]) : `Box ${(svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"] .family-title`) || {}).textContent || sel.id}, selected`);
1921
+ emit('select', { target: sel, targets: picked() });
1922
+ };
1923
+ const edgeRef = e => ({ type: 'edge', ids: edgeRefIds(e), view: cur });
1924
+ const edgeIndex = ref => geo.edges.findIndex(e => { const ids = edgeRefIds(e); return ids.length === ref.ids.length && ids.every((x, i) => x === ref.ids[i]); });
1925
+ const exists = ref => !ref ? true : ref.type === 'node' ? !!geo.byId[ref.id] : ref.type === 'edge' ? edgeIndex(ref) >= 0
1926
+ : ref.type === 'container' ? (geo.families || []).some(f => f.id === ref.id) : false;
1927
+
1928
+ function mount(vid) {
1929
+ cur = vid; ctx.planOf(vid);
1930
+ stage.querySelectorAll('svg.map-svg, .empty-view').forEach(s => s.remove());
1931
+ const v = ctx.V[vid], empty = !(v.nodeIds || []).some(i => ctx.N[i]);
1932
+ if (empty) { // a view with nothing in it gets a friendly card, not a blank canvas
1933
+ geo = { nodes: [], edges: [], groups: [], lanes: [], families: [], byId: {}, stats: {}, bounds: { x: 0, y: 0, w: 400, h: 300 } };
1934
+ stage.insertAdjacentHTML('afterbegin', '<svg class="map-svg" viewBox="0 0 400 300" xmlns="http://www.w3.org/2000/svg"></svg>');
1935
+ const p = v.parent;
1936
+ stage.insertAdjacentHTML('beforeend', `<div class="empty-view"><div class="ico">◌</div><h4>Nothing here yet</h4><p>This view has no nodes yet.</p>${p ? `<button class="btn" data-v="${esc(p)}">← Back to ${esc(String(ctx.V[p].title || p).split(' · ')[0])}</button>` : ''}</div>`);
1937
+ } else {
1938
+ const c = ctx.geoCache[vid] ||= ctx.buildView(vid, { tour: P.tour !== false, openHint: P.openHint !== false });
1939
+ stage.insertAdjacentHTML('afterbegin', c.markup); geo = c.g;
1940
+ }
1941
+ svg = stage.querySelector('svg'); svg.classList.add('map-svg'); if (paused) svg.pauseAnimations();
1942
+ if (!cam) cam = new Camera(stage, svg, { wheel: 'zoom', maxFit: 1.15, max: 4, mapDepth: 0.3, onChange: onCam, skip: e => !!((can('move') || can('reorder') || can('add')) && e.target.closest && e.target.closest('.node, .um-handle')), inset: () => clearOf([].concat(P.inset ? P.inset() : { t: 16, r: 16, b: 16, l: 16 })) });
1943
+ cam.setSvg(svg, geo.bounds);
1944
+ const ub = q('[data-z="up"]'); if (ub) ub.disabled = !upOf();
1945
+ if (options.minimap) buildMini();
1946
+ if (!geo.hub && geo.nodes.length && geo.nodes.every(n => Number.isFinite(n.col) && Number.isFinite(n.row))) placed[vid] = Object.fromEntries(geo.nodes.map(n => [n.id, [n.col, n.row]]));
1947
+ const had = sel; sel = null; tabKeys(); paint();
1948
+ more = [];
1949
+ if (had) emitSel();
1950
+ if (P.onMount) P.onMount(inner);
1951
+ }
1952
+
1953
+ /* at fit, the zoom controls and the minimap keep off the cards (#50): when a card would sit under one of them, the fit
1954
+ also tries room for them at the bottom and at the right, and keeps whichever leaves the map larger */
1955
+ let clear = null; // the last answer, for one view, size and set of insets: the camera asks on every frame of a pan or zoom
1956
+ function clearOf(base) {
1957
+ if (!cam || !geo) return base;
1958
+ const [W, H] = cam.size(), key = JSON.stringify([W, H, base]); if (clear && clear.geo === geo && clear.key === key) return clear.sets;
1959
+ const boxes = [...stage.querySelectorAll('.ctl, .minimap')].map(x => x.getBoundingClientRect()).filter(r => r.width && r.height);
1960
+ let sets = base; // none shown, or no layout (a test DOM): as before
1961
+ if (boxes.length) {
1962
+ const S = stage.getBoundingClientRect(), top = Math.min(...boxes.map(r => r.top)) - S.top - 8, left = Math.min(...boxes.map(r => r.left)) - S.left - 8;
1963
+ const f = cam.fitState(0.94, base), at = (x, y) => [(x - f.cx) * f.k + W / 2 + S.left, (y - f.cy) * f.k + H / 2 + S.top];
1964
+ const covered = geo.nodes.some(n => { const [x0, y0] = at(n.x, n.y), [x1, y1] = at(n.x + n.w, n.y + n.h);
1965
+ return boxes.some(r => x0 < r.right + 8 && r.left - 8 < x1 && y0 < r.bottom + 8 && r.top - 8 < y1); });
1966
+ if (covered) sets = base.flatMap(I => [{ ...I, b: Math.max(I.b, H - top) }, { ...I, r: Math.max(I.r, W - left) }]);
1967
+ }
1968
+ clear = { geo, key, sets }; return sets;
1969
+ }
1970
+ function onCam(c) {
1971
+ const pct = q('.pct'), depth = q('.depth');
1972
+ if (pct) pct.textContent = Math.round(c.s.k * 100) + '%';
1973
+ if (depth) depth.textContent = (stage.dataset.depth || 'read').toUpperCase();
1974
+ if (mini.vp) {
1975
+ const [W, H] = c.size(), k = c.s.k;
1976
+ mini.vp.setAttribute('x', c.s.cx - W / (2 * k)); mini.vp.setAttribute('y', c.s.cy - H / (2 * k));
1977
+ mini.vp.setAttribute('width', W / k); mini.vp.setAttribute('height', H / k);
1978
+ const b = geo.bounds; q('.minimap').classList.toggle('all', W / k >= b.w && H / k >= b.h);
1979
+ }
1980
+ }
1981
+
1982
+ /* minimap */
1983
+ const mini = {};
1984
+ function buildMini() {
1985
+ const b = geo.bounds, el = q('.minimap');
1986
+ const ln = (geo.lanes || []).map(l => `<rect x="${l.x}" y="${l.y}" width="${l.w}" height="${l.h}" rx="14" fill="none" stroke="var(--um-border-2)" stroke-width="${Math.max(2, b.w / 300)}"/>`).join('');
1987
+ const r = geo.nodes.map(n => `<rect x="${n.x}" y="${n.y}" width="${n.w}" height="${n.h}" rx="8" fill="var(--um-${KIND[n.k][0]})" opacity="${n.k === 'm-public' ? .95 : .55}"/>`).join('');
1988
+ const l = geo.edges.map(e => `<polyline points="${e.pts.map(p => p.join(',')).join(' ')}" fill="none" stroke="var(--um-arrow)" stroke-width="${Math.max(2, b.w / 260)}" opacity=".6"/>`).join('');
1989
+ el.innerHTML = `<svg viewBox="${b.x} ${b.y} ${b.w} ${b.h}" preserveAspectRatio="xMidYMid meet">${ln}${l}${r}<rect class="vp" rx="${b.w / 80}"/></svg>`;
1990
+ mini.svg = el.firstChild; mini.vp = el.querySelector('.vp');
1991
+ mini.vp.setAttribute('stroke-width', Math.max(2, b.w / 160));
1992
+ cam && onCam(cam);
1993
+ }
1994
+ if (options.minimap) {
1995
+ const el = q('.minimap'); let down = false;
1996
+ const go2 = (e, anim) => { const p = mini.svg.createSVGPoint(); p.x = e.clientX; p.y = e.clientY; const w = p.matrixTransform(mini.svg.getScreenCTM().inverse()); cam.touched = true; anim ? cam.animate({ cx: w.x, cy: w.y }, 240) : cam.set({ cx: w.x, cy: w.y }); };
1997
+ el.addEventListener('pointerdown', e => { down = true; el.setPointerCapture(e.pointerId); go2(e, true); e.stopPropagation(); });
1998
+ el.addEventListener('pointermove', e => { if (down) go2(e, false); });
1999
+ el.addEventListener('pointerup', () => { down = false; });
2000
+ }
2001
+
2002
+ /* a card's or box's tab, and a card's chip, is a button only for a host that listens for click (#43): it can act on one.
2003
+ A card's tabs and chips get focus targets right after the card, outside it (a button inside the card's button is
2004
+ invalid), tabs first; a box's tab is one itself. */
2005
+ const listening = () => !!((options.on || {}).click || (subs.click || []).length);
2006
+ let keysFor = null; // the picture and state the tabs were last set up for: a second listener changes nothing, focus stays
2007
+ function tabKeys() {
2008
+ const on = listening(); if (!svg || (keysFor && keysFor[0] === svg && keysFor[1] === on)) return; keysFor = [svg, on];
2009
+ svg.querySelectorAll('.tab-key, .chip-key').forEach(k => k.remove());
2010
+ const cards = Object.fromEntries([...svg.querySelectorAll('.node')].map(g => [g.dataset.id, g]));
2011
+ svg.querySelectorAll('.family [data-badge]').forEach(t => { if (on) { t.setAttribute('tabindex', '0'); t.setAttribute('role', 'button'); } else { t.removeAttribute('tabindex'); t.removeAttribute('role'); } });
2012
+ if (on && geo.map) geo.nodes.forEach(n => { let tx = n.x + 12, html = ''; const card = cards[n.id]; if (!card) return;
2013
+ mapTabs(n).forEach(t => { if (t.badge != null) html += `<g class="um-key tab-key" data-node="${esc(n.id)}" data-badge="${t.badge}" tabindex="0" role="button" aria-label="${esc(t.text)}"><rect x="${tx - 2}" y="${n.y - 10}" width="${t.w + 4}" height="19" rx="6" class="key-ring"/></g>`; tx += t.w + 4; });
2014
+ card.querySelectorAll('rect.verb-chip').forEach(r => { const i = +r.dataset.chip, { value } = chipRef(card, i), at = k => +r.getAttribute(k), verb = (n.verbs || [])[i];
2015
+ const name = value === verb ? value : `${value} (more: ${(n.verbs || []).slice(i).join(', ')})`; // the last chip may stand for the rest
2016
+ html += `<g class="um-key chip-key" data-node="${esc(n.id)}" data-chip="${i}" tabindex="0" role="button" aria-label="${esc(name)}"><rect x="${at('x') - 2}" y="${at('y') - 2}" width="${at('width') + 4}" height="${at('height') + 4}" rx="9" class="key-ring"/></g>`; });
2017
+ if (html) card.insertAdjacentHTML('afterend', html); });
2018
+ }
2019
+ const badgeRef = (node, box, i) => { const b = badgesOf(box != null ? boxDef(box) : ctx.nodeIn(node, cur))[i];
2020
+ return { type: 'badge', ...(box != null ? { container: box } : { node }), index: i, value: b ? b.text : '', view: cur }; };
2021
+ const chipRef = (card, i) => { const tx = card.querySelector(`text[data-chip="${i}"]`); return { type: 'chip', node: card.dataset.id, index: i, value: tx ? tx.textContent : '', view: cur }; };
2022
+ function activate(ref, ev) { // a click on something, or Enter on a tab: the host may take it (preventDefault), else it is selected
2023
+ sub = ref && (ref.type === 'chip' || ref.type === 'badge') ? ref : null;
2024
+ let keep = false; emit('click', { target: ref, event: ev, preventDefault() { keep = true; } });
2025
+ return !keep;
2026
+ }
2027
+
2028
+ /* selection: the selected node, its neighbours and wires stay lit; the rest dims */
2029
+ function paint() {
2030
+ if (!svg) return;
2031
+ svg.querySelectorAll('.um-handle').forEach(h => h.remove());
2032
+ if (can('add') && sel && sel.type === 'node' && geo.byId[sel.id]) { const n = geo.byId[sel.id];
2033
+ svg.insertAdjacentHTML('beforeend', `<g class="um-edit um-handle" data-from="${esc(n.id)}"><title>Drag to another card to add a line</title><circle cx="${n.x + n.w + 12}" cy="${n.y + n.h / 2}" r="6"/></g>`); }
2034
+ svg.querySelectorAll('.sel,.nb,.hot').forEach(g => g.classList.remove('sel', 'nb', 'hot')); stage.classList.remove('focusing');
2035
+ svg.querySelectorAll('.node').forEach(g => g.setAttribute('aria-pressed', !!sel && sel.type === 'node' && (g.dataset.id === sel.id || more.includes(g.dataset.id))));
2036
+ const li = sel && sel.type === 'edge' ? edgeIndex(sel) : -1; svg.querySelectorAll('.line-key').forEach(k => k.setAttribute('aria-pressed', +k.dataset.i === li));
2037
+ if (!sel) return;
2038
+ if (sel.type === 'node') {
2039
+ const nid = sel.id, ups = new Set(), downs = new Set();
2040
+ geo.edges.forEach(e => { if (e.to === nid) ups.add(e.from); if (e.from === nid) downs.add(e.to); });
2041
+ svg.querySelectorAll('.node').forEach(g => { const id = g.dataset.id; g.classList.toggle('sel', id === nid || more.includes(id)); g.classList.toggle('nb', ups.has(id) || downs.has(id)); });
2042
+ svg.querySelectorAll('.edge-g').forEach(g => g.classList.toggle('hot', g.dataset.from === nid || g.dataset.to === nid));
2043
+ svg.querySelectorAll('.e-lab').forEach(g => { const e = geo.edges[g.dataset.i]; g.classList.toggle('hot', e.from === nid || e.to === nid); });
2044
+ svg.querySelectorAll('.n-port').forEach(p => p.classList.toggle('hot', p.dataset.edges.split(',').some(i => geo.edges[i].from === nid || geo.edges[i].to === nid)));
2045
+ stage.classList.add('focusing');
2046
+ } else if (sel.type === 'edge') {
2047
+ const i = edgeIndex(sel), e = geo.edges[i];
2048
+ svg.querySelectorAll(`.edge-g[data-i="${i}"], .e-lab[data-i="${i}"]`).forEach(g => g.classList.add('hot'));
2049
+ svg.querySelectorAll('.node').forEach(g => g.classList.toggle('nb', g.dataset.id === e.from || g.dataset.id === e.to));
2050
+ stage.classList.add('focusing');
2051
+ } else if (sel.type === 'container') {
2052
+ const f = svg.querySelector(`.family[data-family="${CSS.escape(sel.id)}"]`); if (f) f.classList.add('sel');
2053
+ }
2054
+ }
2055
+ function select(ref, o = {}) {
2056
+ if (ref && (ref.type === 'chip' || ref.type === 'badge')) ref = ref.container != null ? { type: 'container', id: ref.container } : nodeRef(ref.node); // a chip or badge: its card (box)
2057
+ if (ref && (!geo || !exists(ref))) return;
2058
+ const before = JSON.stringify(sel);
2059
+ const was = more.length; more = [];
2060
+ sel = ref ? { ...ref, view: cur } : null;
2061
+ paint();
2062
+ if (sel && sel.type === 'node' && o.reveal !== false) { const n = geo.byId[sel.id]; requestAnimationFrame(() => { if (!dead) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h }); }); }
2063
+ if (JSON.stringify(sel) !== before || o.again || was) emitSel();
2064
+ }
2065
+
2066
+ /* keyboard: the nearest card in an arrow's direction, by distance along it plus twice the distance across it */
2067
+ function nearest(n, k) {
2068
+ const [dx, dy] = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[k] || [0, 0], cx = n.x + n.w / 2, cy = n.y + n.h / 2;
2069
+ let best = null, bs = Infinity;
2070
+ geo.nodes.forEach(m => { if (m.id === n.id) return; const ax = m.x + m.w / 2 - cx, ay = m.y + m.h / 2 - cy, along = ax * dx + ay * dy, across = Math.abs(ax * dy - ay * dx);
2071
+ if (along <= 0) return; const sc = along + 2 * across; if (sc < bs) { bs = sc; best = m; } });
2072
+ return best;
2073
+ }
2074
+
2075
+ /* several cards: Shift (or Cmd / Ctrl) + click adds a card to the selection, or takes it out */
2076
+ function toggle(id) {
2077
+ if (id === sel.id) { if (!more.length) return select(null); sel = nodeRef(more.pop()); }
2078
+ else if (more.includes(id)) more = more.filter(x => x !== id);
2079
+ else { more.push(sel.id); sel = nodeRef(id); }
2080
+ paint(); emitSel();
2081
+ }
2082
+
2083
+ /* navigation: zoom into the card that opens a view, out to the card a view came from */
2084
+ async function go(vid, how = {}) {
2085
+ if (!ctx.V[vid] || busy || dead) return;
2086
+ if (vid === cur) { if (how.select) select(nodeRef(how.select)); return; }
2087
+ busy = true;
2088
+ const prev = cur, via = how.via;
2089
+ let mode = 'jump';
2090
+ if (prev && ctx.chain(vid).includes(prev) || how.via) mode = 'in';
2091
+ else if (prev && ctx.chain(prev).includes(vid)) mode = 'out';
2092
+ if (P.onNavigate) P.onNavigate(vid, prev, mode);
2093
+ if (prev && !reducedMotion()) {
2094
+ if (mode === 'in' && via && geo.byId[via]) { const n = geo.byId[via]; await cam.animate(cam.zoomTo({ x: n.x, y: n.y, w: n.w, h: n.h }, 1.1), 420); }
2095
+ else await cam.animate({ k: cam.s.k * (mode === 'out' ? 0.6 : 1) }, mode === 'out' ? 260 : 1);
2096
+ stage.classList.add('fading'); await sleep(130);
2097
+ }
2098
+ if (dead) return;
2099
+ mount(vid);
2100
+ const fitS = startView();
2101
+ if (prev && !reducedMotion()) {
2102
+ if (mode === 'in') cam.set({ ...fitS, k: fitS.k * 0.55 });
2103
+ else if (mode === 'out') { const back = geo.nodes.find(n => ctx.childOf(n.id, cur) === prev); cam.set(back ? cam.zoomTo({ x: back.x, y: back.y, w: back.w, h: back.h }, 1.1) : { ...fitS, k: fitS.k * 1.6 }); }
2104
+ else cam.set(fitS);
2105
+ stage.classList.remove('fading');
2106
+ await cam.animate(fitS, mode === 'jump' ? 1 : 460);
2107
+ } else { cam.set(fitS); stage.classList.remove('fading'); }
2108
+ if (dead) return;
2109
+ cam.touched = false;
2110
+ if (mode === 'out') { const back = geo.nodes.find(n => ctx.childOf(n.id, cur) === prev); if (back) flash(back.id); }
2111
+ if (how.select) select(nodeRef(how.select), { reveal: true });
2112
+ busy = false;
2113
+ say('Showing ' + (ctx.V[cur].title || cur));
2114
+ emit('viewchange', { view: vid, from: prev });
2115
+ }
2116
+ /* where the camera starts: fit on a desk; on a phone a readable zoom from the top-left corner */
2117
+ function startView() {
2118
+ const f = cam.fitState();
2119
+ if (wide() || f.k >= 0.6) return f;
2120
+ const [W, H] = cam.size(), k = 0.6, b = geo.bounds;
2121
+ return { k, cx: b.x + (W / 2 - 12) / k, cy: b.y + (H / 2 - 56) / k };
2122
+ }
2123
+ function open(nid) { // a view the host has not loaded (#47): it can load it, update(), then setView(), or update() right here
2124
+ const c = ctx.opensOf(nid, cur);
2125
+ if (!c) return status('No inner view for this node');
2126
+ let stop = false;
2127
+ emit('open', { target: nodeRef(nid), view: c, preventDefault() { stop = true; } });
2128
+ if (!stop && ctx.V[c]) go(c, { via: nid });
2129
+ }
2130
+ const up = () => { const p = upOf(); if (p) go(p); else status('Already at the top'); };
2131
+ function flash(nid) { const g = svg.querySelector(`.node[data-id="${CSS.escape(nid)}"]`); if (!g) return; g.classList.add('flash'); later(() => g.classList.remove('flash'), 1400); }
2132
+ function setFlow(on) {
2133
+ stage.classList.toggle('flowing', on);
2134
+ const b = q('[data-z="flow"]'); if (b) { b.classList.toggle('on', on); b.setAttribute('aria-pressed', on); }
2135
+ if (P.onFlow) P.onFlow(on);
2136
+ }
2137
+ function pause(on) { paused = on; stage.classList.toggle('paused', on); if (svg) on ? svg.pauseAnimations() : svg.unpauseAnimations(); }
2138
+
2139
+ /* keys on the canvas; the page forwards the same keys when the focus is elsewhere */
2140
+ function key(e) {
2141
+ const k = e.key, g = e.target && e.target.closest && e.target.closest('.node');
2142
+ if (E && editKey(e)) return true;
2143
+ if (e.altKey) return false;
2144
+ const lk = e.target && e.target.closest && e.target.closest('.line-key'), tk = e.target && e.target.closest && e.target.closest('.tab-key, .family [data-badge]');
2145
+ const ck = e.target && e.target.closest && e.target.closest('.chip-key'), card = ck && [...svg.querySelectorAll('.node')].find(x => x.dataset.id === ck.dataset.node);
2146
+ if (k === 'Enter' && lk && geo.edges[+lk.dataset.i]) select(edgeRef(geo.edges[+lk.dataset.i]), { again: true }); // as a click on the line (#43)
2147
+ else if (k === 'Enter' && tk) { const fam = tk.closest('.family'), ref = badgeRef(tk.dataset.node, fam ? fam.dataset.family : null, +tk.dataset.badge); if (activate(ref, e)) select(ref); }
2148
+ else if (k === 'Enter' && card) { const ref = chipRef(card, +ck.dataset.chip); if (activate(ref, e)) select(ref); } // as a click on the chip
2149
+ else if (k === '+' || k === '=') cam.zoomAt(1.4, null, null, true);
2150
+ else if (k === '-' || k === '_') cam.zoomAt(1 / 1.4, null, null, true);
2151
+ else if (k === '0') cam.fit();
2152
+ else if (k === '1') cam.actual();
2153
+ else if (k === 'Enter' && (g || (sel && sel.type === 'node'))) { // a selected card opens its inner view; without one it is selected again, as a click does
2154
+ const id = g ? g.dataset.id : sel.id;
2155
+ if (sel && sel.type === 'node' && sel.id === id && ctx.opensOf(id, cur)) open(id); else select(nodeRef(id), { again: true }); }
2156
+ else if (k === 'Escape' || k === 'Backspace') { if (sel) select(null); else up(); }
2157
+ else if (k === 'f' || k === 'F') setFlow(!stage.classList.contains('flowing'));
2158
+ else if (k.startsWith('Arrow') && g && geo.byId[g.dataset.id]) { const to = nearest(geo.byId[g.dataset.id], k); if (!to) return false;
2159
+ const tg = svg.querySelector(`.node[data-id="${CSS.escape(to.id)}"]`); tg.focus({ preventScroll: true }); cam.reveal({ x: to.x, y: to.y, w: to.w, h: to.h }); }
2160
+ else if (k.startsWith('Arrow')) { const d = 80, m = { ArrowLeft: [d, 0], ArrowRight: [-d, 0], ArrowUp: [0, d], ArrowDown: [0, -d] }[k]; if (!m) return false; cam.pan(m[0], m[1], true); }
2161
+ else return false;
2162
+ return true;
2163
+ }
2164
+
2165
+
2166
+ /* ---------- editing: a gesture never changes the picture; it emits a `change` the host may apply (then update()) ----------
2167
+ Each event carries what the user meant and, apart from it, an optional hint: where the card was dropped. */
2168
+ let seq = 0, sub = null, drag = null, conn = null, selectNew = false, swallow = 0;
2169
+ const placed = {}; // per view: the slot every card had when last drawn (stable positions across updates; never in the model)
2170
+ const hints = {}; // per view: id -> [col, row] where the last move dropped a card, for the next update only
2171
+ const slotsH = {}; // per view: id -> [col, row] of a reordered column group (exact), for the next update only
2172
+ const addHints = []; // slots of adds not yet seen as new cards
2173
+ function change(c, exact) {
2174
+ const ev = { id: 'c' + Date.now().toString(36) + '-' + (++seq), view: cur, ...c };
2175
+ // only the latest gesture's hint waits for the host: an edit the host never answered leaves no trace
2176
+ [hints, slotsH].forEach(o => Object.keys(o).forEach(k => delete o[k])); addHints.length = 0;
2177
+ if (exact) slotsH[cur] = exact;
2178
+ else if (ev.hint && Number.isFinite(ev.hint.col)) {
2179
+ if (c.op === 'add' && c.kind === 'node') addHints.push([ev.hint.col, ev.hint.row]);
2180
+ else if (c.target && c.target.type === 'node') (hints[cur] ||= {})[c.target.id] = [ev.hint.col, ev.hint.row];
2181
+ }
2182
+ if (c.op === 'add' && c.kind === 'node') selectNew = true;
2183
+ emit('change', ev);
2184
+ return ev;
2185
+ }
2186
+ const worldOf = e => { const r = stage.getBoundingClientRect(); return cam.toWorld(e.clientX - r.left, e.clientY - r.top); };
2187
+ const area = f => f.w * f.h;
2188
+ const boxAt = (x, y) => (geo.families || []).filter(f => x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h).sort((a, b) => area(a) - area(b))[0] || null;
2189
+ const boxOf = id => (geo.families || []).filter(f => (f.nodeIds || []).includes(id)).sort((a, b) => area(a) - area(b))[0] || null;
2190
+ const gridView = () => !geo.hub && geo.nodes.length && geo.nodes.every(n => Number.isFinite(n.col) && Number.isFinite(n.row));
2191
+ function hintAt(x, y) { // a point in the view, and on a column view its column and row
2192
+ const h = { x: Math.round(x), y: Math.round(y) };
2193
+ if (!gridView()) return h;
2194
+ const cols = [...new Set(geo.nodes.filter(n => !n.aside).map(n => n.col))].sort((a, b) => a - b); // the row of cards with no line keeps off the columns
2195
+ const mid = c => { const ns = geo.nodes.filter(n => n.col === c && !n.aside); return ns.reduce((a, n) => a + n.x + n.w / 2, 0) / ns.length; };
2196
+ const first = cols[0], last = cols[cols.length - 1], step = cols.length > 1 ? (mid(last) - mid(first)) / (last - first) : 260;
2197
+ let col = first, best = Infinity; cols.forEach(c => { const d = Math.abs(mid(c) - x); if (d < best) { best = d; col = c; } });
2198
+ if (x > mid(last) + step / 2) col = last + Math.round((x - mid(last)) / step);
2199
+ else if (x < mid(first) - step / 2) col = Math.max(0, first - Math.round((mid(first) - x) / step));
2200
+ const a = geo.nodes[0], b = geo.nodes.find(n => n.row !== a.row), pitch = b ? (b.y - a.y) / (b.row - a.row) : 108;
2201
+ h.col = col; h.row = Math.max(0, Math.round((y - (a.y - a.row * pitch) - 32) / pitch));
2202
+ return h;
2203
+ }
2204
+ const siblings = n => { const b = boxOf(n.id); return geo.nodes.filter(m => m.col === n.col && !m.aside === !n.aside && boxOf(m.id) === b).sort((p, q) => p.y - q.y); };
2205
+ // a reorder rearranges the card's whole column group: the same slots, the card at its new place among them
2206
+ const reorderSlots = (n, i) => { const sib = siblings(n), rows = sib.map(m => m.row), order = sib.filter(m => m !== n); order.splice(i, 0, n);
2207
+ return Object.fromEntries(order.map((m, k) => [m.id, [n.col, rows[k]]])); };
2208
+ const boxDef = id => ((ctx.V[cur] || {}).containers || []).find(c => c && c.id === id) || {};
2209
+ const listOf = r => { if (r.container != null) return badgesOf(boxDef(r.container)); const n = ctx.nodeIn(r.node, cur); return r.type === 'badge' ? badgesOf(n) : n.verbs || []; };
2210
+ const ofSel = r => !!(r && sel && (sel.type === 'node' ? r.node === sel.id : sel.type === 'container' && r.container === sel.id)); // a chip or badge of the selection
2211
+ function editKey(e) {
2212
+ const k = e.key;
2213
+ if (e.altKey && can('reorder') && k.startsWith('Arrow')) {
2214
+ if (ofSel(sub) && (k === 'ArrowLeft' || k === 'ArrowRight')) { // a chip or badge in its list
2215
+ const i = sub.index + (k === 'ArrowRight' ? 1 : -1); if (i < 0 || i >= listOf(sub).length) return true;
2216
+ change({ op: 'reorder', target: sub, index: i }); return true; }
2217
+ if (sel && sel.type === 'node' && gridView() && (k === 'ArrowUp' || k === 'ArrowDown')) { // a card among its column
2218
+ const n = geo.byId[sel.id], sib = siblings(n), at = sib.indexOf(n), i = at + (k === 'ArrowDown' ? 1 : -1);
2219
+ if (i < 0 || i >= sib.length) return true;
2220
+ const o = sib[i]; change({ op: 'reorder', target: nodeRef(n.id), index: i, hint: { x: Math.round(o.x + o.w / 2), y: Math.round(o.y + o.h / 2), col: o.col, row: o.row } }, reorderSlots(n, i)); return true; }
2221
+ return false;
2222
+ }
2223
+ if (e.altKey) return false;
2224
+ if (k === 'F2' && sel && can('rename')) { rename(sel); return true; }
2225
+ if ((k === 'n' || k === 'N') && can('add')) { addAt(cam.s.cx, cam.s.cy); return true; }
2226
+ if (k === 'Delete' && can('delete') && sel && more.length) { picked().forEach(t => change({ op: 'delete', target: t })); return true; } // one event per card
2227
+ if (k === 'Delete' && can('delete') && sel) { change({ op: 'delete', target: ofSel(sub) ? sub : sel }); return true; }
2228
+ return false;
2229
+ }
2230
+ /* a text field over the canvas: names a new card, or renames a card, line or container */
2231
+ function editor(box, text, done) {
2232
+ root.querySelectorAll('.um-rename').forEach(x => x.remove());
2233
+ const k = cam.s.k, [W, H] = cam.size(), inp = document.createElement('input');
2234
+ inp.className = 'um-edit um-rename'; inp.value = text; inp.setAttribute('aria-label', text ? 'New name' : 'Name of the new card');
2235
+ Object.assign(inp.style, { left: (box.x - cam.s.cx) * k + W / 2 + 'px', top: (box.y - cam.s.cy) * k + H / 2 + 'px', width: Math.max(120, box.w * k) + 'px', height: Math.max(22, box.h * k) + 'px', fontSize: Math.max(11, 13 * k) + 'px' });
2236
+ stage.appendChild(inp); inp.focus(); inp.select();
2237
+ // once only: removing a focused field can fire blur (cancel) in the middle of a commit
2238
+ let closed = false;
2239
+ const finish = ok => { if (closed) return; closed = true; const v = inp.value.trim(); if (ok && v && v !== text) done(v); inp.remove(); stage.focus({ preventScroll: true }); };
2240
+ inp.addEventListener('keydown', e => { e.stopPropagation(); if (e.key === 'Enter') { e.preventDefault(); finish(true); } else if (e.key === 'Escape') { e.preventDefault(); finish(false); } });
2241
+ inp.addEventListener('blur', () => finish(false));
2242
+ inp.addEventListener('pointerdown', e => e.stopPropagation());
2243
+ return inp;
2244
+ }
2245
+ function rename(ref) {
2246
+ if (!can('rename') || !ref) return;
2247
+ if (ref.type === 'node' && geo.byId[ref.id]) { const n = geo.byId[ref.id]; editor({ x: n.x + 50, y: n.y + n.h / 2 - 13, w: n.w - 58, h: 26 }, String(ctx.nodeIn(n.id, cur).label ?? n.label ?? ''), v => change({ op: 'rename', target: ref, label: v })); }
2248
+ else if (ref.type === 'edge') { const e = geo.edges[edgeIndex(ref)]; if (!e) return; const L = e.lbl, m = e.pts[Math.floor(e.pts.length / 2)];
2249
+ editor(L ? { x: L.x - 8, y: L.y - 2, w: L.w + 16, h: L.h + 4 } : { x: m[0] - 60, y: m[1] - 12, w: 120, h: 24 }, String((e.all || [e])[0].label || ''), v => change({ op: 'rename', target: ref, label: v })); }
2250
+ else if (ref.type === 'container') { const f = (geo.families || []).find(x => x.id === ref.id); if (!f) return; editor({ x: f.x + 8, y: f.y + 2, w: Math.min(f.w - 16, 220), h: 24 }, String(f.label || f.id), v => change({ op: 'rename', target: ref, label: v })); }
2251
+ }
2252
+ function addAt(x, y) {
2253
+ if (!can('add') || !geo) return;
2254
+ const hint = hintAt(x, y), box = boxAt(x, y);
2255
+ editor({ x: x - 80, y: y - 13, w: 160, h: 26 }, '', label => change({ op: 'add', kind: 'node', label, ...(box ? { container: box.id } : {}), hint }));
2256
+ }
2257
+ stage.addEventListener('dblclick', e => { if (!can('add') || !e.altKey || e.target.closest('.node, .ctl, .minimap')) return; e.preventDefault(); e.stopPropagation(); const [x, y] = worldOf(e); addAt(x, y); }, true);
2258
+ // the click a browser sends after a drag is not a click on the card; a new press ends that, or 400 ms
2259
+ stage.addEventListener('click', e => { if (swallow > performance.now()) { swallow = 0; e.stopPropagation(); e.preventDefault(); } }, true);
2260
+ /* focus: put it on a card (default: the selected one), else on the canvas. Focus that reaches the canvas from outside
2261
+ and not by a press (host.focus(), as a dialog does when it gives focus back) goes on to the selected card. */
2262
+ let pressed = false;
2263
+ stage.addEventListener('pointerdown', () => { swallow = 0; pressed = true; }, true);
2264
+ ['pointerup', 'pointercancel'].forEach(t => stage.addEventListener(t, () => { pressed = false; }, true));
2265
+ function focusMap(ref = sel) {
2266
+ const line = ref && ref.type === 'edge' && svg && svg.querySelector(`.line-key[data-i="${edgeIndex(ref)}"]`); if (line) return line.focus({ preventScroll: true }); // a line (#43)
2267
+ const id = ref && (ref.type === 'node' ? ref.id : ref.node), g = id && svg && [...svg.querySelectorAll('.node')].find(x => x.dataset.id === id); // no CSS.escape: jsdom has none
2268
+ if (!g) return stage.focus({ preventScroll: true });
2269
+ g.focus({ preventScroll: true }); const n = geo.byId[id]; if (n) cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h });
2270
+ }
2271
+ stage.addEventListener('focus', e => { if (!pressed && sel && (sel.type === 'node' || sel.type === 'edge') && !(e.relatedTarget && root.contains(e.relatedTarget))) focusMap(); });
2272
+ stage.addEventListener('focusin', e => { const t = e.target; if (pressed || !cam || !t.closest || !t.closest('.node, .um-key, .family [data-badge]') || typeof t.getBBox !== 'function') return;
2273
+ const b = t.getBBox(); cam.reveal({ x: b.x, y: b.y, w: b.width, h: b.height }); }); // Tab to something out of view brings it in
2274
+ /* dragging a card: into or out of a container (move), along its column (reorder), or to another spot (move + hint) */
2275
+ stage.addEventListener('pointerdown', e => {
2276
+ if (!E || e.button !== 0 || e.altKey || !geo) return;
2277
+ const hd = e.target.closest('.um-handle');
2278
+ if (hd && can('add')) { e.preventDefault(); try { stage.setPointerCapture(e.pointerId); } catch (_) {} conn = { from: hd.dataset.from, path: null }; return; }
2279
+ const g = e.target.closest('.node');
2280
+ if (g && geo.byId[g.dataset.id] && (can('move') || can('reorder'))) drag = { id: g.dataset.id, g, x0: e.clientX, y0: e.clientY, on: false, ghost: null, pid: e.pointerId };
2281
+ });
2282
+ stage.addEventListener('pointermove', e => {
2283
+ if (conn) {
2284
+ const n = geo.byId[conn.from], [x, y] = worldOf(e);
2285
+ if (!conn.path) { svg.insertAdjacentHTML('beforeend', '<path class="um-edit um-link"/>'); conn.path = svg.lastElementChild; }
2286
+ conn.path.setAttribute('d', `M ${n.x + n.w + 12} ${n.y + n.h / 2} L ${x} ${y}`);
2287
+ return;
2288
+ }
2289
+ if (!drag) return;
2290
+ const k = cam.s.k, dx = (e.clientX - drag.x0) / k, dy = (e.clientY - drag.y0) / k;
2291
+ if (!drag.on) { if (Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 5) return;
2292
+ drag.on = true; try { stage.setPointerCapture(drag.pid); } catch (_) {} drag.ghost = drag.g.cloneNode(true); drag.ghost.classList.add('um-ghost'); drag.ghost.classList.remove('sel'); svg.appendChild(drag.ghost); }
2293
+ drag.ghost.setAttribute('transform', `translate(${dx} ${dy})`);
2294
+ const n = geo.byId[drag.id], into = boxAt(n.x + n.w / 2 + dx, n.y + n.h / 2 + dy);
2295
+ svg.querySelectorAll('.family.um-target').forEach(f => f.classList.remove('um-target'));
2296
+ if (into) { const f = svg.querySelector(`.family[data-family="${CSS.escape(into.id)}"]`); if (f) f.classList.add('um-target'); }
2297
+ });
2298
+ const endGesture = e => {
2299
+ if (conn) { const c = conn; conn = null; if (c.path) c.path.remove(); swallow = performance.now() + 400;
2300
+ const g = root.elementFromPoint(e.clientX, e.clientY), t = g && g.closest && g.closest('.node');
2301
+ if (t && t.dataset.id !== c.from && geo.byId[t.dataset.id]) change({ op: 'add', kind: 'edge', from: c.from, to: t.dataset.id });
2302
+ return; }
2303
+ if (!drag) return;
2304
+ const d = drag; drag = null;
2305
+ if (!d.on) return;
2306
+ swallow = performance.now() + 400;
2307
+ svg.querySelectorAll('.family.um-target').forEach(f => f.classList.remove('um-target'));
2308
+ const k = cam.s.k, dx = (e.clientX - d.x0) / k, dy = (e.clientY - d.y0) / k, n = geo.byId[d.id];
2309
+ const x = n.x + n.w / 2 + dx, y = n.y + n.h / 2 + dy, hint = hintAt(x, y), from = boxOf(n.id), into = boxAt(x, y);
2310
+ const fromId = from ? from.id : null, toId = into ? into.id : null;
2311
+ let ev = null;
2312
+ if (toId !== fromId && can('move')) ev = change({ op: 'move', target: nodeRef(n.id), to: toId, hint });
2313
+ else if (gridView() && hint.col === n.col && can('reorder')) {
2314
+ const sib = siblings(n), at = sib.indexOf(n), i = sib.filter(m => m !== n && m.y + m.h / 2 < y).length;
2315
+ if (i !== at) ev = change({ op: 'reorder', target: nodeRef(n.id), index: i, hint }, reorderSlots(n, i));
2316
+ }
2317
+ else if (can('move') && (!gridView() || hint.col !== n.col || hint.row !== n.row)) ev = change({ op: 'move', target: nodeRef(n.id), to: fromId, hint });
2318
+ if (ev) { d.ghost.classList.add('um-pending'); later(() => d.ghost.remove(), 3000); } // stays at the drop until the host's update, or 3 s
2319
+ else d.ghost.remove();
2320
+ };
2321
+ stage.addEventListener('pointerup', endGesture);
2322
+ stage.addEventListener('pointercancel', () => { if (drag && drag.ghost) drag.ghost.remove(); if (conn && conn.path) conn.path.remove(); drag = conn = null; });
2323
+ /* the model to draw after an update: cards keep the slots they had (the host's own layout wins), a moved or added card
2324
+ takes its hint; kept in this handle only, never written to the host's model */
2325
+ function withPlaces(m) {
2326
+ if (options.stableLayout === false) return m;
2327
+ const out = structuredClone(m);
2328
+ (out.views || []).forEach(v => {
2329
+ const was = placed[v.id], hv = hints[v.id] || {}, ex = slotsH[v.id] || {}, adds = v.id === cur ? addHints.slice() : [];
2330
+ if (!was && !Object.keys(hv).length && !Object.keys(ex).length && !adds.length) return;
2331
+ const ids = (v.nodeIds || []).filter(id => typeof id === 'string'), mine = v.layout || {}, L = {}, taken = new Set(), key = p => p[0] + ',' + p[1];
2332
+ const put = (id, p) => { let [c, r] = p; while (taken.has(key([c, r]))) r++; L[id] = [c, r]; taken.add(key([c, r])); };
2333
+ ids.forEach(id => { if (Array.isArray(mine[id])) { L[id] = mine[id]; taken.add(key(mine[id])); } }); // the host's own layout
2334
+ ids.forEach(id => { if (!L[id] && ex[id]) put(id, ex[id]); }); // a reordered column group
2335
+ ids.filter(id => !L[id] && !hv[id] && was && was[id]).sort((a, b) => was[a][0] - was[b][0] || was[a][1] - was[b][1]).forEach(id => put(id, was[id])); // everyone stays
2336
+ ids.forEach(id => { if (!L[id] && hv[id]) put(id, hv[id]); }); // a moved card: its drop, or the nearest free slot
2337
+ ids.filter(id => !L[id] && !(was && was[id])).forEach(id => { if (adds.length) put(id, adds.shift()); }); // a new card likewise
2338
+ if (Object.keys(L).length) v.layout = L;
2339
+ });
2340
+ return out;
2341
+ }
2342
+
2343
+ /* events: each clickable thing reports its reference */
2344
+ stage.addEventListener('click', e => {
2345
+ const t = e.target;
2346
+ const bv = t.closest('.empty-view [data-v]'); if (bv) return go(bv.dataset.v);
2347
+ if (t.closest('.ctl, .minimap')) return;
2348
+ const g = t.closest('.node'), chip = t.closest('[data-chip]'), badge = t.closest('[data-badge]'), eg = t.closest('.edge-g, .e-lab'), fam = t.closest('.family');
2349
+ if (g && t.closest('.open-hint')) { if (e.detail <= 1) open(g.dataset.id); return; } // the ⤢ badge opens (a double-click on it opens once)
2350
+ let ref = null;
2351
+ if (g && badge) ref = badgeRef(g.dataset.id, null, +badge.dataset.badge);
2352
+ else if (g && chip) ref = chipRef(g, +chip.dataset.chip);
2353
+ else if (g) ref = nodeRef(g.dataset.id);
2354
+ else if (eg && geo.edges[+eg.dataset.i]) ref = edgeRef(geo.edges[+eg.dataset.i]);
2355
+ else if (fam && badge) ref = badgeRef(null, fam.dataset.family, +badge.dataset.badge);
2356
+ else if (fam) ref = { type: 'container', id: fam.dataset.family, view: cur };
2357
+ sub = null;
2358
+ if (ref) {
2359
+ if (!activate(ref, e)) return; // the host took the click (a chip as a link, say): the selection stays as it was
2360
+ if (ref.type === 'node' && (e.shiftKey || e.metaKey || e.ctrlKey) && sel && sel.type === 'node') return toggle(ref.id); // several cards
2361
+ return select(ref, { again: ref.type === 'node' }); }
2362
+ select(null);
2363
+ if (P.onBackground) P.onBackground();
2364
+ });
2365
+ stage.addEventListener('dblclick', e => { const g = e.target.closest('.node'); if (!g) return; e.preventDefault(); if (e.target.closest('.open-hint')) return;
2366
+ if (can('rename') && sel && sel.type === 'node' && sel.id === g.dataset.id && e.target.closest('.n-label')) return rename(sel); // a selected card's name edits
2367
+ open(g.dataset.id); });
2368
+ stage.addEventListener('keydown', e => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || (e.altKey && !E) || e.target.closest('input')) return; if (key(e)) { e.preventDefault(); e.stopPropagation(); } });
2369
+ stage.addEventListener('pointerover', e => { const g = e.target.closest('.node'); if (!g) return; svg.querySelectorAll('.edge-g').forEach(x => x.classList.toggle('hov', x.dataset.from === g.dataset.id || x.dataset.to === g.dataset.id)); });
2370
+ stage.addEventListener('pointerout', e => { if (e.target.closest('.node')) svg.querySelectorAll('.edge-g.hov').forEach(x => x.classList.remove('hov')); });
2371
+ const ctl = q('.ctl');
2372
+ if (ctl) ctl.addEventListener('click', e => { const z = e.target.closest('button')?.dataset.z; if (!z) return;
2373
+ if (z === 'in') cam.zoomAt(1.4, null, null, true); else if (z === 'out') cam.zoomAt(1 / 1.4, null, null, true); else if (z === 'fit') cam.fit(); else if (z === 'one') cam.actual(); else if (z === 'up') up(); else if (z === 'flow') setFlow(!stage.classList.contains('flowing')); else if (z === 'add') addAt(cam.s.cx, cam.s.cy); });
2374
+ setFlow(options.flow ?? !(typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches));
2375
+
2376
+ // the stage changes size with its container: keep the zoom, refit only an untouched view (no ResizeObserver, as in
2377
+ // jsdom: the view is fitted once)
2378
+ let rz;
2379
+ const ro = typeof ResizeObserver === 'function' && new ResizeObserver(() => { if (!cam || dead) return; cancelAnimationFrame(rz); rz = requestAnimationFrame(() => { if (dead) return; if (!cam.touched) cam.set(startView()); else cam.apply(); if (sel && sel.type === 'node' && geo.byId[sel.id]) { const n = geo.byId[sel.id]; cam.reveal({ x: n.x, y: n.y, w: n.w, h: n.h }); } }); });
2380
+ if (ro) ro.observe(stage);
2381
+
2382
+ /* the picture as a file: CSS variables resolved into attributes so it stands alone. It is drawn at rest: no
2383
+ selection, hover, flow animation or edit handles, whatever the screen shows; the screen is left as it was. */
2384
+ function standalone(scale = 1, vid = cur, fontCSS = '') {
2385
+ if (!svg || !geo) return '';
2386
+ let s = svg, g = geo, off = null;
2387
+ if (vid !== cur) { // another view: drawn off screen, inside the shadow root so the same styles apply
2388
+ const v = ctx.V[vid]; if (!v || !(v.nodeIds || []).some(i => ctx.N[i])) return '';
2389
+ const c = ctx.geoCache[vid] ||= ctx.buildView(vid, { tour: P.tour !== false, openHint: P.openHint !== false });
2390
+ off = document.createElement('div'); off.style.cssText = 'position:absolute;left:-100000px;top:0;width:10px;height:10px;overflow:hidden;pointer-events:none';
2391
+ off.innerHTML = c.markup; stage.parentNode.appendChild(off); s = off.querySelector('svg'); s.classList.add('map-svg'); g = c.g;
2392
+ }
2393
+ const s0 = sel, flowing = stage.classList.contains('flowing'), hov = [...svg.querySelectorAll('.hov')];
2394
+ sel = null; paint(); stage.classList.remove('flowing'); hov.forEach(x => x.classList.remove('hov')); stage.classList.add('um-still');
2395
+ const clone = s.cloneNode(true), b = g.bounds;
2396
+ const src = s.querySelectorAll('*'), dst = clone.querySelectorAll('*');
2397
+ const props = ['fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-family', 'font-size', 'font-weight', 'letter-spacing', 'visibility'];
2398
+ const unvar = (t, cs) => t.replace(/var\((--[\w-]+)\s*(?:,\s*([^()]*))?\)/g, (m, n, f) => cs.getPropertyValue(n).trim() || (f || '').trim());
2399
+ try { src.forEach((el, i) => { const cs = getComputedStyle(el), d = dst[i];
2400
+ [...d.attributes].forEach(a => { if (a.value.includes('var(')) d.setAttribute(a.name, unvar(a.value, cs)); });
2401
+ props.forEach(p => { const v = cs.getPropertyValue(p); if (v) d.style.setProperty(p, v); });
2402
+ if (cs.textTransform === 'uppercase' && !d.childElementCount) d.textContent = d.textContent.toUpperCase(); }); } // tab text, as drawn
2403
+ finally { stage.classList.remove('um-still'); hov.forEach(x => x.classList.add('hov')); if (flowing) stage.classList.add('flowing'); sel = s0; paint(); if (off) off.remove(); }
2404
+ clone.querySelectorAll('title, .flowline, .um-edit, .um-ghost, .um-link, .keys, .um-key').forEach(t => t.remove());
2405
+ clone.setAttribute('role', 'img'); clone.querySelectorAll('[tabindex], [aria-pressed]').forEach(t => ['tabindex', 'role', 'aria-pressed'].forEach(a => t.removeAttribute(a)));
2406
+ clone.setAttribute('viewBox', `${b.x} ${b.y} ${b.w} ${b.h}`); clone.setAttribute('width', b.w * scale); clone.setAttribute('height', b.h * scale);
2407
+ const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
2408
+ Object.entries({ x: b.x, y: b.y, width: b.w, height: b.h, fill: getComputedStyle(stage).backgroundColor }).forEach(([k2, v]) => bg.setAttribute(k2, v));
2409
+ clone.insertBefore(bg, clone.firstChild.nextSibling);
2410
+ if (fontCSS) { const st = document.createElementNS('http://www.w3.org/2000/svg', 'style'); st.textContent = fontCSS; clone.insertBefore(st, clone.firstChild); } // the host's own @font-face rules, carried into the file
2411
+ return new XMLSerializer().serializeToString(clone);
2412
+ }
2413
+
2414
+ function update(model2, relayout = false) {
2415
+ if (dead) return;
2416
+ // an identical model: nothing to redraw, unless the host accepted a move or reorder that only changed a position
2417
+ if (!relayout && !Object.keys(hints).length && !Object.keys(slotsH).length && JSON.stringify(model2) === JSON.stringify(current)) { addHints.length = 0; return; }
2418
+ const old = ctx, keys = {}, before = new Set(geo ? geo.nodes.map(n => n.id) : []), wantNew = selectNew;
2419
+ old.views.forEach(v => { keys[v.id] = relayout ? null : old.viewKey(v.id); });
2420
+ current = model2;
2421
+ ctx = mapContext(withPlaces(model2), { measure, kinds: !!P.kinds, prev: options.stableLayout === false ? null : old.planCache });
2422
+ [hints, slotsH].forEach(o => Object.keys(o).forEach(k => delete o[k])); addHints.length = 0; selectNew = false;
2423
+ ctx.views.forEach(v => { const id = v.id; if (keys[id] == null || keys[id] !== ctx.viewKey(id)) return;
2424
+ if (id in old.planCache) ctx.planCache[id] = old.planCache[id];
2425
+ if (old.layoutCache[id]) ctx.layoutCache[id] = old.layoutCache[id];
2426
+ if (old.geoCache[id]) ctx.geoCache[id] = old.geoCache[id]; });
2427
+ if (!cur) return;
2428
+ if (!ctx.V[cur]) { const from = cur; mount(ctx.rootId); cam.set(startView()); cam.touched = false; emit('viewchange', { view: cur, from }); return; }
2429
+ if (ctx.geoCache[cur] && keys[cur] === ctx.viewKey(cur)) return; // this view's picture did not change
2430
+ const keep = sel, keepMore = more, touched = cam.touched;
2431
+ mount(cur);
2432
+ if (touched) cam.set({}); else cam.set(startView());
2433
+ cam.touched = touched;
2434
+ const fresh = geo.nodes.filter(n => !before.has(n.id));
2435
+ if (wantNew && fresh.length === 1) select(nodeRef(fresh[0].id), { reveal: false });
2436
+ else if (keep && exists(keep)) { sel = keep; more = keep.type === 'node' ? keepMore.filter(id => geo.byId[id]) : []; paint(); emitSel(); }
2437
+ }
2438
+
2439
+ function destroy() {
2440
+ if (dead) return;
2441
+ dead = true; if (ro) ro.disconnect(); cancelAnimationFrame(rz);
2442
+ timers.forEach(clearTimeout); timers.clear();
2443
+ if (cam) cam.stopAnimation();
2444
+ host.remove();
2445
+ Object.keys(subs).forEach(k => delete subs[k]);
2446
+ }
2447
+
2448
+ const inner = { ctx: () => ctx, geo: () => geo, cam: () => cam, svg: () => svg, stage, host, go, open, up, flash, setFlow, pause, key, startView, standalone,
2449
+ select, busy: () => busy, flowing: () => stage.classList.contains('flowing'), view: () => cur };
2450
+ const first = () => {
2451
+ if (dead) return;
2452
+ const v0 = ctx.V[options.view] ? options.view : ctx.rootId;
2453
+ if (!v0) return;
2454
+ mount(v0); cam.set(startView()); cam.touched = false;
2455
+ if (options.select) select(options.select, { reveal: false });
2456
+ if (P.onReady) P.onReady();
2457
+ };
2458
+ if (P.defer) requestAnimationFrame(() => later(first, 16)); else first();
2459
+ // the host's fonts may still be loading: measure again once they are in, and redraw
2460
+ if (measure && !own && document.fonts && document.fonts.status !== 'loaded') document.fonts.ready.then(() => { if (dead) return; measure = hostMeasure(host); update(current, true); });
2461
+
2462
+ return {
2463
+ update: m => update(m),
2464
+ setView: id => go(id),
2465
+ select: ref => select(ref),
2466
+ fit: () => { if (cam) cam.fit(); },
2467
+ toSVG: (o = {}) => dead ? '' : standalone(o.scale ?? 1, o.view ?? cur, o.fontCSS),
2468
+ toPNG: (o = {}) => dead ? Promise.resolve(null) : pngOf(standalone(o.scale ?? 2, o.view ?? cur, o.fontCSS)),
2469
+ focus: ref => { if (!dead) focusMap(ref); },
2470
+ on(type, fn) { (subs[type] ||= []).push(fn); if (type === 'click') tabKeys();
2471
+ return () => { subs[type] = (subs[type] || []).filter(f => f !== fn); if (type === 'click' && !dead) tabKeys(); }; },
2472
+ get view() { return cur; },
2473
+ get selection() { return sel; },
2474
+ get selected() { return picked(); },
2475
+ destroy,
2476
+ [INNER]: inner,
2477
+ };
2478
+ }
2479
+
2480
+ /* ================= the standalone viewer =================
2481
+ One-row top bar · column browser of views on the left · map on top · details strip below; built on render().
2482
+ It owns the page: the document title, the URL hash (#view=&node=), keyboard shortcuts and saved preferences. */
2483
+ function toggleTheme() {
2484
+ const cur = document.documentElement.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
2485
+ const next = cur === 'light' ? 'dark' : 'light';
2486
+ document.documentElement.dataset.theme = next;
2487
+ try { localStorage.setItem('visual-theme', next); } catch (e) {}
2488
+ }
2489
+
2490
+ /* undo for a host: the models it had and has undone, in order. The host makes it, holds it and decides what goes in;
2491
+ render() never sees it. `limit` caps how many steps back are kept. */
2492
+ export function history(model, limit = 100) {
2493
+ const past = [], future = []; let now = model;
2494
+ return {
2495
+ get model() { return now; },
2496
+ get canUndo() { return past.length > 0; },
2497
+ get canRedo() { return future.length > 0; },
2498
+ push(m) { past.push(now); if (past.length > limit) past.shift(); future.length = 0; return (now = m); },
2499
+ undo() { if (past.length) { future.push(now); now = past.pop(); } return now; },
2500
+ redo() { if (future.length) { past.push(now); now = future.pop(); } return now; },
2501
+ };
2502
+ }
2503
+
2504
+ export function mountPage(container, model, options = {}) {
2505
+ const Q = options.params || new URLSearchParams();
2506
+ if (options.instant) INSTANT = true;
2507
+ document.body.classList.add('map-mode');
2508
+ let { D, views, nodes, N, V, rootId, nodeIn: nodeIn0, childOf: childOf0, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = mapContext(model);
2509
+ const nodeIn = (nid, vid = cur) => nodeIn0(nid, vid), childOf = (nid, vid = cur) => childOf0(nid, vid);
2510
+ const shortT = t => String(t || '').split(' · ')[0];
2511
+ const root = D.meta && D.meta.root ? D.meta.root.replace(/\/$/, '') : '';
2512
+ const base = D.sourcesBase ? String(D.sourcesBase).replace(/\/$/, '') + '/' : '';
2513
+ const srcPath = p => p.replace(/:\d+(-\d+)?$/, '');
2514
+ // meta.root is the repo root relative to this page (older pages: absolute). `~/…` sources live
2515
+ // outside the repo, so they get no link.
2516
+ // meta.sourceBase (render --source-base): in-pack sources were rewritten to URLs under it and
2517
+ // show as their pack-relative path; any other relative source gets no link on a published page.
2518
+ const sb = D.meta && D.meta.sourceBase ? String(D.meta.sourceBase) : '';
2519
+ const srcLabel = p => sb && p.startsWith(sb) ? decodeURI(p.slice(sb.length)).replace(/#L(\d+)(?:-L(\d+))?$/, (_, a, b) => ':' + a + (b ? '-' + b : '')) : p;
2520
+ const srcHref = p => /^https?:/.test(p) ? p
2521
+ : p.startsWith('~') || sb ? ''
2522
+ : p.startsWith('/') ? 'file://' + encodeURI(srcPath(p))
2523
+ : root.startsWith('/') ? 'file://' + encodeURI(root + '/' + base + srcPath(p))
2524
+ : encodeURI((root ? root + '/' : '') + base + srcPath(p));
2525
+ const srcList = n => (Array.isArray(n.source) ? n.source : n.source ? [n.source] : []);
2526
+ const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || '');
2527
+ const modK = mac ? '⌘K' : 'Ctrl K';
2528
+ const touch = matchMedia('(hover: none)').matches;
2529
+ const HINT = touch ? '<span>pinch = zoom</span><span>drag = move</span><span>tap = details</span>'
2530
+ : `<span>scroll = zoom</span><span>drag = move</span><span>double-click ⤢ = open</span><span>${modK} = search</span><span>? = keys</span>`;
2531
+ const ghost = [[10, 26, 22], [40, 18, 24], [40, 46, 24], [10, 60, 22], [70, 32, 20], [70, 70, 20]].map(([l, t, w]) => `<span style="left:${l}%;top:${t}%;width:${w}%"></span>`).join('');
2532
+
2533
+ const app = container; app.className = 'mapapp';
2534
+ app.innerHTML = `
2535
+ <header class="mbar">
2536
+ <button class="ibtn" id="m-outline" title="Views (O)" aria-label="Toggle the views list" aria-expanded="false">☰</button>
2537
+ <span class="mbrand"><i></i><span>${esc(D.title || 'Map')}</span></span>
2538
+ <nav class="crumbs" id="m-crumbs" aria-label="Breadcrumb"></nav>
2539
+ <button class="findbtn" id="m-find" type="button" aria-label="Search nodes and views (${modK})"><span aria-hidden="true">⌕</span><span class="ft">Find a node or view…</span><span class="kbd">${modK}</span></button>
2540
+ <button class="ibtn" id="m-theme" title="Light / dark" aria-label="Toggle theme">◐</button>
2541
+ <div class="menu"><button class="pbtn" id="m-export" aria-haspopup="true" aria-expanded="false">Export ▾</button>
2542
+ <div class="menu-pop" id="m-export-pop" hidden><button data-x="svg">SVG · this view</button><button data-x="png">PNG · this view (2×)</button><button data-x="link">Copy link to view</button><hr><button data-x="json">Map model (JSON)</button></div></div>
2543
+ <button class="ibtn" id="m-pause" type="button" title="Pause" aria-label="Pause" aria-pressed="false">❚❚</button>
2544
+ <button class="ibtn" id="m-help" title="Keys (?)" aria-label="Keyboard shortcuts" aria-haspopup="true" aria-expanded="false">?</button>
2545
+ </header>
2546
+ <aside class="outline" id="m-tree" aria-label="Views"></aside>
2547
+ <div class="mmain">
2548
+ <div class="mstage" id="m-stage">
2549
+ <div class="mcanvas" id="m-canvas"></div>
2550
+ <div class="ghosts" id="m-ghosts" aria-hidden="true">${ghost}</div>
2551
+ <div class="vpill" id="m-vpill"></div>
2552
+ <div class="m-legend" id="m-legend"></div>
2553
+ <div class="hint" id="m-hint" role="status" aria-live="polite">${HINT}</div>
2554
+ </div>
2555
+ <aside class="panel" id="m-panel" aria-label="Node details" hidden></aside>
2556
+ </div>
2557
+ <div class="hpop" id="m-helpbox" role="dialog" aria-label="Keys" hidden><b>Keys</b>
2558
+ <dl><dt>/ or ${modK}</dt><dd>search nodes and views</dd><dt>Scroll · pinch</dt><dd>zoom (two-finger drag pans)</dd><dt>Click</dt><dd>details below the map</dd><dt>Double-click · Enter</dt><dd>open a stacked node ⤢</dd>
2559
+ <dt>Esc · Backspace</dt><dd>close, then up one level</dd><dt>+ − 0 1</dt><dd>zoom in, out, fit, 100%</dd><dt>← ↑ → ↓</dt><dd>pan</dd><dt>[ ]</dt><dd>previous / next details tab</dd><dt>i</dt><dd>about this view</dd><dt>O</dt><dd>views list</dd><dt>F</dt><dd>flow on/off</dd></dl></div>
2560
+ <div class="scrim" id="m-pal" hidden><div class="pal" role="dialog" aria-label="Search">
2561
+ <div class="in"><span aria-hidden="true">⌕</span><input id="m-q" type="text" autocomplete="off" spellcheck="false" placeholder="Search nodes and views" aria-label="Search nodes and views" aria-controls="m-res"><span class="kbd">esc</span></div>
2562
+ <div class="res" id="m-res" role="listbox"></div>
2563
+ <div class="pft"><span>↑↓ move</span><span>↵ open</span><span>esc close</span></div></div></div>`;
2564
+ const $ = id => document.getElementById(id);
2565
+ const stage = $('m-stage'), panel = $('m-panel');
2566
+ let X = null, cam = null, geo = null, svg = null, cur = null, sel = null, stack = [], paused = false, replaceNext = false;
2567
+ let tab = 'when'; try { tab = localStorage.getItem('visual-tab') || 'when'; } catch (_) {}
2568
+ const status = (t, ms = 1800) => { const el = $('m-hint'); el.textContent = t; el.classList.add('msg'); clearTimeout(status.t); status.t = setTimeout(() => { el.innerHTML = HINT; el.classList.remove('msg'); }, ms); };
2569
+
2570
+ function renderChrome() {
2571
+ const v = V[cur];
2572
+ $('m-crumbs').innerHTML = stack.map((id, i) => i === stack.length - 1
2573
+ ? `<span class="crumb cur" aria-current="page">${esc(V[id].title || id)}</span>`
2574
+ : `<button class="crumb" data-v="${esc(id)}">${esc(shortT(V[id].title || id))}</button><span class="crumb-sep">›</span>`).join('');
2575
+ const parent = stack.length > 1 ? stack[stack.length - 2] : v.parent;
2576
+ renderPill(parent); renderLegend(); renderTree();
2577
+ document.title = `${v.title || v.id} · ${D.title || 'map'}`;
2578
+ }
2579
+ function renderPill(parent) {
2580
+ const v = V[cur], mainSteps = v.mainSteps || v.render === 'process', seq = v.render === 'sequence', ve = viewEdges(v), L = planCache[cur] && planCache[cur].hub ? planCache[cur] : null;
2581
+ const depth = Math.max(...views.map(x => chain(x.id).length));
2582
+ $('m-vpill').innerHTML = `<button class="pill" id="m-pillbtn" aria-expanded="false" aria-controls="m-vpop" title="About this view (i)"><b>${esc(shortT(v.title || v.id))}</b><span class="n">${mainSteps ? (v.stepIds || v.nodeIds || []).length + ' steps' : seq ? ve.length + ' steps' : geo.nodes.length + ' nodes'}</span><span class="kbd">i</span></button>
2583
+ ${mainSteps && (v.note || v.caption) ? `<p class="view-note">${inline(v.note || v.caption)}</p>` : ''}
2584
+ <div class="vpop" id="m-vpop" hidden>${parent ? `<button class="back" data-v="${esc(parent)}">← ${esc(shortT(V[parent].title || parent))}</button>` : ''}
2585
+ <h4>${esc(v.title || v.id)}</h4>${v.caption || v.description ? `<p>${inline(v.caption || v.description)}</p>` : ''}
2586
+ ${L ? `<div class="vlanes">${L.lanes.map((l, i) => `${i ? '<i>→</i>' : ''}<span><em>${i + 1}</em>${esc(l.title)} <small>${l.ids.length}</small></span>`).join('')}</div>` : ''}
2587
+ ${seq ? `<ol>${ve.map(e => `<li title="${esc((N[e.from] || {}).label || e.from)} → ${esc((N[e.to] || {}).label || e.to)}">${esc(e.label || (N[e.to] || {}).label || e.to)}</li>`).join('')}</ol>` : ''}
2588
+ <div class="vm">${geo.nodes.length} nodes · ${geo.edges.length} wires${views.length > 1 ? ` · level ${chain(cur).length} of ${depth}` : ''}</div></div>`;
2589
+ }
2590
+ const togglePill = force => { const b = $('m-pillbtn'), p = $('m-vpop'); if (!b) return; const on = force != null ? force : p.hidden; p.hidden = !on; b.setAttribute('aria-expanded', on); };
2591
+ let legendFolded = false; try { legendFolded = localStorage.getItem('visual-legend') === '0'; } catch (_) {}
2592
+ function renderLegend() {
2593
+ const used = [...new Set(geo.nodes.map(n => n.k))];
2594
+ const order = ['m-public', 'm-step', 'm-lane', 'm-ext', 'm-verb', 'm-board', 'm-hook', 'm-script', 'frontend', 'backend', 'ai', 'queue', 'database', 'security', 'cloud', 'external', 'file'];
2595
+ const shape = k => k === 'm-verb' ? 'p' : (k === 'm-ext' || k === 'external') ? 'd' : k === 'm-hook' ? 'h' : '';
2596
+ const items = order.filter(k => used.includes(k)).map(k => `<span class="lgi" title="${esc(KIND[k][1] + ' — ' + KIND[k][2])}"><i class="${shape(k)}" style="border-color:var(--um-${KIND[k][0]});background:var(--um-${KIND[k][0]}-f)${k === 'm-hook' ? `;background:var(--um-${KIND[k][0]})` : ''}"></i><b>${KIND[k][1]}</b><span>${KIND[k][2]}</span></span>`)
2597
+ .concat(geo.nodes.some(n => n.kids || n.opens) ? ['<span class="lgi"><i class="s"></i><b>⤢ Stacked</b><span>double-click to open</span></span>'] : [])
2598
+ .concat(geo.nodes.some(n => n.step) ? ['<span class="lgi"><i class="num">1</i><b>Step</b><span>run order</span></span>'] : [])
2599
+ .concat(geo.nodes.some(n => n.tag) ? ['<span class="lgi"><i class="tag"></i><b>@author</b><span>who wrote it</span></span>'] : [])
2600
+ .concat(WIRES.filter(w => geo.edges.some(e => e.wk === w[0])).map(w => `<span class="lgi"><i class="w" style="border-color:var(--um-w-${w[0]})"></i><b>${w[1]}</b><span>${w[2]}</span></span>`))
2601
+ .concat(geo.edges.some(e => e.back) ? ['<span class="lgi"><i class="w dash" style="border-color:var(--um-w-calls)"></i><b>loops back</b><span>the one line that runs right to left</span></span>'] : []);
2602
+ const el = $('m-legend');
2603
+ el.hidden = !items.length;
2604
+ el.classList.toggle('folded', legendFolded);
2605
+ el.innerHTML = `<div class="t"><span>LEGEND</span><button type="button" id="m-lfold" aria-label="${legendFolded ? 'Show' : 'Hide'} legend" aria-expanded="${!legendFolded}">${legendFolded ? '+' : '–'}</button></div>${items.join('')}`;
2606
+ }
2607
+ function renderTree() { // Readable columns; a declared order may include shortcuts to descendants.
2608
+ const ch = chain(cur);
2609
+ const roots = views.filter(x => !x.parent), cols = roots.length > 1 ? [{ head: 'Map', items: roots, on: ch[0] }] : [];
2610
+ ch.forEach((id, i) => { const order = V[id].browserOrder || [], k = [...new Set([...order, ...kidsOf(id).map(v => v.id)])].map(id => V[id]).filter(Boolean); if (k.length) cols.push({ head: shortT(V[id].title || id), items: k, on: ch[i + 1] }); });
2611
+ document.body.style.setProperty('--browser-width', Math.min(448, Math.max(1, cols.length) * 224) + 'px');
2612
+ $('m-tree').innerHTML = `<div class="miller">${cols.map(c => `<div class="mcol"><div class="mch" title="${esc(c.head)}">${esc(c.head)}</div>${c.items.map(x => { const n = kidsOf(x.id).length;
2613
+ return `<button class="mit${x.id === cur ? ' on' : x.id === c.on ? ' path' : ''}" data-v="${esc(x.id)}" title="${esc(x.title || x.id)}"${x.id === cur ? ' aria-current="page"' : ''}><span>${esc(shortT(x.title || x.id))}</span><em>${(x.nodeIds || []).length}${n ? ' ›' : ''}</em></button>`; }).join('')}</div>`).join('')}</div>`;
2614
+ const m = $('m-tree').firstChild, last = m.lastElementChild; m.style.scrollBehavior = 'auto'; m.scrollLeft = last ? Math.max(0, last.offsetLeft + last.offsetWidth - m.clientWidth) : 0; m.style.scrollBehavior = '';
2615
+ }
2616
+ const setTree = on => { document.body.classList.toggle('tree-open', on); $('m-outline').setAttribute('aria-expanded', on); };
2617
+
2618
+ // Pull concise points from captured prose; retain wording and keep the full point in its tooltip.
2619
+ function detailBullets(body) {
2620
+ const normalized = String(body || '').replace(/^\s*(?:[-*]|\d+[.)])\s+/gm, '').replace(/\n+/g, ' ').trim();
2621
+ let parts = normalized.split(/(?<=[.!?])\s+(?=[A-Z`/])|;\s+|\s+[·|]\s+/).filter(Boolean);
2622
+ while (parts.length < 2 || parts.some(p => p.length > 160) && parts.length < 4) {
2623
+ const index = parts.reduce((best, p, i) => p.length > (parts[best] || '').length ? i : best, 0), value = parts[index];
2624
+ if (!value || value.length < 8) break;
2625
+ const separators = [...value.matchAll(/:\s+|,\s+|\s+(?=then\b|with\b|before\b|after\b|once\b|to\b|and\b|or\b|during\b|by\b|whose\b|outside\b|on\b|for\b|as\b|whenever\b|into\b|in\b|from\b|when\b|includes\b|typed\b)/g)].map(m => m.index + m[0].length);
2626
+ const safe = i => (value.slice(0, i).match(/`/g) || []).length % 2 === 0;
2627
+ const cuts = separators.filter(i => i >= 6 && i < value.length - 4 && safe(i));
2628
+ if (!cuts.length && parts.length < 2) cuts.push(...[...value.matchAll(/\s+/g)].map(m => m.index).filter(i => i >= 6 && i < value.length - 4 && safe(i)));
2629
+ const cut = cuts.sort((a, b) => Math.abs(a - value.length / 2) - Math.abs(b - value.length / 2))[0];
2630
+ if (!cut) break;
2631
+ parts.splice(index, 1, value.slice(0, cut).replace(/[,;:]\s*$/, '').trim(), value.slice(cut).trim());
2632
+ }
2633
+ if (parts.length > 4) parts = [...parts.slice(0, 3), parts.slice(3).join(' ')];
2634
+ return parts;
2635
+ }
2636
+ /* selection + details strip */
2637
+ /* selection + details strip: the canvas reports, the page shows the panel and records the hash */
2638
+ const select = (nid, opts = {}) => X.select({ type: 'node', id: nid }, opts);
2639
+ function hidePanel() { panel.hidden = true; }
2640
+ const deselect = () => X.select(null);
2641
+ function relRows(list, dir) {
2642
+ if (!list.length) return `<div class="rel-none">${dir === 'up' ? 'Nothing calls this here' : 'Calls nothing here'}</div>`;
2643
+ return '<ul class="relation-list">' + list.map(({ e, id, inView }) => { const o = N[id] || { label: id, kind: '' }, k = kindOf(o.kind, true);
2644
+ return `<li><button class="rel" data-n="${esc(id)}" title="${esc((e.when ? e.when + ' — ' : '') + (inView ? 'Select' : 'Find in another view'))}"><i style="background:var(--um-${KIND[k][0]})"></i><span class="rel-l">${esc(o.label)}</span>
2645
+ <span class="rel-e">${e.step != null ? esc(e.step) + ' · ' : ''}${esc(e.label || '')}${inView ? '' : ' ↗'}</span></button></li>`; }).join('') + '</ul>';
2646
+ }
2647
+ const authorChip = login => { const A = (D.authors || {})[login] || {};
2648
+ return `<a class="author" href="${esc(A.url || 'https://github.com/' + login)}" target="_blank" rel="noopener">${A.avatar ? `<img src="${A.avatar}" alt="">` : `<i>${esc(login.slice(0, 2).toUpperCase())}</i>`}<span>${esc(A.name || login)} <b>@${esc(login)}</b></span></a>`; };
2649
+ function renderPanel(nid) {
2650
+ const n = nodeIn(nid), k = kindOf(n.kind, true), c = `var(--um-${KIND[k][0]})`, pub = k === 'm-public', ext = k === 'm-ext';
2651
+ const inView = new Set(geo.nodes.map(x => x.id)), seen = new Set();
2652
+ const collect = dir => {
2653
+ const out = [];
2654
+ const add = (e, id, iv) => { const key = dir + id + (e.label || ''); if (seen.has(key)) return; seen.add(key); out.push({ e, id, inView: iv }); };
2655
+ geo.edges.forEach(e => { if (dir === 'up' && e.to === nid) add(e, e.from, true); if (dir === 'down' && e.from === nid) add(e, e.to, true); });
2656
+ viewEdges(V[cur]).forEach((e, i) => { const e2 = V[cur].render === 'sequence' ? { ...e, step: i + 1 } : e; if (dir === 'up' && e.to === nid) add(e2, e.from, true); if (dir === 'down' && e.from === nid) add(e2, e.to, true); });
2657
+ (D.edges || []).forEach(e => { if (dir === 'up' && e.to === nid) add(e, e.from, inView.has(e.from)); if (dir === 'down' && e.from === nid) add(e, e.to, inView.has(e.to)); });
2658
+ return out;
2659
+ };
2660
+ const ups = collect('up'), downs = collect('down'), srcs = srcList(n);
2661
+ const child = childOf(nid), canOpen = child && child !== cur;
2662
+ const vIn = viewsWith(nid).filter(v => v.id !== cur);
2663
+ const by = n.author ? `<span>·</span>${authorChip(n.author)}` : ext ? '<span class="origin">· author unknown</span>' : '';
2664
+ const exs = Array.isArray(n.examples) ? n.examples.filter(x => x && (x.thumb || x.href)) : [];
2665
+ const tabs = [['when', 'When to use'], ['what', 'What it is'], ['how', 'How it works'], ...(exs.length ? [['examples', 'Example']] : []), ['links', 'Behind the scenes']];
2666
+ TABS = tabs.map(t => t[0]);
2667
+ if (!TABS.includes(tab)) tab = 'when';
2668
+ const text = (h4, body, key) => body ? `<h4>${h4}</h4><ul class="detail-bullets">${detailBullets(body).map(b => `<li title="${esc(b)}">${inline(b)}</li>`).join('')}</ul>` : `<h4>${h4}</h4><div class="pnone">Not written yet — add <code>${key}</code> to this node in the map model.</div>`;
2669
+ const verbs = (n.verbs || []).length ? `<div class="pverbs">${n.verbs.map(v => { const vid = N[nid + ':' + v] ? nid + ':' + v : null; return `<${vid ? `button data-n="${esc(vid)}"` : 'span'} class="pverb" style="--c:${c}">${esc(v)}</${vid ? 'button' : 'span'}>`; }).join('')}</div>` : '';
2670
+ const srcHtml = srcs.length ? '<ul class="source-list">' + srcs.map(s => `<li class="src">${srcHref(s) ? `<a href="${esc(srcHref(s))}" title="Open ${esc(sb ? srcLabel(s) : base + s)}"${sb ? ' target="_blank" rel="noopener"' : ''}>${esc(srcLabel(s))}</a>` : `<span>${esc(s)}</span>`}<button class="ibtn sm" data-copy="${esc(sb ? srcLabel(s) : base + s)}" title="Copy path" aria-label="Copy path">⧉</button></li>`).join('') + '</ul>' : '<div class="rel-none">No source listed</div>';
2671
+ const bodies = {
2672
+ when: text('When to use', n.when, 'when'),
2673
+ what: text('What it is', n.what, 'what') + verbs,
2674
+ how: text('How it works', n.how, 'how'),
2675
+ examples: `<h4>Example</h4><ul class="exs">${exs.map((x, i) => `<li class="ex">${x.thumb ? `<button class="ex-thumb" data-ex="${i}" title="Open full size" aria-label="Open ${esc(x.title || 'example')} full size"><img src="${esc(x.thumb)}" alt="" loading="lazy"></button>` : ''}<b>${inline(x.title || '')}</b>${x.caption ? `<small>${inline(x.caption)}</small>` : ''}${x.href ? `<a href="${esc(x.href)}" target="_blank" rel="noopener">Open live page ↗</a>` : ''}</li>`).join('')}</ul>`,
2676
+ links: `<div class="links"><div><h4>← Comes from</h4>${relRows(ups, 'up')}</div><div><h4>→ Leads to</h4>${relRows(downs, 'down')}</div>
2677
+ <div><h4>Source files</h4>${srcHtml}${vIn.length ? `<h4 style="margin-top:10px">Also shown in</h4><ul class="pviews">${vIn.map(v => `<li><button class="pview" data-v="${esc(v.id)}" data-sel="${esc(nid)}">${esc(shortT(v.title || v.id))}</button></li>`).join('')}</ul>` : ''}</div></div>`,
2678
+ };
2679
+ panel.innerHTML = `<div class="ph" style="--c:${c}">
2680
+ ${(() => { const I = (D.icons || {})[n.icon];
2681
+ return I && I.src ? `<span class="big logo${I.dark ? ' dark' : ''}" aria-hidden="true"><img src="${I.src}" width="22" height="22" alt=""></span>`
2682
+ : `<span class="big" style="background:${c}" aria-hidden="true"><svg viewBox="0 0 ${I && I.g ? 24 : 16} ${I && I.g ? 24 : 16}" width="20" height="20" fill="none" stroke="currentColor" stroke-width="${I && I.g ? 2 : 1.5}" stroke-linecap="round" stroke-linejoin="round">${I && I.g ? I.g : SIGIL[k]}</svg></span>`; })()}
2683
+ <div class="pt"><h3>${esc(n.label || nid)}</h3><small><span>${esc(KIND[k][1])}</span><span class="vis ${pub ? 'pub' : ext ? 'ext' : ''}">${badgesOf(n).length ? badgesOf(n).map(b => esc(b.text.toUpperCase())).join(' · ') : pub ? 'PUBLIC' : ext ? 'EXTERNAL' : 'INTERNAL'}</span>${by}${n.origin ? `<span class="origin">${esc(n.origin)}</span>` : ''}</small></div>
2684
+ <div class="acts">${canOpen ? `<button class="obtn" data-open="${esc(child)}" style="--c:${c}">⤢ Open ${esc(shortT(V[child].title || child))} <span>${(V[child].nodeIds || []).length} nodes · Enter</span></button>` : ''}<button class="ibtn x" data-close title="Close (Esc)" aria-label="Close details">×</button></div>
2685
+ </div>
2686
+ <div class="ptabs" role="tablist" aria-label="Details">${tabs.map(t => `<button class="ptab" role="tab" id="pt-${t[0]}" data-tab="${t[0]}" aria-selected="${t[0] === tab}" aria-controls="m-pbody">${t[1]}${t[2] != null ? `<em>${t[2]}</em>` : ''}</button>`).join('')}
2687
+ <span class="pnav"><button class="ibtn sm" data-step="-1" title="Previous ([)" aria-label="Previous tab">‹</button><button class="ibtn sm" data-step="1" title="Next (])" aria-label="Next tab">›</button></span></div>
2688
+ <div class="pbody" id="m-pbody" role="tabpanel" aria-labelledby="pt-${tab}">${bodies[tab]}</div>`;
2689
+ panel.hidden = false;
2690
+ panel.dataset.node = nid;
2691
+ panel.querySelectorAll('.clamp').forEach(el => { if (el.scrollHeight > el.clientHeight + 2) el.insertAdjacentHTML('afterend', '<button class="morebtn" data-more>Show all</button>'); });
2692
+ }
2693
+ let TABS = ['when', 'what', 'how', 'links'];
2694
+ function openExample(nid, i) {
2695
+ const x = ((nodeIn(nid) || {}).examples || [])[i]; if (!x) return;
2696
+ let lb = $('m-lightbox');
2697
+ if (!lb) { lb = h(`<div class="lightbox" id="m-lightbox" role="dialog" aria-modal="true" aria-label="Example" hidden></div>`); document.body.appendChild(lb);
2698
+ lb.addEventListener('click', e => { if (e.target === lb || e.target.closest('[data-lbclose]')) closeExample(); }); }
2699
+ lb.innerHTML = `<figure><img src="${esc(x.full || x.thumb)}" alt="${esc(x.title || '')}"><figcaption><span>${inline(x.title || '')}</span>${x.href ? `<a href="${esc(x.href)}" target="_blank" rel="noopener">Open live page ↗</a>` : ''}<button class="ibtn" data-lbclose title="Close (Esc)" aria-label="Close">×</button></figcaption></figure>`;
2700
+ lb.hidden = false; lb.querySelector('[data-lbclose]').focus();
2701
+ }
2702
+ const closeExample = () => { const lb = $('m-lightbox'); if (lb) lb.hidden = true; };
2703
+ const setTab = t => { tab = t; try { localStorage.setItem('visual-tab', t); } catch (_) {} if (sel) renderPanel(sel); };
2704
+
2705
+ /* navigation */
2706
+ function setHash(replace) {
2707
+ const p = new URLSearchParams(); p.set('view', cur); if (sel) p.set('node', sel);
2708
+ ['theme', 'check', 'shot'].forEach(k => Q.get(k) && p.set(k, Q.get(k)));
2709
+ const h = '#' + p.toString();
2710
+ if (location.hash === h) return;
2711
+ if (replace) window.history.replaceState(null, '', h); else window.history.pushState(null, '', h); // window's: history() is this module's undo helper
2712
+ }
2713
+ const go = (vid, how = {}) => { if (how.replace) replaceNext = true; return X.go(vid, how); };
2714
+ const open = nid => X.open(nid), up = () => X.up();
2715
+ const findNode = nid => { if (geo.byId[nid]) return select(nid); const vs = viewsWith(nid); const v = vs.find(x => chain(x.id).includes(cur)) || vs[0]; if (v) go(v.id, { select: nid }); };
2716
+
2717
+ /* events */
2718
+ stage.addEventListener('click', e => {
2719
+ if (e.target.closest('#m-pillbtn')) return togglePill();
2720
+ if (e.target.closest('#m-lfold')) { legendFolded = !legendFolded; try { localStorage.setItem('visual-legend', legendFolded ? '0' : '1'); } catch (_) {} renderLegend(); if (!cam.touched) cam.set(X.startView()); return; }
2721
+ const bv = e.target.closest('.vpop [data-v]'); if (bv) return go(bv.dataset.v);
2722
+ });
2723
+ const setFlow = on => X.setFlow(on);
2724
+ const closeHelp = () => { $('m-helpbox').hidden = true; $('m-help').setAttribute('aria-expanded', 'false'); };
2725
+ const closeExport = () => { $('m-export-pop').hidden = true; $('m-export').setAttribute('aria-expanded', 'false'); };
2726
+ panel.addEventListener('click', e => {
2727
+ const t = e.target;
2728
+ const tb = t.closest('[data-tab]'); if (tb) return setTab(tb.dataset.tab);
2729
+ const st = t.closest('[data-step]'); if (st) return setTab(TABS[(TABS.indexOf(tab) + +st.dataset.step + TABS.length) % TABS.length]);
2730
+ const mb = t.closest('[data-more]'); if (mb) { mb.previousElementSibling.classList.add('open'); mb.remove(); }
2731
+ const ex = t.closest('[data-ex]'); if (ex) return openExample(panel.dataset.node, +ex.dataset.ex);
2732
+ });
2733
+ document.addEventListener('click', e => {
2734
+ const t = e.target;
2735
+ if (t.closest('[data-close]')) return deselect();
2736
+ const ob = t.closest('[data-open]'); if (ob) return go(ob.dataset.open, { via: sel });
2737
+ const pv = t.closest('.pview'); if (pv) return go(pv.dataset.v, { select: pv.dataset.sel });
2738
+ const rn = t.closest('#m-panel [data-n]'); if (rn) return findNode(rn.dataset.n);
2739
+ const cv = t.closest('#m-crumbs [data-v], #m-tree [data-v]'); if (cv) { if (innerWidth <= 820) setTree(false); return go(cv.dataset.v); }
2740
+ const cp = t.closest('[data-copy]'); if (cp) { navigator.clipboard && navigator.clipboard.writeText(cp.dataset.copy).then(() => status('Path copied'), () => status(cp.dataset.copy)); return; }
2741
+ if (!t.closest('.menu')) closeExport();
2742
+ if (!t.closest('#m-helpbox, #m-help')) closeHelp();
2743
+ });
2744
+ $('m-outline').addEventListener('click', () => setTree(!document.body.classList.contains('tree-open')));
2745
+ if (innerWidth > 1180 && views.length > 1) setTree(true);
2746
+ $('m-help').addEventListener('click', () => { const on = $('m-helpbox').hidden; $('m-helpbox').hidden = !on; $('m-help').setAttribute('aria-expanded', on); });
2747
+ $('m-theme').addEventListener('click', toggleTheme);
2748
+ $('m-pause').addEventListener('click', e => { paused = !paused; const b = e.currentTarget, l = paused ? 'Play' : 'Pause'; X.pause(paused); b.setAttribute('aria-pressed', paused); b.title = l; b.setAttribute('aria-label', l); b.textContent = paused ? '▶' : '❚❚'; });
2749
+ $('m-export').addEventListener('click', () => { const on = $('m-export-pop').hidden; $('m-export-pop').hidden = !on; $('m-export').setAttribute('aria-expanded', on); });
2750
+ $('m-export-pop').addEventListener('click', e => { const x = e.target.closest('[data-x]')?.dataset.x; if (!x) return; closeExport(); exportView(x); });
2751
+
2752
+ /* ⌘K command box: nodes and views, Enter jumps */
2753
+ const nodeIdx = nodes.map(n => ({ n, hay: [n.label, n.id, (n.verbs || []).join(' '), n.what, n.kind, n.author, ((D.authors || {})[n.author] || {}).name, n.origin].join(' ').toLowerCase() }));
2754
+ const viewIdx = views.map(v => ({ v, hay: [v.title, v.id, v.caption].join(' ').toLowerCase() }));
2755
+ const pal = $('m-pal'), q = $('m-q'), res = $('m-res');
2756
+ let hits = [], hi = 0;
2757
+ const mark = (s, words) => { let out = esc(s); words.filter(w => w.length > 1).forEach(w => { out = out.replace(new RegExp('(' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig'), '<mark>$1</mark>'); }); return out; };
2758
+ const lev = (a, b) => { const d = Array.from({ length: b.length + 1 }, (_, i) => i); for (let i = 1; i <= a.length; i++) { let p = d[0]; d[0] = i; for (let j = 1; j <= b.length; j++) { const t = d[j]; d[j] = Math.min(d[j] + 1, d[j - 1] + 1, p + (a[i - 1] === b[j - 1] ? 0 : 1)); p = t; } } return d[b.length]; };
2759
+ function drawHits() {
2760
+ const qs = q.value.trim().toLowerCase(), words = qs.split(/\s+/).filter(Boolean);
2761
+ if (!qs) { // empty query: the views you can jump to, starting from where you are
2762
+ hits = [...kidsOf(cur), ...views.filter(v => v.id !== cur && !kidsOf(cur).includes(v))].slice(0, 8).map(v => ({ t: 'v', v }));
2763
+ } else {
2764
+ const starts = s => String(s || '').toLowerCase().startsWith(qs) ? 0 : 1;
2765
+ const ns = nodeIdx.filter(x => words.every(w => x.hay.includes(w))).sort((a, b) => starts(a.n.label) - starts(b.n.label) || (geo.byId[b.n.id] ? 1 : 0) - (geo.byId[a.n.id] ? 1 : 0)).slice(0, 7).map(x => ({ t: 'n', n: x.n }));
2766
+ const vs = viewIdx.filter(x => words.every(w => x.hay.includes(w))).slice(0, 5).map(x => ({ t: 'v', v: x.v }));
2767
+ hits = [...ns, ...vs];
2768
+ }
2769
+ hi = Math.min(hi, Math.max(0, hits.length - 1));
2770
+ if (!hits.length) {
2771
+ const near = nodes.map(n => [n, lev(qs, String(n.label).toLowerCase().replace(/^\//, '').slice(0, qs.length + 2))]).sort((a, b) => a[1] - b[1])[0];
2772
+ res.innerHTML = `<div class="none"><div aria-hidden="true">⌕</div><h4>Nothing called “${esc(q.value.trim())}”</h4>${near && near[1] <= Math.max(2, qs.length / 3) ? `Did you mean <button data-fill="${esc(near[0].label)}">${esc(near[0].label)}</button>?` : 'Try a skill, verb, script or view name.'}</div>`;
2773
+ return;
2774
+ }
2775
+ let html = '', last = '';
2776
+ hits.forEach((h, i) => {
2777
+ const grp = !qs ? 'Views' : h.t === 'n' ? 'Nodes' : 'Views';
2778
+ if (grp !== last) { html += `<div class="gt">${grp}</div>`; last = grp; }
2779
+ if (h.t === 'n') { const k = kindOf(h.n.kind, true), vs = viewsWith(h.n.id), here = geo.byId[h.n.id];
2780
+ html += `<button class="fr${i === hi ? ' on' : ''}" role="option" aria-selected="${i === hi}" data-i="${i}"><i style="background:var(--um-${KIND[k][0]})"></i><b>${mark(h.n.label, words)}</b><span>${esc(KIND[k][1])} · ${here ? 'this view' : esc(vs.map(v => shortT(v.title || v.id)).slice(0, 2).join(', ') || 'no view')}</span></button>`; }
2781
+ else html += `<button class="fr${i === hi ? ' on' : ''}" role="option" aria-selected="${i === hi}" data-i="${i}"><i style="background:var(--um-accent)"></i><b>${mark(h.v.title || h.v.id, words)}</b><span>${(h.v.nodeIds || []).length} nodes${h.v.id === cur ? ' · here' : ''}</span></button>`;
2782
+ });
2783
+ res.innerHTML = html;
2784
+ const on = res.querySelector('.fr.on'); on && on.scrollIntoView({ block: 'nearest' });
2785
+ }
2786
+ const openPal = () => { closeHelp(); closeExport(); pal.hidden = false; q.value = ''; hi = 0; drawHits(); q.focus(); };
2787
+ const closePal = () => { pal.hidden = true; q.blur(); };
2788
+ const pickHit = h => { if (!h) return; closePal(); if (h.t === 'n') findNode(h.n.id); else go(h.v.id); };
2789
+ q.addEventListener('input', () => { hi = 0; drawHits(); });
2790
+ q.addEventListener('keydown', e => {
2791
+ if (e.key === 'ArrowDown') { hi = Math.min(hits.length - 1, hi + 1); drawHits(); e.preventDefault(); }
2792
+ else if (e.key === 'ArrowUp') { hi = Math.max(0, hi - 1); drawHits(); e.preventDefault(); }
2793
+ else if (e.key === 'Enter') { pickHit(hits[hi]); e.preventDefault(); }
2794
+ else if (e.key === 'Escape') { closePal(); e.preventDefault(); }
2795
+ e.stopPropagation();
2796
+ });
2797
+ res.addEventListener('click', e => { const f = e.target.closest('[data-fill]'); if (f) { q.value = f.dataset.fill; hi = 0; drawHits(); q.focus(); return; } const b = e.target.closest('[data-i]'); if (b) pickHit(hits[+b.dataset.i]); });
2798
+ res.addEventListener('pointermove', e => { const b = e.target.closest('[data-i]'); if (b && +b.dataset.i !== hi) { hi = +b.dataset.i; res.querySelectorAll('.fr').forEach(x => { const on = +x.dataset.i === hi; x.classList.toggle('on', on); x.setAttribute('aria-selected', on); }); } });
2799
+ pal.addEventListener('click', e => { if (e.target === pal) closePal(); });
2800
+ $('m-find').addEventListener('click', openPal);
2801
+
2802
+ /* keyboard: the page's own keys first (search, help, panels); the map's keys reach the canvas wherever the focus is */
2803
+ document.addEventListener('keydown', e => {
2804
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); return pal.hidden ? openPal() : closePal(); }
2805
+ if (!X || (e.target.matches && e.target.matches('input, textarea')) || e.metaKey || e.ctrlKey || e.altKey) return;
2806
+ const k = e.key, inCanvas = e.composedPath().includes(X.stage);
2807
+ if (k === 'Escape' || k === 'Backspace') {
2808
+ if ($('m-lightbox') && !$('m-lightbox').hidden) closeExample(); else if (!$('m-helpbox').hidden) closeHelp(); else if (!$('m-export-pop').hidden) closeExport(); else if ($('m-vpop') && !$('m-vpop').hidden) togglePill(false);
2809
+ else if (!inCanvas) X.key(e); else return;
2810
+ }
2811
+ else if (k === '/') openPal();
2812
+ else if (k === '?') $('m-help').click();
2813
+ else if (k === 'i' || k === 'I') togglePill();
2814
+ else if ((k === '[' || k === ']') && sel) setTab(TABS[(TABS.indexOf(tab) + (k === ']' ? 1 : -1) + TABS.length) % TABS.length]);
2815
+ else if (k === 'o' || k === 'O') setTree(!document.body.classList.contains('tree-open'));
2816
+ else if (inCanvas || !X.key(e)) return;
2817
+ e.preventDefault(); e.stopPropagation();
2818
+ }, true);
2819
+ addEventListener('popstate', () => { const p = new URLSearchParams(location.hash.slice(1)); const v = p.get('view') || rootId; if (v !== cur) go(v, { replace: true, select: p.get('node') || undefined }); else if (p.get('node')) select(p.get('node')); else if (sel) deselect(); });
2820
+
2821
+ /* export: resolve CSS variables into attributes so the file stands alone */
2822
+ const standalone = scale => X.standalone(scale);
2823
+ if (Q.get('still')) { const sv = Q.get('still'), sc = +(Q.get('scale') || 2);
2824
+ const tick = async () => { if (!geo || !V[sv]) return setTimeout(tick, 100); if (cur !== sv) { await go(sv, {}); return setTimeout(tick, 200); }
2825
+ X.stage.querySelectorAll('.flowline').forEach(e => e.remove()); const out = standalone(sc), b = geo.bounds; document.documentElement.setAttribute('data-still', JSON.stringify({ view: sv, w: Math.ceil(b.w * sc), h: Math.ceil(b.h * sc) }));
2826
+ document.body.innerHTML = '<div id="still">' + out + '</div>'; document.body.style.margin = '0'; };
2827
+ setTimeout(tick, 300); }
2828
+ function download(name, blob) { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 500); }
2829
+ function exportView(x) {
2830
+ const stem = (D.slug || D.title || 'map').toLowerCase().replace(/[^a-z0-9]+/g, '-') + '-' + cur.replace(/[^a-z0-9]+/gi, '-');
2831
+ if (x === 'svg') download(stem + '.svg', new Blob([standalone(1)], { type: 'image/svg+xml' }));
2832
+ else if (x === 'png') {
2833
+ pngOf(standalone(2)).then(bl => bl && download(stem + '.png', bl));
2834
+ } else if (x === 'link') { navigator.clipboard && navigator.clipboard.writeText(location.href).then(() => status('Link copied'), () => status(location.href)); }
2835
+ else if (x === 'json') { const { meta, kind, ...model } = D; download(stem.split('-' + cur)[0] + '.json', new Blob([JSON.stringify(model, null, 2)], { type: 'application/json' })); }
2836
+ if (x !== 'link') status('Exported ' + x.toUpperCase());
2837
+ }
2838
+
2839
+
2840
+ let flowPref = null; try { flowPref = localStorage.getItem('visual-flow'); } catch (_) {}
2841
+ const p0 = new URLSearchParams(location.hash.slice(1));
2842
+ const v0 = V[p0.get('view')] ? p0.get('view') : rootId;
2843
+ const handle = render($('m-canvas'), model, {
2844
+ view: v0, minimap: true, flow: flowPref ? flowPref === '1' : undefined,
2845
+ _page: {
2846
+ instant: options.instant, defer: !INSTANT, tour: !Q.get('still'), openHint: !Q.get('reader'), kinds: true, status,
2847
+ wide: () => innerWidth > 820,
2848
+ upTarget: () => stack.length > 1 ? stack[stack.length - 2] : (V[cur] || {}).parent,
2849
+ inset: () => { const top = Math.max(58, 26 + $('m-vpill').offsetHeight); if (innerWidth <= 820) return { t: top, r: 12, b: 12, l: 12 }; const lg = $('m-legend'), on = lg && !lg.hidden; // the legend box sits bottom left
2850
+ return [{ t: top, r: 20, b: 40 + (on ? lg.offsetHeight + 8 : 0), l: 20 }, { t: top, r: 20, b: 40, l: 20 + (on ? lg.offsetWidth + 8 : 0) }]; },
2851
+ onNavigate: (vid, prev, mode) => { togglePill(false);
2852
+ if (stack.includes(vid)) stack = stack.slice(0, stack.indexOf(vid) + 1); else if (mode === 'in' && prev) stack = [...stack, vid]; else stack = chain(vid); },
2853
+ onMount: inner => { X = inner; ({ D, views, nodes, N, V, rootId, subjectOf, viewsWith, kidsOf, chain, viewEdges, planCache } = X.ctx()); nodeIn0 = X.ctx().nodeIn; childOf0 = X.ctx().childOf;
2854
+ cam = X.cam(); geo = X.geo(); svg = X.svg(); cur = X.view(); if (paused) X.pause(true);
2855
+ if (!stack.length || !stack.includes(cur)) stack = chain(cur);
2856
+ renderChrome(); hidePanel(); },
2857
+ onBackground: () => togglePill(false),
2858
+ onFlow: on => { try { localStorage.setItem('visual-flow', on ? '1' : '0'); } catch (_) {} },
2859
+ onReady: () => {
2860
+ $('m-ghosts').remove();
2861
+ const n0 = p0.get('node') || (Q.get('shot') && cur !== rootId ? subjectOf(V[cur]) || (V[cur].nodeIds || [])[0] : null);
2862
+ if (n0 && geo.byId[n0]) select(n0, { reveal: false });
2863
+ setHash(true);
2864
+ },
2865
+ },
2866
+ });
2867
+ handle.on('select', ({ target }) => { sel = target && target.type === 'node' ? target.id : null; if (sel) renderPanel(sel); else hidePanel(); setHash(true); });
2868
+ handle.on('viewchange', () => { setHash(replaceNext); replaceNext = false; });
2869
+ return handle;
2870
+ }