mellos-mapping 0.19.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 +366 -56
  2. package/README.zh-CN.md +319 -47
  3. package/dist/hook-session-start.mjs +239 -0
  4. package/dist/mmap.mjs +338 -0
  5. package/dist/server.mjs +1737 -897
  6. package/dist/store-paths.mjs +107 -0
  7. package/dist/watch.mjs +1612 -902
  8. package/lib/domain/ops.d.ts +171 -0
  9. package/lib/domain/ops.js +384 -0
  10. package/lib/domain/types.d.ts +283 -0
  11. package/lib/domain/types.js +153 -0
  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 +88 -0
  21. package/lib/render/render.js +128 -0
  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 +169 -0
  31. package/lib/semantics/semantics.js +380 -0
  32. package/lib/semantics/vocabulary.d.ts +79 -0
  33. package/lib/semantics/vocabulary.js +112 -0
  34. package/lib/store/format.d.ts +67 -0
  35. package/lib/store/format.js +334 -0
  36. package/lib/store/store.d.ts +296 -0
  37. package/lib/store/store.js +734 -0
  38. package/package.json +41 -5
  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,39 @@
1
+ /**
2
+ * Layer 4a — what a caller asks the renderer for.
3
+ *
4
+ * Every stage below (canvas, skins, layout, drawing) reads these, so they
5
+ * live in a module of their own rather than in whichever stage happens to
6
+ * touch them first: a picture's inputs are not the property of one stage.
7
+ *
8
+ * Pure types; no behavior.
9
+ */
10
+ import type { ZoomStep } from '../semantics/semantics.js';
11
+ export interface RenderOptions {
12
+ /** Emit ANSI color codes. */
13
+ readonly color: boolean;
14
+ /** Use box-drawing characters; false falls back to pure ASCII. */
15
+ readonly unicode: boolean;
16
+ /** Spinner frame index for in-progress nodes; caller advances it over time. */
17
+ readonly spinnerFrame: number;
18
+ /**
19
+ * Id of the BOX to spotlight: its border and every wire touching it render
20
+ * bright instead of faint. Color mode only — monochrome output ignores it.
21
+ *
22
+ * VIOLATION: no-primitive-obsession - a raw string where NodeId exists,
23
+ * because at the far zoom the boxes are aggregated GROUPS, so this is a
24
+ * NodeId or a GroupId and the caller (a hit test) cannot know which. See
25
+ * focusInfo in ../semantics/semantics.ts, which resolves the same value and
26
+ * carries the full reasoning; typing it here as a union would only move the
27
+ * cast to whichever side of the hit test names it first.
28
+ */
29
+ readonly focus?: string | undefined;
30
+ /** Position on the zoom ladder; omitted means ZOOM_DEFAULT (100%). */
31
+ readonly zoom?: ZoomStep | undefined;
32
+ }
33
+ /** A window over the rendered picture, in cell coordinates (0-based). */
34
+ export interface Viewport {
35
+ readonly x: number;
36
+ readonly y: number;
37
+ readonly width: number;
38
+ readonly height: number;
39
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Layer 4a — what a caller asks the renderer for.
3
+ *
4
+ * Every stage below (canvas, skins, layout, drawing) reads these, so they
5
+ * live in a module of their own rather than in whichever stage happens to
6
+ * touch them first: a picture's inputs are not the property of one stage.
7
+ *
8
+ * Pure types; no behavior.
9
+ */
10
+ export {};
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Layer 4 (presentation) — pure rendering of a MellosMap to terminal lines.
3
+ *
4
+ * Shared by the MCP `mmap_view` tool (monochrome) and the split-pane watcher
5
+ * (colored, animated). Pure function of (map, options): no I/O, no clock —
6
+ * animation is driven by the caller passing a spinner frame index.
7
+ *
8
+ * 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
+ *
24
+ * Visual language — a dark circuit board:
25
+ * rank 0 renders at the BOTTOM of the picture ("primitives are the
26
+ * ground"). Wiring and band bars are FAINT; the glowing things are the
27
+ * nodes. Junctions where a wire enters a box inherit the box's color,
28
+ * like lit pins. Dependency lines only ever travel downward.
29
+ *
30
+ * planned dashed dim rounded box, '·' — a ghost: designed, not built
31
+ * in-progress amber rounded box, spinner — where attention currently is
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
35
+ * regressed heavy red box, '✗' — was done, foundation cracked
36
+ *
37
+ * Zoom — terminals cannot scale glyphs, so zooming out first COMPRESSES the
38
+ * geometry (gaps, breathing rows, padding shrink; labels truncate toward a
39
+ * scale-proportional budget) while boxes stay boxes. Only when a further
40
+ * step would leave labels too short to mean anything does the picture switch
41
+ * mode — to a borderless glyph constellation whose band bars carry
42
+ * done/total counts. Zooming in past 100% unfolds evidence and design notes
43
+ * inside the boxes; a second step widens the boxes and unfolds the notes
44
+ * further. The ladder, one wheel tick per step:
45
+ * +2 detail+ wider boxes, design notes unfold almost fully
46
+ * +1 detail evidence + design notes unfold inside boxes
47
+ * 0 100% the standard working view (default, unchanged)
48
+ * -1 85% labels truncated to 85%, geometry still roomy
49
+ * -2 70% padding and breathing rows collapse
50
+ * -3 55% tightest meaningful boxes; band bars gain done/total
51
+ * -4 overview MODE SWITCH: borderless status glyphs, pure topology
52
+ * The same layout/routing machinery runs at every step; only the per-node
53
+ * box spec (size, border, content) and the whitespace geometry change.
54
+ */
55
+ import type { MellosMap } from '../domain/types.js';
56
+ import type { RenderOptions, Viewport } from './options.js';
57
+ export type { RenderOptions, Viewport } from './options.js';
58
+ export { displayWidth, fitWidth, wrapWidth } from './width.js';
59
+ export { statusSgr } from './skins.js';
60
+ export { type ZoomStep, ZOOM_DEFAULT, ZOOM_MAX, ZOOM_MIN, clampZoom, isNeutralKind, kindGlyph, spinnerGlyph, statusGlyph, unverifiedDoneGlyph, zoomLabel, } from '../semantics/semantics.js';
61
+ /** Render the whole map as terminal lines. */
62
+ export declare function renderMap(map: MellosMap, opts: RenderOptions): string[];
63
+ /** Where a box sits on the full (unwindowed) picture, for hit testing. */
64
+ export interface BoxHit {
65
+ /**
66
+ * The box's id — a node id, or a GROUP id on the aggregated far zoom.
67
+ *
68
+ * VIOLATION: no-primitive-obsession - raw string where NodeId exists, for
69
+ * the reason spelled out at focusInfo (../semantics/semantics.ts): which of
70
+ * the two an id names is what the consumer of a hit calls that function to
71
+ * find out, so this type cannot promise either.
72
+ */
73
+ readonly id: string;
74
+ readonly x: number;
75
+ readonly y: number;
76
+ readonly w: number;
77
+ readonly h: number;
78
+ }
79
+ export interface WindowedRender {
80
+ readonly lines: string[];
81
+ /** Full extent of the picture, for viewport clamping. */
82
+ readonly contentWidth: number;
83
+ readonly contentHeight: number;
84
+ /** Node hit regions in full-picture coordinates, for mouse interaction. */
85
+ readonly hits: readonly BoxHit[];
86
+ }
87
+ /** Render only the given viewport of the map, plus the full content extent. */
88
+ export declare function renderMapWindow(map: MellosMap, opts: RenderOptions, viewport: Viewport): WindowedRender;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Layer 4 (presentation) — pure rendering of a MellosMap to terminal lines.
3
+ *
4
+ * Shared by the MCP `mmap_view` tool (monochrome) and the split-pane watcher
5
+ * (colored, animated). Pure function of (map, options): no I/O, no clock —
6
+ * animation is driven by the caller passing a spinner frame index.
7
+ *
8
+ * 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
+ *
24
+ * Visual language — a dark circuit board:
25
+ * rank 0 renders at the BOTTOM of the picture ("primitives are the
26
+ * ground"). Wiring and band bars are FAINT; the glowing things are the
27
+ * nodes. Junctions where a wire enters a box inherit the box's color,
28
+ * like lit pins. Dependency lines only ever travel downward.
29
+ *
30
+ * planned dashed dim rounded box, '·' — a ghost: designed, not built
31
+ * in-progress amber rounded box, spinner — where attention currently is
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
35
+ * regressed heavy red box, '✗' — was done, foundation cracked
36
+ *
37
+ * Zoom — terminals cannot scale glyphs, so zooming out first COMPRESSES the
38
+ * geometry (gaps, breathing rows, padding shrink; labels truncate toward a
39
+ * scale-proportional budget) while boxes stay boxes. Only when a further
40
+ * step would leave labels too short to mean anything does the picture switch
41
+ * mode — to a borderless glyph constellation whose band bars carry
42
+ * done/total counts. Zooming in past 100% unfolds evidence and design notes
43
+ * inside the boxes; a second step widens the boxes and unfolds the notes
44
+ * further. The ladder, one wheel tick per step:
45
+ * +2 detail+ wider boxes, design notes unfold almost fully
46
+ * +1 detail evidence + design notes unfold inside boxes
47
+ * 0 100% the standard working view (default, unchanged)
48
+ * -1 85% labels truncated to 85%, geometry still roomy
49
+ * -2 70% padding and breathing rows collapse
50
+ * -3 55% tightest meaningful boxes; band bars gain done/total
51
+ * -4 overview MODE SWITCH: borderless status glyphs, pure topology
52
+ * The same layout/routing machinery runs at every step; only the per-node
53
+ * box spec (size, border, content) and the whitespace geometry change.
54
+ */
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';
69
+ /** Render the whole map as terminal lines. */
70
+ export function renderMap(map, opts) {
71
+ const built = buildCanvas(map, opts);
72
+ return built.canvas.emit(opts);
73
+ }
74
+ /** Render only the given viewport of the map, plus the full content extent. */
75
+ export function renderMapWindow(map, opts, viewport) {
76
+ const built = buildCanvas(map, opts);
77
+ return {
78
+ lines: built.canvas.emit(opts, viewport),
79
+ contentWidth: built.canvas.width,
80
+ contentHeight: built.canvas.height,
81
+ hits: built.hits,
82
+ };
83
+ }
84
+ function buildCanvas(map, opts) {
85
+ const oriented = flipForSequence(map);
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.
89
+ const aggregated = plainGeo.mode === 'constellation' ? aggregateMap(oriented) : undefined;
90
+ const drawn = aggregated ?? oriented;
91
+ return paint(drawn, opts, aggregated !== undefined ? AGGREGATE_GEO : plainGeo, unverifiedDoneIds(oriented, drawn));
92
+ }
93
+ function paint(map, opts, geo, unverified) {
94
+ const canvas = new Canvas();
95
+ if (map.layers.length === 0) {
96
+ canvas.text(0, 0, map.title ?? 'mellos mapping', 'none', true);
97
+ canvas.text(0, 2, '(empty map — declare layers and nodes to begin)', 'dim');
98
+ return { canvas, hits: [] };
99
+ }
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));
108
+ if (map.title !== undefined)
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) => ({
121
+ id: b.node.id,
122
+ x: b.x,
123
+ y: b.y,
124
+ w: b.w,
125
+ h: b.h,
126
+ }));
127
+ return { canvas, hits };
128
+ }
@@ -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>;