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,56 @@
1
+ /**
2
+ * Layer 4a — how a dependency edge gets from one box to another.
3
+ *
4
+ * Routing preference, in order:
5
+ * 1. STRAIGHT — an adjacent-band edge whose box borders share a free
6
+ * column is one vertical line, no corners.
7
+ * 2. DOGLEG — descend, run horizontally on a track row in the gap above
8
+ * the target band, descend. Tracks are PACKED: segments that do not
9
+ * overlap share a row, keeping bands close together.
10
+ * 3. THREAD — a skip-level edge descends through the nearest column that
11
+ * is free of boxes in every intermediate band (threading the needle
12
+ * between boxes); only if no such column exists does it fall back to a
13
+ * private column on the right margin.
14
+ *
15
+ * The output is a value, not a drawing: every edge comes back as the columns
16
+ * and track rows it will use, and a separate step turns that into a polyline
17
+ * once the rows are placed. Crossings need no cleverness at all — the canvas
18
+ * merges masks, so two wires meeting become ┼ by construction.
19
+ */
20
+ import type { MellosMap } from '../domain/types.js';
21
+ import type { ColumnedBox, ColumnLayout, RowLayout } from './layout.js';
22
+ /** An edge, resolved to the columns and rows it occupies. */
23
+ export type RoutedEdge = {
24
+ readonly from: ColumnedBox;
25
+ readonly to: ColumnedBox;
26
+ readonly fromBand: number;
27
+ readonly toBand: number;
28
+ } & ({
29
+ readonly kind: 'straight';
30
+ readonly x: number;
31
+ } | {
32
+ readonly kind: 'dogleg';
33
+ readonly exitX: number;
34
+ readonly entryX: number;
35
+ readonly landingRow: number;
36
+ } | {
37
+ readonly kind: 'thread';
38
+ readonly exitX: number;
39
+ readonly entryX: number;
40
+ readonly descentX: number;
41
+ readonly exitRow: number;
42
+ readonly landingRow: number;
43
+ });
44
+ export interface Routing {
45
+ readonly edges: readonly RoutedEdge[];
46
+ /** Track rows each band gap needs; the row layout turns these into space. */
47
+ readonly gapRowCount: readonly number[];
48
+ /** Skip edges that found no column between the boxes and took the margin. */
49
+ readonly fallbackCount: number;
50
+ }
51
+ export declare function routeEdges(map: MellosMap, columns: ColumnLayout): Routing;
52
+ /**
53
+ * The polyline one edge draws, once the rows are placed: box bottom border,
54
+ * down the exit column, along a track row, and into the target's top border.
55
+ */
56
+ export declare function edgePolyline(edge: RoutedEdge, rows: RowLayout): ReadonlyArray<readonly [number, number]>;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Layer 4a — how a dependency edge gets from one box to another.
3
+ *
4
+ * Routing preference, in order:
5
+ * 1. STRAIGHT — an adjacent-band edge whose box borders share a free
6
+ * column is one vertical line, no corners.
7
+ * 2. DOGLEG — descend, run horizontally on a track row in the gap above
8
+ * the target band, descend. Tracks are PACKED: segments that do not
9
+ * overlap share a row, keeping bands close together.
10
+ * 3. THREAD — a skip-level edge descends through the nearest column that
11
+ * is free of boxes in every intermediate band (threading the needle
12
+ * between boxes); only if no such column exists does it fall back to a
13
+ * private column on the right margin.
14
+ *
15
+ * The output is a value, not a drawing: every edge comes back as the columns
16
+ * and track rows it will use, and a separate step turns that into a polyline
17
+ * once the rows are placed. Crossings need no cleverness at all — the canvas
18
+ * merges masks, so two wires meeting become ┼ by construction.
19
+ */
20
+ import { LEFT_MARGIN } from './zoom-geometry.js';
21
+ export function routeEdges(map, columns) {
22
+ const { bandIndexOf, bandBoxes, boxOf, contentWidth } = columns;
23
+ const pending = map.edges.map((e) => {
24
+ const from = boxOf.get(e.from);
25
+ const to = boxOf.get(e.to);
26
+ return {
27
+ from,
28
+ to,
29
+ fromBand: bandIndexOf.get(from.node.layer),
30
+ toBand: bandIndexOf.get(to.node.layer),
31
+ };
32
+ });
33
+ /**
34
+ * Columns already carrying a VERTICAL run inside each band gap, and the
35
+ * edge that owns each.
36
+ *
37
+ * Two edges given the same column in one gap do not cross there — they run
38
+ * on top of each other for the whole gap, and where the shorter one turns
39
+ * onto its track row the union of masks becomes ├ or ┤: one wire that
40
+ * appears to branch, which is a lie about the dependencies. Packing tracks
41
+ * by horizontal extent alone could not see this, because the collision is
42
+ * vertical. A wire may of course reuse a column it owns itself — a skip
43
+ * edge descending straight out of its own exit slot is the ideal route, not
44
+ * a collision — and horizontal segments still cross verticals freely, which
45
+ * is an honest ┼.
46
+ */
47
+ const gapVerticals = Array.from({ length: Math.max(0, columns.bands.length - 1) }, () => new Map());
48
+ const verticalFree = (gap, x, edge) => {
49
+ const owner = gapVerticals[gap]?.get(x);
50
+ return owner === undefined || owner === edge;
51
+ };
52
+ const takeVertical = (gap, x, edge) => {
53
+ gapVerticals[gap]?.set(x, edge);
54
+ };
55
+ // Attach columns already promised on a box's border (either side).
56
+ const claimedColumns = new Map();
57
+ const isFree = (box, x) => !(claimedColumns.get(box)?.has(x) ?? false);
58
+ const claim = (box, x) => {
59
+ let set = claimedColumns.get(box);
60
+ if (!set)
61
+ claimedColumns.set(box, (set = new Set()));
62
+ set.add(x);
63
+ return x;
64
+ };
65
+ // 1. STRAIGHT edges
66
+ for (const r of pending) {
67
+ if (r.toBand - r.fromBand !== 1)
68
+ continue;
69
+ const lo = Math.max(r.from.x + 1, r.to.x + 1);
70
+ const hi = Math.min(r.from.x + r.from.w - 2, r.to.x + r.to.w - 2);
71
+ if (lo > hi)
72
+ continue; // no vertical overlap — a dogleg is genuinely needed
73
+ const mid = Math.floor((lo + hi) / 2);
74
+ for (let d = 0; d <= hi - lo && r.straightX === undefined; d++) {
75
+ for (const x of d === 0 ? [mid] : [mid - d, mid + d]) {
76
+ if (x >= lo && x <= hi && isFree(r.from, x) && isFree(r.to, x) && verticalFree(r.fromBand, x, r)) {
77
+ r.straightX = claim(r.to, claim(r.from, x));
78
+ takeVertical(r.fromBand, x, r);
79
+ break;
80
+ }
81
+ }
82
+ }
83
+ }
84
+ // 2. attach slots for the bent rest, nudged off claimed columns
85
+ const bent = pending.filter((r) => r.straightX === undefined);
86
+ const outgoing = new Map();
87
+ const incoming = new Map();
88
+ for (const r of bent) {
89
+ outgoing.set(r.from, [...(outgoing.get(r.from) ?? []), r]);
90
+ incoming.set(r.to, [...(incoming.get(r.to) ?? []), r]);
91
+ }
92
+ const freeSlot = (box, k, n, edge, gap) => {
93
+ const lo = box.x + 1;
94
+ const hi = box.x + box.w - 2;
95
+ const ideal = box.x + Math.min(box.w - 2, Math.max(1, Math.round(((k + 1) * (box.w - 1)) / (n + 1))));
96
+ for (let d = 0; d <= hi - lo; d++) {
97
+ for (const x of d === 0 ? [ideal] : [ideal - d, ideal + d]) {
98
+ if (x >= lo && x <= hi && isFree(box, x) && verticalFree(gap, x, edge)) {
99
+ takeVertical(gap, x, edge);
100
+ return claim(box, x);
101
+ }
102
+ }
103
+ }
104
+ return ideal; // every column claimed (extremely crowded box) — overlap and live with it
105
+ };
106
+ for (const r of bent) {
107
+ const outs = outgoing.get(r.from);
108
+ const ins = incoming.get(r.to);
109
+ // The exit descends through the gap below the source band; the entry
110
+ // climbs out of the gap above the target band. For an adjacent edge those
111
+ // are one gap, so the entry column also avoids the exit column.
112
+ r.exitX = freeSlot(r.from, outs.indexOf(r), outs.length, r, r.fromBand);
113
+ r.entryX = freeSlot(r.to, ins.indexOf(r), ins.length, r, r.toBand - 1);
114
+ }
115
+ // 3. THREAD descent columns for skip-level edges
116
+ const usedDescent = new Set();
117
+ let fallbackCount = 0;
118
+ const blockedByBox = (band, x) => bandBoxes[band].some((b) => x >= b.x && x <= b.x + b.w - 1);
119
+ /** The descent runs through every gap from the source band to the target's. */
120
+ const descentGapsFree = (r, c) => {
121
+ for (let g = r.fromBand; g <= r.toBand - 1; g++) {
122
+ if (!verticalFree(g, c, r))
123
+ return false;
124
+ }
125
+ return true;
126
+ };
127
+ for (const r of bent.filter((e) => e.toBand - e.fromBand > 1)) {
128
+ const ex = r.entryX;
129
+ let chosen;
130
+ for (let d = 0; d <= contentWidth && chosen === undefined; d++) {
131
+ for (const c of d === 0 ? [ex] : [ex - d, ex + d]) {
132
+ if (c < LEFT_MARGIN || c > contentWidth + 1 || usedDescent.has(c))
133
+ continue;
134
+ if (!descentGapsFree(r, c))
135
+ continue;
136
+ let blocked = false;
137
+ for (let b = r.fromBand + 1; b < r.toBand && !blocked; b++)
138
+ blocked = blockedByBox(b, c);
139
+ if (!blocked) {
140
+ chosen = c;
141
+ break;
142
+ }
143
+ }
144
+ }
145
+ if (chosen === undefined)
146
+ chosen = contentWidth + 2 + fallbackCount++ * 2; // margin fallback
147
+ usedDescent.add(chosen);
148
+ for (let g = r.fromBand; g <= r.toBand - 1; g++)
149
+ takeVertical(g, chosen, r);
150
+ r.descentX = chosen;
151
+ }
152
+ // 4. pack horizontal segments into shared track rows per gap
153
+ const gapCount = Math.max(0, columns.bands.length - 1);
154
+ const gapSegments = Array.from({ length: gapCount }, () => []);
155
+ for (const r of bent) {
156
+ const sx = r.exitX;
157
+ const ex = r.entryX;
158
+ if (r.descentX === undefined) {
159
+ gapSegments[r.toBand - 1].push({
160
+ edge: r,
161
+ kind: 'landing',
162
+ segment: { lo: Math.min(sx, ex), hi: Math.max(sx, ex) },
163
+ });
164
+ }
165
+ else {
166
+ const c = r.descentX;
167
+ gapSegments[r.fromBand].push({ edge: r, kind: 'exit', segment: { lo: Math.min(sx, c), hi: Math.max(sx, c) } });
168
+ gapSegments[r.toBand - 1].push({
169
+ edge: r,
170
+ kind: 'landing',
171
+ segment: { lo: Math.min(c, ex), hi: Math.max(c, ex) },
172
+ });
173
+ }
174
+ }
175
+ const exitRow = new Map();
176
+ const landingRow = new Map();
177
+ const gapRowCount = gapSegments.map((entries) => {
178
+ const rowEnds = []; // rightmost occupied column per packed row
179
+ for (const e of [...entries].sort((a, b) => a.segment.lo - b.segment.lo)) {
180
+ let row = rowEnds.findIndex((end) => e.segment.lo > end + 1);
181
+ if (row === -1) {
182
+ rowEnds.push(e.segment.hi);
183
+ row = rowEnds.length - 1;
184
+ }
185
+ else {
186
+ rowEnds[row] = Math.max(rowEnds[row], e.segment.hi);
187
+ }
188
+ (e.kind === 'exit' ? exitRow : landingRow).set(e.edge, row);
189
+ }
190
+ return rowEnds.length;
191
+ });
192
+ const edges = pending.map((r) => {
193
+ const common = { from: r.from, to: r.to, fromBand: r.fromBand, toBand: r.toBand };
194
+ if (r.straightX !== undefined)
195
+ return { ...common, kind: 'straight', x: r.straightX };
196
+ if (r.descentX === undefined) {
197
+ return { ...common, kind: 'dogleg', exitX: r.exitX, entryX: r.entryX, landingRow: landingRow.get(r) };
198
+ }
199
+ return {
200
+ ...common,
201
+ kind: 'thread',
202
+ exitX: r.exitX,
203
+ entryX: r.entryX,
204
+ descentX: r.descentX,
205
+ exitRow: exitRow.get(r),
206
+ landingRow: landingRow.get(r),
207
+ };
208
+ });
209
+ return { edges, gapRowCount, fallbackCount };
210
+ }
211
+ /**
212
+ * The polyline one edge draws, once the rows are placed: box bottom border,
213
+ * down the exit column, along a track row, and into the target's top border.
214
+ */
215
+ export function edgePolyline(edge, rows) {
216
+ const from = rows.boxOf.get(edge.from.node.id);
217
+ const to = rows.boxOf.get(edge.to.node.id);
218
+ const sy = from.y + from.h - 1; // bottom border row of the source box
219
+ const ey = to.y; // top border row of the target box
220
+ if (edge.kind === 'straight') {
221
+ return [
222
+ [edge.x, sy],
223
+ [edge.x, ey],
224
+ ];
225
+ }
226
+ const landingY = rows.gapTrackStartY[edge.toBand - 1] + edge.landingRow;
227
+ if (edge.kind === 'dogleg') {
228
+ return [
229
+ [edge.exitX, sy],
230
+ [edge.exitX, landingY],
231
+ [edge.entryX, landingY],
232
+ [edge.entryX, ey],
233
+ ];
234
+ }
235
+ const exitY = rows.gapTrackStartY[edge.fromBand] + edge.exitRow;
236
+ return [
237
+ [edge.exitX, sy],
238
+ [edge.exitX, exitY],
239
+ [edge.descentX, exitY],
240
+ [edge.descentX, landingY],
241
+ [edge.entryX, landingY],
242
+ [edge.entryX, ey],
243
+ ];
244
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Layer 4a — the status vocabulary as this medium wears it.
3
+ *
4
+ * The GLYPHS are the map's shared alphabet and live in ../semantics; what a
5
+ * TERMINAL owns is decided here and only here: which border repertoire a
6
+ * status draws with, and which SGR color it burns. One table, so the pane's
7
+ * chrome (tab strip, panel header) and the picture's boxes cannot drift
8
+ * apart — the pane reads statusSgr from this very file.
9
+ */
10
+ import type { MapNode, MellosMap, NodeStatus } from '../domain/types.js';
11
+ import { type Style } from './canvas.js';
12
+ import type { RenderOptions } from './options.js';
13
+ export interface BoxSkin {
14
+ readonly h: string;
15
+ readonly v: string;
16
+ readonly corners: readonly [string, string, string, string];
17
+ readonly style: Style;
18
+ }
19
+ /**
20
+ * What a box paints for its status. `done` has two FACES, because the ledger
21
+ * has a rule about it ("no evidence, no done") that the picture could not
22
+ * show: a node built and verified and a node merely declared done were the
23
+ * same solid green square at every zoom. This is not a fifth status —
24
+ * evidence is a property of a node, and whether it exists is presentation.
25
+ */
26
+ export type StatusFace = NodeStatus | 'done-unverified';
27
+ /** The color role a status face wears in a terminal. */
28
+ export declare function styleFor(face: StatusFace): Style;
29
+ /**
30
+ * SGR parameters of a status' skin, for terminal chrome painted outside the
31
+ * canvas (a tab strip, a panel header). The pane's chrome and the picture's
32
+ * boxes therefore wear one palette by construction, not by a second table.
33
+ */
34
+ export declare function statusSgr(status: NodeStatus): string;
35
+ export declare function skinFor(face: StatusFace, unicode: boolean): BoxSkin;
36
+ /**
37
+ * The glyph a node shows for its status face. A terminal box CAN animate, so
38
+ * in-progress spins through the shared frames; every other face is a shared
39
+ * static glyph.
40
+ */
41
+ export declare function glyphFor(face: StatusFace, opts: RenderOptions): string;
42
+ /** Plain solid box for documentation diagrams — presence, not progress. */
43
+ export declare function neutralSkin(unicode: boolean): BoxSkin;
44
+ /** The glyph slot of a box on a documentation page: the KIND, or a bullet. */
45
+ export declare function neutralGlyph(node: MapNode, unicode: boolean): string;
46
+ /**
47
+ * Ids of the boxes whose `done` has nothing behind it.
48
+ *
49
+ * Computed against the map as DECLARED, not as drawn: an aggregated group box
50
+ * carries no evidence field of its own, and reading that absence literally
51
+ * would paint every grouped far-zoom view as unverified. A group is exactly
52
+ * as backed as the members it stands for.
53
+ */
54
+ export declare function unverifiedDoneIds(declared: MellosMap, drawn: MellosMap): Set<string>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Layer 4a — the status vocabulary as this medium wears it.
3
+ *
4
+ * The GLYPHS are the map's shared alphabet and live in ../semantics; what a
5
+ * TERMINAL owns is decided here and only here: which border repertoire a
6
+ * status draws with, and which SGR color it burns. One table, so the pane's
7
+ * chrome (tab strip, panel header) and the picture's boxes cannot drift
8
+ * apart — the pane reads statusSgr from this very file.
9
+ */
10
+ import { kindGlyph, spinnerGlyph, statusGlyph, unverifiedDoneGlyph } from '../semantics/semantics.js';
11
+ import { SGR } from './canvas.js';
12
+ /** The color role a status face wears in a terminal. */
13
+ export function styleFor(face) {
14
+ switch (face) {
15
+ case 'planned':
16
+ return 'dim';
17
+ case 'in-progress':
18
+ return 'amber';
19
+ case 'done':
20
+ return 'green';
21
+ case 'done-unverified':
22
+ return 'greenDim';
23
+ case 'regressed':
24
+ return 'red';
25
+ }
26
+ }
27
+ /**
28
+ * SGR parameters of a status' skin, for terminal chrome painted outside the
29
+ * canvas (a tab strip, a panel header). The pane's chrome and the picture's
30
+ * boxes therefore wear one palette by construction, not by a second table.
31
+ */
32
+ export function statusSgr(status) {
33
+ return SGR[styleFor(status)];
34
+ }
35
+ export function skinFor(face, unicode) {
36
+ const style = styleFor(face);
37
+ if (!unicode) {
38
+ return face === 'planned'
39
+ ? { h: '.', v: ':', corners: ['+', '+', '+', '+'], style }
40
+ : { h: '-', v: '|', corners: ['+', '+', '+', '+'], style };
41
+ }
42
+ switch (face) {
43
+ case 'planned':
44
+ return { h: '╌', v: '╎', corners: ['╭', '╮', '╰', '╯'], style };
45
+ case 'in-progress':
46
+ return { h: '─', v: '│', corners: ['╭', '╮', '╰', '╯'], style };
47
+ // an unverified done keeps the heavy border of done — it is the same
48
+ // claim, told with a hollow glyph and a dimmer green
49
+ case 'done':
50
+ case 'done-unverified':
51
+ case 'regressed':
52
+ return { h: '━', v: '┃', corners: ['┏', '┓', '┗', '┛'], style };
53
+ }
54
+ }
55
+ /**
56
+ * The glyph a node shows for its status face. A terminal box CAN animate, so
57
+ * in-progress spins through the shared frames; every other face is a shared
58
+ * static glyph.
59
+ */
60
+ export function glyphFor(face, opts) {
61
+ if (face === 'in-progress')
62
+ return spinnerGlyph(opts.spinnerFrame, opts.unicode);
63
+ return face === 'done-unverified' ? unverifiedDoneGlyph(opts.unicode) : statusGlyph(face, opts.unicode);
64
+ }
65
+ /** Plain solid box for documentation diagrams — presence, not progress. */
66
+ export function neutralSkin(unicode) {
67
+ return unicode
68
+ ? { h: '─', v: '│', corners: ['╭', '╮', '╰', '╯'], style: 'none' }
69
+ : { h: '-', v: '|', corners: ['+', '+', '+', '+'], style: 'none' };
70
+ }
71
+ /** The glyph slot of a box on a documentation page: the KIND, or a bullet. */
72
+ export function neutralGlyph(node, unicode) {
73
+ return ((node.kind !== undefined ? kindGlyph(node.kind, unicode) : undefined) ?? (unicode ? '·' : '.'));
74
+ }
75
+ /**
76
+ * Ids of the boxes whose `done` has nothing behind it.
77
+ *
78
+ * Computed against the map as DECLARED, not as drawn: an aggregated group box
79
+ * carries no evidence field of its own, and reading that absence literally
80
+ * would paint every grouped far-zoom view as unverified. A group is exactly
81
+ * as backed as the members it stands for.
82
+ */
83
+ export function unverifiedDoneIds(declared, drawn) {
84
+ const out = new Set();
85
+ const declaredById = new Map(declared.nodes.map((n) => [n.id, n]));
86
+ for (const node of drawn.nodes) {
87
+ if (node.status !== 'done')
88
+ continue;
89
+ const own = declaredById.get(node.id);
90
+ if (own !== undefined) {
91
+ if (own.evidence === undefined)
92
+ out.add(node.id);
93
+ }
94
+ else if (declared.nodes.some((m) => m.group === node.id && m.status === 'done' && m.evidence === undefined)) {
95
+ out.add(node.id); // a group box stands for a member nobody verified
96
+ }
97
+ }
98
+ return out;
99
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Layer 4a — how many terminal columns a string occupies.
3
+ *
4
+ * Everything above this file lays out a grid, and a grid is only as honest as
5
+ * its widths: a label measured one column short shears every row below its
6
+ * box, and one column long leaves a gap no border closes. Labels are DATA —
7
+ * people write Chinese, emoji and accented letters — so this is not a detail
8
+ * of the drawing code but its foundation.
9
+ *
10
+ * The two tables are the Unicode East Asian Width W/F set and the
11
+ * zero-advance set, kept deliberately narrow: everything the standard calls
12
+ * AMBIGUOUS (■ ● ✗ ○ and the rest of this map's own glyph alphabet) stays ONE
13
+ * column, which is what a Western terminal draws.
14
+ *
15
+ * Pure functions of a string; no canvas, no options, no I/O.
16
+ */
17
+ /** Columns one code point occupies: 0 (a mark riding on its base), 1, or 2. */
18
+ export declare function charWidth(cp: number): number;
19
+ /** Terminal column width of a string (CJK chars occupy two columns). */
20
+ export declare function displayWidth(text: string): number;
21
+ /** Truncate to a display width, ANSI-free input, appending … when cut. */
22
+ export declare function fitWidth(s: string, width: number): string;
23
+ /** Hard word-wrap by display width (CJK-aware, splits anywhere). */
24
+ export declare function wrapWidth(s: string, width: number): string[];
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Layer 4a — how many terminal columns a string occupies.
3
+ *
4
+ * Everything above this file lays out a grid, and a grid is only as honest as
5
+ * its widths: a label measured one column short shears every row below its
6
+ * box, and one column long leaves a gap no border closes. Labels are DATA —
7
+ * people write Chinese, emoji and accented letters — so this is not a detail
8
+ * of the drawing code but its foundation.
9
+ *
10
+ * The two tables are the Unicode East Asian Width W/F set and the
11
+ * zero-advance set, kept deliberately narrow: everything the standard calls
12
+ * AMBIGUOUS (■ ● ✗ ○ and the rest of this map's own glyph alphabet) stays ONE
13
+ * column, which is what a Western terminal draws.
14
+ *
15
+ * Pure functions of a string; no canvas, no options, no I/O.
16
+ */
17
+ const WIDE_RANGES = [
18
+ [0x1100, 0x115f], // Hangul Jamo
19
+ // Wide symbols scattered through the BMP — mostly emoji that predate the
20
+ // emoji planes (⌚ ⏰ ⚡ ✅ ✨ ❌ ❓ ⭐ ⬛ …).
21
+ [0x231a, 0x231b],
22
+ [0x2329, 0x232a],
23
+ [0x23e9, 0x23ec],
24
+ [0x23f0, 0x23f0],
25
+ [0x23f3, 0x23f3],
26
+ [0x25fd, 0x25fe],
27
+ [0x2614, 0x2615],
28
+ [0x2648, 0x2653],
29
+ [0x267f, 0x267f],
30
+ [0x2693, 0x2693],
31
+ [0x26a1, 0x26a1],
32
+ [0x26aa, 0x26ab],
33
+ [0x26bd, 0x26be],
34
+ [0x26c4, 0x26c5],
35
+ [0x26ce, 0x26ce],
36
+ [0x26d4, 0x26d4],
37
+ [0x26ea, 0x26ea],
38
+ [0x26f2, 0x26f3],
39
+ [0x26f5, 0x26f5],
40
+ [0x26fa, 0x26fa],
41
+ [0x26fd, 0x26fd],
42
+ [0x2705, 0x2705],
43
+ [0x270a, 0x270b],
44
+ [0x2728, 0x2728],
45
+ [0x274c, 0x274c],
46
+ [0x274e, 0x274e],
47
+ [0x2753, 0x2755],
48
+ [0x2757, 0x2757],
49
+ [0x2795, 0x2797],
50
+ [0x27b0, 0x27b0],
51
+ [0x27bf, 0x27bf],
52
+ [0x2b1b, 0x2b1c],
53
+ [0x2b50, 0x2b50],
54
+ [0x2b55, 0x2b55],
55
+ [0x2e80, 0xa4cf], // CJK radicals .. Yi (covers CJK Unified Ideographs)
56
+ [0xa960, 0xa97f],
57
+ [0xac00, 0xd7a3], // Hangul syllables
58
+ [0xf900, 0xfaff], // CJK compatibility ideographs
59
+ [0xfe10, 0xfe19],
60
+ [0xfe30, 0xfe6f],
61
+ [0xff00, 0xff60], // fullwidth forms
62
+ [0xffe0, 0xffe6],
63
+ [0x1f300, 0x1f64f], // pictographs, transport, emoticons (🚀 🎯 😀 …)
64
+ [0x1f680, 0x1f6ff],
65
+ [0x1f900, 0x1f9ff], // supplemental symbols (🤖 🧱 …)
66
+ [0x1fa70, 0x1faff], // symbols extended-A
67
+ [0x20000, 0x3fffd], // CJK extension planes
68
+ ];
69
+ /** Code points that advance the cursor by nothing at all. */
70
+ const ZERO_WIDTH_RANGES = [
71
+ [0x0300, 0x036f], // combining diacritical marks (decomposed 'e' + ´)
72
+ [0x1ab0, 0x1aff],
73
+ [0x1dc0, 0x1dff],
74
+ [0x200b, 0x200f], // zero-width space .. RLM, zero-width joiner among them
75
+ [0x20d0, 0x20f0], // combining marks for symbols
76
+ [0xfe00, 0xfe0f], // variation selectors, VS16 (emoji presentation) included
77
+ [0xfe20, 0xfe2f], // combining half marks
78
+ [0x1f3fb, 0x1f3ff], // emoji skin tone modifiers — always applied to a base
79
+ ];
80
+ function inRanges(cp, ranges) {
81
+ for (const [lo, hi] of ranges) {
82
+ if (cp >= lo && cp <= hi)
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+ /** Columns one code point occupies: 0 (a mark riding on its base), 1, or 2. */
88
+ export function charWidth(cp) {
89
+ if (inRanges(cp, ZERO_WIDTH_RANGES))
90
+ return 0;
91
+ return inRanges(cp, WIDE_RANGES) ? 2 : 1;
92
+ }
93
+ /** Terminal column width of a string (CJK chars occupy two columns). */
94
+ export function displayWidth(text) {
95
+ let w = 0;
96
+ for (const ch of text)
97
+ w += charWidth(ch.codePointAt(0));
98
+ return w;
99
+ }
100
+ /** Truncate to a display width, ANSI-free input, appending … when cut. */
101
+ export function fitWidth(s, width) {
102
+ if (displayWidth(s) <= width)
103
+ return s;
104
+ let out = '';
105
+ let w = 0;
106
+ for (const ch of s) {
107
+ const cw = displayWidth(ch);
108
+ if (w + cw > width - 1)
109
+ break;
110
+ out += ch;
111
+ w += cw;
112
+ }
113
+ return out + '…';
114
+ }
115
+ /** Hard word-wrap by display width (CJK-aware, splits anywhere). */
116
+ export function wrapWidth(s, width) {
117
+ const lines = [];
118
+ let line = '';
119
+ let w = 0;
120
+ for (const ch of s.replace(/\r/g, '')) {
121
+ if (ch === '\n') {
122
+ lines.push(line);
123
+ line = '';
124
+ w = 0;
125
+ continue;
126
+ }
127
+ const cw = displayWidth(ch);
128
+ if (w + cw > width) {
129
+ lines.push(line);
130
+ line = '';
131
+ w = 0;
132
+ }
133
+ line += ch;
134
+ w += cw;
135
+ }
136
+ if (line !== '')
137
+ lines.push(line);
138
+ return lines;
139
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Layer 4a — what one rung of the zoom ladder buys, in terminal cells.
3
+ *
4
+ * Terminals cannot scale glyphs, so zooming out COMPRESSES geometry instead:
5
+ * gaps, breathing rows and padding shrink, labels truncate toward a
6
+ * scale-proportional budget, and only when a further step would leave labels
7
+ * too short to mean anything does the picture switch mode. WHICH steps switch
8
+ * mode is semantics (zoomMode over in ../semantics); this module owns only
9
+ * the whitespace and label budgets each step spends.
10
+ */
11
+ import { type ZoomStep } from '../semantics/semantics.js';
12
+ /** Height of a box with nothing unfolded inside it: border, content, border. */
13
+ export declare const BOX_H = 3;
14
+ /** Columns between two boxes of one band at the roomy default. */
15
+ export declare const BOX_GAP = 2;
16
+ /** Columns before the leftmost box; the picture never starts at the edge. */
17
+ export declare const LEFT_MARGIN = 2;
18
+ /** Bar cells kept left of the narrowest band label, so a band still reads as a band. */
19
+ export declare const BAR_MIN_RUN = 7;
20
+ /** Box content budget for a detail step; present exactly when mode is 'detail'. */
21
+ export interface DetailBudget {
22
+ /** Clamp range for the box's inner width. */
23
+ readonly innerMin: number;
24
+ readonly innerMax: number;
25
+ /** Design-note rows shown before the … cut. */
26
+ readonly noteRows: number;
27
+ }
28
+ /** How one zoom step translates into whitespace geometry; see the module header. */
29
+ export interface ZoomGeometry {
30
+ readonly mode: 'constellation' | 'boxes' | 'detail';
31
+ /** Label width multiplier while scaling down (boxes mode). */
32
+ readonly scale: number;
33
+ /** Inner padding around "glyph label" (1 = the roomy standard look). */
34
+ readonly pad: 0 | 1;
35
+ readonly boxGap: number;
36
+ /** Breathing rows around wire track rows in a band gap. */
37
+ readonly breathe: 0 | 1;
38
+ /** Blank row after the title. */
39
+ readonly titleGap: 0 | 1;
40
+ /** Blank row between a band bar and its boxes. */
41
+ readonly barGap: 0 | 1;
42
+ /** Band bars carry done/total counts once boxes are too small to speak. */
43
+ readonly bandCounts: boolean;
44
+ /** Content budget of the detail steps; set exactly when mode is 'detail'. */
45
+ readonly detail?: DetailBudget;
46
+ }
47
+ export declare function zoomGeometry(zoom: ZoomStep): ZoomGeometry;
48
+ /**
49
+ * Geometry for the aggregated far zoom: tight chrome, but FULL labels — the
50
+ * point of collapsing a map into its groups is reading their names.
51
+ */
52
+ export declare const AGGREGATE_GEO: ZoomGeometry;