pi-weave 0.1.12 → 0.1.13

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 (71) hide show
  1. package/README.md +8 -37
  2. package/package.json +1 -2
  3. package/src/core/concurrency.ts +3 -6
  4. package/src/core/frontmatter.ts +0 -53
  5. package/src/core/graph/build.ts +6 -7
  6. package/src/core/graph/current.ts +2 -4
  7. package/src/core/graph/model.ts +1 -1
  8. package/src/core/graph/wikilinks.ts +3 -3
  9. package/src/core/index.ts +26 -27
  10. package/src/core/paths.ts +0 -7
  11. package/src/core/vault.ts +16 -681
  12. package/src/core/view/detail.ts +1 -1
  13. package/src/core/view/health.ts +1 -1
  14. package/src/core/view/tree.ts +1 -1
  15. package/src/pi/index.ts +6 -85
  16. package/src/pi/summarize.ts +2 -2
  17. package/src/pi/viewer/tui/bodyStore.ts +4 -7
  18. package/src/pi/viewer/tui/branding.ts +7 -148
  19. package/src/pi/viewer/tui/run.ts +3 -17
  20. package/src/pi/viewer/tui/surface/base.ts +24 -3
  21. package/src/pi/viewer/tui/surface/explore.ts +41 -6
  22. package/src/pi/viewer/tui/workspace.ts +23 -351
  23. package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
  24. package/src/pi/viewer/web/run.ts +7 -117
  25. package/src/web/client/api.dom.ts +2 -2
  26. package/src/web/client/api.ts +14 -223
  27. package/src/web/client/bootstrap.ts +5 -14
  28. package/src/web/client/context/context.model.ts +9 -11
  29. package/src/web/client/dist/app.js +93 -219
  30. package/src/web/client/graph/dynamics.ts +5 -65
  31. package/src/web/client/graph/renderer.dom.ts +7 -8
  32. package/src/web/client/graph/renderer.ts +9 -35
  33. package/src/web/client/main.tsx +1 -1
  34. package/src/web/client/note/Note.tsx +21 -63
  35. package/src/web/client/search/SearchPalette.tsx +45 -36
  36. package/src/web/client/search/search.model.ts +33 -454
  37. package/src/web/client/shell/Columns.tsx +13 -83
  38. package/src/web/client/shell/Header.tsx +2 -10
  39. package/src/web/client/shell/Shell.tsx +50 -125
  40. package/src/web/client/shell/StatusBar.tsx +1 -4
  41. package/src/web/client/shell/icons.model.ts +4 -7
  42. package/src/web/client/shell/keys.model.ts +5 -42
  43. package/src/web/client/shell/keys.ts +2 -2
  44. package/src/web/client/shell/shell.model.ts +10 -133
  45. package/src/web/client/shell/theme.model.ts +2 -2
  46. package/src/web/client/shell/theme.ts +33 -157
  47. package/src/web/client/state.ts +9 -89
  48. package/src/web/client/tree/Tree.tsx +25 -575
  49. package/src/web/client/tree/tree.model.ts +8 -162
  50. package/src/web/client/workspace.ts +72 -242
  51. package/src/web/server/page.ts +8 -10
  52. package/src/web/server/routes.ts +30 -563
  53. package/src/web/server/server.ts +6 -145
  54. package/src/web/shared/layout.ts +72 -624
  55. package/src/web/shared/wire.ts +10 -196
  56. package/src/core/sessions.ts +0 -929
  57. package/src/pi/sessionScan.ts +0 -104
  58. package/src/pi/viewer/tui/explorer.ts +0 -586
  59. package/src/web/client/live.model.ts +0 -275
  60. package/src/web/client/live.ts +0 -151
  61. package/src/web/client/note/Editor.tsx +0 -109
  62. package/src/web/client/note/editor.controller.ts +0 -151
  63. package/src/web/client/note/editor.model.ts +0 -686
  64. package/src/web/client/search/search.ts +0 -107
  65. package/src/web/client/shell/Divider.tsx +0 -44
  66. package/src/web/client/shell/cssvars.ts +0 -70
  67. package/src/web/client/shell/drag.model.ts +0 -170
  68. package/src/web/client/shell/layout.model.ts +0 -500
  69. package/src/web/client/shell/viewport.ts +0 -29
  70. package/src/web/server/sse.ts +0 -321
  71. package/src/web/server/watcher.ts +0 -507
@@ -1,557 +1,131 @@
1
- /**
2
- * Force-directed layout: `GraphModel` → `Map<id, Point>` (weave-workspace §7).
3
- *
4
- * Isomorphic by contract (§2 tier table): this module runs in Node — the
5
- * server precomputes positions for `GraphPayload.positions`, and the dynamics
6
- * gate exercises it headless — and in the browser, where the client re-runs it
7
- * on drag and expand/collapse. It therefore imports **only** `d3-force` and
8
- * the wire DTOs from `./graph`. No `node:*`, no DOM, no `src/pi`, no
9
- * `src/core`.
10
- *
11
- * It used to take its graph types from `src/core/graph/model` as `import
12
- * type`. That was legal under the §2 table and still wrong: §7.1 has
13
- * `src/web/client/graph/project.ts` consuming this module, and the day that
14
- * lands, resolving a core type would drag the whole `node:fs`-flavoured core
15
- * type graph into `tsconfig.web.json` — the identical failure `wire.ts` hit.
16
- * Fixed here pre-emptively rather than left as a tripwire for P3.
17
- *
18
- * ## The recipe — d3's own force-directed tree, not ours
19
- *
20
- * This module used to carry ~250 lines of derived geometry: a ring-radius
21
- * formula per hub fan-out, hash-angled seed rings, a disc-packed root ring,
22
- * and seed-anchored gravity. All of it existed to shape a force simulation
23
- * into a readable tree. d3's own force-directed-tree example does that in five
24
- * lines:
25
- *
26
- * ```js
27
- * const root = d3.hierarchy(data);
28
- * const links = root.links();
29
- * const nodes = root.descendants();
30
- *
31
- * const simulation = d3.forceSimulation(nodes)
32
- * .force("link", d3.forceLink(links).id(d => d.id).distance(0).strength(1))
33
- * .force("charge", d3.forceManyBody().strength(-50))
34
- * .force("x", d3.forceX())
35
- * .force("y", d3.forceY());
36
- * ```
37
- *
38
- * The containment tree wants children at their parent with full strength, and
39
- * the picture emerges from gentle repulsion and collision. The property that
40
- * matters: a tree's radius is set by its **depth**, not by any single node's
41
- * fan-out — a 188-child directory shares the angular space with its siblings
42
- * instead of defining a ring that the whole graph has to fit inside. That is
43
- * why the retired ring-radius family (`ringRadius`, `RING_CAP`,
44
- * `seedPositions`, `clusterGap`, `rootRingRadius`, `arcShare`, `rootAngles`
45
- * and the seed-anchored gravity) is deleted rather than tuned: it was a wheel
46
- * d3 already ships.
47
- *
48
- * Two deviations from the example, both stated rather than hidden:
49
- *
50
- * 1. **`forceCollide`** — the example draws 3.5-pixel dots with no labels; we
51
- * draw 6–18-unit nodes with zoomed labels, so nodes keep a collision radius
52
- * (`nodeSize(degree) + label room`, per node — see {@link collideRadius})
53
- * and siblings never overlap.
54
- * 2. **Relation edges** (`links-to` / `mentions`) are not part of the tree.
55
- * They ride along at a longer distance and a fraction of the strength, so
56
- * they decorate the structure instead of distorting it.
57
- *
58
- * And one extension, because single-centre gravity has a failure the example
59
- * never has to face: **big sibling blobs interleave** (see
60
- * {@link branchAnchors}). A 195-node `module:.okf` and a 40-node
61
- * `vfolder:sessions` share an origin, share almost no edges, and tangle into
62
- * one hairball — measured gap 0 between their bounding boxes on this
63
- * repository. When a model has branches that big, the layout runs a second
64
- * pass with each branch's gravity re-targeted onto a ring slot sized from the
65
- * first pass, and the blobs hold apart with a guaranteed corridor between
66
- * them. Graphs without big branches skip the second pass entirely and keep
67
- * the exact single-pass behaviour the §8 gate was written against.
68
- *
69
- * `forceX()`/`forceY()` (d3 defaults: target 0, no accessor) replace the
70
- * seed-anchored gravity — they pull every component toward the origin, which
71
- * is the no-component-escapes-to-infinity guarantee the anchors existed for,
72
- * and they mean a released drag needs no anchor bookkeeping at all.
73
- *
74
- * ## Why d3-force (§7.2)
75
- *
76
- * The retired simulation collapsed to a vertical line because repulsion and
77
- * collision derive their direction as `dx / d`: once two nodes share an `x`,
78
- * the x-component of the push is exactly zero forever, and damping freezes it
79
- * there. d3-force injects `jiggle()` on exactly that zero (`manyBody.js`,
80
- * `collide.js`, `link.js`), drawn from a **seeded** LCG — so we get symmetry
81
- * breaking *and* reproducibility, which is what makes §8 a stable CI gate
82
- * rather than a flaky one. d3 also fills nodes without positions on a
83
- * deterministic phyllotaxis spiral, so cold starts need no invented seeding.
84
- */
1
+ /** Deterministic force layout shared by the server, browser and tests. */
85
2
 
86
3
  import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from "d3-force";
87
- import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from "d3-force";
4
+ import type { Simulation, SimulationNodeDatum } from "d3-force";
88
5
  import type { WireEdgeKind as EdgeKind, WireGraphEdge as GraphEdge, WireGraphModel as GraphModel } from "./graph";
89
- import { bbox } from "./metrics";
90
6
  import type { Point } from "./metrics";
91
7
 
92
8
  export type { Point } from "./metrics";
93
9
 
94
10
  export interface LayoutOptions {
95
- /** Simulation steps. Alpha decay is derived from this, so the budget stays meaningful. Default 300. */
96
11
  ticks?: number;
97
- /** Seeds d3's jiggle LCG. Default 1. */
98
12
  seed?: number;
99
- /**
100
- * Warm-start positions by node id. The client passes current positions when
101
- * re-running after a drag or an expand so the graph does not jump; the
102
- * dynamics gate passes coincident points to prove symmetry breaking. Ids
103
- * absent here start on d3's own deterministic phyllotaxis spiral.
104
- */
105
13
  initial?: ReadonlyMap<string, Point>;
106
- /**
107
- * Hold the warm-started nodes in place while the simulation integrates the
108
- * newcomers (d3's `fx`/`fy`). The collapse/expand pattern: an expand must
109
- * hand its new children to the layout without shoving everything else out
110
- * of the way — the existing arrangement is the user's, and the collide
111
- * force packs the newcomers around it. Ignored without `initial`.
112
- */
113
14
  pinWarm?: boolean;
114
15
  }
115
16
 
116
- /**
117
- * The visual node ramp, in layout units.
118
- *
119
- * The renderer reads these as `graph.model.ts`'s sizes, but they live *here*
120
- * because the collision force has to reserve the same room the renderer will
121
- * paint — a size the layout and the renderer disagree about is a layout whose
122
- * non-overlap proof is invalid on the screen. So one module states the ramp
123
- * and {@link collideRadius} derives the collision disc from it; §8's gate then
124
- * keeps being a statement about pixels, not just about positions.
125
- */
126
17
  export const NODE_RADIUS = 9;
127
- /**
128
- * The degree-0 floor: a leaf must stay inside a pointer's reach. `sigma`'s hit
129
- * test is the drawn radius, and at overview zoom a leaf renders at roughly
130
- * `MIN_NODE_SIZE · cameraCorrection`, so this is the smallest clickable node.
131
- */
132
18
  export const MIN_NODE_SIZE = 6;
133
- /**
134
- * The hub ceiling, ≈2× the base radius.
135
- *
136
- * The brief for Tier 6's "graph as hero" is hierarchy through size, and the
137
- * old ramp (6→9) read as "everything nearly the same size", which is how a
138
- * 60-child hub came to look like one more dot. 18 keeps the ceiling inside the
139
- * 2–2.5× the brief suggests while leaves stay at 6, so the diameter ratio is
140
- * 3× — a hub reads as a *place* rather than a slightly thicker dot.
141
- */
142
19
  export const MAX_NODE_SIZE = 18;
143
- /**
144
- * The degree at which a node reaches {@link MAX_NODE_SIZE}.
145
- *
146
- * Fixed rather than "the maximum degree in this graph": a ceiling derived from
147
- * the largest hub would make every other node shrink when one module gains a
148
- * file, so the same note would render at two sizes on two loads of the same
149
- * vault. A constant keeps size comparable across graphs and across sessions.
150
- */
151
20
  export const DEGREE_AT_MAX_SIZE = 32;
21
+ export const LABEL_ROOM = 9;
22
+ export const COLLIDE_RADIUS = NODE_RADIUS + LABEL_ROOM;
152
23
 
153
- /**
154
- * Node radius from incident-edge degree.
155
- *
156
- * Logarithmic between the leaf floor and the hub ceiling, so a 60-child hub
157
- * reads as much bigger than a 6-child module without a degree-0 note becoming
158
- * invisible next to it. Pure, and shared with the renderer (§10): the layout's
159
- * collision discs and the renderer's circles are the *same* numbers, which is
160
- * what keeps "the layout separates the nodes it drew" true.
161
- */
162
24
  export function nodeSize(degree: number): number {
163
25
  const d = Number.isFinite(degree) && degree > 0 ? degree : 0;
164
26
  const share = Math.min(1, Math.log2(1 + d) / Math.log2(1 + DEGREE_AT_MAX_SIZE));
165
27
  return MIN_NODE_SIZE + (MAX_NODE_SIZE - MIN_NODE_SIZE) * share;
166
28
  }
167
29
 
168
- /**
169
- * The room a collision disc reserves beyond the drawn circle: breathing room
170
- * for the leading edge of the zoomed label, exactly what the old uniform
171
- * `COLLIDE_RADIUS` added to `NODE_RADIUS`.
172
- */
173
- export const LABEL_ROOM = 9;
174
-
175
- /**
176
- * Collision radius: the drawn size plus the label's breathing room.
177
- *
178
- * Per **degree now**, not per graph — a hub reserves more room than a leaf, so
179
- * the degree-sized renderer can never outgrow the disc its layout reserved.
180
- * The old uniform value (`NODE_RADIUS + 9`) survives as {@link COLLIDE_RADIUS},
181
- * which is what the label grid, the stage padding and the §8 corridor metrics
182
- * are still written against.
183
- */
184
30
  export function collideRadius(drawnSize: number): number {
185
31
  return drawnSize + LABEL_ROOM;
186
32
  }
187
33
 
188
- /** Collision radius: the node plus breathing room for the leading edge of its label. */
189
- export const COLLIDE_RADIUS = NODE_RADIUS + 9;
190
-
191
- /**
192
- * `links-to` / `mentions` are associative, not structural: longer and weak.
193
- * 170 rather than d3-tree-era 220 — a wiki-linked island riding only relation
194
- * edges used to sit a fifth of the canvas further out than its containment
195
- * neighbours, which is the floating "dust at the frame edges" the Tier 6
196
- * pass is about.
197
- */
198
- const RELATION_DISTANCE = 170;
199
-
200
- /** Relation edges pull at a fraction of the containment link's strength — decoration, not structure. */
201
- const RELATION_STRENGTH = 0.05;
202
-
203
- /** Body repulsion — the force-directed-tree example's own value. */
204
- const CHARGE_STRENGTH = -50;
205
-
206
- /**
207
- * The containment tree's spring: rest length and stiffness.
208
- *
209
- * The example's `distance(0).strength(1)` is rigid — correct for 3.5-pixel
210
- * dots with no collide, and violent here: with a collision radius and a
211
- * 189-child hub, dragging the hub yanked every child at full strength and the
212
- * whole tree thrashed (measured: >9000 units of other-node motion per tick on
213
- * this repository's real graph). Springs with real rest length and low
214
- * stiffness keep every drag a local ripple while collide still packs the
215
- * cluster; the shape stays a tree because every node is *in* the tree, not
216
- * because the links are rigid.
217
- */
218
- const CONTAINS_REST = 90;
219
- const CONTAINS_STRENGTH = 0.02;
220
-
221
- /**
222
- * A branch (a depth-1 subtree) with at least this many nodes earns its own
223
- * gravity slot. Below it, the branch is a twig that reads as part of its
224
- * root's cluster and joins the root group at the origin.
225
- *
226
- * 8 is the smallest arrangement that is a *blob* rather than a fringe: eight
227
- * collision discs already cover a 3×3 patch around their parent, which is the
228
- * shape two of them interleaving would wreck. It is a count, not a tuned
229
- * fraction, so the same graph gets the same groups on every machine.
230
- */
231
- export const BIG_BRANCH_MIN = 8;
232
-
233
- /**
234
- * The guaranteed corridor between separated groups, in layout units: one
235
- * collision diameter — two groups of collision-spaced nodes can never be
236
- * closer without their members overlapping anyway, so this is the minimum
237
- * distance that still reads as "separated" rather than "denser".
238
- *
239
- * Sized against the *largest* collision disc, since a hub's disc is what
240
- * cannot fit through a corridor sized for a leaf's.
241
- */
242
- export const BRANCH_GAP = 2 * collideRadius(MAX_NODE_SIZE);
243
- /**
244
- * How hard `forceX`/`forceY` pull toward each node's gravity target (origin,
245
- * or a big branch's ring slot), on the scale d3 defaults to 0.05.
246
- *
247
- * The Tier 6 pass wants disconnected islands pulled into one organic cloud
248
- * instead of orbiting the frame edges, and every node's centre gravity is the
249
- * only pull an *unconnected* node feels — repulsion and collide both push.
250
- * 0.09 is "slightly":
251
- * - enough that a degree-0 island ends up inside the cloud the connected part
252
- * of the graph forms, instead of drifting to the periphery;
253
- * - well below the branch-anchored ring's own geometry, because the anchors
254
- * are targets, not pins — the corridor test still passes with the extra
255
- * squeeze, which the §8 gate asserts rather than assumes.
256
- */
257
- export const CENTER_STRENGTH = 0.09;
258
-
259
34
  const DEFAULT_TICKS = 300;
260
35
  const DEFAULT_SEED = 1;
261
-
262
- /** d3-force's own alpha floor (`simulation.js`); mirrored so `alphaDecay` can be derived from `ticks`. */
263
36
  const ALPHA_MIN = 0.001;
37
+ const CONTAINS_REST = 90;
38
+ const CONTAINS_STRENGTH = 0.02;
39
+ const RELATION_DISTANCE = 170;
40
+ const RELATION_STRENGTH = 0.05;
41
+ const CHARGE_STRENGTH = -50;
42
+ const CENTER_STRENGTH = 0.09;
264
43
 
265
- interface SimNode extends SimulationNodeDatum, CollideNode {
266
- id: string;
267
- x?: number;
268
- y?: number;
269
- }
270
-
271
- interface SimLink extends SimulationLinkDatum<SimNode> {
272
- source: string | SimNode;
273
- target: string | SimNode;
274
- kind: EdgeKind;
275
- }
276
-
277
- /** Containment edges define the hierarchy; everything else is an association. */
278
44
  export function isContainment(kind: EdgeKind): boolean {
279
45
  return kind === "contains" || kind === "anchored-at";
280
46
  }
281
47
 
282
- /**
283
- * d3-force's LCG (`lcg.js`: a = 1664525, c = 1013904223, m = 2³²), re-exposed
284
- * so `seed` actually selects a stream — d3 always builds its own with s = 1,
285
- * and `simulation.randomSource()` is the documented way to replace it.
286
- */
48
+ /** d3's deterministic random source, with a caller-selected seed. */
287
49
  export function lcg(seed: number): () => number {
288
- let s = Math.trunc(seed) >>> 0;
289
- return () => (s = (1664525 * s + 1013904223) % 4294967296) / 4294967296;
50
+ let state = Math.trunc(seed) >>> 0;
51
+ return () => (state = (1664525 * state + 1013904223) % 4294967296) / 4294967296;
290
52
  }
291
53
 
292
- interface Structure {
293
- /** Ids in `model.nodes` order, deduped. */
294
- ids: string[];
295
- /** Edges with both endpoints present, no self-loops, deduped. */
296
- edges: GraphEdge[];
54
+ interface SimNode extends SimulationNodeDatum {
55
+ id: string;
56
+ r?: number;
297
57
  }
298
58
 
299
- /**
300
- * Normalise the model into something a simulation can consume: drop self-edges
301
- * and edges pointing at ids that are not nodes (d3's `forceLink` throws on
302
- * those), and dedupe. Malformed input is the caller's bug, but it must not be
303
- * the layout's crash.
304
- */
305
- function analyse(model: GraphModel): Structure {
59
+ function analyse(model: GraphModel): { ids: string[]; edges: GraphEdge[] } {
306
60
  const ids: string[] = [];
307
61
  const known = new Set<string>();
308
- for (const n of model.nodes) {
309
- if (known.has(n.id)) continue;
310
- known.add(n.id);
311
- ids.push(n.id);
62
+ for (const node of model.nodes) {
63
+ if (!known.has(node.id)) {
64
+ known.add(node.id);
65
+ ids.push(node.id);
66
+ }
312
67
  }
313
68
 
314
69
  const edges: GraphEdge[] = [];
315
70
  const seen = new Set<string>();
316
- for (const e of model.edges) {
317
- if (e.source === e.target) continue;
318
- if (!known.has(e.source) || !known.has(e.target)) continue;
319
- const key = `${e.source}\u0000${e.target}\u0000${e.kind}`;
71
+ for (const edge of model.edges) {
72
+ if (edge.source === edge.target || !known.has(edge.source) || !known.has(edge.target)) continue;
73
+ const key = `${edge.source}\u0000${edge.target}\u0000${edge.kind}`;
320
74
  if (seen.has(key)) continue;
321
75
  seen.add(key);
322
- edges.push(e);
76
+ edges.push(edge);
323
77
  }
324
78
  return { ids, edges };
325
79
  }
326
80
 
327
- // --- separating big sibling branches ---------------------------------------------
328
-
329
- /**
330
- * The least a module must satisfy to describe a containment forest. Both
331
- * `GraphModel` and the client's `RenderGraph` satisfy it structurally, so the
332
- * static layout and the live driver ask the same question of the same shape.
333
- */
334
- export interface ContainmentLike {
335
- nodes: readonly { readonly id: string }[];
336
- edges: readonly { readonly source: string; readonly target: string; readonly kind: EdgeKind }[];
337
- }
338
-
339
- /** One depth-1 subtree big enough to earn its own gravity slot. */
340
- export interface Branch {
341
- readonly id: string;
342
- /** The branch node itself plus every containment descendant. */
343
- readonly members: readonly string[];
344
- }
345
-
346
- /** Children by containment edge: first parent only, so the result is a forest. */
347
- function forestOf(model: ContainmentLike): { kids: ReadonlyMap<string, readonly string[]>; roots: readonly string[] } {
348
- const known = new Set(model.nodes.map((n) => n.id));
349
- const kids = new Map<string, string[]>();
350
- const hasParent = new Set<string>();
351
- for (const e of model.edges) {
352
- if (!isContainment(e.kind)) continue;
353
- if (e.source === e.target) continue;
354
- if (!known.has(e.source) || !known.has(e.target)) continue;
355
- // A second containment parent (a cycle, or a hand-edited index) must not
356
- // turn the walk into a diamond: the first edge wins, like `analyse`.
357
- if (hasParent.has(e.target)) continue;
358
- hasParent.add(e.target);
359
- const list = kids.get(e.source);
360
- if (list === undefined) kids.set(e.source, [e.target]);
361
- else list.push(e.target);
362
- }
363
- const roots = model.nodes.filter((n) => !hasParent.has(n.id)).map((n) => n.id);
364
- return { kids, roots };
365
- }
366
-
367
- /**
368
- * The big branches of a model, in codepoint-id order.
369
- *
370
- * A branch is a depth-1 child of a root whose containment subtree holds at
371
- * least {@link BIG_BRANCH_MIN} nodes — the shape that reads as a blob of its
372
- * own. Depth 1 exactly: deeper groupings would shred a deep module tree into
373
- * ring slots, while the tangle being fixed is always *sibling* blobs pulling
374
- * at the same origin. Sorted by id so the ring geometry is a pure function of
375
- * structure, never of insertion order.
376
- */
377
- export function bigBranches(model: ContainmentLike): readonly Branch[] {
378
- const { kids, roots } = forestOf(model);
379
- const membersOf = (id: string, seen: Set<string>): string[] => {
380
- if (seen.has(id)) return []; // containment cycle: stop, keep both walks finite
381
- seen.add(id);
382
- const out = [id];
383
- for (const k of kids.get(id) ?? []) for (const m of membersOf(k, seen)) out.push(m);
384
- return out;
385
- };
386
- const branches: Branch[] = [];
387
- for (const root of roots) {
388
- for (const kid of kids.get(root) ?? []) {
389
- const members = membersOf(kid, new Set());
390
- if (members.length >= BIG_BRANCH_MIN) branches.push({ id: kid, members });
391
- }
392
- }
393
- branches.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
394
- return branches;
395
- }
396
-
397
- /** Finite settled positions of a simulation's nodes, by id. */
398
- function settledOf(nodes: readonly { id: string; x?: number; y?: number }[]): Map<string, Point> {
399
- const out = new Map<string, Point>();
400
- for (const n of nodes) {
401
- if (n.x !== undefined && n.y !== undefined && Number.isFinite(n.x) && Number.isFinite(n.y)) {
402
- out.set(n.id, { x: n.x, y: n.y });
403
- }
404
- }
405
- return out;
81
+ export interface CollideNode {
82
+ r?: number;
406
83
  }
407
84
 
408
- /** A set's spread around its own centroid: the disc the ring must house. */
409
- function radiusOf(ids: readonly string[], settled: ReadonlyMap<string, Point>): number {
410
- const pts: Point[] = [];
411
- for (const id of ids) {
412
- const p = settled.get(id);
413
- if (p !== undefined) pts.push(p);
414
- }
415
- if (pts.length === 0) return COLLIDE_RADIUS;
416
- const box = bbox(pts);
417
- // The circumscribing radius, not half a side: a blob pulled toward its slot
418
- // keeps whatever shape it had, and the ring must house the widest turn of it.
419
- return Math.hypot(box.w, box.h) / 2;
85
+ export interface ForceSimulationOptions<N> {
86
+ nodes: N[];
87
+ links: Array<{ source: string | N; target: string | N; kind: EdgeKind }>;
88
+ seed?: number;
420
89
  }
421
90
 
422
- /**
423
- * Per-node gravity anchors that keep big sibling branches apart.
424
- *
425
- * The tangle this fixes is structural: every node gravitates toward the
426
- * origin, so two blobs that share a root — measured on this repository, a
427
- * 195-node `module:.okf` and a 40-node `vfolder:sessions`, connected by almost
428
- * nothing — interleave at the same centre and read as one hairball. The
429
- * anchor map sends each big branch's gravity to its own slot on a ring instead:
430
- *
431
- * - Slots are sized from the branches' *measured* pass-1 spread (`settled`),
432
- * allocated arc share proportional to disc size, on a ring whose radius
433
- * clears the root group at the centre — so the geometry is a function of the
434
- * graph, not of constants that would need retuning per vault.
435
- * - The root group — roots, single nodes and small twigs — keeps the origin,
436
- * which preserves the recipe's no-component-escapes guarantee and the
437
- * root-separation behaviour the §8 gate asserts on five-root fixtures.
438
- * - Absent ids mean the origin: only branch members appear in the map.
439
- *
440
- * Returned per node, so `createForceSimulation`'s `forceX`/`forceY` accessors
441
- * read it directly. Deterministic: same model, same settled positions, same
442
- * anchors — the sort is by id and the arithmetic is plain.
443
- */
444
- export function branchAnchors(
445
- model: ContainmentLike,
446
- settled: ReadonlyMap<string, Point>,
447
- branches?: readonly Branch[],
448
- ): ReadonlyMap<string, Point> {
449
- const list = branches ?? bigBranches(model);
450
- if (list.length === 0) return new Map();
451
-
452
- const inBranch = new Set<string>();
453
- for (const b of list) for (const m of b.members) inBranch.add(m);
454
- const centerIds = model.nodes.filter((n) => !inBranch.has(n.id)).map((n) => n.id);
455
- const centerRadius = radiusOf(centerIds, settled);
456
-
457
- const radii = list.map((b) => radiusOf(b.members, settled));
458
- const arcs = radii.map((r) => 2 * r + BRANCH_GAP);
459
- const circumference = arcs.reduce((a, b) => a + b, 0);
460
- const ring = Math.max(
461
- circumference / (2 * Math.PI),
462
- // The ring must also clear the root group sitting at the origin — a slot
463
- // closer than this would park a branch on top of the centre blob.
464
- centerRadius + BRANCH_GAP + Math.max(...radii),
465
- );
91
+ /** One d3 recipe for both cold layout and live drag dynamics. */
92
+ export function createForceSimulation<N extends SimulationNodeDatum & { id: string; r?: number }>(
93
+ opts: ForceSimulationOptions<N>,
94
+ ): Simulation<N, undefined> {
95
+ const link = forceLink<N, { source: string | N; target: string | N; kind: EdgeKind }>(opts.links)
96
+ .id((node) => node.id)
97
+ .distance((edge) => (isContainment(edge.kind) ? CONTAINS_REST : RELATION_DISTANCE))
98
+ .strength((edge) => (isContainment(edge.kind) ? CONTAINS_STRENGTH : RELATION_STRENGTH))
99
+ .iterations(2);
466
100
 
467
- const anchors = new Map<string, Point>();
468
- let cursor = 0;
469
- for (let i = 0; i < list.length; i++) {
470
- const branch = list[i]!;
471
- const arc = arcs[i]!;
472
- // Mid-arc, clockwise from twelve o'clock: deterministic for a given model.
473
- const angle = -Math.PI / 2 + ((cursor + arc / 2) / circumference) * 2 * Math.PI;
474
- cursor += arc;
475
- const at = { x: Math.cos(angle) * ring, y: Math.sin(angle) * ring };
476
- for (const member of branch.members) anchors.set(member, at);
477
- }
478
- return anchors;
101
+ return forceSimulation<N>(opts.nodes)
102
+ .randomSource(lcg(opts.seed ?? DEFAULT_SEED))
103
+ .force("charge", forceManyBody<N>().strength(CHARGE_STRENGTH))
104
+ .force("link", link)
105
+ .force("collide", forceCollide<N>((node) => node.r ?? COLLIDE_RADIUS).strength(1))
106
+ .force("x", forceX<N>(0).strength(CENTER_STRENGTH))
107
+ .force("y", forceY<N>(0).strength(CENTER_STRENGTH))
108
+ .stop();
479
109
  }
480
110
 
481
- /**
482
- * Teleport every big branch onto its ring slot, as a rigid translation.
483
- *
484
- * Gravity alone does not get a blob to its slot on a sane tick budget —
485
- * measured on the sibling-blobs fixture, 150 anchored ticks moved the smallest
486
- * branch 40 % of the way, and the corridor the ring geometry guarantees only
487
- * exists once the blobs arrive. Physics is the wrong tool for transport: the
488
- * slot is exact, so the whole branch is simply *moved* there — centroid onto
489
- * slot, intra-branch geometry carried — and the relaxation that follows only
490
- * has to settle the neighbourhood, which converges at any budget. Without the
491
- * translation the tick budget would be a quality parameter, which §7.3
492
- * explicitly promises it is not.
493
- *
494
- * Pinned nodes (`fx`/`fy`, a warm expand) are skipped by the position write —
495
- * the sim holds them where they are anyway, and a warm arrangement already
496
- * has its blobs near their slots.
497
- */
498
- function parkBranches(
499
- nodes: readonly SimNode[],
500
- branches: readonly Branch[],
501
- anchors: ReadonlyMap<string, Point>,
502
- settled: ReadonlyMap<string, Point>,
503
- ): void {
504
- const byId = new Map(nodes.map((node) => [node.id, node]));
505
- for (const branch of branches) {
506
- const slot = anchors.get(branch.id);
507
- if (slot === undefined) continue;
508
- let sumX = 0;
509
- let sumY = 0;
510
- let n = 0;
511
- for (const member of branch.members) {
512
- const p = settled.get(member);
513
- if (p === undefined) continue;
514
- sumX += p.x;
515
- sumY += p.y;
516
- n++;
517
- }
518
- if (n === 0) continue;
519
- const dx = slot.x - sumX / n;
520
- const dy = slot.y - sumY / n;
521
- for (const member of branch.members) {
522
- const node = byId.get(member);
523
- if (node === undefined || node.x === undefined || node.y === undefined) continue;
524
- // Pinned on either axis (`fx`/`fy`, a warm expand): d3 holds the node
525
- // where it is, so the translation would fight the pin on the free axis.
526
- // Pins always arrive as a pair (both `computeLayout`'s pinWarm and the
527
- // live drag set both), so this is one condition, not two.
528
- if (node.fx != null || node.fy != null) continue;
529
- node.x += dx;
530
- node.y += dy;
531
- }
532
- }
111
+ function runSimulation(nodes: SimNode[], edges: readonly GraphEdge[], options: { ticks: number; seed: number; warm: boolean }): void {
112
+ const simulation = createForceSimulation({
113
+ nodes,
114
+ links: edges.map(({ source, target, kind }) => ({ source, target, kind })),
115
+ seed: options.seed,
116
+ })
117
+ .alpha(options.warm ? 0.3 : 1)
118
+ .alphaMin(ALPHA_MIN)
119
+ .alphaDecay(options.ticks > 0 ? 1 - Math.pow(ALPHA_MIN, 1 / options.ticks) : 0);
120
+ for (let tick = 0; tick < options.ticks; tick++) simulation.tick();
533
121
  }
534
122
 
535
- /**
536
- * Lay a graph out. Deterministic: the same model with the same `seed` produces
537
- * byte-identical output, in Node or the browser.
538
- *
539
- * The simulation is stepped **synchronously** — `stop()` then a manual `tick()`
540
- * loop — so it never touches `requestAnimationFrame` and works headless.
541
- * Warm ids keep their positions; ids without one start on d3's deterministic
542
- * phyllotaxis spiral, which is why a cold start needs no invented seeding.
543
- */
123
+ /** Lay out a graph in one deterministic, synchronous d3-force pass. */
544
124
  export function computeLayout(model: GraphModel, options: LayoutOptions = {}): Map<string, Point> {
545
125
  const ticks = Math.max(0, Math.trunc(options.ticks ?? DEFAULT_TICKS));
546
- const seed = options.seed ?? DEFAULT_SEED;
547
-
548
126
  const { ids, edges } = analyse(model);
549
- const out = new Map<string, Point>();
550
- if (ids.length === 0) return out;
551
-
552
- const warm = options.initial;
553
127
  const nodes: SimNode[] = ids.map((id) => {
554
- const at = warm?.get(id);
128
+ const at = options.initial?.get(id);
555
129
  const node: SimNode = { id, vx: 0, vy: 0 };
556
130
  if (at !== undefined && Number.isFinite(at.x) && Number.isFinite(at.y)) {
557
131
  node.x = at.x;
@@ -564,146 +138,20 @@ export function computeLayout(model: GraphModel, options: LayoutOptions = {}): M
564
138
  return node;
565
139
  });
566
140
 
567
- // Per-node collision discs, from the same degree ramp the renderer paints
568
- // with. Over the analysed edges (already deduped and endpoint-filtered) in
569
- // one pass, so a degree-0 island still gets the leaf floor's disc and a hub
570
- // reserves the room its drawn circle plus label needs.
571
141
  const degree = new Map<string, number>();
572
- for (const e of edges) {
573
- degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
574
- degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
575
- }
576
- for (const n of nodes) n.r = collideRadius(nodeSize(degree.get(n.id) ?? 0));
577
-
578
- if (nodes.length > 1) {
579
- const branches = bigBranches(model);
580
- if (branches.length === 0 || ticks === 0) {
581
- // No big branches — or no budget — the plain recipe at the full budget.
582
- // Zero ticks still constructs the simulation, which is what seeds d3's
583
- // deterministic phyllotaxis; skipping it would leave every node at the
584
- // origin instead. Byte-identical to the old single pass either way.
585
- runSimulation(nodes, edges, { ticks, seed, warm: warm !== undefined });
586
- } else {
587
- // Two passes. Pass 1 assembles the tree under origin gravity — the same
588
- // recipe — so the ring pass measures *actual* blob radii rather than
589
- // guessing them from head counts. Then every branch is teleported onto
590
- // its slot (see `parkBranches` for why transport is arithmetic and not
591
- // physics) and pass 2 relaxes the arrangement into place at d3's own
592
- // re-heat alpha, warm-started so nothing else re-arranges.
593
- const settle = Math.ceil(ticks / 2);
594
- runSimulation(nodes, edges, { ticks: settle, seed, warm: warm !== undefined });
595
- const settled = settledOf(nodes);
596
- const anchors = branchAnchors(model, settled, branches);
597
- parkBranches(nodes, branches, anchors, settled);
598
- runSimulation(nodes, edges, { ticks: ticks - settle, seed, warm: true, anchors });
599
- }
142
+ for (const edge of edges) {
143
+ degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
144
+ degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
600
145
  }
601
- // A pinned run leaves the pins behind; clear them so the returned map is
602
- // positions, not a promise to keep standing there forever.
603
- if (options.pinWarm === true) for (const n of nodes) { n.fx = null; n.fy = null; }
146
+ for (const node of nodes) node.r = collideRadius(nodeSize(degree.get(node.id) ?? 0));
147
+ if (nodes.length > 1) runSimulation(nodes, edges, { ticks, seed: options.seed ?? DEFAULT_SEED, warm: options.initial !== undefined });
604
148
 
605
- for (const n of nodes) {
606
- // Guarded on the way out as well as in: the contract is that no caller
607
- // ever receives a NaN. d3 fills every node during `initialize`, so the
608
- // fallback is unreachable in practice — but the contract is the contract.
609
- out.set(n.id, { x: Number.isFinite(n.x as number) ? (n.x as number) : 0, y: Number.isFinite(n.y as number) ? (n.y as number) : 0 });
149
+ const out = new Map<string, Point>();
150
+ for (const node of nodes) {
151
+ out.set(node.id, {
152
+ x: Number.isFinite(node.x as number) ? (node.x as number) : 0,
153
+ y: Number.isFinite(node.y as number) ? (node.y as number) : 0,
154
+ });
610
155
  }
611
156
  return out;
612
157
  }
613
-
614
- export interface ForceSimulationOptions<N> {
615
- nodes: N[];
616
- /** String endpoints are resolved by node id; dangling ids must be filtered out by the caller. */
617
- links: Array<{ source: string | N; target: string | N; kind: EdgeKind }>;
618
- /**
619
- * Per-node `forceX`/`forceY` targets, re-read every tick. Absent (or an id
620
- * missing from the map) targets the origin — d3's own default — which is the
621
- * no-component-escapes guarantee. A live driver mutates the map on drag
622
- * release so a dropped node rests where the user put it.
623
- */
624
- anchors?: ReadonlyMap<string, Point> | undefined;
625
- /** Seeds d3's jiggle LCG. Default 1 — deterministic in Node and browser. */
626
- seed?: number;
627
- }
628
-
629
- /**
630
- * The node shape the collision force reads.
631
- *
632
- * `r` is the node's **collision** radius ({@link collideRadius} of its drawn
633
- * size), set by the caller — the static layout from the degree ramp, the live
634
- * driver from the `RenderNode` sizes it was handed. Absent falls back to the
635
- * uniform {@link COLLIDE_RADIUS}, so a caller that never heard of the ramp
636
- * still gets the old, correct behaviour.
637
- */
638
- export interface CollideNode {
639
- r?: number;
640
- }
641
-
642
- /**
643
- * The force configuration, as ONE definition shared by the static layout
644
- * ({@link computeLayout}) and the live driver (`dynamics.ts`).
645
- *
646
- * It is d3's force-directed-tree recipe: the containment tree holds children
647
- * at their parent with full strength, and the arrangement emerges from gentle
648
- * repulsion and collision; `forceX()`/`forceY()` pull every component toward
649
- * the origin at d3's default strength, so nothing drifts to infinity.
650
- *
651
- * The forces live here once so the static and live equilibria cannot diverge:
652
- * whatever the static layout settles to is exactly what the live sim holds.
653
- * The simulation is returned stopped; the driver owns the alpha policy — the
654
- * static path decays to the alpha floor over its tick budget, the live path
655
- * re-heats on interaction and sleeps when the floor is reached. Velocity
656
- * decay stays at d3's own default, exactly like the example.
657
- */
658
- export function createForceSimulation<
659
- N extends SimulationNodeDatum & { id: string; r?: number },
660
- >(opts: ForceSimulationOptions<N>): Simulation<N, undefined> {
661
- const link = forceLink<N, { source: string | N; target: string | N; kind: EdgeKind }>(opts.links)
662
- .id((n) => n.id)
663
- .distance((l) => (isContainment(l.kind) ? CONTAINS_REST : RELATION_DISTANCE))
664
- .strength((l) => (isContainment(l.kind) ? CONTAINS_STRENGTH : RELATION_STRENGTH))
665
- .iterations(2);
666
-
667
- return forceSimulation<N>(opts.nodes)
668
- .randomSource(lcg(opts.seed ?? DEFAULT_SEED))
669
- .force("charge", forceManyBody<N>().strength(CHARGE_STRENGTH))
670
- .force("link", link)
671
- // One collision pass per tick, not d3's three: at §8's expected ~240-node
672
- // scale it cuts a cold 300-tick run roughly in half (measured 325 ms →
673
- // 179 ms) and the non-degeneracy gate cannot tell the difference. The
674
- // link force keeps its own two passes — edge untangling is where the
675
- // quality actually lives. The radius is per node, so a hub reserves the
676
- // room its drawn size needs while leaves keep packing tightly — see
677
- // `CollideNode`.
678
- .force("collide", forceCollide<N>((n) => n.r ?? COLLIDE_RADIUS).strength(1).iterations(1))
679
- .force(
680
- "x",
681
- forceX<N>((n) => opts.anchors?.get(n.id)?.x ?? 0).strength(CENTER_STRENGTH),
682
- )
683
- .force(
684
- "y",
685
- forceY<N>((n) => opts.anchors?.get(n.id)?.y ?? 0).strength(CENTER_STRENGTH),
686
- )
687
- .stop();
688
- }
689
-
690
- /** Configure and step the d3 simulation in place. Mutates `nodes`. */
691
- function runSimulation(
692
- nodes: SimNode[],
693
- edges: readonly GraphEdge[],
694
- opts: { ticks: number; seed: number; warm: boolean; anchors?: ReadonlyMap<string, Point> | undefined },
695
- ): void {
696
- const links: SimLink[] = edges.map((e) => ({ source: e.source, target: e.target, kind: e.kind }));
697
- const sim = createForceSimulation({ nodes, links, seed: opts.seed, anchors: opts.anchors });
698
- sim
699
- // A cold start assembles from d3's phyllotaxis at full alpha; a warm start
700
- // relaxes what is already on screen — d3's own re-heat value — so the
701
- // graph does not re-arrange itself under the user on every expand.
702
- .alpha(opts.warm ? 0.3 : 1)
703
- .alphaMin(ALPHA_MIN)
704
- // Reach the same convergence at whatever tick budget the caller asked for.
705
- // Velocity decay stays at d3's own default — the example sets neither.
706
- .alphaDecay(opts.ticks > 0 ? 1 - Math.pow(ALPHA_MIN, 1 / opts.ticks) : 0);
707
-
708
- for (let i = 0; i < opts.ticks; i++) sim.tick();
709
- }