pi-weave 0.1.7 → 0.1.8

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 (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The graph column (weave-workspace §1.2, §7, P3).
3
+ *
4
+ * Props in, JSX out, and one `ref` handed to a renderer. Every string, every
5
+ * branch and every set comes from `column.model.ts`, `graph.model.ts` or
6
+ * `positions.ts`; what is left here is a `useState`, three effects and four
7
+ * handlers. §10's rule, and the reason the 95 % gate survives a phase whose
8
+ * whole deliverable is a WebGL canvas.
9
+ *
10
+ * The three effects, all one-liners over injected units:
11
+ *
12
+ * 1. **mount** — build a renderer, attach it to the `<div>`, subscribe to
13
+ * selection. The returned `destroy` is the cleanup, so a hot reload cannot
14
+ * leak a WebGL context.
15
+ * 2. **graph** — push the drawn model whenever the reduction or the layout
16
+ * changes. Keyed on `model.key`, which is the *shape* digest, so an SSE
17
+ * tick that changed a note's body does not re-upload the graph (§7.3:
18
+ * re-run on drag or expand/collapse, not every frame).
19
+ * 3. **highlight** — push the neighbourhood whenever the selection moves.
20
+ * This is the §1.3 bus arriving from the tree, a wikilink or the context
21
+ * rail; the graph does not care which, and that is the whole mechanism
22
+ * behind "selecting anywhere highlights everywhere".
23
+ *
24
+ * ## The `live` ref
25
+ *
26
+ * The renderer's `onSelect` is registered once at mount and outlives every
27
+ * render, so a handler that closed over `view` and `model` directly would keep
28
+ * answering with the state as it was at mount. The same `live.current` pattern
29
+ * `Shell.tsx` uses for its drag handlers, for the same reason.
30
+ */
31
+
32
+ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
33
+ import type { GraphPayload } from "../../shared/wire";
34
+ import type { GraphViewState } from "./column.model";
35
+ import {
36
+ DEPTHS,
37
+ FIT_HINT,
38
+ FIT_LABEL,
39
+ LEGEND,
40
+ allExpanded,
41
+ depthHint,
42
+ depthLabel,
43
+ effectiveView,
44
+ expandHint,
45
+ expandLabel,
46
+ graphClick,
47
+ graphColumnModel,
48
+ graphCountLabel,
49
+ parseDepth,
50
+ setDepth,
51
+ toggleExpandAll,
52
+ } from "./column.model";
53
+ import type { PositionStorage } from "./positions";
54
+ import type { GraphRenderer, RendererFactory } from "./renderer";
55
+ import { schemeOf } from "./scheme";
56
+ import type { SchemeHost } from "./scheme";
57
+
58
+ export interface GraphProps {
59
+ graph: GraphPayload | null;
60
+ selectedId: string | null;
61
+ onSelect: (id: string | null) => void;
62
+ /** Injected: `createSigmaRenderer` in the browser, a fake in a test. */
63
+ renderer: RendererFactory;
64
+ /** Injected: `localStorage`. See `positions.ts` for why it is a port. */
65
+ storage: PositionStorage;
66
+ /** Injected: `window`. Read once, for `prefers-color-scheme`. */
67
+ host: SchemeHost;
68
+ /**
69
+ * A slot the column writes its `fit` into, so the global `g` key can reach
70
+ * it (§11 P4).
71
+ *
72
+ * A ref rather than lifting the renderer into the shell: the renderer is
73
+ * created, owned and destroyed by the mount effect below, and hoisting it
74
+ * would move a WebGL lifecycle out of the one component that can see the
75
+ * `<div>` it attaches to. A slot the column fills and clears on unmount
76
+ * keeps the ownership where it is and costs one line at each end.
77
+ */
78
+ fit: { current: (() => void) | null };
79
+ }
80
+
81
+ export function Graph(props: GraphProps) {
82
+ const canvas = useRef<HTMLDivElement | null>(null);
83
+ const renderer = useRef<GraphRenderer | null>(null);
84
+ // `null` means "the user has not touched the expansion" — not "nothing is
85
+ // expanded". `effectiveView` resolves the difference; see its doc comment.
86
+ const [state, setState] = useState<GraphViewState | null>(null);
87
+
88
+ const scheme = useMemo(() => schemeOf(props.host), [props.host]);
89
+ const view = effectiveView(props.graph, state);
90
+ const model = graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme);
91
+ const everything = allExpanded(view, model.clusters);
92
+
93
+ // Read by the mount-time `onSelect`, which outlives this render.
94
+ const live = useRef({ view, model, onSelect: props.onSelect });
95
+ live.current = { view, model, onSelect: props.onSelect };
96
+
97
+ useEffect(() => {
98
+ const instance = props.renderer(scheme);
99
+ instance.onSelect((id) => {
100
+ const next = graphClick(live.current.view, live.current.model.clusters, id);
101
+ setState(next.state);
102
+ live.current.onSelect(next.selectedId);
103
+ });
104
+ instance.mount(canvas.current ?? { clientWidth: 0, clientHeight: 0 });
105
+ renderer.current = instance;
106
+ props.fit.current = () => instance.fit();
107
+ return () => {
108
+ instance.destroy();
109
+ renderer.current = null;
110
+ // Cleared, not left dangling: `g` after a breakpoint has unmounted the
111
+ // graph column would otherwise call `fit` on a destroyed WebGL context.
112
+ props.fit.current = null;
113
+ };
114
+ }, [props.renderer, scheme]);
115
+
116
+ useEffect(() => {
117
+ renderer.current?.setGraph(model.graph);
118
+ }, [model.key]);
119
+
120
+ useEffect(() => {
121
+ renderer.current?.setHighlight(model.highlight);
122
+ }, [model.highlight]);
123
+
124
+ return (
125
+ <div class="weave-graph">
126
+ {/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`,
127
+ and what is left here is whether to render the paragraph it returned. */}
128
+ {model.empty === null ? null : <p class="weave-graph-empty">{model.empty}</p>}
129
+ <div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" />
130
+ <div class="weave-graph-controls">
131
+ <button type="button" class="weave-chip" title={FIT_HINT} onClick={() => renderer.current?.fit()}>
132
+ {FIT_LABEL}
133
+ </button>
134
+ <button type="button" class="weave-chip" title={expandHint(everything)} onClick={() => setState(toggleExpandAll(view, model.clusters))}>
135
+ {expandLabel(everything)}
136
+ </button>
137
+ <select
138
+ class="weave-chip weave-depth"
139
+ title={depthHint(view.depth)}
140
+ aria-label="Highlight depth"
141
+ value={String(view.depth)}
142
+ onChange={(event) => setState(setDepth(view, parseDepth(event.currentTarget.value, view.depth)))}
143
+ >
144
+ {DEPTHS.map((depth) => (
145
+ <option key={depth} value={String(depth)}>
146
+ {depthLabel(depth)}
147
+ </option>
148
+ ))}
149
+ </select>
150
+ <span class="weave-graph-legend">
151
+ <span class="weave-legend-on">◉ {LEGEND.selected}</span>
152
+ <span class="weave-legend-near">● {LEGEND.neighborhood}</span>
153
+ </span>
154
+ </div>
155
+ <p class="weave-graph-count">{graphCountLabel(model.visible, model.total)}</p>
156
+ </div>
157
+ );
158
+ }
@@ -0,0 +1,431 @@
1
+ /**
2
+ * The graph column's own state: what is expanded, what is highlighted, and
3
+ * what the control strip says (weave-workspace §1.2, §1.3, §7.4, §10, P3).
4
+ *
5
+ * `graph.model.ts` decides how a *node* is drawn. This module decides what the
6
+ * *column* is currently showing — which is a different question with a
7
+ * different lifetime, and keeping them apart is what stops either becoming a
8
+ * grab-bag:
9
+ *
10
+ * ```text
11
+ * payload + selection + view state ← this module
12
+ * │
13
+ * ▼ clusterAggregate (core) which nodes exist right now
14
+ * ▼ positions.ts where they are
15
+ * ▼ graph.model.ts how each one looks
16
+ * ▼ project.ts → renderer.ts drawn
17
+ * ```
18
+ *
19
+ * Every branch lives here for §10's reason: sigma needs a canvas, so a
20
+ * conditional inside the renderer is a conditional no test can reach.
21
+ * `Graph.tsx` is left with a `useState`, three effects and four handlers.
22
+ *
23
+ * ## The §1.3 context bus, from the graph's side
24
+ *
25
+ * There is one signal, `selectedId`, and the graph both writes and reads it.
26
+ * Clicking a node writes it — which drives the note column and the context
27
+ * rail with no further wiring. Selecting *anywhere* (tree row, wikilink,
28
+ * context rail) is read back here as {@link highlightFor}, so "selecting in
29
+ * the tree highlights in the graph" needs no code beyond deriving the
30
+ * neighbourhood from the same signal. That is §1.3's whole claim, and the
31
+ * assertion that proves it is a test showing tree-driven and graph-driven
32
+ * selection produce the identical highlight set.
33
+ *
34
+ * ## Tier rules (§2)
35
+ *
36
+ * `src/web/client/**`. Core view-models via `../../shared/view` — the one
37
+ * sanctioned door (§2.1.1), through which P3 brings `clusterAggregate`,
38
+ * `focusNeighborhood` and `degreeOf`. No DOM type is named and no npm package
39
+ * is imported, so the root `tsconfig.json` project compiles the tests.
40
+ */
41
+
42
+ import type { Point } from "../../shared/layout";
43
+ import type { ClusterAggregate, ClusterInfo, ViewGraphModel } from "../../shared/view";
44
+ import { clusterAggregate, degreeOf, focusNeighborhood } from "../../shared/view";
45
+ import type { GraphPayload, WireGraphEdge, WireGraphNode } from "../../shared/wire";
46
+ import { viewModel } from "../tree/tree.model";
47
+ import type { ColorScheme, RenderGraph } from "./graph.model";
48
+ import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
49
+ import type { PositionStorage } from "./positions";
50
+ import { resolveLayout } from "./positions";
51
+
52
+ // --- the view state ------------------------------------------------------------------
53
+
54
+ /**
55
+ * How many hops of neighbourhood the highlight covers.
56
+ *
57
+ * The `[depth 1 ▾]` control from the §1.2 mock. 1 is core's
58
+ * `focusNeighborhood` verbatim; 2 and 3 are that set expanded by re-applying
59
+ * it, which is the only definition that cannot disagree with the context
60
+ * rail's at depth 1.
61
+ *
62
+ * Capped at 3 deliberately. On this repository's shape the 60-child hub is two
63
+ * hops from most of the graph, so depth 4 highlights nearly everything and a
64
+ * highlight that covers everything conveys nothing. A number rather than a
65
+ * boolean because "just the neighbours" and "the neighbourhood around this
66
+ * module" are genuinely different questions.
67
+ */
68
+ export type Depth = 1 | 2 | 3;
69
+
70
+ /** Every depth the control offers, in order. */
71
+ export const DEPTHS: readonly Depth[] = [1, 2, 3];
72
+
73
+ /** The graph column's state. Owned by the column; never on the context bus. */
74
+ export interface GraphViewState {
75
+ /**
76
+ * Cluster ids the user has expanded (§7.4).
77
+ *
78
+ * Passed straight to `clusterAggregate`, whose contract is that a node is
79
+ * visible when every containment ancestor between it and a root is
80
+ * expanded. An empty set is therefore the maximally collapsed view — the
81
+ * roots alone — which is the right first frame for a repository with
82
+ * thousands of files and the wrong one for a vault with nine notes. See
83
+ * {@link initialGraphView}.
84
+ */
85
+ readonly expanded: ReadonlySet<string>;
86
+ /** How far the highlight reaches. */
87
+ readonly depth: Depth;
88
+ }
89
+
90
+ /**
91
+ * How many nodes a graph may have before it opens collapsed.
92
+ *
93
+ * Not a preference — a legibility bound. Above roughly this many, a
94
+ * force-directed layout of a containment tree is a disc of overlapping labels
95
+ * whichever way you draw it, and the honest first frame is the five clusters
96
+ * with their counts. Below it, collapsing hides a graph the user could simply
97
+ * have read.
98
+ *
99
+ * 120 is the point at which this repository's own fixture (89 nodes) still
100
+ * opens whole while a real `src/` scan (hundreds of files) does not.
101
+ */
102
+ export const AUTO_COLLAPSE_ABOVE = 120;
103
+
104
+ /**
105
+ * The state a freshly loaded graph opens in.
106
+ *
107
+ * Everything expanded for a small graph, nothing for a large one. `expanded`
108
+ * has to name the clusters explicitly rather than carry an "all" flag, because
109
+ * `clusterAggregate` takes a set and the first thing a user does is collapse
110
+ * one — which has to leave the others open.
111
+ */
112
+ export function initialGraphView(model: ViewGraphModel): GraphViewState {
113
+ const expanded =
114
+ model.nodes.length > AUTO_COLLAPSE_ABOVE ? new Set<string>() : new Set(clusterAggregate(model, new Set()).clusters.keys());
115
+ return { expanded, depth: 1 };
116
+ }
117
+
118
+ /**
119
+ * The state to render with: the user's, or the default for this payload.
120
+ *
121
+ * The column holds `GraphViewState | null` and `null` means "the user has not
122
+ * touched the expansion yet" — which is not the same as "nothing is expanded",
123
+ * because {@link initialGraphView} opens a small graph whole. Resolving that
124
+ * here rather than in the component keeps a real branch out of a `.tsx` (§10)
125
+ * and, more importantly, makes the rule testable: a refetch must **not** reset
126
+ * an expansion the user has chosen, and the watcher fires on every file save.
127
+ */
128
+ export function effectiveView(payload: GraphPayload | null, state: GraphViewState | null): GraphViewState {
129
+ if (state !== null) return state;
130
+ if (payload === null) return { expanded: new Set(), depth: 1 };
131
+ return initialGraphView(viewModel(payload));
132
+ }
133
+
134
+ /** Open or close one cluster. Returns a new state; see {@link expandAll}. */
135
+ export function toggleCluster(state: GraphViewState, id: string): GraphViewState {
136
+ const expanded = new Set(state.expanded);
137
+ if (!expanded.delete(id)) expanded.add(id);
138
+ return { ...state, expanded };
139
+ }
140
+
141
+ /**
142
+ * Open every cluster. The `[expand]` control from the §1.2 mock.
143
+ *
144
+ * `clusters` is `ClusterAggregate.clusters`, which holds **every** node with a
145
+ * containment child whether or not it is currently visible — so one press
146
+ * opens the whole tree rather than one level of it. That is deliberate: the
147
+ * per-level walk is what the tree column is for, and a graph control that
148
+ * needed six presses to show the graph would be a worse version of it.
149
+ */
150
+ export function expandAll(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): GraphViewState {
151
+ return { ...state, expanded: new Set(clusters.keys()) };
152
+ }
153
+
154
+ /** Close every cluster, back to the roots. The other half of `[expand]`. */
155
+ export function collapseAll(state: GraphViewState): GraphViewState {
156
+ return { ...state, expanded: new Set() };
157
+ }
158
+
159
+ /** Set the highlight depth. */
160
+ export function setDepth(state: GraphViewState, depth: Depth): GraphViewState {
161
+ return state.depth === depth ? state : { ...state, depth };
162
+ }
163
+
164
+ // --- the highlight (§1.3, §7.4) ---------------------------------------------------------
165
+
166
+ /**
167
+ * The set of ids that stay lit when `selectedId` is selected.
168
+ *
169
+ * Depth 1 is `focusNeighborhood` **exactly** — core's function, the same one
170
+ * the context rail's "Related" is built from, reached through the §2.1.1 door
171
+ * rather than reimplemented. Deeper is that operation applied again, which is
172
+ * the only definition of "2 hops" that cannot disagree with "1 hop" at the
173
+ * boundary.
174
+ *
175
+ * `null` in, `null` out: nothing selected means no highlight at all, which the
176
+ * reducers treat as "render everything normally" — deliberately not the same
177
+ * as an empty set (see `graph.model.ts`).
178
+ *
179
+ * Computed over the **currently visible** edges, not the full model. A
180
+ * neighbour inside a collapsed cluster is not on screen, and `clusterAggregate`
181
+ * has already retargeted the edge onto the cluster standing in for it — so
182
+ * highlighting the hidden id would light nothing while leaving the cluster
183
+ * that actually represents it dimmed.
184
+ */
185
+ export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string | null, depth: Depth): Set<string> | null {
186
+ if (selectedId === null) return null;
187
+ let frontier = focusNeighborhood(selectedId, edges);
188
+ for (let hop = 1; hop < depth; hop++) {
189
+ const next = new Set(frontier);
190
+ for (const id of frontier) for (const neighbour of focusNeighborhood(id, edges)) next.add(neighbour);
191
+ // A neighbourhood that stopped growing has reached its whole connected
192
+ // component, so further hops cannot add anything and the loop is waste.
193
+ if (next.size === frontier.size) break;
194
+ frontier = next;
195
+ }
196
+ return frontier;
197
+ }
198
+
199
+ // --- the control strip (§1.2) ---------------------------------------------------------------
200
+
201
+ /** The `[depth 1 ▾]` control's label. */
202
+ export function depthLabel(depth: Depth): string {
203
+ return `depth ${depth}`;
204
+ }
205
+
206
+ /** Its tooltip — says what the number means, not what it is. */
207
+ export function depthHint(depth: Depth): string {
208
+ return depth === 1
209
+ ? "highlighting direct neighbours of the selection"
210
+ : `highlighting everything within ${depth} hops of the selection`;
211
+ }
212
+
213
+ /** The `[expand]` control's label, which is really a toggle. */
214
+ export function expandLabel(allExpanded: boolean): string {
215
+ return allExpanded ? "collapse" : "expand";
216
+ }
217
+
218
+ /** Its tooltip. */
219
+ export function expandHint(allExpanded: boolean): string {
220
+ return allExpanded ? "collapse every cluster back to the roots" : "expand every cluster";
221
+ }
222
+
223
+ /** Whether every cluster in the graph is currently open. */
224
+ export function allExpanded(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): boolean {
225
+ if (clusters.size === 0) return false;
226
+ for (const id of clusters.keys()) if (!state.expanded.has(id)) return false;
227
+ return true;
228
+ }
229
+
230
+ /**
231
+ * What the `[expand]` control does when pressed.
232
+ *
233
+ * One function rather than a ternary in the component, for §10's reason: the
234
+ * choice between expanding and collapsing is a branch, and a branch in a
235
+ * `.tsx` is a branch no test can reach. It is also the *only* place the
236
+ * toggle's two halves are paired, so its label and its effect cannot disagree
237
+ * — {@link expandLabel} reads the same predicate.
238
+ */
239
+ export function toggleExpandAll(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): GraphViewState {
240
+ return allExpanded(state, clusters) ? collapseAll(state) : expandAll(state, clusters);
241
+ }
242
+
243
+ /**
244
+ * Parse the depth `<select>`'s value.
245
+ *
246
+ * A `<select>` yields a string and the component must not cast it — an
247
+ * `as Depth` on unvalidated input is exactly how a `depth 7` ends up in state
248
+ * and the highlight silently covers the whole graph. Anything unrecognised
249
+ * falls back to the current depth, so a malformed event changes nothing.
250
+ */
251
+ export function parseDepth(value: string, fallback: Depth): Depth {
252
+ const parsed = Number(value);
253
+ return (DEPTHS as readonly number[]).includes(parsed) ? (parsed as Depth) : fallback;
254
+ }
255
+
256
+ /** The `[fit]` control. Constant, but named here so the component holds no copy. */
257
+ export const FIT_LABEL = "fit";
258
+ export const FIT_HINT = "frame the whole graph";
259
+
260
+ /**
261
+ * The legend under the canvas, from the §1.2 mock:
262
+ * `◉ selected ● neighborhood`.
263
+ */
264
+ export const LEGEND = { selected: "selected", neighborhood: "neighborhood", dimmed: "not related" } as const;
265
+
266
+ /**
267
+ * The count line: what is on screen out of what exists.
268
+ *
269
+ * Both numbers, always. "89 nodes" next to a canvas showing 5 is the reading
270
+ * that makes a collapsed graph look broken; "5 of 89 nodes" makes it obvious
271
+ * that there is more behind the clusters.
272
+ */
273
+ export function graphCountLabel(visible: number, total: number): string {
274
+ const noun = total === 1 ? "node" : "nodes";
275
+ return visible === total ? `${total} ${noun}` : `${visible} of ${total} ${noun}`;
276
+ }
277
+
278
+ /** What the column says instead of a canvas. `null` means "there is a graph". */
279
+ export function graphEmptyMessage(payload: GraphPayload | null, visible: number): string | null {
280
+ if (payload === null) return "Loading…";
281
+ if (payload.model.nodes.length === 0) return "Nothing indexed yet — add a note or run /weave-scan.";
282
+ // A payload with nodes but nothing visible cannot happen through the UI
283
+ // (`clusterAggregate` always keeps the roots), but a truncated response
284
+ // could produce it, and a blank canvas with no explanation reads as a crash.
285
+ return visible === 0 ? "Nothing to draw at this expansion." : null;
286
+ }
287
+
288
+ // --- the whole column state -----------------------------------------------------------------
289
+
290
+ /** Everything the column needs to render one frame. */
291
+ export interface GraphColumnModel {
292
+ /** What sigma draws. */
293
+ readonly graph: RenderGraph;
294
+ /** What the reducers dim outside of. `null` when nothing is selected. */
295
+ readonly highlight: Set<string> | null;
296
+ /** The layout's cache key, so the caller can tell one shape from another. */
297
+ readonly key: string;
298
+ /** True when the layout came from `localStorage` rather than the simulation. */
299
+ readonly cached: boolean;
300
+ /** Every cluster in the *unreduced* model — what `[expand]` operates on. */
301
+ readonly clusters: ReadonlyMap<string, ClusterInfo>;
302
+ /** Visible / total, for the count line. */
303
+ readonly visible: number;
304
+ readonly total: number;
305
+ /** Non-null when there is nothing to draw. */
306
+ readonly empty: string | null;
307
+ }
308
+
309
+ /** The column with nothing in it. What the first render produces. */
310
+ export const EMPTY_COLUMN: GraphColumnModel = {
311
+ graph: EMPTY_RENDER_GRAPH,
312
+ highlight: null,
313
+ key: "",
314
+ cached: false,
315
+ clusters: new Map(),
316
+ visible: 0,
317
+ total: 0,
318
+ empty: "Loading…",
319
+ };
320
+
321
+ /**
322
+ * Reduce, lay out, resolve and highlight — one frame of the graph column.
323
+ *
324
+ * The order is the design, and each step depends on the one before it:
325
+ *
326
+ * 1. **Reduce** (`clusterAggregate`, core). Collapsed subtrees become their
327
+ * parent and boundary-crossing edges are retargeted onto it. The renderer
328
+ * is never handed a node it is expected to hide (§7.4).
329
+ * 2. **Lay out** the *reduced* graph. Laying out the full model and then
330
+ * hiding nodes would leave the visible ones spread across the holes the
331
+ * hidden ones left — the collapsed view would have the geometry of the
332
+ * expanded one, which is the opposite of the point.
333
+ * 3. **Resolve** to a `RenderGraph`.
334
+ * 4. **Highlight** over the reduced edges, so a neighbour inside a collapsed
335
+ * cluster lights the cluster rather than nothing.
336
+ *
337
+ * Step 2 is also why the layout cache is keyed by shape: every expand and
338
+ * collapse produces a genuinely different graph, gets its own key, and is
339
+ * remembered independently — so collapsing and re-expanding returns the
340
+ * arrangement the user had, rather than a fresh one.
341
+ */
342
+ export function graphColumnModel(
343
+ payload: GraphPayload | null,
344
+ selectedId: string | null,
345
+ state: GraphViewState,
346
+ storage: PositionStorage,
347
+ scheme: ColorScheme,
348
+ ): GraphColumnModel {
349
+ if (payload === null) return EMPTY_COLUMN;
350
+
351
+ const model = viewModel(payload);
352
+ const reduced: ClusterAggregate = clusterAggregate(model, state.expanded);
353
+ const layout = resolveLayout(storage, reduced.nodes, reduced.edges);
354
+
355
+ return {
356
+ graph: renderGraph(reduced.nodes, reduced.edges, layout.positions, scheme),
357
+ highlight: highlightFor(reduced.edges, selectedId, state.depth),
358
+ key: layout.key,
359
+ cached: layout.cached,
360
+ clusters: reduced.clusters,
361
+ visible: reduced.nodes.length,
362
+ total: model.nodes.length,
363
+ empty: graphEmptyMessage(payload, reduced.nodes.length),
364
+ };
365
+ }
366
+
367
+ // --- clicking a node -------------------------------------------------------------------------
368
+
369
+ /** What a click on the canvas produced. */
370
+ export interface GraphClick {
371
+ /** The next view state — changed only when a collapsed cluster was opened. */
372
+ readonly state: GraphViewState;
373
+ /** What to write to `selectedId`. Always set, even when a cluster expanded. */
374
+ readonly selectedId: string | null;
375
+ }
376
+
377
+ /**
378
+ * Apply a click on a node (or on empty stage, with `null`).
379
+ *
380
+ * A click does **two** things when it lands on a collapsed cluster: it selects
381
+ * it *and* it expands it (§7.4, "expand on click"). Both, deliberately — the
382
+ * selection is what drives the note column and the context rail, and dropping
383
+ * it in favour of the expansion would make clicking a module the one gesture
384
+ * in the workspace that does not update the other columns.
385
+ *
386
+ * A click on an already-expanded cluster selects without collapsing. Collapse
387
+ * is the `[expand]` control and the tree; making a second click close a
388
+ * cluster would make it impossible to select one and read it.
389
+ */
390
+ export function graphClick(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>, id: string | null): GraphClick {
391
+ if (id === null) return { state, selectedId: null };
392
+ const cluster = clusters.get(id);
393
+ if (cluster === undefined || state.expanded.has(id)) return { state, selectedId: id };
394
+ return { state: toggleCluster(state, id), selectedId: id };
395
+ }
396
+
397
+ // --- tooltips ---------------------------------------------------------------------------------
398
+
399
+ /**
400
+ * What a node says when it stands in for a collapsed subtree.
401
+ *
402
+ * `ClusterInfo.members` is exactly the hidden descendants this cluster is
403
+ * currently representing — not `descendants`, which overlaps freely between a
404
+ * cluster and its ancestors and would report the same file under both
405
+ * `src/core` and `repository`. `members` partitions the hidden set across the
406
+ * visible clusters, so the counts add up to the number of nodes that are not
407
+ * on screen.
408
+ */
409
+ export function clusterBadge(cluster: ClusterInfo | undefined): string | null {
410
+ if (cluster === undefined || cluster.members.length === 0) return null;
411
+ return cluster.members.length === 1 ? "1 hidden" : `${cluster.members.length} hidden`;
412
+ }
413
+
414
+ /**
415
+ * A node's hover text: its label, its degree, and what it is standing in for.
416
+ *
417
+ * `degreeOf` is core's, reached through the §2.1.1 door. The bulk `degrees`
418
+ * pass in `graph.model.ts` is a different algorithm for a different question
419
+ * (every node at once, O(edges)); this is one node, which is what a tooltip
420
+ * asks.
421
+ */
422
+ export function nodeTooltip(node: WireGraphNode, edges: readonly WireGraphEdge[], cluster: ClusterInfo | undefined): string {
423
+ const degree = degreeOf(node.id, edges);
424
+ const parts = [node.label, degree === 1 ? "1 link" : `${degree} links`];
425
+ const badge = clusterBadge(cluster);
426
+ if (badge !== null) parts.push(badge);
427
+ return parts.join(" · ");
428
+ }
429
+
430
+ /** Positions keyed by id, for a caller warm-starting a re-run. */
431
+ export type LayoutSnapshot = ReadonlyMap<string, Point>;