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
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Layer 4a — the drawing surface: a grid of cells that knows how to merge
3
+ * crossing lines, and how to emit itself as terminal rows.
4
+ *
5
+ * Two kinds of ink live in a cell. A LITERAL is a character somebody chose (a
6
+ * label, a box border); a MASK is a set of directions a routed line passes
7
+ * through, and the character comes out of the mask at emit time. That is what
8
+ * makes junctions free: two wires crossing simply union their masks, and ┼
9
+ * appears without anyone routing around anything.
10
+ *
11
+ * The surface knows nothing about maps, statuses or zoom — it takes a Style
12
+ * (the ink palette below) and coordinates, and it is the only place ANSI is
13
+ * produced.
14
+ */
15
+ import { charWidth } from './width.js';
16
+ /** SGR parameter per style; combined with bold ("1") at emit time. */
17
+ export const SGR = {
18
+ none: '',
19
+ dim: '2',
20
+ amber: '33',
21
+ green: '32',
22
+ greenDim: '32;2', // done, but nothing behind the claim: green, not fully lit
23
+ red: '31',
24
+ faint: '90',
25
+ };
26
+ export const ANSI_RESET = '\x1b[0m';
27
+ // ---------------------------------------------------------------------------
28
+ // line-character algebra — junctions emerge from direction bitmask unions
29
+ // ---------------------------------------------------------------------------
30
+ export const UP = 1;
31
+ export const DOWN = 2;
32
+ export const LEFT = 4;
33
+ export const RIGHT = 8;
34
+ const LIGHT_BY_MASK = {
35
+ [UP]: '│',
36
+ [DOWN]: '│',
37
+ [LEFT]: '─',
38
+ [RIGHT]: '─',
39
+ [UP | DOWN]: '│',
40
+ [LEFT | RIGHT]: '─',
41
+ [DOWN | RIGHT]: '┌',
42
+ [DOWN | LEFT]: '┐',
43
+ [UP | RIGHT]: '└',
44
+ [UP | LEFT]: '┘',
45
+ [UP | DOWN | RIGHT]: '├',
46
+ [UP | DOWN | LEFT]: '┤',
47
+ [DOWN | LEFT | RIGHT]: '┬',
48
+ [UP | LEFT | RIGHT]: '┴',
49
+ [UP | DOWN | LEFT | RIGHT]: '┼',
50
+ };
51
+ function maskChar(mask, heavyHorizontal, unicode) {
52
+ if (!unicode) {
53
+ const hasV = (mask & (UP | DOWN)) !== 0;
54
+ const hasH = (mask & (LEFT | RIGHT)) !== 0;
55
+ if (hasV && hasH)
56
+ return '+';
57
+ return hasV ? '|' : '-';
58
+ }
59
+ if (heavyHorizontal) {
60
+ if (mask === (LEFT | RIGHT))
61
+ return '━';
62
+ if (mask === (UP | DOWN | LEFT | RIGHT))
63
+ return '┿';
64
+ }
65
+ return LIGHT_BY_MASK[mask] ?? '┼';
66
+ }
67
+ /** Junction replacements when a routed line meets a literal border character. */
68
+ const BORDER_JUNCTION = {
69
+ '─': { down: '┬', up: '┴' },
70
+ '╌': { down: '┬', up: '┴' },
71
+ '━': { down: '┯', up: '┷' },
72
+ '-': { down: '+', up: '+' },
73
+ '.': { down: '+', up: '+' },
74
+ };
75
+ export class Canvas {
76
+ rows = [];
77
+ cell(x, y) {
78
+ while (this.rows.length <= y)
79
+ this.rows.push([]);
80
+ const row = this.rows[y];
81
+ while (row.length <= x)
82
+ row.push({ mask: 0, heavyHorizontal: false, bright: false, style: 'none', bold: false });
83
+ return row[x];
84
+ }
85
+ get height() {
86
+ return this.rows.length;
87
+ }
88
+ get width() {
89
+ return this.rows.reduce((max, row) => Math.max(max, row.length), 0);
90
+ }
91
+ /** Write literal text starting at (x, y). Returns the column just past it. */
92
+ text(x, y, s, style, bold = false) {
93
+ let cx = x;
94
+ for (const ch of s) {
95
+ const w = charWidth(ch.codePointAt(0));
96
+ if (w === 0) {
97
+ // A combining mark, a variation selector or a skin tone takes no
98
+ // column of its own: it rides on the cell it modifies. Given one, it
99
+ // would overwrite the base character and the row would shift left.
100
+ const base = this.cell(Math.max(0, cx - 1), y);
101
+ const target = base.literal === '' ? this.cell(Math.max(0, cx - 2), y) : base; // skip a wide char's phantom half
102
+ target.literal = (target.literal ?? '') + ch;
103
+ continue;
104
+ }
105
+ const c = this.cell(cx, y);
106
+ c.literal = ch;
107
+ c.style = style;
108
+ c.bold = bold;
109
+ if (w === 2) {
110
+ // The second column of a wide character is a phantom cell: it must
111
+ // exist so later writes don't overlap, but it emits nothing.
112
+ const phantom = this.cell(cx + 1, y);
113
+ phantom.literal = '';
114
+ phantom.style = style;
115
+ }
116
+ cx += w;
117
+ }
118
+ return cx;
119
+ }
120
+ /** Merge a routed-line direction mask into (x, y). */
121
+ line(x, y, mask, heavyHorizontal = false, bright = false) {
122
+ const c = this.cell(x, y);
123
+ if (c.literal !== undefined) {
124
+ const junction = BORDER_JUNCTION[c.literal];
125
+ const replacement = mask & DOWN ? junction?.down : mask & UP ? junction?.up : undefined;
126
+ if (replacement !== undefined)
127
+ c.literal = replacement;
128
+ return; // literals other than borders (labels) are never overdrawn
129
+ }
130
+ c.mask |= mask;
131
+ c.heavyHorizontal = c.heavyHorizontal || heavyHorizontal;
132
+ c.bright = c.bright || bright;
133
+ }
134
+ /**
135
+ * Emit terminal lines, optionally windowed to a viewport. Slicing happens
136
+ * at the cell level so ANSI codes reopen correctly inside the window and a
137
+ * CJK character cut in half at either edge degrades to a space instead of
138
+ * shifting the whole row. Routed wiring (mask cells) emits FAINT — the
139
+ * circuit board recedes, the boxes glow.
140
+ */
141
+ emit(opts, viewport) {
142
+ const vp = viewport ?? { x: 0, y: 0, width: this.width, height: this.height };
143
+ const out = [];
144
+ for (let y = vp.y; y < vp.y + vp.height; y++) {
145
+ const row = this.rows[y] ?? [];
146
+ let line = '';
147
+ let open = '';
148
+ const end = Math.min(vp.x + vp.width, row.length);
149
+ for (let x = Math.max(0, vp.x); x < end; x++) {
150
+ const c = row[x];
151
+ const isWire = c.literal === undefined && c.mask !== 0;
152
+ let ch = c.literal !== undefined ? c.literal : isWire ? maskChar(c.mask, c.heavyHorizontal, opts.unicode) : ' ';
153
+ if (ch === '') {
154
+ if (x !== Math.max(0, vp.x))
155
+ continue; // phantom half inside the window: already emitted
156
+ ch = ' '; // window starts on the right half of a wide character
157
+ }
158
+ else if (charWidth(ch.codePointAt(0)) === 2 && x + 1 >= vp.x + vp.width) {
159
+ ch = ' '; // wide character whose right half would spill past the window
160
+ }
161
+ const params = ch === ' '
162
+ ? ''
163
+ : isWire
164
+ ? c.bright
165
+ ? '1' // spotlighted wire: bold default color against the faint board
166
+ : SGR.faint
167
+ : [SGR[c.style], c.bold ? '1' : ''].filter(Boolean).join(';');
168
+ if (opts.color && params !== open) {
169
+ line += (open !== '' ? ANSI_RESET : '') + (params !== '' ? `\x1b[${params}m` : '');
170
+ open = params;
171
+ }
172
+ line += ch;
173
+ }
174
+ if (opts.color && open !== '')
175
+ line += ANSI_RESET;
176
+ out.push(line.replace(/ +$/, ''));
177
+ }
178
+ return out;
179
+ }
180
+ }
181
+ /**
182
+ * Draw an orthogonal polyline through `points` (consecutive points must share
183
+ * an x or a y). Interior cells of a segment carry the segment's axis mask;
184
+ * every point cell carries only the directions of the segments that actually
185
+ * touch it — so path endpoints become clean junction stubs (e.g. ┬ when
186
+ * entering a box border) and turning points become corner characters, all via
187
+ * the same mask union. Zero-length segments vanish naturally.
188
+ */
189
+ export function drawPath(canvas, points, bright = false) {
190
+ for (let i = 0; i + 1 < points.length; i++) {
191
+ const [x1, y1] = points[i];
192
+ const [x2, y2] = points[i + 1];
193
+ if (x1 === x2 && y1 === y2)
194
+ continue;
195
+ if (x1 === x2) {
196
+ const [lo, hi] = y1 < y2 ? [y1, y2] : [y2, y1];
197
+ for (let yy = lo + 1; yy < hi; yy++)
198
+ canvas.line(x1, yy, UP | DOWN, false, bright);
199
+ canvas.line(x1, y1, y2 > y1 ? DOWN : UP, false, bright);
200
+ canvas.line(x1, y2, y2 > y1 ? UP : DOWN, false, bright);
201
+ }
202
+ else {
203
+ const [lo, hi] = x1 < x2 ? [x1, x2] : [x2, x1];
204
+ for (let xx = lo + 1; xx < hi; xx++)
205
+ canvas.line(xx, y1, LEFT | RIGHT, false, bright);
206
+ canvas.line(x1, y1, x2 > x1 ? RIGHT : LEFT, false, bright);
207
+ canvas.line(x2, y1, x2 > x1 ? LEFT : RIGHT, false, bright);
208
+ }
209
+ }
210
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Layer 4a — putting the decided picture onto the canvas.
3
+ *
4
+ * Everything here is the LAST stage: positions, columns and track rows are
5
+ * already values by the time these functions run, so drawing is a
6
+ * transcription and never a decision. Order matters in exactly one way — the
7
+ * band bars go down before the boxes and the wires, so a wire crossing a band
8
+ * merges into it as ┿ instead of being refused by a literal.
9
+ */
10
+ import type { MellosMap } from '../domain/types.js';
11
+ import { Canvas } from './canvas.js';
12
+ import type { ColumnLayout, PlacedBox, RowLayout } from './layout.js';
13
+ import type { RenderOptions } from './options.js';
14
+ import { type RoutedEdge } from './routing.js';
15
+ import { type StatusFace } from './skins.js';
16
+ /** The map's title, at the top left of the picture. */
17
+ export declare function drawTitle(canvas: Canvas, title: string): void;
18
+ /** Lane headers, centered over their column region. */
19
+ export declare function drawLaneHeaders(canvas: Canvas, map: MellosMap, columns: ColumnLayout, rows: RowLayout): void;
20
+ /**
21
+ * The band bars and their labels. A bar spans everything a wire may occupy;
22
+ * the label lives in a margin of its own to the right of that, because a
23
+ * label written over the bar REPLACES cells, and the canvas refuses to
24
+ * overdraw a literal — a wire crossing under a label was simply cut.
25
+ */
26
+ export declare function drawBands(canvas: Canvas, columns: ColumnLayout, rows: RowLayout, wiredWidth: number, totalWidth: number): void;
27
+ /** One node's box: border, glyph, label, and any unfolded detail rows. */
28
+ export declare function drawBox(canvas: Canvas, box: PlacedBox, opts: RenderOptions, neutral: boolean, face: StatusFace, focused?: boolean): void;
29
+ /** Every wire. Those touching the focused node render bright. */
30
+ export declare function drawEdges(canvas: Canvas, edges: readonly RoutedEdge[], rows: RowLayout, opts: RenderOptions): void;
31
+ /**
32
+ * The legend under the picture: the status vocabulary on a dev page, the kind
33
+ * vocabulary on a documentation page. The hollow "done, no evidence" face is
34
+ * named only where the picture shows one — the four statuses are the
35
+ * vocabulary, that one is a rule being broken in THIS map.
36
+ */
37
+ export declare function drawLegend(canvas: Canvas, map: MellosMap, opts: RenderOptions, legendY: number, neutral: boolean, anyUnverified: boolean): void;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Layer 4a — putting the decided picture onto the canvas.
3
+ *
4
+ * Everything here is the LAST stage: positions, columns and track rows are
5
+ * already values by the time these functions run, so drawing is a
6
+ * transcription and never a decision. Order matters in exactly one way — the
7
+ * band bars go down before the boxes and the wires, so a wire crossing a band
8
+ * merges into it as ┿ instead of being refused by a literal.
9
+ */
10
+ import { kindGlyph } from '../semantics/semantics.js';
11
+ import { LEFT, RIGHT, drawPath } from './canvas.js';
12
+ import { edgePolyline } from './routing.js';
13
+ import { glyphFor, neutralGlyph, neutralSkin, skinFor, styleFor } from './skins.js';
14
+ import { displayWidth, fitWidth } from './width.js';
15
+ import { LEFT_MARGIN } from './zoom-geometry.js';
16
+ /** The map's title, at the top left of the picture. */
17
+ export function drawTitle(canvas, title) {
18
+ canvas.text(LEFT_MARGIN, 0, title, 'none', true);
19
+ }
20
+ /** Lane headers, centered over their column region. */
21
+ export function drawLaneHeaders(canvas, map, columns, rows) {
22
+ if (rows.laneHeaderY === undefined)
23
+ return;
24
+ for (let i = 0; i < map.lanes.length; i++) {
25
+ const region = columns.lanes[i];
26
+ const label = fitWidth(map.lanes[i].label, region.w);
27
+ const cx = region.x + Math.max(0, Math.floor((region.w - displayWidth(label)) / 2));
28
+ canvas.text(cx, rows.laneHeaderY, label, 'faint', true);
29
+ }
30
+ }
31
+ /**
32
+ * The band bars and their labels. A bar spans everything a wire may occupy;
33
+ * the label lives in a margin of its own to the right of that, because a
34
+ * label written over the bar REPLACES cells, and the canvas refuses to
35
+ * overdraw a literal — a wire crossing under a label was simply cut.
36
+ */
37
+ export function drawBands(canvas, columns, rows, wiredWidth, totalWidth) {
38
+ for (let b = 0; b < columns.bands.length; b++) {
39
+ const label = columns.bandLabel[b];
40
+ for (let x = 0; x < wiredWidth; x++)
41
+ canvas.line(x, rows.barY[b], LEFT | RIGHT, true);
42
+ canvas.text(totalWidth - displayWidth(label), rows.barY[b], label, 'none', true);
43
+ }
44
+ }
45
+ /** One node's box: border, glyph, label, and any unfolded detail rows. */
46
+ export function drawBox(canvas, box, opts, neutral, face, focused = false) {
47
+ const { node, x, y, w } = box;
48
+ const skin = neutral ? neutralSkin(opts.unicode) : skinFor(face, opts.unicode);
49
+ // Neutral pages give the glyph slot to the node kind (a bullet when kindless).
50
+ const slotGlyph = neutral ? neutralGlyph(node, opts.unicode) : glyphFor(face, opts);
51
+ if (box.borderless) {
52
+ // Constellation mode: the node IS its glyph. Wires simply end
53
+ // beside it — a glyph is not a border, so no junction chars appear.
54
+ canvas.text(x + 1, y, slotGlyph, skin.style, true);
55
+ return;
56
+ }
57
+ const inner = w - 2;
58
+ const pad = box.pad === 1 ? ' ' : '';
59
+ canvas.text(x, y, skin.corners[0] + skin.h.repeat(inner) + skin.corners[1], skin.style, focused);
60
+ canvas.text(x, y + 1, skin.v, skin.style, focused);
61
+ canvas.text(x + 1, y + 1, `${pad}${slotGlyph} ${box.label}${pad}`, skin.style, true);
62
+ canvas.text(x + w - 1, y + 1, skin.v, skin.style, focused);
63
+ for (let i = 0; i < box.extra.length; i++) {
64
+ const row = box.extra[i];
65
+ const yy = y + 2 + i;
66
+ canvas.text(x, yy, skin.v, skin.style, focused);
67
+ canvas.text(x + 1, yy, row.text, row.style);
68
+ canvas.text(x + w - 1, yy, skin.v, skin.style, focused);
69
+ }
70
+ canvas.text(x, y + box.h - 1, skin.corners[2] + skin.h.repeat(inner) + skin.corners[3], skin.style, focused);
71
+ }
72
+ /** Every wire. Those touching the focused node render bright. */
73
+ export function drawEdges(canvas, edges, rows, opts) {
74
+ for (const edge of edges) {
75
+ const bright = opts.focus !== undefined &&
76
+ (edge.from.node.id === opts.focus || edge.to.node.id === opts.focus);
77
+ drawPath(canvas, edgePolyline(edge, rows), bright);
78
+ }
79
+ }
80
+ /**
81
+ * The legend under the picture: the status vocabulary on a dev page, the kind
82
+ * vocabulary on a documentation page. The hollow "done, no evidence" face is
83
+ * named only where the picture shows one — the four statuses are the
84
+ * vocabulary, that one is a rule being broken in THIS map.
85
+ */
86
+ export function drawLegend(canvas, map, opts, legendY, neutral, anyUnverified) {
87
+ let lx = LEFT_MARGIN;
88
+ if (neutral) {
89
+ lx = canvas.text(lx, legendY, map.kind, 'faint');
90
+ const seen = new Set();
91
+ for (const n of map.nodes) {
92
+ const k = n.kind;
93
+ if (k === undefined || seen.has(k) || kindGlyph(k, opts.unicode) === undefined)
94
+ continue;
95
+ seen.add(k);
96
+ lx = canvas.text(lx, legendY, ' ', 'none');
97
+ lx = canvas.text(lx, legendY, `${kindGlyph(k, opts.unicode)} ${k}`, 'none');
98
+ }
99
+ return;
100
+ }
101
+ const legendOpts = { ...opts, spinnerFrame: 0 };
102
+ const faces = ['planned', 'in-progress', 'done', 'regressed'];
103
+ if (anyUnverified)
104
+ faces.push('done-unverified');
105
+ for (const face of faces) {
106
+ if (lx > LEFT_MARGIN)
107
+ lx = canvas.text(lx, legendY, ' ', 'none');
108
+ const word = face === 'done-unverified' ? 'done, no evidence' : face;
109
+ lx = canvas.text(lx, legendY, `${glyphFor(face, legendOpts)} ${word}`, styleFor(face));
110
+ }
111
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Layer 4a — where everything goes, decided before anything is drawn.
3
+ *
4
+ * Two stages, because the picture's height cannot be known until the wires
5
+ * are routed: first COLUMNS (how wide each box is and which column it starts
6
+ * at, which fixes the picture's width), then — once routing has said how many
7
+ * track rows each band gap needs — ROWS. Nothing here draws; nothing here
8
+ * mutates a box after placing it, so a position is a value another stage can
9
+ * hold on to.
10
+ *
11
+ * Bands are the map's layers, top band first: rank 0 renders at the BOTTOM
12
+ * ("primitives are the ground"), so the sort is descending.
13
+ */
14
+ import type { MapLayer, MapNode, MellosMap } from '../domain/types.js';
15
+ import type { Style } from './canvas.js';
16
+ import { type ZoomGeometry } from './zoom-geometry.js';
17
+ /** In-box rows below the label row (detail mode only). */
18
+ export interface ExtraRow {
19
+ readonly text: string;
20
+ readonly style: Style;
21
+ }
22
+ /** A box sized for the zoom — content and extent, no position yet. */
23
+ export interface BoxSpec {
24
+ readonly node: MapNode;
25
+ readonly w: number;
26
+ readonly h: number;
27
+ /** Label truncated to the zoom's budget; '' in constellation mode. */
28
+ readonly label: string;
29
+ readonly pad: 0 | 1;
30
+ readonly borderless: boolean;
31
+ readonly extra: readonly ExtraRow[];
32
+ }
33
+ /** A sized box with its column decided. */
34
+ export interface ColumnedBox extends BoxSpec {
35
+ readonly x: number;
36
+ }
37
+ /** A box on the picture. */
38
+ export interface PlacedBox extends ColumnedBox {
39
+ readonly y: number;
40
+ }
41
+ /** A lane's column region (the last one holds the boxes in no lane at all). */
42
+ export interface LaneRegion {
43
+ readonly x: number;
44
+ readonly w: number;
45
+ }
46
+ /** The picture's horizontal decisions. */
47
+ export interface ColumnLayout {
48
+ /** Layers sorted top band first (descending rank). */
49
+ readonly bands: readonly MapLayer[];
50
+ readonly bandIndexOf: ReadonlyMap<string, number>;
51
+ /** Boxes per band, in declaration order (which is also left-to-right without lanes). */
52
+ readonly bandBoxes: readonly (readonly ColumnedBox[])[];
53
+ /** Every box by node id, in declaration order. */
54
+ readonly boxOf: ReadonlyMap<string, ColumnedBox>;
55
+ /** Lane regions, empty when the map declares no lanes. */
56
+ readonly lanes: readonly LaneRegion[];
57
+ /** The text each band bar carries, one per band. */
58
+ readonly bandLabel: readonly string[];
59
+ /** Rightmost column any box or lane occupies, plus the left margin. */
60
+ readonly contentWidth: number;
61
+ }
62
+ /** The picture's vertical decisions. */
63
+ export interface RowLayout {
64
+ readonly boxOf: ReadonlyMap<string, PlacedBox>;
65
+ readonly bandBoxes: readonly (readonly PlacedBox[])[];
66
+ /** Row of each band's bar. */
67
+ readonly barY: readonly number[];
68
+ /** First track row of each band gap; a segment's row index is added to it. */
69
+ readonly gapTrackStartY: readonly number[];
70
+ /** Row of the lane header strip, when the map has lanes. */
71
+ readonly laneHeaderY: number | undefined;
72
+ readonly legendY: number;
73
+ }
74
+ /** Size and content of one node's box under the given zoom geometry. */
75
+ export declare function boxSpec(node: MapNode, geo: ZoomGeometry, unicode: boolean, neutral: boolean): Omit<BoxSpec, 'node'>;
76
+ /**
77
+ * Column assignment. Without lanes each band packs left-to-right in
78
+ * declaration order. With lanes every band is partitioned into the lane
79
+ * columns (plus a trailing region for boxes in no lane); a lane is as wide as
80
+ * its widest band row or its own header label, so members align vertically
81
+ * under their column across all bands.
82
+ */
83
+ export declare function layoutColumns(map: MellosMap, geo: ZoomGeometry, unicode: boolean, neutral: boolean): ColumnLayout;
84
+ /**
85
+ * Row assignment, once routing has said how many track rows each band gap
86
+ * needs: title, lane headers, then band after band — bar, breathing row,
87
+ * boxes, breathing row, tracks — and the legend under everything.
88
+ */
89
+ export declare function layoutRows(columns: ColumnLayout, geo: ZoomGeometry, gapRowCount: readonly number[], hasTitle: boolean, hasLanes: boolean): RowLayout;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Layer 4a — where everything goes, decided before anything is drawn.
3
+ *
4
+ * Two stages, because the picture's height cannot be known until the wires
5
+ * are routed: first COLUMNS (how wide each box is and which column it starts
6
+ * at, which fixes the picture's width), then — once routing has said how many
7
+ * track rows each band gap needs — ROWS. Nothing here draws; nothing here
8
+ * mutates a box after placing it, so a position is a value another stage can
9
+ * hold on to.
10
+ *
11
+ * Bands are the map's layers, top band first: rank 0 renders at the BOTTOM
12
+ * ("primitives are the ground"), so the sort is descending.
13
+ */
14
+ import { kindGlyph } from '../semantics/semantics.js';
15
+ import { BAR_MIN_RUN, BOX_H, LEFT_MARGIN } from './zoom-geometry.js';
16
+ import { displayWidth, fitWidth, wrapWidth } from './width.js';
17
+ const LABEL_BUDGET_MIN = 4;
18
+ /** Size and content of one node's box under the given zoom geometry. */
19
+ export function boxSpec(node, geo, unicode, neutral) {
20
+ // On dev pages the glyph slot belongs to the status, so a known kind glyph
21
+ // joins the label text; on neutral pages the kind takes the slot itself.
22
+ // A node with a child map wears the dive badge at the end of its label —
23
+ // appended AFTER width fitting, so truncation can never eat the badge.
24
+ const glyph = node.kind !== undefined ? kindGlyph(node.kind, unicode) : undefined;
25
+ const badge = node.submap !== undefined ? (unicode ? ' ⊞' : ' +') : '';
26
+ const badgeW = displayWidth(badge);
27
+ const text = !neutral && glyph !== undefined ? `${glyph} ${node.label}` : node.label;
28
+ if (geo.mode === 'constellation') {
29
+ return { w: 3, h: 1, label: '', pad: 0, borderless: true, extra: [] };
30
+ }
31
+ if (geo.mode === 'detail' && geo.detail !== undefined) {
32
+ const budget = geo.detail;
33
+ const innerW = Math.min(Math.max(displayWidth(text) + badgeW + 4, budget.innerMin), budget.innerMax);
34
+ const extra = [];
35
+ if (node.evidence !== undefined)
36
+ extra.push({ text: fitWidth(` ${node.evidence}`, innerW), style: 'faint' });
37
+ if (node.detail !== undefined) {
38
+ const wrapped = wrapWidth(node.detail, innerW - 2);
39
+ for (let i = 0; i < Math.min(wrapped.length, budget.noteRows); i++) {
40
+ const cut = i === budget.noteRows - 1 && wrapped.length > budget.noteRows;
41
+ extra.push({ text: ` ${cut ? fitWidth(wrapped[i] + '…', innerW - 2) : wrapped[i]}`, style: 'none' });
42
+ }
43
+ }
44
+ return {
45
+ w: innerW + 2,
46
+ h: BOX_H + extra.length,
47
+ label: fitWidth(text, innerW - 4 - badgeW) + badge,
48
+ pad: 1,
49
+ borderless: false,
50
+ extra,
51
+ };
52
+ }
53
+ const budget = Math.max(LABEL_BUDGET_MIN, Math.ceil(displayWidth(text) * geo.scale));
54
+ const label = fitWidth(text, budget) + badge;
55
+ return {
56
+ w: displayWidth(label) + 4 + 2 * geo.pad,
57
+ h: BOX_H,
58
+ label,
59
+ pad: geo.pad,
60
+ borderless: false,
61
+ extra: [],
62
+ };
63
+ }
64
+ /**
65
+ * Column assignment. Without lanes each band packs left-to-right in
66
+ * declaration order. With lanes every band is partitioned into the lane
67
+ * columns (plus a trailing region for boxes in no lane); a lane is as wide as
68
+ * its widest band row or its own header label, so members align vertically
69
+ * under their column across all bands.
70
+ */
71
+ export function layoutColumns(map, geo, unicode, neutral) {
72
+ const bands = [...map.layers].sort((a, b) => b.rank - a.rank); // index 0 = top band
73
+ const bandIndexOf = new Map(bands.map((l, i) => [l.id, i]));
74
+ const sized = new Map();
75
+ const bandSized = bands.map(() => []);
76
+ for (const node of map.nodes) {
77
+ const spec = { node, ...boxSpec(node, geo, unicode, neutral) };
78
+ bandSized[bandIndexOf.get(node.layer)].push(spec);
79
+ sized.set(node.id, spec);
80
+ }
81
+ const columnOf = new Map();
82
+ const lanes = [];
83
+ if (map.lanes.length === 0) {
84
+ for (const row of bandSized) {
85
+ let x = LEFT_MARGIN;
86
+ for (const spec of row) {
87
+ columnOf.set(spec, x);
88
+ x += spec.w + geo.boxGap;
89
+ }
90
+ }
91
+ }
92
+ else {
93
+ const laneCount = map.lanes.length;
94
+ const laneGap = geo.boxGap + 2;
95
+ const laneIndexOf = new Map(map.lanes.map((l, i) => [l.id, i]));
96
+ const regions = laneCount + 1; // trailing region for off-lane nodes
97
+ const grouped = bandSized.map((row) => {
98
+ const cells = Array.from({ length: regions }, () => []);
99
+ for (const spec of row) {
100
+ const lane = spec.node.lane;
101
+ cells[lane !== undefined ? laneIndexOf.get(lane) : regions - 1].push(spec);
102
+ }
103
+ return cells;
104
+ });
105
+ const regionW = Array.from({ length: regions }, () => 0);
106
+ for (const cells of grouped) {
107
+ for (let i = 0; i < regions; i++) {
108
+ const rowW = cells[i].reduce((sum, b, k) => sum + b.w + (k > 0 ? geo.boxGap : 0), 0);
109
+ regionW[i] = Math.max(regionW[i], rowW);
110
+ }
111
+ }
112
+ for (let i = 0; i < laneCount; i++)
113
+ regionW[i] = Math.max(regionW[i], displayWidth(map.lanes[i].label) + 2);
114
+ let x0 = LEFT_MARGIN;
115
+ // Every region gets an entry, the trailing off-lane one included: it holds
116
+ // real boxes (a group node carries no lane, and aggregateMap emits those
117
+ // first), and a picture that forgets its last region measures short.
118
+ for (let i = 0; i < regions; i++) {
119
+ lanes.push({ x: x0, w: regionW[i] });
120
+ x0 += regionW[i] + laneGap;
121
+ }
122
+ for (const cells of grouped) {
123
+ for (let i = 0; i < regions; i++) {
124
+ let x = lanes[i].x;
125
+ for (const spec of cells[i]) {
126
+ columnOf.set(spec, x);
127
+ x += spec.w + geo.boxGap;
128
+ }
129
+ }
130
+ }
131
+ }
132
+ // One instance per box, shared by both views of it: later stages key maps
133
+ // on the box itself, and two copies of one box are two boxes to them.
134
+ const placed = new Map();
135
+ for (const [, spec] of sized)
136
+ placed.set(spec, { ...spec, x: columnOf.get(spec) ?? LEFT_MARGIN });
137
+ const bandBoxes = bandSized.map((row) => row.map((spec) => placed.get(spec)));
138
+ // Declaration order, which is the order boxes are drawn and reported as hits.
139
+ const boxOf = new Map();
140
+ for (const node of map.nodes)
141
+ boxOf.set(node.id, placed.get(sized.get(node.id)));
142
+ // Band bar labels; at small scales the boxes go mute, so the bars carry
143
+ // the aggregate progress (done/total) for their band instead. Neutral
144
+ // documentation kinds never count progress.
145
+ const bandLabel = bands.map((l, i) => {
146
+ const row = bandBoxes[i];
147
+ const done = row.filter((b) => b.node.status === 'done').length;
148
+ return geo.bandCounts && row.length > 0 && !neutral ? ` ${l.name} ${done}/${row.length}` : ` ${l.name}`;
149
+ });
150
+ // The RIGHTMOST box, not the last-declared one: lanes reorder a band into
151
+ // its lane regions, so declaration order says nothing about position, and a
152
+ // measurement taken from the wrong box left every band bar short.
153
+ let contentWidth = LEFT_MARGIN + BAR_MIN_RUN;
154
+ for (const row of bandBoxes)
155
+ for (const box of row)
156
+ contentWidth = Math.max(contentWidth, box.x + box.w);
157
+ for (const lane of lanes)
158
+ contentWidth = Math.max(contentWidth, lane.x + lane.w);
159
+ return { bands, bandIndexOf, bandBoxes, boxOf, lanes, bandLabel, contentWidth };
160
+ }
161
+ /**
162
+ * Row assignment, once routing has said how many track rows each band gap
163
+ * needs: title, lane headers, then band after band — bar, breathing row,
164
+ * boxes, breathing row, tracks — and the legend under everything.
165
+ */
166
+ export function layoutRows(columns, geo, gapRowCount, hasTitle, hasLanes) {
167
+ let y = 0;
168
+ if (hasTitle)
169
+ y += 1 + geo.titleGap;
170
+ let laneHeaderY;
171
+ if (hasLanes) {
172
+ laneHeaderY = y;
173
+ y += 1 + geo.barGap;
174
+ }
175
+ const barY = [];
176
+ const gapTrackStartY = [];
177
+ const bandBoxes = [];
178
+ const placed = new Map();
179
+ const gapCount = columns.bands.length - 1;
180
+ for (let b = 0; b < columns.bands.length; b++) {
181
+ barY.push(y);
182
+ y += 1 + geo.barGap;
183
+ const row = columns.bandBoxes[b];
184
+ for (const box of row)
185
+ placed.set(box, { ...box, y });
186
+ bandBoxes.push(row.map((box) => placed.get(box)));
187
+ y += row.reduce((max, box) => Math.max(max, box.h), geo.mode === 'constellation' ? 1 : BOX_H);
188
+ if (b < gapCount) {
189
+ y += geo.breathe; // breathing row below the boxes
190
+ gapTrackStartY.push(y);
191
+ y += gapRowCount[b];
192
+ y += geo.breathe; // breathing row above the next bar
193
+ }
194
+ }
195
+ // Declaration order again — the columns layout already holds it.
196
+ const boxOf = new Map();
197
+ for (const [id, box] of columns.boxOf)
198
+ boxOf.set(id, placed.get(box));
199
+ return { boxOf, bandBoxes, barY, gapTrackStartY, laneHeaderY, legendY: y + 1 };
200
+ }