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.
@@ -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
+ }