mellos-mapping 0.20.0 → 0.20.2
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/README.md +360 -63
- package/README.zh-CN.md +314 -54
- package/dist/hook-session-start.mjs +239 -0
- package/dist/mmap.mjs +338 -0
- package/dist/server.mjs +1614 -809
- package/dist/store-paths.mjs +107 -0
- package/dist/watch.mjs +1391 -760
- package/lib/domain/ops.d.ts +71 -12
- package/lib/domain/ops.js +145 -14
- package/lib/domain/types.d.ts +47 -6
- package/lib/domain/types.js +34 -3
- package/lib/render/canvas.d.ts +50 -0
- package/lib/render/canvas.js +210 -0
- package/lib/render/draw.d.ts +37 -0
- package/lib/render/draw.js +111 -0
- package/lib/render/layout.d.ts +89 -0
- package/lib/render/layout.js +200 -0
- package/lib/render/options.d.ts +39 -0
- package/lib/render/options.js +10 -0
- package/lib/render/render.d.ts +32 -46
- package/lib/render/render.js +58 -789
- package/lib/render/routing.d.ts +56 -0
- package/lib/render/routing.js +244 -0
- package/lib/render/skins.d.ts +54 -0
- package/lib/render/skins.js +99 -0
- package/lib/render/width.d.ts +24 -0
- package/lib/render/width.js +139 -0
- package/lib/render/zoom-geometry.d.ts +52 -0
- package/lib/render/zoom-geometry.js +56 -0
- package/lib/semantics/semantics.d.ts +53 -4
- package/lib/semantics/semantics.js +130 -6
- package/lib/semantics/vocabulary.d.ts +79 -0
- package/lib/semantics/vocabulary.js +112 -0
- package/lib/store/format.d.ts +17 -0
- package/lib/store/format.js +185 -66
- package/lib/store/store.d.ts +220 -20
- package/lib/store/store.js +491 -38
- package/package.json +12 -4
- package/scripts/codex-register.mjs +89 -20
- package/scripts/install-mmap-command.mjs +293 -0
- package/scripts/mmap.mjs +213 -0
- package/scripts/open-pane.mjs +115 -254
- package/scripts/pane-core.mjs +418 -0
package/lib/render/render.js
CHANGED
|
@@ -5,6 +5,22 @@
|
|
|
5
5
|
* (colored, animated). Pure function of (map, options): no I/O, no clock —
|
|
6
6
|
* animation is driven by the caller passing a spinner frame index.
|
|
7
7
|
*
|
|
8
|
+
* This file is the COMPOSITION ROOT of the picture and holds the public
|
|
9
|
+
* surface; each stage lives beside it and knows nothing of the next:
|
|
10
|
+
*
|
|
11
|
+
* width.ts how many columns a string takes (the grid's foundation)
|
|
12
|
+
* options.ts what a caller asks for
|
|
13
|
+
* canvas.ts the drawing surface: cells, mask algebra, ANSI emission
|
|
14
|
+
* skins.ts status vocabulary -> this medium's borders and colors
|
|
15
|
+
* zoom-geometry.ts what one rung of the zoom ladder buys, in cells
|
|
16
|
+
* layout.ts where every box goes (columns, then rows)
|
|
17
|
+
* routing.ts how each edge gets there (columns and track rows)
|
|
18
|
+
* draw.ts transcription of all of the above onto the canvas
|
|
19
|
+
*
|
|
20
|
+
* The pipeline reads in that order and only forward: sizes and columns fix
|
|
21
|
+
* the width, routing needs the columns, the row layout needs routing's track
|
|
22
|
+
* counts, and drawing needs everything. Nothing drawn is ever decided.
|
|
23
|
+
*
|
|
8
24
|
* Visual language — a dark circuit board:
|
|
9
25
|
* rank 0 renders at the BOTTOM of the picture ("primitives are the
|
|
10
26
|
* ground"). Wiring and band bars are FAINT; the glowing things are the
|
|
@@ -14,21 +30,10 @@
|
|
|
14
30
|
* planned dashed dim rounded box, '·' — a ghost: designed, not built
|
|
15
31
|
* in-progress amber rounded box, spinner — where attention currently is
|
|
16
32
|
* done heavy green box, '■' — built and verified
|
|
33
|
+
* done, no evidence the same box, hollow '□' in a dimmer green — the
|
|
34
|
+
* ledger's fourth rule, made visible
|
|
17
35
|
* regressed heavy red box, '✗' — was done, foundation cracked
|
|
18
36
|
*
|
|
19
|
-
* Routing preference, in order:
|
|
20
|
-
* 1. STRAIGHT — an adjacent-band edge whose box borders share a free
|
|
21
|
-
* column is one vertical line, no corners.
|
|
22
|
-
* 2. DOGLEG — descend, run horizontally on a track row in the gap above
|
|
23
|
-
* the target band, descend. Tracks are PACKED: segments that do not
|
|
24
|
-
* overlap share a row, keeping bands close together.
|
|
25
|
-
* 3. THREAD — a skip-level edge descends through the nearest column that
|
|
26
|
-
* is free of boxes in every intermediate band (threading the needle
|
|
27
|
-
* between boxes); only if no such column exists does it fall back to a
|
|
28
|
-
* private column on the right margin.
|
|
29
|
-
* Crossings merge into proper junction characters via a direction-bitmask
|
|
30
|
-
* union instead of any routing cleverness.
|
|
31
|
-
*
|
|
32
37
|
* Zoom — terminals cannot scale glyphs, so zooming out first COMPRESSES the
|
|
33
38
|
* geometry (gaps, breathing rows, padding shrink; labels truncate toward a
|
|
34
39
|
* scale-proportional budget) while boxes stay boxes. Only when a further
|
|
@@ -47,408 +52,20 @@
|
|
|
47
52
|
* The same layout/routing machinery runs at every step; only the per-node
|
|
48
53
|
* box spec (size, border, content) and the whitespace geometry change.
|
|
49
54
|
*/
|
|
50
|
-
import { ZOOM_DEFAULT, aggregateMap, flipForSequence, isNeutralKind
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
[0xfe30, 0xfe6f],
|
|
65
|
-
[0xff00, 0xff60], // fullwidth forms
|
|
66
|
-
[0xffe0, 0xffe6],
|
|
67
|
-
[0x20000, 0x3fffd], // CJK extension planes
|
|
68
|
-
];
|
|
69
|
-
function charWidth(cp) {
|
|
70
|
-
for (const [lo, hi] of WIDE_RANGES) {
|
|
71
|
-
if (cp >= lo && cp <= hi)
|
|
72
|
-
return 2;
|
|
73
|
-
}
|
|
74
|
-
return 1;
|
|
75
|
-
}
|
|
76
|
-
/** Terminal column width of a string (CJK chars occupy two columns). */
|
|
77
|
-
export function displayWidth(text) {
|
|
78
|
-
let w = 0;
|
|
79
|
-
for (const ch of text)
|
|
80
|
-
w += charWidth(ch.codePointAt(0));
|
|
81
|
-
return w;
|
|
82
|
-
}
|
|
83
|
-
/** Truncate to a display width, ANSI-free input, appending … when cut. */
|
|
84
|
-
export function fitWidth(s, width) {
|
|
85
|
-
if (displayWidth(s) <= width)
|
|
86
|
-
return s;
|
|
87
|
-
let out = '';
|
|
88
|
-
let w = 0;
|
|
89
|
-
for (const ch of s) {
|
|
90
|
-
const cw = displayWidth(ch);
|
|
91
|
-
if (w + cw > width - 1)
|
|
92
|
-
break;
|
|
93
|
-
out += ch;
|
|
94
|
-
w += cw;
|
|
95
|
-
}
|
|
96
|
-
return out + '…';
|
|
97
|
-
}
|
|
98
|
-
/** Hard word-wrap by display width (CJK-aware, splits anywhere). */
|
|
99
|
-
export function wrapWidth(s, width) {
|
|
100
|
-
const lines = [];
|
|
101
|
-
let line = '';
|
|
102
|
-
let w = 0;
|
|
103
|
-
for (const ch of s.replace(/\r/g, '')) {
|
|
104
|
-
if (ch === '\n') {
|
|
105
|
-
lines.push(line);
|
|
106
|
-
line = '';
|
|
107
|
-
w = 0;
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
const cw = displayWidth(ch);
|
|
111
|
-
if (w + cw > width) {
|
|
112
|
-
lines.push(line);
|
|
113
|
-
line = '';
|
|
114
|
-
w = 0;
|
|
115
|
-
}
|
|
116
|
-
line += ch;
|
|
117
|
-
w += cw;
|
|
118
|
-
}
|
|
119
|
-
if (line !== '')
|
|
120
|
-
lines.push(line);
|
|
121
|
-
return lines;
|
|
122
|
-
}
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
|
-
// line-character algebra — junctions emerge from direction bitmask unions
|
|
125
|
-
// ---------------------------------------------------------------------------
|
|
126
|
-
const UP = 1;
|
|
127
|
-
const DOWN = 2;
|
|
128
|
-
const LEFT = 4;
|
|
129
|
-
const RIGHT = 8;
|
|
130
|
-
const LIGHT_BY_MASK = {
|
|
131
|
-
[UP]: '│',
|
|
132
|
-
[DOWN]: '│',
|
|
133
|
-
[LEFT]: '─',
|
|
134
|
-
[RIGHT]: '─',
|
|
135
|
-
[UP | DOWN]: '│',
|
|
136
|
-
[LEFT | RIGHT]: '─',
|
|
137
|
-
[DOWN | RIGHT]: '┌',
|
|
138
|
-
[DOWN | LEFT]: '┐',
|
|
139
|
-
[UP | RIGHT]: '└',
|
|
140
|
-
[UP | LEFT]: '┘',
|
|
141
|
-
[UP | DOWN | RIGHT]: '├',
|
|
142
|
-
[UP | DOWN | LEFT]: '┤',
|
|
143
|
-
[DOWN | LEFT | RIGHT]: '┬',
|
|
144
|
-
[UP | LEFT | RIGHT]: '┴',
|
|
145
|
-
[UP | DOWN | LEFT | RIGHT]: '┼',
|
|
146
|
-
};
|
|
147
|
-
function maskChar(mask, heavyHorizontal, unicode) {
|
|
148
|
-
if (!unicode) {
|
|
149
|
-
const hasV = (mask & (UP | DOWN)) !== 0;
|
|
150
|
-
const hasH = (mask & (LEFT | RIGHT)) !== 0;
|
|
151
|
-
if (hasV && hasH)
|
|
152
|
-
return '+';
|
|
153
|
-
return hasV ? '|' : '-';
|
|
154
|
-
}
|
|
155
|
-
if (heavyHorizontal) {
|
|
156
|
-
if (mask === (LEFT | RIGHT))
|
|
157
|
-
return '━';
|
|
158
|
-
if (mask === (UP | DOWN | LEFT | RIGHT))
|
|
159
|
-
return '┿';
|
|
160
|
-
}
|
|
161
|
-
return LIGHT_BY_MASK[mask] ?? '┼';
|
|
162
|
-
}
|
|
163
|
-
/** SGR parameter per style; combined with bold ("1") at emit time. */
|
|
164
|
-
const SGR = {
|
|
165
|
-
none: '',
|
|
166
|
-
dim: '2',
|
|
167
|
-
amber: '33',
|
|
168
|
-
green: '32',
|
|
169
|
-
red: '31',
|
|
170
|
-
faint: '90',
|
|
171
|
-
};
|
|
172
|
-
const ANSI_RESET = '\x1b[0m';
|
|
173
|
-
/** Junction replacements when a routed line meets a literal border character. */
|
|
174
|
-
const BORDER_JUNCTION = {
|
|
175
|
-
'─': { down: '┬', up: '┴' },
|
|
176
|
-
'╌': { down: '┬', up: '┴' },
|
|
177
|
-
'━': { down: '┯', up: '┷' },
|
|
178
|
-
'-': { down: '+', up: '+' },
|
|
179
|
-
'.': { down: '+', up: '+' },
|
|
180
|
-
};
|
|
181
|
-
class Canvas {
|
|
182
|
-
rows = [];
|
|
183
|
-
cell(x, y) {
|
|
184
|
-
while (this.rows.length <= y)
|
|
185
|
-
this.rows.push([]);
|
|
186
|
-
const row = this.rows[y];
|
|
187
|
-
while (row.length <= x)
|
|
188
|
-
row.push({ mask: 0, heavyHorizontal: false, bright: false, style: 'none', bold: false });
|
|
189
|
-
return row[x];
|
|
190
|
-
}
|
|
191
|
-
get height() {
|
|
192
|
-
return this.rows.length;
|
|
193
|
-
}
|
|
194
|
-
get width() {
|
|
195
|
-
return this.rows.reduce((max, row) => Math.max(max, row.length), 0);
|
|
196
|
-
}
|
|
197
|
-
/** Write literal text starting at (x, y). Returns the column just past it. */
|
|
198
|
-
text(x, y, s, style, bold = false) {
|
|
199
|
-
let cx = x;
|
|
200
|
-
for (const ch of s) {
|
|
201
|
-
const c = this.cell(cx, y);
|
|
202
|
-
c.literal = ch;
|
|
203
|
-
c.style = style;
|
|
204
|
-
c.bold = bold;
|
|
205
|
-
const w = charWidth(ch.codePointAt(0));
|
|
206
|
-
if (w === 2) {
|
|
207
|
-
// The second column of a wide character is a phantom cell: it must
|
|
208
|
-
// exist so later writes don't overlap, but it emits nothing.
|
|
209
|
-
const phantom = this.cell(cx + 1, y);
|
|
210
|
-
phantom.literal = '';
|
|
211
|
-
phantom.style = style;
|
|
212
|
-
}
|
|
213
|
-
cx += w;
|
|
214
|
-
}
|
|
215
|
-
return cx;
|
|
216
|
-
}
|
|
217
|
-
/** Merge a routed-line direction mask into (x, y). */
|
|
218
|
-
line(x, y, mask, heavyHorizontal = false, bright = false) {
|
|
219
|
-
const c = this.cell(x, y);
|
|
220
|
-
if (c.literal !== undefined) {
|
|
221
|
-
const junction = BORDER_JUNCTION[c.literal];
|
|
222
|
-
const replacement = mask & DOWN ? junction?.down : mask & UP ? junction?.up : undefined;
|
|
223
|
-
if (replacement !== undefined)
|
|
224
|
-
c.literal = replacement;
|
|
225
|
-
return; // literals other than borders (labels) are never overdrawn
|
|
226
|
-
}
|
|
227
|
-
c.mask |= mask;
|
|
228
|
-
c.heavyHorizontal = c.heavyHorizontal || heavyHorizontal;
|
|
229
|
-
c.bright = c.bright || bright;
|
|
230
|
-
}
|
|
231
|
-
/**
|
|
232
|
-
* Emit terminal lines, optionally windowed to a viewport. Slicing happens
|
|
233
|
-
* at the cell level so ANSI codes reopen correctly inside the window and a
|
|
234
|
-
* CJK character cut in half at either edge degrades to a space instead of
|
|
235
|
-
* shifting the whole row. Routed wiring (mask cells) emits FAINT — the
|
|
236
|
-
* circuit board recedes, the boxes glow.
|
|
237
|
-
*/
|
|
238
|
-
emit(opts, viewport) {
|
|
239
|
-
const vp = viewport ?? { x: 0, y: 0, width: this.width, height: this.height };
|
|
240
|
-
const out = [];
|
|
241
|
-
for (let y = vp.y; y < vp.y + vp.height; y++) {
|
|
242
|
-
const row = this.rows[y] ?? [];
|
|
243
|
-
let line = '';
|
|
244
|
-
let open = '';
|
|
245
|
-
const end = Math.min(vp.x + vp.width, row.length);
|
|
246
|
-
for (let x = Math.max(0, vp.x); x < end; x++) {
|
|
247
|
-
const c = row[x];
|
|
248
|
-
const isWire = c.literal === undefined && c.mask !== 0;
|
|
249
|
-
let ch = c.literal !== undefined ? c.literal : isWire ? maskChar(c.mask, c.heavyHorizontal, opts.unicode) : ' ';
|
|
250
|
-
if (ch === '') {
|
|
251
|
-
if (x !== Math.max(0, vp.x))
|
|
252
|
-
continue; // phantom half inside the window: already emitted
|
|
253
|
-
ch = ' '; // window starts on the right half of a wide character
|
|
254
|
-
}
|
|
255
|
-
else if (charWidth(ch.codePointAt(0)) === 2 && x + 1 >= vp.x + vp.width) {
|
|
256
|
-
ch = ' '; // wide character whose right half would spill past the window
|
|
257
|
-
}
|
|
258
|
-
const params = ch === ' '
|
|
259
|
-
? ''
|
|
260
|
-
: isWire
|
|
261
|
-
? c.bright
|
|
262
|
-
? '1' // spotlighted wire: bold default color against the faint board
|
|
263
|
-
: SGR.faint
|
|
264
|
-
: [SGR[c.style], c.bold ? '1' : ''].filter(Boolean).join(';');
|
|
265
|
-
if (opts.color && params !== open) {
|
|
266
|
-
line += (open !== '' ? ANSI_RESET : '') + (params !== '' ? `\x1b[${params}m` : '');
|
|
267
|
-
open = params;
|
|
268
|
-
}
|
|
269
|
-
line += ch;
|
|
270
|
-
}
|
|
271
|
-
if (opts.color && open !== '')
|
|
272
|
-
line += ANSI_RESET;
|
|
273
|
-
out.push(line.replace(/ +$/, ''));
|
|
274
|
-
}
|
|
275
|
-
return out;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
/**
|
|
279
|
-
* Draw an orthogonal polyline through `points` (consecutive points must share
|
|
280
|
-
* an x or a y). Interior cells of a segment carry the segment's axis mask;
|
|
281
|
-
* every point cell carries only the directions of the segments that actually
|
|
282
|
-
* touch it — so path endpoints become clean junction stubs (e.g. ┬ when
|
|
283
|
-
* entering a box border) and turning points become corner characters, all via
|
|
284
|
-
* the same mask union. Zero-length segments vanish naturally.
|
|
285
|
-
*/
|
|
286
|
-
function drawPath(canvas, points, bright = false) {
|
|
287
|
-
for (let i = 0; i + 1 < points.length; i++) {
|
|
288
|
-
const [x1, y1] = points[i];
|
|
289
|
-
const [x2, y2] = points[i + 1];
|
|
290
|
-
if (x1 === x2 && y1 === y2)
|
|
291
|
-
continue;
|
|
292
|
-
if (x1 === x2) {
|
|
293
|
-
const [lo, hi] = y1 < y2 ? [y1, y2] : [y2, y1];
|
|
294
|
-
for (let yy = lo + 1; yy < hi; yy++)
|
|
295
|
-
canvas.line(x1, yy, UP | DOWN, false, bright);
|
|
296
|
-
canvas.line(x1, y1, y2 > y1 ? DOWN : UP, false, bright);
|
|
297
|
-
canvas.line(x1, y2, y2 > y1 ? UP : DOWN, false, bright);
|
|
298
|
-
}
|
|
299
|
-
else {
|
|
300
|
-
const [lo, hi] = x1 < x2 ? [x1, x2] : [x2, x1];
|
|
301
|
-
for (let xx = lo + 1; xx < hi; xx++)
|
|
302
|
-
canvas.line(xx, y1, LEFT | RIGHT, false, bright);
|
|
303
|
-
canvas.line(x1, y1, x2 > x1 ? RIGHT : LEFT, false, bright);
|
|
304
|
-
canvas.line(x2, y1, x2 > x1 ? LEFT : RIGHT, false, bright);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
// ---------------------------------------------------------------------------
|
|
309
|
-
// status vocabulary -> visual vocabulary
|
|
310
|
-
// ---------------------------------------------------------------------------
|
|
311
|
-
const SPINNER_UNICODE = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
312
|
-
const SPINNER_ASCII = ['|', '/', '-', '\\'];
|
|
313
|
-
function skinFor(status, unicode) {
|
|
314
|
-
const style = status === 'planned' ? 'dim' : status === 'in-progress' ? 'amber' : status === 'done' ? 'green' : 'red';
|
|
315
|
-
if (!unicode) {
|
|
316
|
-
return status === 'planned'
|
|
317
|
-
? { h: '.', v: ':', corners: ['+', '+', '+', '+'], style }
|
|
318
|
-
: { h: '-', v: '|', corners: ['+', '+', '+', '+'], style };
|
|
319
|
-
}
|
|
320
|
-
switch (status) {
|
|
321
|
-
case 'planned':
|
|
322
|
-
return { h: '╌', v: '╎', corners: ['╭', '╮', '╰', '╯'], style };
|
|
323
|
-
case 'in-progress':
|
|
324
|
-
return { h: '─', v: '│', corners: ['╭', '╮', '╰', '╯'], style };
|
|
325
|
-
case 'done':
|
|
326
|
-
case 'regressed':
|
|
327
|
-
return { h: '━', v: '┃', corners: ['┏', '┓', '┗', '┛'], style };
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
function glyphFor(status, opts) {
|
|
331
|
-
const spinner = opts.unicode ? SPINNER_UNICODE : SPINNER_ASCII;
|
|
332
|
-
switch (status) {
|
|
333
|
-
case 'planned':
|
|
334
|
-
return opts.unicode ? '·' : '.';
|
|
335
|
-
case 'in-progress':
|
|
336
|
-
return spinner[opts.spinnerFrame % spinner.length];
|
|
337
|
-
case 'done':
|
|
338
|
-
return opts.unicode ? '■' : '#';
|
|
339
|
-
case 'regressed':
|
|
340
|
-
return opts.unicode ? '✗' : 'X';
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
/**
|
|
344
|
-
* Known node kinds -> [unicode, ascii] glyphs (all display width 1).
|
|
345
|
-
* Behavior trees, dataflow and architecture vocabularies; an unknown kind
|
|
346
|
-
* renders without a glyph and stays readable in the detail panel.
|
|
347
|
-
*/
|
|
348
|
-
const NODE_KIND_GLYPHS = {
|
|
349
|
-
selector: ['?', '?'],
|
|
350
|
-
sequence: ['»', '>'],
|
|
351
|
-
parallel: ['‖', '='],
|
|
352
|
-
decorator: ['◌', 'o'],
|
|
353
|
-
condition: ['◇', 'c'],
|
|
354
|
-
action: ['·', '.'],
|
|
355
|
-
source: ['○', 'o'],
|
|
356
|
-
transform: ['◐', '%'],
|
|
357
|
-
sink: ['●', '*'],
|
|
358
|
-
service: ['◆', 'S'],
|
|
359
|
-
db: ['▤', 'D'],
|
|
360
|
-
queue: ['≣', 'Q'],
|
|
361
|
-
ui: ['▣', 'U'],
|
|
362
|
-
};
|
|
363
|
-
/** Glyph for a node kind, or undefined for unknown kinds. Shared with the watcher's panel. */
|
|
364
|
-
export function kindGlyph(kind, unicode) {
|
|
365
|
-
const pair = NODE_KIND_GLYPHS[kind];
|
|
366
|
-
return pair === undefined ? undefined : unicode ? pair[0] : pair[1];
|
|
367
|
-
}
|
|
368
|
-
/** Plain solid box for documentation diagrams — presence, not progress. */
|
|
369
|
-
function neutralSkin(unicode) {
|
|
370
|
-
return unicode
|
|
371
|
-
? { h: '─', v: '│', corners: ['╭', '╮', '╰', '╯'], style: 'none' }
|
|
372
|
-
: { h: '-', v: '|', corners: ['+', '+', '+', '+'], style: 'none' };
|
|
373
|
-
}
|
|
374
|
-
// ---------------------------------------------------------------------------
|
|
375
|
-
// layout
|
|
376
|
-
// ---------------------------------------------------------------------------
|
|
377
|
-
const BOX_H = 3;
|
|
378
|
-
const BOX_GAP = 2;
|
|
379
|
-
const LEFT_MARGIN = 2;
|
|
380
|
-
/** +1: a readable unfold. +2: the box grows into a reading card. */
|
|
381
|
-
const DETAIL_BUDGET = { innerMin: 22, innerMax: 32, noteRows: 3 };
|
|
382
|
-
const DETAIL_PLUS_BUDGET = { innerMin: 30, innerMax: 48, noteRows: 12 };
|
|
383
|
-
function zoomGeometry(zoom) {
|
|
384
|
-
// WHICH steps switch mode is semantics (zoomMode); this table owns only the
|
|
385
|
-
// whitespace and label budgets each step buys in terminal cells.
|
|
386
|
-
const m = zoomMode(zoom);
|
|
387
|
-
const mode = m === 'overview' ? 'constellation' : m;
|
|
388
|
-
switch (zoom) {
|
|
389
|
-
case 2:
|
|
390
|
-
return { mode, scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false, detail: DETAIL_PLUS_BUDGET };
|
|
391
|
-
case 1:
|
|
392
|
-
return { mode, scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false, detail: DETAIL_BUDGET };
|
|
393
|
-
case 0:
|
|
394
|
-
return { mode, scale: 1, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false };
|
|
395
|
-
case -1:
|
|
396
|
-
return { mode, scale: 0.85, pad: 1, boxGap: BOX_GAP, breathe: 1, titleGap: 1, barGap: 1, bandCounts: false };
|
|
397
|
-
case -2:
|
|
398
|
-
return { mode, scale: 0.7, pad: 0, boxGap: BOX_GAP, breathe: 0, titleGap: 0, barGap: 1, bandCounts: false };
|
|
399
|
-
case -3:
|
|
400
|
-
return { mode, scale: 0.55, pad: 0, boxGap: 1, breathe: 0, titleGap: 0, barGap: 1, bandCounts: true };
|
|
401
|
-
case -4:
|
|
402
|
-
return { mode, scale: 0, pad: 0, boxGap: BOX_GAP, breathe: 0, titleGap: 0, barGap: 1, bandCounts: true };
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
const LABEL_BUDGET_MIN = 4;
|
|
406
|
-
/** Size and content of one node's box under the given zoom geometry. */
|
|
407
|
-
function boxSpec(node, geo, unicode, neutral) {
|
|
408
|
-
// On dev pages the glyph slot belongs to the status, so a known kind glyph
|
|
409
|
-
// joins the label text; on neutral pages the kind takes the slot itself.
|
|
410
|
-
// A node with a child map wears the dive badge at the end of its label —
|
|
411
|
-
// appended AFTER width fitting, so truncation can never eat the badge.
|
|
412
|
-
const glyph = node.kind !== undefined ? kindGlyph(node.kind, unicode) : undefined;
|
|
413
|
-
const badge = node.submap !== undefined ? (unicode ? ' ⊞' : ' +') : '';
|
|
414
|
-
const badgeW = displayWidth(badge);
|
|
415
|
-
const text = !neutral && glyph !== undefined ? `${glyph} ${node.label}` : node.label;
|
|
416
|
-
if (geo.mode === 'constellation') {
|
|
417
|
-
return { w: 3, h: 1, label: '', pad: 0, borderless: true, extra: [] };
|
|
418
|
-
}
|
|
419
|
-
if (geo.mode === 'detail' && geo.detail !== undefined) {
|
|
420
|
-
const budget = geo.detail;
|
|
421
|
-
const innerW = Math.min(Math.max(displayWidth(text) + badgeW + 4, budget.innerMin), budget.innerMax);
|
|
422
|
-
const extra = [];
|
|
423
|
-
if (node.evidence !== undefined)
|
|
424
|
-
extra.push({ text: fitWidth(` ${node.evidence}`, innerW), style: 'faint' });
|
|
425
|
-
if (node.detail !== undefined) {
|
|
426
|
-
const wrapped = wrapWidth(node.detail, innerW - 2);
|
|
427
|
-
for (let i = 0; i < Math.min(wrapped.length, budget.noteRows); i++) {
|
|
428
|
-
const cut = i === budget.noteRows - 1 && wrapped.length > budget.noteRows;
|
|
429
|
-
extra.push({ text: ` ${cut ? fitWidth(wrapped[i] + '…', innerW - 2) : wrapped[i]}`, style: 'none' });
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
return {
|
|
433
|
-
w: innerW + 2,
|
|
434
|
-
h: BOX_H + extra.length,
|
|
435
|
-
label: fitWidth(text, innerW - 4 - badgeW) + badge,
|
|
436
|
-
pad: 1,
|
|
437
|
-
borderless: false,
|
|
438
|
-
extra,
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
const budget = Math.max(LABEL_BUDGET_MIN, Math.ceil(displayWidth(text) * geo.scale));
|
|
442
|
-
const label = fitWidth(text, budget) + badge;
|
|
443
|
-
return {
|
|
444
|
-
w: displayWidth(label) + 4 + 2 * geo.pad,
|
|
445
|
-
h: BOX_H,
|
|
446
|
-
label,
|
|
447
|
-
pad: geo.pad,
|
|
448
|
-
borderless: false,
|
|
449
|
-
extra: [],
|
|
450
|
-
};
|
|
451
|
-
}
|
|
55
|
+
import { ZOOM_DEFAULT, aggregateMap, flipForSequence, isNeutralKind } from '../semantics/semantics.js';
|
|
56
|
+
import { Canvas } from './canvas.js';
|
|
57
|
+
import { drawBands, drawBox, drawEdges, drawLaneHeaders, drawLegend, drawTitle } from './draw.js';
|
|
58
|
+
import { layoutColumns, layoutRows } from './layout.js';
|
|
59
|
+
import { routeEdges } from './routing.js';
|
|
60
|
+
import { unverifiedDoneIds } from './skins.js';
|
|
61
|
+
import { displayWidth } from './width.js';
|
|
62
|
+
import { AGGREGATE_GEO, zoomGeometry } from './zoom-geometry.js';
|
|
63
|
+
export { displayWidth, fitWidth, wrapWidth } from './width.js';
|
|
64
|
+
export { statusSgr } from './skins.js';
|
|
65
|
+
// The medium-neutral vocabulary (zoom ladder, neutral-kind rule, status and
|
|
66
|
+
// node-kind glyphs) is defined in ../semantics and re-exported here so
|
|
67
|
+
// terminal consumers keep one import site.
|
|
68
|
+
export { ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN, clampZoom, isNeutralKind, kindGlyph, spinnerGlyph, statusGlyph, unverifiedDoneGlyph, zoomLabel, } from '../semantics/semantics.js';
|
|
452
69
|
/** Render the whole map as terminal lines. */
|
|
453
70
|
export function renderMap(map, opts) {
|
|
454
71
|
const built = buildCanvas(map, opts);
|
|
@@ -464,362 +81,43 @@ export function renderMapWindow(map, opts, viewport) {
|
|
|
464
81
|
hits: built.hits,
|
|
465
82
|
};
|
|
466
83
|
}
|
|
467
|
-
/** Geometry for the aggregated far zoom: tight chrome, but FULL labels — the point is names. */
|
|
468
|
-
const AGGREGATE_GEO = {
|
|
469
|
-
mode: 'boxes',
|
|
470
|
-
scale: 1,
|
|
471
|
-
pad: 0,
|
|
472
|
-
boxGap: 1,
|
|
473
|
-
breathe: 0,
|
|
474
|
-
titleGap: 0,
|
|
475
|
-
barGap: 1,
|
|
476
|
-
bandCounts: false,
|
|
477
|
-
};
|
|
478
84
|
function buildCanvas(map, opts) {
|
|
479
85
|
const oriented = flipForSequence(map);
|
|
480
86
|
const plainGeo = zoomGeometry(opts.zoom ?? ZOOM_DEFAULT);
|
|
87
|
+
// The far zoom does not shrink the map, it AGGREGATES it: groups become one
|
|
88
|
+
// box each. Which map is drawn is decided here, once.
|
|
481
89
|
const aggregated = plainGeo.mode === 'constellation' ? aggregateMap(oriented) : undefined;
|
|
482
|
-
|
|
90
|
+
const drawn = aggregated ?? oriented;
|
|
91
|
+
return paint(drawn, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo, unverifiedDoneIds(oriented, drawn));
|
|
483
92
|
}
|
|
484
|
-
function
|
|
93
|
+
function paint(map, opts, geo, unverified) {
|
|
485
94
|
const canvas = new Canvas();
|
|
486
|
-
|
|
487
|
-
const bands = [...map.layers].sort((a, b) => b.rank - a.rank); // index 0 = top band
|
|
488
|
-
if (bands.length === 0) {
|
|
95
|
+
if (map.layers.length === 0) {
|
|
489
96
|
canvas.text(0, 0, map.title ?? 'mellos mapping', 'none', true);
|
|
490
97
|
canvas.text(0, 2, '(empty map — declare layers and nodes to begin)', 'dim');
|
|
491
98
|
return { canvas, hits: [] };
|
|
492
99
|
}
|
|
493
|
-
|
|
494
|
-
const
|
|
495
|
-
const
|
|
496
|
-
const
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
boxes.set(node.id, box);
|
|
502
|
-
}
|
|
503
|
-
// Column assignment. Without lanes each band packs left-to-right in
|
|
504
|
-
// declaration order. With lanes every band is partitioned into the lane
|
|
505
|
-
// columns (plus a trailing off-lane region); a lane is as wide as its
|
|
506
|
-
// widest band row or its own header label, so members align vertically
|
|
507
|
-
// under their column across all bands.
|
|
508
|
-
const laneCount = map.lanes.length;
|
|
509
|
-
const laneX = [];
|
|
510
|
-
const laneW = [];
|
|
511
|
-
if (laneCount === 0) {
|
|
512
|
-
for (const row of bandBoxes) {
|
|
513
|
-
let x = LEFT_MARGIN;
|
|
514
|
-
for (const box of row) {
|
|
515
|
-
box.x = x;
|
|
516
|
-
x += box.w + geo.boxGap;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
else {
|
|
521
|
-
const laneGap = geo.boxGap + 2;
|
|
522
|
-
const laneIndexOf = new Map(map.lanes.map((l, i) => [l.id, i]));
|
|
523
|
-
const regions = laneCount + 1; // trailing region for off-lane nodes
|
|
524
|
-
const grouped = bandBoxes.map((row) => {
|
|
525
|
-
const cells = Array.from({ length: regions }, () => []);
|
|
526
|
-
for (const box of row) {
|
|
527
|
-
const lane = box.node.lane;
|
|
528
|
-
cells[lane !== undefined ? laneIndexOf.get(lane) : regions - 1].push(box);
|
|
529
|
-
}
|
|
530
|
-
return cells;
|
|
531
|
-
});
|
|
532
|
-
const regionW = Array.from({ length: regions }, () => 0);
|
|
533
|
-
for (const cells of grouped) {
|
|
534
|
-
for (let i = 0; i < regions; i++) {
|
|
535
|
-
const rowW = cells[i].reduce((sum, b, k) => sum + b.w + (k > 0 ? geo.boxGap : 0), 0);
|
|
536
|
-
regionW[i] = Math.max(regionW[i], rowW);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
for (let i = 0; i < laneCount; i++)
|
|
540
|
-
regionW[i] = Math.max(regionW[i], displayWidth(map.lanes[i].label) + 2);
|
|
541
|
-
let x0 = LEFT_MARGIN;
|
|
542
|
-
for (let i = 0; i < regions; i++) {
|
|
543
|
-
laneX.push(x0);
|
|
544
|
-
laneW.push(regionW[i]);
|
|
545
|
-
x0 += regionW[i] + laneGap;
|
|
546
|
-
}
|
|
547
|
-
for (const cells of grouped) {
|
|
548
|
-
for (let i = 0; i < regions; i++) {
|
|
549
|
-
let x = laneX[i];
|
|
550
|
-
for (const box of cells[i]) {
|
|
551
|
-
box.x = x;
|
|
552
|
-
x += box.w + geo.boxGap;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
// Band bar labels; at small scales the boxes go mute, so the bars carry
|
|
558
|
-
// the aggregate progress (done/total) for their band instead. Neutral
|
|
559
|
-
// documentation kinds never count progress.
|
|
560
|
-
const bandLabel = bands.map((l, i) => {
|
|
561
|
-
const row = bandBoxes[i];
|
|
562
|
-
const done = row.filter((b) => b.node.status === 'done').length;
|
|
563
|
-
return geo.bandCounts && row.length > 0 && !neutral ? ` ${l.name} ${done}/${row.length}` : ` ${l.name}`;
|
|
564
|
-
});
|
|
565
|
-
let contentWidth = LEFT_MARGIN;
|
|
566
|
-
for (const row of bandBoxes) {
|
|
567
|
-
const last = row[row.length - 1];
|
|
568
|
-
if (last)
|
|
569
|
-
contentWidth = Math.max(contentWidth, last.x + last.w);
|
|
570
|
-
}
|
|
571
|
-
for (let i = 0; i < laneCount; i++)
|
|
572
|
-
contentWidth = Math.max(contentWidth, laneX[i] + laneW[i]);
|
|
573
|
-
for (const label of bandLabel)
|
|
574
|
-
contentWidth = Math.max(contentWidth, LEFT_MARGIN + displayWidth(label) + 7);
|
|
575
|
-
// -- edge analysis (see module header for the routing preference order) --
|
|
576
|
-
const routes = map.edges.map((e) => {
|
|
577
|
-
const fromBox = boxes.get(e.from);
|
|
578
|
-
const toBox = boxes.get(e.to);
|
|
579
|
-
return {
|
|
580
|
-
fromBox,
|
|
581
|
-
toBox,
|
|
582
|
-
fromBand: bandIndexOf.get(fromBox.node.layer),
|
|
583
|
-
toBand: bandIndexOf.get(toBox.node.layer),
|
|
584
|
-
};
|
|
585
|
-
});
|
|
586
|
-
// Attach columns already promised on a box's border (either side).
|
|
587
|
-
const claimedColumns = new Map();
|
|
588
|
-
const isFree = (box, x) => !(claimedColumns.get(box)?.has(x) ?? false);
|
|
589
|
-
const claim = (box, x) => {
|
|
590
|
-
let set = claimedColumns.get(box);
|
|
591
|
-
if (!set)
|
|
592
|
-
claimedColumns.set(box, (set = new Set()));
|
|
593
|
-
set.add(x);
|
|
594
|
-
return x;
|
|
595
|
-
};
|
|
596
|
-
// 1. STRAIGHT edges
|
|
597
|
-
const straightX = new Map();
|
|
598
|
-
for (const r of routes) {
|
|
599
|
-
if (r.toBand - r.fromBand !== 1)
|
|
600
|
-
continue;
|
|
601
|
-
const lo = Math.max(r.fromBox.x + 1, r.toBox.x + 1);
|
|
602
|
-
const hi = Math.min(r.fromBox.x + r.fromBox.w - 2, r.toBox.x + r.toBox.w - 2);
|
|
603
|
-
if (lo > hi)
|
|
604
|
-
continue; // no vertical overlap — a dogleg is genuinely needed
|
|
605
|
-
const mid = Math.floor((lo + hi) / 2);
|
|
606
|
-
for (let d = 0; d <= hi - lo && !straightX.has(r); d++) {
|
|
607
|
-
for (const x of d === 0 ? [mid] : [mid - d, mid + d]) {
|
|
608
|
-
if (x >= lo && x <= hi && isFree(r.fromBox, x) && isFree(r.toBox, x)) {
|
|
609
|
-
straightX.set(r, claim(r.toBox, claim(r.fromBox, x)));
|
|
610
|
-
break;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
// 2. attach slots for the bent rest, nudged off claimed columns
|
|
616
|
-
const bent = routes.filter((r) => !straightX.has(r));
|
|
617
|
-
const outgoing = new Map();
|
|
618
|
-
const incoming = new Map();
|
|
619
|
-
for (const r of bent) {
|
|
620
|
-
outgoing.set(r.fromBox, [...(outgoing.get(r.fromBox) ?? []), r]);
|
|
621
|
-
incoming.set(r.toBox, [...(incoming.get(r.toBox) ?? []), r]);
|
|
622
|
-
}
|
|
623
|
-
const freeSlot = (box, k, n) => {
|
|
624
|
-
const lo = box.x + 1;
|
|
625
|
-
const hi = box.x + box.w - 2;
|
|
626
|
-
const ideal = box.x + Math.min(box.w - 2, Math.max(1, Math.round(((k + 1) * (box.w - 1)) / (n + 1))));
|
|
627
|
-
for (let d = 0; d <= hi - lo; d++) {
|
|
628
|
-
for (const x of d === 0 ? [ideal] : [ideal - d, ideal + d]) {
|
|
629
|
-
if (x >= lo && x <= hi && isFree(box, x))
|
|
630
|
-
return claim(box, x);
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
return ideal; // every column claimed (extremely crowded box) — overlap and live with it
|
|
634
|
-
};
|
|
635
|
-
const attach = new Map();
|
|
636
|
-
for (const r of bent) {
|
|
637
|
-
const outs = outgoing.get(r.fromBox);
|
|
638
|
-
const ins = incoming.get(r.toBox);
|
|
639
|
-
attach.set(r, {
|
|
640
|
-
sx: freeSlot(r.fromBox, outs.indexOf(r), outs.length),
|
|
641
|
-
ex: freeSlot(r.toBox, ins.indexOf(r), ins.length),
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
// 3. THREAD descent columns for skip-level edges
|
|
645
|
-
const skipRoutes = bent.filter((r) => r.toBand - r.fromBand > 1);
|
|
646
|
-
const usedDescent = new Set();
|
|
647
|
-
const descentX = new Map();
|
|
648
|
-
let fallbackCount = 0;
|
|
649
|
-
const blockedByBox = (band, x) => bandBoxes[band].some((b) => x >= b.x && x <= b.x + b.w - 1);
|
|
650
|
-
for (const r of skipRoutes) {
|
|
651
|
-
const { ex } = attach.get(r);
|
|
652
|
-
let chosen;
|
|
653
|
-
for (let d = 0; d <= contentWidth && chosen === undefined; d++) {
|
|
654
|
-
for (const c of d === 0 ? [ex] : [ex - d, ex + d]) {
|
|
655
|
-
if (c < LEFT_MARGIN || c > contentWidth + 1 || usedDescent.has(c))
|
|
656
|
-
continue;
|
|
657
|
-
let blocked = false;
|
|
658
|
-
for (let b = r.fromBand + 1; b < r.toBand && !blocked; b++)
|
|
659
|
-
blocked = blockedByBox(b, c);
|
|
660
|
-
if (!blocked) {
|
|
661
|
-
chosen = c;
|
|
662
|
-
break;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
if (chosen === undefined)
|
|
667
|
-
chosen = contentWidth + 2 + fallbackCount++ * 2; // margin fallback
|
|
668
|
-
usedDescent.add(chosen);
|
|
669
|
-
descentX.set(r, chosen);
|
|
670
|
-
}
|
|
671
|
-
const totalWidth = fallbackCount > 0 ? contentWidth + 2 + fallbackCount * 2 : contentWidth;
|
|
672
|
-
// 4. pack horizontal segments into shared track rows per gap
|
|
673
|
-
const gapCount = bands.length - 1;
|
|
674
|
-
const gapSegments = Array.from({ length: gapCount }, () => []);
|
|
675
|
-
const segmentOf = new Map();
|
|
676
|
-
for (const r of bent) {
|
|
677
|
-
const { sx, ex } = attach.get(r);
|
|
678
|
-
if (r.toBand - r.fromBand === 1) {
|
|
679
|
-
const landing = { route: r, kind: 'landing', lo: Math.min(sx, ex), hi: Math.max(sx, ex) };
|
|
680
|
-
gapSegments[r.toBand - 1].push(landing);
|
|
681
|
-
segmentOf.set(r, { landing });
|
|
682
|
-
}
|
|
683
|
-
else {
|
|
684
|
-
const c = descentX.get(r);
|
|
685
|
-
const exit = { route: r, kind: 'exit', lo: Math.min(sx, c), hi: Math.max(sx, c) };
|
|
686
|
-
const landing = { route: r, kind: 'landing', lo: Math.min(c, ex), hi: Math.max(c, ex) };
|
|
687
|
-
gapSegments[r.fromBand].push(exit);
|
|
688
|
-
gapSegments[r.toBand - 1].push(landing);
|
|
689
|
-
segmentOf.set(r, { exit, landing });
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
const segmentRow = new Map();
|
|
693
|
-
const gapRowCount = gapSegments.map((segments) => {
|
|
694
|
-
const rowEnds = []; // rightmost occupied column per packed row
|
|
695
|
-
for (const s of [...segments].sort((a, b) => a.lo - b.lo)) {
|
|
696
|
-
let row = rowEnds.findIndex((end) => s.lo > end + 1);
|
|
697
|
-
if (row === -1) {
|
|
698
|
-
rowEnds.push(s.hi);
|
|
699
|
-
row = rowEnds.length - 1;
|
|
700
|
-
}
|
|
701
|
-
else {
|
|
702
|
-
rowEnds[row] = Math.max(rowEnds[row], s.hi);
|
|
703
|
-
}
|
|
704
|
-
segmentRow.set(s, row);
|
|
705
|
-
}
|
|
706
|
-
return rowEnds.length;
|
|
707
|
-
});
|
|
708
|
-
// -- vertical layout --
|
|
709
|
-
let y = 0;
|
|
710
|
-
if (map.title !== undefined)
|
|
711
|
-
y += 1 + geo.titleGap;
|
|
712
|
-
let laneHeaderY;
|
|
713
|
-
if (laneCount > 0) {
|
|
714
|
-
laneHeaderY = y;
|
|
715
|
-
y += 1 + geo.barGap;
|
|
716
|
-
}
|
|
717
|
-
const barY = [];
|
|
718
|
-
const gapTrackStartY = [];
|
|
719
|
-
for (let b = 0; b < bands.length; b++) {
|
|
720
|
-
barY.push(y);
|
|
721
|
-
y += 1 + geo.barGap;
|
|
722
|
-
const row = bandBoxes[b];
|
|
723
|
-
for (const box of row)
|
|
724
|
-
box.y = y;
|
|
725
|
-
y += row.reduce((max, box) => Math.max(max, box.h), geo.mode === 'constellation' ? 1 : BOX_H);
|
|
726
|
-
if (b < gapCount) {
|
|
727
|
-
y += geo.breathe; // breathing row below the boxes
|
|
728
|
-
gapTrackStartY.push(y);
|
|
729
|
-
y += gapRowCount[b];
|
|
730
|
-
y += geo.breathe; // breathing row above the next bar
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
const legendY = y + 1;
|
|
734
|
-
const rowYOf = (gap, s) => gapTrackStartY[gap] + segmentRow.get(s);
|
|
735
|
-
// -- draw: title, lane headers, band bars, boxes --
|
|
100
|
+
const neutral = isNeutralKind(map);
|
|
101
|
+
const columns = layoutColumns(map, geo, opts.unicode, neutral);
|
|
102
|
+
const routing = routeEdges(map, columns);
|
|
103
|
+
const rows = layoutRows(columns, geo, routing.gapRowCount, map.title !== undefined, map.lanes.length > 0);
|
|
104
|
+
/** Everything a wire may occupy: the boxes, plus any margin corridor. */
|
|
105
|
+
const wiredWidth = routing.fallbackCount > 0 ? columns.contentWidth + 2 + routing.fallbackCount * 2 : columns.contentWidth;
|
|
106
|
+
/** Plus the band labels' own right margin, which nothing else may enter. */
|
|
107
|
+
const totalWidth = wiredWidth + Math.max(...columns.bandLabel.map(displayWidth));
|
|
736
108
|
if (map.title !== undefined)
|
|
737
|
-
canvas
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
// flush right; only a margin-fallback column pushes it back to the content edge
|
|
750
|
-
const labelStart = (fallbackCount > 0 ? contentWidth : totalWidth) - displayWidth(label);
|
|
751
|
-
canvas.text(labelStart, barY[b], label, 'none', true);
|
|
752
|
-
}
|
|
753
|
-
for (const box of boxes.values()) {
|
|
754
|
-
drawBox(canvas, box, opts, neutral, opts.focus !== undefined && box.node.id === opts.focus);
|
|
755
|
-
}
|
|
756
|
-
// -- draw: edges (wires touching the focused node render bright) --
|
|
757
|
-
for (const r of routes) {
|
|
758
|
-
const sy = r.fromBox.y + r.fromBox.h - 1; // bottom border row of the source box
|
|
759
|
-
const ey = r.toBox.y; // top border row of the target box
|
|
760
|
-
const bright = opts.focus !== undefined &&
|
|
761
|
-
(r.fromBox.node.id === opts.focus || r.toBox.node.id === opts.focus);
|
|
762
|
-
const direct = straightX.get(r);
|
|
763
|
-
if (direct !== undefined) {
|
|
764
|
-
drawPath(canvas, [
|
|
765
|
-
[direct, sy],
|
|
766
|
-
[direct, ey],
|
|
767
|
-
], bright);
|
|
768
|
-
continue;
|
|
769
|
-
}
|
|
770
|
-
const { sx, ex } = attach.get(r);
|
|
771
|
-
const segments = segmentOf.get(r);
|
|
772
|
-
const landingY = rowYOf(r.toBand - 1, segments.landing);
|
|
773
|
-
if (r.toBand - r.fromBand === 1) {
|
|
774
|
-
drawPath(canvas, [
|
|
775
|
-
[sx, sy],
|
|
776
|
-
[sx, landingY],
|
|
777
|
-
[ex, landingY],
|
|
778
|
-
[ex, ey],
|
|
779
|
-
], bright);
|
|
780
|
-
}
|
|
781
|
-
else {
|
|
782
|
-
const c = descentX.get(r);
|
|
783
|
-
const exitY = rowYOf(r.fromBand, segments.exit);
|
|
784
|
-
drawPath(canvas, [
|
|
785
|
-
[sx, sy],
|
|
786
|
-
[sx, exitY],
|
|
787
|
-
[c, exitY],
|
|
788
|
-
[c, landingY],
|
|
789
|
-
[ex, landingY],
|
|
790
|
-
[ex, ey],
|
|
791
|
-
], bright);
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
// -- legend: status vocabulary on dev pages, kind vocabulary on neutral pages --
|
|
795
|
-
let lx = LEFT_MARGIN;
|
|
796
|
-
if (neutral) {
|
|
797
|
-
lx = canvas.text(lx, legendY, map.kind, 'faint');
|
|
798
|
-
const seen = new Set();
|
|
799
|
-
for (const n of map.nodes) {
|
|
800
|
-
const k = n.kind;
|
|
801
|
-
if (k === undefined || seen.has(k) || kindGlyph(k, opts.unicode) === undefined)
|
|
802
|
-
continue;
|
|
803
|
-
seen.add(k);
|
|
804
|
-
lx = canvas.text(lx, legendY, ' ', 'none');
|
|
805
|
-
lx = canvas.text(lx, legendY, `${kindGlyph(k, opts.unicode)} ${k}`, 'none');
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
else {
|
|
809
|
-
const legendOpts = { ...opts, spinnerFrame: 0 };
|
|
810
|
-
const legendEntries = [
|
|
811
|
-
['planned', 'dim'],
|
|
812
|
-
['in-progress', 'amber'],
|
|
813
|
-
['done', 'green'],
|
|
814
|
-
['regressed', 'red'],
|
|
815
|
-
];
|
|
816
|
-
for (const [status, style] of legendEntries) {
|
|
817
|
-
if (lx > LEFT_MARGIN)
|
|
818
|
-
lx = canvas.text(lx, legendY, ' ', 'none');
|
|
819
|
-
lx = canvas.text(lx, legendY, `${glyphFor(status, legendOpts)} ${status}`, style);
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
const hits = [...boxes.values()].map((b) => ({
|
|
109
|
+
drawTitle(canvas, map.title);
|
|
110
|
+
drawLaneHeaders(canvas, map, columns, rows);
|
|
111
|
+
drawBands(canvas, columns, rows, wiredWidth, totalWidth);
|
|
112
|
+
/** The face a node presents: its status, or the hollow face of an unbacked done. */
|
|
113
|
+
const faceOf = (id, status) => (unverified.has(id) ? 'done-unverified' : status);
|
|
114
|
+
for (const box of rows.boxOf.values()) {
|
|
115
|
+
const id = box.node.id;
|
|
116
|
+
drawBox(canvas, box, opts, neutral, faceOf(id, box.node.status), opts.focus !== undefined && id === opts.focus);
|
|
117
|
+
}
|
|
118
|
+
drawEdges(canvas, routing.edges, rows, opts);
|
|
119
|
+
drawLegend(canvas, map, opts, rows.legendY, neutral, unverified.size > 0);
|
|
120
|
+
const hits = [...rows.boxOf.values()].map((b) => ({
|
|
823
121
|
id: b.node.id,
|
|
824
122
|
x: b.x,
|
|
825
123
|
y: b.y,
|
|
@@ -828,32 +126,3 @@ function buildCanvasWith(map, opts, geo) {
|
|
|
828
126
|
}));
|
|
829
127
|
return { canvas, hits };
|
|
830
128
|
}
|
|
831
|
-
function drawBox(canvas, box, opts, neutral, focused = false) {
|
|
832
|
-
const { node, x, y, w } = box;
|
|
833
|
-
const skin = neutral ? neutralSkin(opts.unicode) : skinFor(node.status, opts.unicode);
|
|
834
|
-
// Neutral pages give the glyph slot to the node kind (a bullet when kindless).
|
|
835
|
-
const slotGlyph = neutral
|
|
836
|
-
? (node.kind !== undefined ? kindGlyph(node.kind, opts.unicode) : undefined) ??
|
|
837
|
-
(opts.unicode ? '·' : '.')
|
|
838
|
-
: glyphFor(node.status, opts);
|
|
839
|
-
if (box.borderless) {
|
|
840
|
-
// Constellation mode: the node IS its glyph. Wires simply end
|
|
841
|
-
// beside it — a glyph is not a border, so no junction chars appear.
|
|
842
|
-
canvas.text(x + 1, y, slotGlyph, skin.style, true);
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
const inner = w - 2;
|
|
846
|
-
const pad = box.pad === 1 ? ' ' : '';
|
|
847
|
-
canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1], skin.style, focused);
|
|
848
|
-
canvas.text(x, y + 1, skin.v, skin.style, focused);
|
|
849
|
-
canvas.text(x + 1, y + 1, `${pad}${slotGlyph} ${box.label}${pad}`, skin.style, true);
|
|
850
|
-
canvas.text(x + w - 1, y + 1, skin.v, skin.style, focused);
|
|
851
|
-
for (let i = 0; i < box.extra.length; i++) {
|
|
852
|
-
const row = box.extra[i];
|
|
853
|
-
const yy = y + 2 + i;
|
|
854
|
-
canvas.text(x, yy, skin.v, skin.style, focused);
|
|
855
|
-
canvas.text(x + 1, yy, row.text, row.style);
|
|
856
|
-
canvas.text(x + w - 1, yy, skin.v, skin.style, focused);
|
|
857
|
-
}
|
|
858
|
-
canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3], skin.style, focused);
|
|
859
|
-
}
|