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.
Files changed (43) hide show
  1. package/README.md +360 -63
  2. package/README.zh-CN.md +314 -54
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1614 -809
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1391 -760
  8. package/lib/domain/ops.d.ts +71 -12
  9. package/lib/domain/ops.js +145 -14
  10. package/lib/domain/types.d.ts +47 -6
  11. package/lib/domain/types.js +34 -3
  12. package/lib/render/canvas.d.ts +50 -0
  13. package/lib/render/canvas.js +210 -0
  14. package/lib/render/draw.d.ts +37 -0
  15. package/lib/render/draw.js +111 -0
  16. package/lib/render/layout.d.ts +89 -0
  17. package/lib/render/layout.js +200 -0
  18. package/lib/render/options.d.ts +39 -0
  19. package/lib/render/options.js +10 -0
  20. package/lib/render/render.d.ts +32 -46
  21. package/lib/render/render.js +58 -789
  22. package/lib/render/routing.d.ts +56 -0
  23. package/lib/render/routing.js +244 -0
  24. package/lib/render/skins.d.ts +54 -0
  25. package/lib/render/skins.js +99 -0
  26. package/lib/render/width.d.ts +24 -0
  27. package/lib/render/width.js +139 -0
  28. package/lib/render/zoom-geometry.d.ts +52 -0
  29. package/lib/render/zoom-geometry.js +56 -0
  30. package/lib/semantics/semantics.d.ts +53 -4
  31. package/lib/semantics/semantics.js +130 -6
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +17 -0
  35. package/lib/store/format.js +185 -66
  36. package/lib/store/store.d.ts +220 -20
  37. package/lib/store/store.js +491 -38
  38. package/package.json +12 -4
  39. package/scripts/codex-register.mjs +89 -20
  40. package/scripts/install-mmap-command.mjs +293 -0
  41. package/scripts/mmap.mjs +213 -0
  42. package/scripts/open-pane.mjs +115 -254
  43. package/scripts/pane-core.mjs +418 -0
@@ -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, 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
- }
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
- return buildCanvasWith(aggregated ?? oriented, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo);
90
+ const drawn = aggregated ?? oriented;
91
+ return paint(drawn, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo, unverifiedDoneIds(oriented, drawn));
483
92
  }
484
- function buildCanvasWith(map, opts, geo) {
93
+ function paint(map, opts, geo, unverified) {
485
94
  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) {
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
- // -- 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 --
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.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) => ({
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
- }