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.
- package/LICENSE +21 -0
- package/README.md +174 -2
- package/THIRD_PARTY_NOTICES.md +215 -0
- package/lib/umlimator-theme.css +64 -0
- package/lib/umlimator.d.ts +140 -0
- package/lib/umlimator.js +2870 -0
- package/package.json +13 -4
package/lib/umlimator.js
ADDED
|
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
+
}
|