mellos-mapping 0.18.0 → 0.20.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/README.md +32 -4
- package/README.zh-CN.md +29 -4
- package/dist/server.mjs +185 -62
- package/dist/watch.mjs +306 -216
- package/lib/domain/ops.d.ts +112 -0
- package/lib/domain/ops.js +253 -0
- package/lib/domain/types.d.ts +242 -0
- package/lib/domain/types.js +122 -0
- package/lib/render/render.d.ts +102 -0
- package/lib/render/render.js +859 -0
- package/lib/semantics/semantics.d.ts +120 -0
- package/lib/semantics/semantics.js +256 -0
- package/lib/store/format.d.ts +50 -0
- package/lib/store/format.js +215 -0
- package/lib/store/store.d.ts +96 -0
- package/lib/store/store.js +281 -0
- package/package.json +30 -2
- package/scripts/codex-register.mjs +1 -1
- package/scripts/open-pane.mjs +1 -1
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 4 (presentation) — pure rendering of a MellosMap to terminal lines.
|
|
3
|
+
*
|
|
4
|
+
* Shared by the MCP `mmap_view` tool (monochrome) and the split-pane watcher
|
|
5
|
+
* (colored, animated). Pure function of (map, options): no I/O, no clock —
|
|
6
|
+
* animation is driven by the caller passing a spinner frame index.
|
|
7
|
+
*
|
|
8
|
+
* Visual language — a dark circuit board:
|
|
9
|
+
* rank 0 renders at the BOTTOM of the picture ("primitives are the
|
|
10
|
+
* ground"). Wiring and band bars are FAINT; the glowing things are the
|
|
11
|
+
* nodes. Junctions where a wire enters a box inherit the box's color,
|
|
12
|
+
* like lit pins. Dependency lines only ever travel downward.
|
|
13
|
+
*
|
|
14
|
+
* planned dashed dim rounded box, '·' — a ghost: designed, not built
|
|
15
|
+
* in-progress amber rounded box, spinner — where attention currently is
|
|
16
|
+
* done heavy green box, '■' — built and verified
|
|
17
|
+
* regressed heavy red box, '✗' — was done, foundation cracked
|
|
18
|
+
*
|
|
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
|
+
* Zoom — terminals cannot scale glyphs, so zooming out first COMPRESSES the
|
|
33
|
+
* geometry (gaps, breathing rows, padding shrink; labels truncate toward a
|
|
34
|
+
* scale-proportional budget) while boxes stay boxes. Only when a further
|
|
35
|
+
* step would leave labels too short to mean anything does the picture switch
|
|
36
|
+
* mode — to a borderless glyph constellation whose band bars carry
|
|
37
|
+
* done/total counts. Zooming in past 100% unfolds evidence and design notes
|
|
38
|
+
* inside the boxes; a second step widens the boxes and unfolds the notes
|
|
39
|
+
* further. The ladder, one wheel tick per step:
|
|
40
|
+
* +2 detail+ wider boxes, design notes unfold almost fully
|
|
41
|
+
* +1 detail evidence + design notes unfold inside boxes
|
|
42
|
+
* 0 100% the standard working view (default, unchanged)
|
|
43
|
+
* -1 85% labels truncated to 85%, geometry still roomy
|
|
44
|
+
* -2 70% padding and breathing rows collapse
|
|
45
|
+
* -3 55% tightest meaningful boxes; band bars gain done/total
|
|
46
|
+
* -4 overview MODE SWITCH: borderless status glyphs, pure topology
|
|
47
|
+
* The same layout/routing machinery runs at every step; only the per-node
|
|
48
|
+
* box spec (size, border, content) and the whitespace geometry change.
|
|
49
|
+
*/
|
|
50
|
+
import { ZOOM_DEFAULT, aggregateMap, flipForSequence, isNeutralKind, zoomMode } from '../semantics/semantics.js';
|
|
51
|
+
// The view-semantics vocabulary (zoom ladder, neutral-kind rule) is defined in
|
|
52
|
+
// ../semantics and re-exported here so terminal consumers keep one import site.
|
|
53
|
+
export { ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN, clampZoom, isNeutralKind, zoomLabel } from '../semantics/semantics.js';
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// display width — CJK-aware, because labels will often be Chinese
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
const WIDE_RANGES = [
|
|
58
|
+
[0x1100, 0x115f], // Hangul Jamo
|
|
59
|
+
[0x2e80, 0xa4cf], // CJK radicals .. Yi (covers CJK Unified Ideographs)
|
|
60
|
+
[0xa960, 0xa97f],
|
|
61
|
+
[0xac00, 0xd7a3], // Hangul syllables
|
|
62
|
+
[0xf900, 0xfaff], // CJK compatibility ideographs
|
|
63
|
+
[0xfe10, 0xfe19],
|
|
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
|
+
}
|
|
452
|
+
/** Render the whole map as terminal lines. */
|
|
453
|
+
export function renderMap(map, opts) {
|
|
454
|
+
const built = buildCanvas(map, opts);
|
|
455
|
+
return built.canvas.emit(opts);
|
|
456
|
+
}
|
|
457
|
+
/** Render only the given viewport of the map, plus the full content extent. */
|
|
458
|
+
export function renderMapWindow(map, opts, viewport) {
|
|
459
|
+
const built = buildCanvas(map, opts);
|
|
460
|
+
return {
|
|
461
|
+
lines: built.canvas.emit(opts, viewport),
|
|
462
|
+
contentWidth: built.canvas.width,
|
|
463
|
+
contentHeight: built.canvas.height,
|
|
464
|
+
hits: built.hits,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
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
|
+
function buildCanvas(map, opts) {
|
|
479
|
+
const oriented = flipForSequence(map);
|
|
480
|
+
const plainGeo = zoomGeometry(opts.zoom ?? ZOOM_DEFAULT);
|
|
481
|
+
const aggregated = plainGeo.mode === 'constellation' ? aggregateMap(oriented) : undefined;
|
|
482
|
+
return buildCanvasWith(aggregated ?? oriented, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo);
|
|
483
|
+
}
|
|
484
|
+
function buildCanvasWith(map, opts, geo) {
|
|
485
|
+
const canvas = new Canvas();
|
|
486
|
+
const neutral = isNeutralKind(map);
|
|
487
|
+
const bands = [...map.layers].sort((a, b) => b.rank - a.rank); // index 0 = top band
|
|
488
|
+
if (bands.length === 0) {
|
|
489
|
+
canvas.text(0, 0, map.title ?? 'mellos mapping', 'none', true);
|
|
490
|
+
canvas.text(0, 2, '(empty map — declare layers and nodes to begin)', 'dim');
|
|
491
|
+
return { canvas, hits: [] };
|
|
492
|
+
}
|
|
493
|
+
// -- horizontal layout: one row of boxes per band --
|
|
494
|
+
const bandIndexOf = new Map(bands.map((l, i) => [l.id, i]));
|
|
495
|
+
const boxes = new Map();
|
|
496
|
+
const bandBoxes = bands.map(() => []);
|
|
497
|
+
for (const node of map.nodes) {
|
|
498
|
+
const band = bandIndexOf.get(node.layer);
|
|
499
|
+
const box = { node, ...boxSpec(node, geo, opts.unicode, neutral), x: LEFT_MARGIN, y: 0 };
|
|
500
|
+
bandBoxes[band].push(box);
|
|
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 --
|
|
736
|
+
if (map.title !== undefined)
|
|
737
|
+
canvas.text(LEFT_MARGIN, 0, map.title, 'none', true);
|
|
738
|
+
if (laneHeaderY !== undefined) {
|
|
739
|
+
for (let i = 0; i < laneCount; i++) {
|
|
740
|
+
const label = fitWidth(map.lanes[i].label, laneW[i]);
|
|
741
|
+
const cx = laneX[i] + Math.max(0, Math.floor((laneW[i] - displayWidth(label)) / 2));
|
|
742
|
+
canvas.text(cx, laneHeaderY, label, 'faint', true);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
for (let b = 0; b < bands.length; b++) {
|
|
746
|
+
const label = bandLabel[b];
|
|
747
|
+
for (let x = 0; x < totalWidth; x++)
|
|
748
|
+
canvas.line(x, barY[b], LEFT | RIGHT, true);
|
|
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) => ({
|
|
823
|
+
id: b.node.id,
|
|
824
|
+
x: b.x,
|
|
825
|
+
y: b.y,
|
|
826
|
+
w: b.w,
|
|
827
|
+
h: b.h,
|
|
828
|
+
}));
|
|
829
|
+
return { canvas, hits };
|
|
830
|
+
}
|
|
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
|
+
}
|