pi-weave 0.1.7 → 0.1.9

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 (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Computing the layout, and keeping it across sessions
3
+ * (weave-workspace §7.3, §11 P3).
4
+ *
5
+ * ## Where layout runs, as built
6
+ *
7
+ * §7.3 plans for the server to precompute positions and ship them in
8
+ * `GraphPayload.positions`. That is **not** what happens: `positions` is
9
+ * `null` by design (§5.3), because `shared/layout.ts` imports `d3-force` and
10
+ * the server tier's npm allowlist is empty. So the client runs the identical
11
+ * `shared/layout` code itself, which is exactly why that module lives in
12
+ * `shared/` and takes no DOM.
13
+ *
14
+ * The consequence §7.3 was trying to avoid — "the graph appears already laid
15
+ * out with no visible settling" — is what {@link loadPositions} is for. A
16
+ * cached layout from the last session is *better* than a server-computed one:
17
+ * it settles instantly **and** it is the arrangement the user already has a
18
+ * mental map of.
19
+ *
20
+ * ## When the simulation re-runs
21
+ *
22
+ * §7.3: "The client re-runs the sim only on user drag or expand/collapse." Not
23
+ * every frame, not on every SSE tick, and not on a resize. {@link layoutFor}
24
+ * is the single entry point and it is called from exactly those places;
25
+ * {@link shouldRelayout} is the rule for the case that is easy to get wrong —
26
+ * a new payload arriving over the wire.
27
+ *
28
+ * ## Tier rules (§2)
29
+ *
30
+ * `src/web/client/**`. `localStorage` arrives as a two-method port, not as the
31
+ * global, for the reason `layout.model.ts`'s `LayoutStorage` records: the real
32
+ * one *throws* in partitioned-storage contexts, and a narrow injected port is
33
+ * a better place for that wrapper than a component. No DOM type is named, so
34
+ * the root `tsconfig.json` project compiles the tests.
35
+ */
36
+
37
+ import { computeLayout } from "../../shared/layout";
38
+ import type { LayoutOptions, Point } from "../../shared/layout";
39
+ import type { WireGraphEdge, WireGraphNode } from "../../shared/wire";
40
+
41
+ /**
42
+ * FNV-1a (32-bit) followed by MurmurHash3's `fmix32` avalanche.
43
+ *
44
+ * The finalizer is not optional: FNV-1a mixes its *low* bits well but its high
45
+ * bits poorly for short, near-identical inputs — and the digest folds the hash
46
+ * into a bounded key, so the high bits are the significant part. Raw FNV-1a
47
+ * over `leaf001…leaf199` put 199 ids into six of twelve sectors; `fmix32`
48
+ * costs four lines and makes every bit depend on every input bit.
49
+ */
50
+ export function hashId(id: string): number {
51
+ let h = 0x811c9dc5;
52
+ for (let i = 0; i < id.length; i++) {
53
+ h ^= id.charCodeAt(i);
54
+ h = Math.imul(h, 0x01000193);
55
+ }
56
+ h ^= h >>> 16;
57
+ h = Math.imul(h, 0x85ebca6b);
58
+ h ^= h >>> 13;
59
+ h = Math.imul(h, 0xc2b2ae35);
60
+ h ^= h >>> 16;
61
+ return h >>> 0;
62
+ }
63
+
64
+
65
+ // --- computing ----------------------------------------------------------------------
66
+
67
+ /**
68
+ * Tick budget for a layout run.
69
+ *
70
+ * `shared/layout.ts` derives its alpha decay from this, so the simulation
71
+ * reaches the same convergence at any budget — the number buys smoothness of
72
+ * the *path*, not quality of the result. 300 is the value §8's gate asserts
73
+ * against, and matching it means the graph a user sees is the graph the
74
+ * dynamics test proved non-degenerate. A different budget here would make the
75
+ * gate a statement about a layout nobody renders.
76
+ */
77
+ export const LAYOUT_TICKS = 300;
78
+
79
+ /** The seed. Fixed, so two loads of one vault produce one arrangement. */
80
+ export const LAYOUT_SEED = 1;
81
+
82
+ /**
83
+ * Lay out a node/edge set.
84
+ *
85
+ * `warm` is `computeLayout`'s `initial`: positions to start from rather than
86
+ * the deterministic seeding. Passed on a re-run so the picture *moves* to its
87
+ * new arrangement instead of being replaced by an unrelated one — §8 pins that
88
+ * re-running from a settled state is a near-fixed-point, which is the property
89
+ * that makes an expand feel like an expand.
90
+ *
91
+ * **New containment children start at their parent** (d3's own collapse/
92
+ * expand pattern): a newly visible node without a stored position enters the
93
+ * simulation at its parent's warm position, so the relaxation it needs is
94
+ * local — the collide force makes it step out among its siblings — instead of
95
+ * d3's phyllotaxis spiral dropping it across the graph and dragging the whole
96
+ * arrangement along.
97
+ *
98
+ * The viewport is **not** a parameter, deliberately. The layout is centred on
99
+ * the origin by `forceX()`/`forceY()`, and sigma's camera fits whatever extent
100
+ * it is handed — so feeding the real column width in would re-run the
101
+ * simulation on every divider drag to produce a picture the camera
102
+ * immediately normalises away. Keeping the layout a function of the *graph*
103
+ * alone is what makes it cacheable at all.
104
+ */
105
+ export function layoutFor(
106
+ nodes: readonly WireGraphNode[],
107
+ edges: readonly WireGraphEdge[],
108
+ warm?: ReadonlyMap<string, Point>,
109
+ ): Map<string, Point> {
110
+ const seeded = new Map<string, Point>(warm ?? []);
111
+ if (seeded.size > 0) {
112
+ for (const e of edges) {
113
+ if (e.kind !== "contains" && e.kind !== "anchored-at") continue;
114
+ if (seeded.has(e.target)) continue;
115
+ const at = seeded.get(e.source);
116
+ if (at !== undefined) seeded.set(e.target, { x: at.x, y: at.y });
117
+ }
118
+ }
119
+ const options: LayoutOptions = { ticks: LAYOUT_TICKS, seed: LAYOUT_SEED, pinWarm: true };
120
+ if (seeded.size > 0) options.initial = seeded;
121
+ return computeLayout(
122
+ { generatedAt: "", staleness: null, nodes: [...nodes], edges: [...edges], contentDigest: "" },
123
+ options,
124
+ );
125
+ }
126
+
127
+ // --- the cache key ---------------------------------------------------------------------
128
+
129
+ /**
130
+ * A digest of the graph's *shape* — which nodes and edges exist, not where
131
+ * they are or what they say.
132
+ *
133
+ * This is the "repo hash" §11 P3 keys position persistence by, and what it
134
+ * hashes is the whole design. Three properties matter:
135
+ *
136
+ * 1. **Shape only.** A note's title changing, a tag being added, `generatedAt`
137
+ * advancing — none of those move a node, so none of them should throw away
138
+ * a layout the user has built a mental map of. Hashing the payload's
139
+ * `stamp` instead would invalidate on every edit, which is the same as not
140
+ * caching.
141
+ * 2. **Order-independent.** Node and edge order is stable today
142
+ * (`buildGraph` is byte-deterministic), but a cache that silently
143
+ * invalidates when core reorders its output would be a mystery, so the
144
+ * per-element hashes are combined by **XOR and sum** rather than by
145
+ * concatenation. Two graphs with the same elements in a different order
146
+ * share a key, which is correct: the layout does not depend on the order.
147
+ * 3. **Cheap.** One `hashId` per node and per edge, no string building. This
148
+ * runs on every payload arrival.
149
+ *
150
+ * The count is mixed in alongside the accumulators because XOR alone cannot
151
+ * see a duplicate (`x ^ x = 0`) and a sum alone collides too easily.
152
+ */
153
+ export function graphShapeKey(nodes: readonly WireGraphNode[], edges: readonly WireGraphEdge[]): string {
154
+ let xor = 0;
155
+ let sum = 0;
156
+ for (const node of nodes) {
157
+ const h = hashId(node.id);
158
+ xor ^= h;
159
+ sum = (sum + h) >>> 0;
160
+ }
161
+ for (const edge of edges) {
162
+ const h = hashId(`${edge.source}\u0000${edge.target}\u0000${edge.kind}`);
163
+ xor ^= h;
164
+ sum = (sum + h) >>> 0;
165
+ }
166
+ const parts = [nodes.length, edges.length, xor >>> 0, sum].map((n) => n.toString(16));
167
+ return parts.join("-");
168
+ }
169
+
170
+ /**
171
+ * Whether a new payload needs the simulation re-run.
172
+ *
173
+ * The one decision that is easy to get wrong in both directions. Re-running on
174
+ * every payload makes the graph jump whenever anyone saves a file — the
175
+ * watcher fires, the graph refetches, and a picture the user was reading
176
+ * rearranges itself for no visible reason. Never re-running leaves new nodes
177
+ * unplaced and therefore undrawn (`renderGraph` drops a node with no
178
+ * position), so a note created in Obsidian would never appear.
179
+ *
180
+ * The shape key is exactly the line between those: it changes iff a node or an
181
+ * edge was added or removed, which is precisely when the existing positions
182
+ * are no longer a complete answer.
183
+ */
184
+ export function shouldRelayout(previous: string | null, next: string): boolean {
185
+ return previous !== next;
186
+ }
187
+
188
+ // --- persistence (§7.4, §11 P3) ------------------------------------------------------------
189
+
190
+ /**
191
+ * The slice of `Storage` this module uses.
192
+ *
193
+ * Same two-method port as `layout.model.ts`'s `LayoutStorage`, and declared
194
+ * separately rather than imported from it because the two are unrelated
195
+ * concerns that merely happen to share a shape — coupling the graph's cache to
196
+ * the column-width persistence would mean one cannot change without the other.
197
+ */
198
+ export interface PositionStorage {
199
+ getItem(key: string): string | null;
200
+ setItem(key: string, value: string): void;
201
+ removeItem(key: string): void;
202
+ }
203
+
204
+ /** The `localStorage` key. Namespaced and versioned, like the layout's. */
205
+ export const POSITIONS_STORAGE_KEY = "pi-weave.graph.positions.v1";
206
+
207
+ /**
208
+ * Coordinates are rounded to **one** decimal before storage.
209
+ *
210
+ * The layout spans thousands of units, so a tenth of a unit is far below one
211
+ * screen pixel at any zoom a person uses. It roughly halves the stored string
212
+ * and, more usefully, makes a hand-inspected entry readable. The rounding is
213
+ * *not* fed back into the simulation as a warm start's only source — it is a
214
+ * near-fixed-point either way (§8 pins re-running from settled positions moves
215
+ * nothing by more than `CONTAINS_DISTANCE`), and a tenth of a unit is orders
216
+ * of magnitude inside that.
217
+ */
218
+ function round1(value: number): number {
219
+ return Math.round(value * 10) / 10;
220
+ }
221
+
222
+ /**
223
+ * Serialize a layout for storage.
224
+ *
225
+ * `[x, y]` pairs rather than `{x, y}` objects: same information, about 40 %
226
+ * fewer bytes, and `localStorage` is a shared 5 MB budget this is not the only
227
+ * consumer of.
228
+ */
229
+ export function serializePositions(key: string, positions: ReadonlyMap<string, Point>): string {
230
+ const at: Record<string, [number, number]> = {};
231
+ for (const [id, point] of positions) at[id] = [round1(point.x), round1(point.y)];
232
+ return JSON.stringify({ v: 1, key, at });
233
+ }
234
+
235
+ /**
236
+ * Parse stored positions for a shape key, or `null`.
237
+ *
238
+ * Everything here is untrusted: the string is user-editable by construction
239
+ * (it lives in a devtools pane anyone can type into) and it outlives the
240
+ * schema, so a v1 reader will one day meet a v2 entry written by a newer build
241
+ * the user ran yesterday. Both have the same correct answer — return `null`
242
+ * and let the caller lay out from scratch. There is no repair path, because a
243
+ * partially-repaired layout is a bug report that says "my graph is weird
244
+ * sometimes".
245
+ *
246
+ * **A key mismatch is a miss, not an error.** That is the invalidation §11 P3
247
+ * asks for, and it is one comparison: the stored layout describes a graph with
248
+ * different nodes or edges in it, so it cannot be completed and is discarded
249
+ * whole rather than merged. A partial merge would place the old nodes and
250
+ * leave the new ones at the origin, which is the hairball §7.2 exists to
251
+ * prevent.
252
+ */
253
+ export function deserializePositions(raw: string | null, key: string): Map<string, Point> | null {
254
+ if (raw === null) return null;
255
+ let parsed: unknown;
256
+ try {
257
+ parsed = JSON.parse(raw);
258
+ } catch {
259
+ return null;
260
+ }
261
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
262
+
263
+ const record = parsed as Record<string, unknown>;
264
+ if (record["v"] !== 1) return null;
265
+ if (record["key"] !== key) return null;
266
+
267
+ const at = record["at"];
268
+ if (typeof at !== "object" || at === null || Array.isArray(at)) return null;
269
+
270
+ const out = new Map<string, Point>();
271
+ for (const [id, value] of Object.entries(at as Record<string, unknown>)) {
272
+ if (!Array.isArray(value) || value.length !== 2) return null;
273
+ const [x, y] = value as [unknown, unknown];
274
+ if (typeof x !== "number" || typeof y !== "number") return null;
275
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
276
+ out.set(id, { x, y });
277
+ }
278
+ // An entry that parsed to nothing is indistinguishable from no entry, and
279
+ // returning an empty map would make the caller warm-start from nowhere while
280
+ // believing it had a cache hit.
281
+ return out.size === 0 ? null : out;
282
+ }
283
+
284
+ /**
285
+ * Read the cached layout for a shape, or `null`.
286
+ *
287
+ * A throwing `getItem` — Safari private browsing, partitioned storage — is
288
+ * indistinguishable from a miss as far as the graph is concerned, and a
289
+ * workspace that lays out from scratch is not an error worth surfacing.
290
+ */
291
+ export function loadPositions(storage: PositionStorage, key: string): Map<string, Point> | null {
292
+ let raw: string | null;
293
+ try {
294
+ raw = storage.getItem(POSITIONS_STORAGE_KEY);
295
+ } catch {
296
+ return null;
297
+ }
298
+ return deserializePositions(raw, key);
299
+ }
300
+
301
+ /**
302
+ * Persist a layout, best-effort. Returns whether it stuck.
303
+ *
304
+ * `setItem` fails on quota exhaustion and in partitioned-storage contexts, and
305
+ * neither is a reason to break a graph that is already on screen — the user's
306
+ * layout is correct, it just does not survive a reload.
307
+ *
308
+ * Only **one** layout is stored, keyed by shape rather than accumulated per
309
+ * shape. A user with three repositories open over a week would otherwise
310
+ * accumulate three multi-hundred-node position maps in a 5 MB budget shared
311
+ * with every other pi-weave key, to save a few hundred milliseconds on a
312
+ * switch back. One slot, last graph wins.
313
+ */
314
+ export function savePositions(storage: PositionStorage, key: string, positions: ReadonlyMap<string, Point>): boolean {
315
+ try {
316
+ storage.setItem(POSITIONS_STORAGE_KEY, serializePositions(key, positions));
317
+ return true;
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+
323
+ /** Drop the cache. The escape hatch behind a re-layout control. */
324
+ export function clearPositions(storage: PositionStorage): boolean {
325
+ try {
326
+ storage.removeItem(POSITIONS_STORAGE_KEY);
327
+ return true;
328
+ } catch {
329
+ return false;
330
+ }
331
+ }
332
+
333
+ // --- the whole resolution -------------------------------------------------------------------
334
+
335
+ /** A layout, and where it came from. */
336
+ export interface ResolvedLayout {
337
+ readonly key: string;
338
+ readonly positions: Map<string, Point>;
339
+ /**
340
+ * `true` when the cache answered and the simulation did not run.
341
+ *
342
+ * Reported rather than inferred so a test can assert the cache is actually
343
+ * being used — a persistence layer that silently always misses is a
344
+ * persistence layer that passes every test about its serializer.
345
+ */
346
+ readonly cached: boolean;
347
+ }
348
+
349
+ /**
350
+ * The layout for a graph: cache first, simulation second.
351
+ *
352
+ * This is the whole of §11 P3's "position persistence in `localStorage` keyed
353
+ * by repo hash", and the order is the point. A hit is instant and is the
354
+ * arrangement the user already knows; a miss pays ~300 ticks once and is then
355
+ * written back, so the second load of any graph is free.
356
+ *
357
+ * A cache hit is **used verbatim**, not re-simulated from. Re-running even
358
+ * from a perfect warm start would move every node slightly (§8 measures the
359
+ * drift as bounded but non-zero), so a user reopening the workspace would
360
+ * watch the graph shuffle for no reason — which is exactly the "reshuffle
361
+ * between sessions" §11 P3 asks to prevent.
362
+ */
363
+ export function resolveLayout(
364
+ storage: PositionStorage,
365
+ nodes: readonly WireGraphNode[],
366
+ edges: readonly WireGraphEdge[],
367
+ ): ResolvedLayout {
368
+ const key = graphShapeKey(nodes, edges);
369
+ const cached = loadPositions(storage, key);
370
+ // A cached map that is missing an id cannot be trusted as complete — the
371
+ // shape key says the node set matches, so a gap means a truncated or
372
+ // hand-edited entry. Fall through to a real layout rather than dropping the
373
+ // node (which is what `renderGraph` would do with a missing position).
374
+ if (cached !== null && nodes.every((node) => cached.has(node.id))) {
375
+ return { key, positions: cached, cached: true };
376
+ }
377
+ const positions = layoutFor(nodes, edges);
378
+ savePositions(storage, key, positions);
379
+ return { key, positions, cached: false };
380
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * `RenderGraph` → graphology instance (weave-workspace §7.1).
3
+ *
4
+ * ```text
5
+ * GraphModel (core, authoritative)
6
+ * │
7
+ * ▼ shared/layout.ts positions
8
+ * ▼ graph/graph.model.ts RenderGraph — every decision already made
9
+ * ▼ graph/project.ts ← you are here
10
+ * graphology instance (render projection only)
11
+ * ▼
12
+ * sigma
13
+ * ```
14
+ *
15
+ * §7.1 states the rule this file exists to obey: the projection is **never
16
+ * edited and never a second source of truth**. Every mutation flows
17
+ * `GraphModel → projection`; nothing flows back. Concretely that means:
18
+ *
19
+ * - Nothing here reads from the graphology instance in order to decide
20
+ * something. {@link project} writes; {@link syncPositions} writes.
21
+ * - There is no `onNodeUpdated` handler, no drag writing back into a model, no
22
+ * "the graph knows best" path. A user drag re-runs `shared/layout.ts` from
23
+ * the model and re-projects, which is why {@link syncPositions} exists as a
24
+ * *write* rather than a merge.
25
+ * - `graphology` exists solely because sigma consumes it (§0 V3). It is not
26
+ * the client's graph type; `RenderGraph` is.
27
+ *
28
+ * ## Why this file is thin, and testable anyway
29
+ *
30
+ * graphology is a plain in-memory data structure with no DOM dependency — it
31
+ * is `sigma` that needs a canvas. So unlike `renderer.ts`, this module *is*
32
+ * covered by ordinary unit tests, and it stays small only because
33
+ * `graph.model.ts` has already made every decision. What is left is two loops
34
+ * and the argument for `multi: false`.
35
+ */
36
+
37
+ import Graph from "graphology";
38
+ import type { Point } from "../../shared/layout";
39
+ import type { RenderEdge, RenderGraph, RenderNode } from "./graph.model";
40
+
41
+ /**
42
+ * Node attributes as sigma reads them.
43
+ *
44
+ * `x`, `y`, `size`, `label`, `color`, `type` and `zIndex` are sigma's own
45
+ * reserved names — it reads them off the node attributes directly. `kind` and
46
+ * `provenance` are ours, carried so the reducers can decide from the node's
47
+ * data rather than by looking anything up.
48
+ */
49
+ export type ProjectedNode = Omit<RenderNode, "id">;
50
+
51
+ /** Edge attributes as sigma reads them. */
52
+ export type ProjectedEdge = Omit<RenderEdge, "key">;
53
+
54
+ /** The graphology type the whole graph column is stated over. */
55
+ export type ProjectedGraph = Graph<ProjectedNode, ProjectedEdge>;
56
+
57
+ /**
58
+ * A fresh, empty projection.
59
+ *
60
+ * `multi: true` and `type: "directed"`, both deliberate.
61
+ *
62
+ * **Multi**, because the model genuinely admits parallel edges. A note can
63
+ * both `links-to` and `mentions` the same module — §4.4's mention pass runs
64
+ * over the body independently of the wikilink pass — and `graph.model.ts`
65
+ * dedupes on `(source, target, kind)`, not on the pair. Under `multi: false`
66
+ * graphology *throws* on the second one, so a simple graph would mean either a
67
+ * crash on real data or silently discarding a relationship, which is precisely
68
+ * the retired viewer's bug: "a link from a hidden file to a visible note
69
+ * simply vanished" (§7.4).
70
+ *
71
+ * The guard that `multi: false` looked like it was providing is not lost.
72
+ * `addDirectedEdgeWithKey` still throws on a duplicate **key**, and
73
+ * `edgeKey` is `(source, target, kind)` — so a genuinely duplicated edge is
74
+ * still a mount-time failure, while two *different* relationships between the
75
+ * same pair are still two edges. The key was always doing that work; `multi`
76
+ * only ever governed the pair.
77
+ *
78
+ * **Directed.** Every edge kind in `WIRE_EDGE_KINDS` has a direction that
79
+ * means something — `contains` is not symmetric, and neither is `mentions`.
80
+ * The renderer draws them as plain lines today, but throwing the direction
81
+ * away at the projection would make an arrowhead a re-derivation later.
82
+ */
83
+ export function emptyProjection(): ProjectedGraph {
84
+ return new Graph<ProjectedNode, ProjectedEdge>({ multi: true, type: "directed" });
85
+ }
86
+
87
+ /**
88
+ * Build a graphology instance from a {@link RenderGraph}.
89
+ *
90
+ * Total on any `RenderGraph` produced by `renderGraph`, which guarantees the
91
+ * three things graphology throws over: unique node ids, unique edge keys, and
92
+ * both endpoints of every edge present. This function deliberately does *not*
93
+ * re-check them — a second, quieter validation here would let a malformed
94
+ * model reach sigma with some of its edges silently missing, and "the graph
95
+ * drew but three links vanished" is a far worse failure than a stack trace
96
+ * naming the id.
97
+ *
98
+ * `tests/web/client-graph.test.ts` runs the whole §8 repo fixture through
99
+ * `renderGraph` and then through here, so "the invariants hold on the real
100
+ * shape" is an assertion rather than a claim.
101
+ */
102
+ export function project(model: RenderGraph): ProjectedGraph {
103
+ const graph = emptyProjection();
104
+ for (const node of model.nodes) {
105
+ const { id, ...attributes } = node;
106
+ graph.addNode(id, attributes);
107
+ }
108
+ for (const edge of model.edges) {
109
+ const { key, source, target, ...rest } = edge;
110
+ graph.addDirectedEdgeWithKey(key, source, target, { source, target, ...rest });
111
+ }
112
+ return graph;
113
+ }
114
+
115
+ /**
116
+ * Write new positions onto an existing projection.
117
+ *
118
+ * The cheap path for a re-run of the simulation: the node and edge sets have
119
+ * not changed, only where things are, so replacing the whole graph would make
120
+ * sigma rebuild every WebGL buffer and lose the camera. Ids absent from
121
+ * `positions` keep their current coordinates — a partial update is a partial
122
+ * update, not an instruction to move everything else to the origin.
123
+ *
124
+ * Returns how many nodes moved, which is what makes it observable without a
125
+ * renderer.
126
+ */
127
+ export function syncPositions(graph: ProjectedGraph, positions: ReadonlyMap<string, Point>): number {
128
+ let moved = 0;
129
+ for (const [id, at] of positions) {
130
+ if (!graph.hasNode(id)) continue;
131
+ if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) continue;
132
+ graph.mergeNodeAttributes(id, { x: at.x, y: at.y });
133
+ moved++;
134
+ }
135
+ return moved;
136
+ }
137
+
138
+ /**
139
+ * The projection's current positions, as `shared/layout.ts` wants them.
140
+ *
141
+ * This is the one read, and it is not an exception to the "never a second
142
+ * source of truth" rule: it exists so a re-run can **warm-start** from where
143
+ * the graph currently is (`LayoutOptions.initial`), which is what stops the
144
+ * picture jumping on expand/collapse. The values still originated in
145
+ * `computeLayout`; nothing user-authored ever enters the projection.
146
+ */
147
+ export function positionsOf(graph: ProjectedGraph): Map<string, Point> {
148
+ const out = new Map<string, Point>();
149
+ graph.forEachNode((id, attributes) => {
150
+ out.set(id, { x: attributes.x, y: attributes.y });
151
+ });
152
+ return out;
153
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The real `new Sigma(...)`, adapted to the renderer's injected port
3
+ * (weave-workspace §7.5, §10).
4
+ *
5
+ * `renderer.ts` deliberately does not import sigma: it is a `.ts` compiled by
6
+ * the **root** `tsconfig.json` whenever a test imports it, and `import Sigma
7
+ * from "sigma"` is a `ReferenceError` there — sigma evaluates
8
+ * `WebGL2RenderingContext.BOOL` at module scope while building its default
9
+ * program table, so the import alone dies outside a browser.
10
+ *
11
+ * Something still has to call the real thing. That is this file, and it is
12
+ * separate so the untestable surface is *one constructor call* rather than the
13
+ * whole renderer: `sigmaRenderer` holds the lifecycle, the reducers and the
14
+ * delegation, and is unit-tested against `SigmaLike` with a recording fake.
15
+ * Identical shape and identical reasoning to `api.dom.ts` for `fetch` and
16
+ * `domEventSource` in `live.ts` for `EventSource`.
17
+ *
18
+ * This module is only reachable from a `.tsx` entry point, so it is compiled
19
+ * exclusively by `tsconfig.web.json` (which has `DOM`) and never pulled into
20
+ * the root project. That is what lets it name sigma at all.
21
+ *
22
+ * ## This is §7.5's swap point
23
+ *
24
+ * If sigma stops being the right answer — §0.2's "revisit only if we
25
+ * measurably exceed sigma's comfort zone (tens of thousands of nodes)" —
26
+ * this file and the {@link SigmaLike} port in `renderer.ts` are what change.
27
+ * Nothing above the seam mentions sigma at all.
28
+ */
29
+
30
+ import Sigma from "sigma";
31
+ import type { ColorScheme } from "./graph.model";
32
+ import type { GraphRenderer, RendererFactory, SigmaFactory, SigmaLike } from "./renderer";
33
+ import { sigmaRenderer } from "./renderer";
34
+
35
+ /** Sigma's own container parameter type, recovered without naming the DOM. */
36
+ type SigmaContainer = ConstructorParameters<typeof Sigma>[1];
37
+
38
+ /**
39
+ * `new Sigma(graph, container, settings)`, as a {@link SigmaFactory}.
40
+ *
41
+ * The real `Sigma` satisfies `SigmaLike` structurally — `on`, `setSetting`,
42
+ * `setGraph`, `refresh`, `getCamera` and `kill` are all present with
43
+ * compatible shapes — so the only work here is the container cast, which is
44
+ * checked against sigma's own declared parameter type rather than against an
45
+ * `HTMLElement` asserted from memory.
46
+ */
47
+ const createSigma: SigmaFactory = (graph, container, settings) =>
48
+ new Sigma(graph, container as unknown as SigmaContainer, settings) as unknown as SigmaLike;
49
+
50
+ /** The browser's renderer factory. Passed to the graph column by the shell. */
51
+ export const createSigmaRenderer: RendererFactory = (scheme: ColorScheme): GraphRenderer =>
52
+ sigmaRenderer(createSigma, scheme);