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,497 @@
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
+ * ## Why d3-force (§7.2)
19
+ *
20
+ * The retired simulation collapsed to a vertical line because repulsion and
21
+ * collision derive their direction as `dx / d`: once two nodes share an `x`,
22
+ * the x-component of the push is exactly zero forever, gravity pins x to W/2,
23
+ * and damping freezes it there. d3-force injects `jiggle()` on exactly that
24
+ * zero (`manyBody.js`, `collide.js`, `link.js`), drawn from a **seeded** LCG —
25
+ * so we get symmetry breaking *and* reproducibility, which is what makes §8 a
26
+ * stable CI gate rather than a flaky one.
27
+ *
28
+ * ## The four failure mechanisms, and what answers each
29
+ *
30
+ * | Mechanism | Answer here |
31
+ * | ----------------------------- | ------------------------------------------- |
32
+ * | zero-direction repulsion | d3's `jiggle`, in all three forces |
33
+ * | children seeded at the parent | {@link seedPositions} — hash-derived ring |
34
+ * | gravity pinning x to W/2 | `forceX`/`forceY` at 0.03 — positions, never pins |
35
+ * | hub leaves crushed to a line | ring-sized `contains` distance (below) |
36
+ *
37
+ * ## Ring sizing — the one non-obvious formula
38
+ *
39
+ * A parent with `k` containment children wants those children on a ring. For
40
+ * them to sit `RING_SPACING` apart without the collision force fighting the
41
+ * link force, the ring's circumference must be at least `RING_SPACING * k`, so
42
+ * its radius must be at least `RING_SPACING * k / 2π`. That is
43
+ * {@link ringRadius}, and it is why a 60-child hub gets a ~380 unit link
44
+ * distance while a 3-child node gets the 70 unit floor. Hairballs are a
45
+ * *geometry* problem, not a tuning problem.
46
+ */
47
+
48
+ import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from "d3-force";
49
+ import type { SimulationLinkDatum, SimulationNodeDatum } from "d3-force";
50
+ import type { WireEdgeKind as EdgeKind, WireGraphEdge as GraphEdge, WireGraphModel as GraphModel } from "./graph";
51
+ import type { Point } from "./metrics";
52
+
53
+ export type { Point } from "./metrics";
54
+
55
+ export interface LayoutOptions {
56
+ /** Simulation steps. Alpha decay is derived from this, so the budget stays meaningful. Default 300. */
57
+ ticks?: number;
58
+ /** Seeds d3's jiggle LCG. Default 1. */
59
+ seed?: number;
60
+ /** Viewport width; the layout is centred on it. Default 1280. */
61
+ width?: number;
62
+ /** Viewport height; the layout is centred on it. Default 800. */
63
+ height?: number;
64
+ /**
65
+ * Warm-start positions by node id. The client passes current positions when
66
+ * re-running after a drag or an expand so the graph does not jump; the
67
+ * dynamics gate passes coincident points to prove symmetry breaking. Ids
68
+ * absent here fall back to {@link seedPositions}.
69
+ */
70
+ initial?: ReadonlyMap<string, Point>;
71
+ }
72
+
73
+ /** Visual node radius in layout units. The renderer must not draw larger than this. */
74
+ export const NODE_RADIUS = 9;
75
+
76
+ /** Collision radius: the node plus breathing room for the leading edge of its label. */
77
+ export const COLLIDE_RADIUS = NODE_RADIUS + 9;
78
+
79
+ /** Target arc between two siblings on a parent's ring — a collision diameter plus margin. */
80
+ const RING_SPACING = 2 * COLLIDE_RADIUS + 4;
81
+
82
+ /** Shortest a `contains` edge ever gets, for parents with one or two children. */
83
+ export const CONTAINS_DISTANCE = 70;
84
+
85
+ /** `links-to` / `mentions` are associative, not structural: longer and weaker. */
86
+ const RELATION_DISTANCE = 220;
87
+
88
+ /** Relation edges pull at this fraction of a containment edge's strength. */
89
+ const RELATION_STRENGTH_SCALE = 0.35;
90
+
91
+ /**
92
+ * Clearance between the outer rings of two adjacent top-level clusters.
93
+ *
94
+ * Derived from Gestalt proximity, not chosen: a boundary only reads as a
95
+ * boundary if it is emptier than anything *inside* a cluster. The largest
96
+ * empty span within any cluster is the annulus between a hub and its own ring,
97
+ * i.e. `max ringRadius(k)` over the roots — so the inter-cluster gap must be
98
+ * at least that. This is why the gap scales with the graph (a 60-child hub
99
+ * pushes its neighbours further away than a 3-child node does) instead of
100
+ * being a pixel constant that would be wrong at either extreme.
101
+ */
102
+ function clusterGap(roots: readonly string[], children: ReadonlyMap<string, string[]>): number {
103
+ let widest = CONTAINS_DISTANCE;
104
+ for (const id of roots) {
105
+ const r = ringRadius(children.get(id)?.length ?? 0);
106
+ if (r > widest) widest = r;
107
+ }
108
+ return widest;
109
+ }
110
+
111
+ /** Body repulsion. Negative is repulsive; scaled up from d3's -30 for our node sizes. */
112
+ const CHARGE_STRENGTH = -180;
113
+
114
+ /**
115
+ * Gravity is **seed-anchored**, not centre-anchored, and that is a deliberate
116
+ * correction of the third failure mechanism rather than a style preference.
117
+ *
118
+ * `forceX(W/2)` accelerates a node by `(W/2 - x)·s·α`, which grows *linearly*
119
+ * with distance, while repulsion falls off as `1/d`. Past a few hundred units
120
+ * centre-gravity therefore wins by orders of magnitude and drags every cluster
121
+ * back onto the middle — measured here as a five-root separation collapsing
122
+ * from 562 to 269 units between seeding and settling. "Gravity pins x to W/2"
123
+ * is the post-mortem's own wording; a weak constant does not fix it, because
124
+ * the problem is the *shape* of the term, not its coefficient.
125
+ *
126
+ * Anchoring each node to its own seeded slot keeps the restoring force bounded
127
+ * by how far that node has actually moved, which is small. It still guarantees
128
+ * no component escapes to infinity — the property centre-gravity was there for
129
+ * — and it additionally makes re-runs stable, which the client needs on drag
130
+ * and expand/collapse (§7.3).
131
+ */
132
+ const ANCHOR_ROOT = 0.10;
133
+
134
+ /**
135
+ * Non-roots are anchored an order of magnitude more weakly than roots: their
136
+ * placement is the simulation's job, and at 0.02 this is 50× weaker than the
137
+ * strength-1 link holding a leaf to its parent, so it bounds drift without
138
+ * competing with the structure.
139
+ */
140
+ const ANCHOR_CHILD = 0.02;
141
+
142
+ /** Fraction of velocity retained per tick. Below d3's 0.6 default: we want settling, not motion. */
143
+ const VELOCITY_DECAY = 0.4;
144
+
145
+ const DEFAULT_TICKS = 300;
146
+ const DEFAULT_SEED = 1;
147
+ const DEFAULT_WIDTH = 1280;
148
+ const DEFAULT_HEIGHT = 800;
149
+
150
+ /** d3-force's own alpha floor (`simulation.js`); mirrored so `alphaDecay` can be derived from `ticks`. */
151
+ const ALPHA_MIN = 0.001;
152
+
153
+ const TAU = Math.PI * 2;
154
+
155
+ /**
156
+ * Rotation applied to the root ring. Without it the accumulator starts at
157
+ * angle 0 and a two-root graph lands at 90° and 270° — a vertical pair in a
158
+ * landscape viewport. A quarter turn back puts the first boundary on the
159
+ * horizontal, so few-root graphs spread along the wide axis.
160
+ */
161
+ const ROOT_RING_PHASE = -Math.PI / 2;
162
+
163
+ interface SimNode extends SimulationNodeDatum {
164
+ id: string;
165
+ x: number;
166
+ y: number;
167
+ }
168
+
169
+ interface SimLink extends SimulationLinkDatum<SimNode> {
170
+ source: string | SimNode;
171
+ target: string | SimNode;
172
+ kind: EdgeKind;
173
+ }
174
+
175
+ /** Containment edges define the hierarchy; everything else is an association. */
176
+ function isContainment(kind: EdgeKind): boolean {
177
+ return kind === "contains" || kind === "anchored-at";
178
+ }
179
+
180
+ /**
181
+ * FNV-1a (32-bit) followed by MurmurHash3's `fmix32` avalanche.
182
+ *
183
+ * The finalizer is not optional here, and its absence was a real bug caught by
184
+ * the ring assertion. FNV-1a mixes its *low* bits well but its high bits
185
+ * poorly for short, near-identical inputs — and `hashUnit` divides by 2³², so
186
+ * the high bits become the most significant part of the angle. Raw FNV-1a over
187
+ * `leaf001…leaf199` put 199 siblings into six of twelve compass sectors, three
188
+ * of them holding over a third of the ring each. `fmix32` costs four lines and
189
+ * makes every bit depend on every input bit.
190
+ */
191
+ export function hashId(id: string): number {
192
+ let h = 0x811c9dc5;
193
+ for (let i = 0; i < id.length; i++) {
194
+ h ^= id.charCodeAt(i);
195
+ h = Math.imul(h, 0x01000193);
196
+ }
197
+ h ^= h >>> 16;
198
+ h = Math.imul(h, 0x85ebca6b);
199
+ h ^= h >>> 13;
200
+ h = Math.imul(h, 0xc2b2ae35);
201
+ h ^= h >>> 16;
202
+ return h >>> 0;
203
+ }
204
+
205
+ /** `hashId` folded into [0, 1). `salt` gives an independent stream per id. */
206
+ function hashUnit(id: string, salt: number): number {
207
+ return hashId(`${salt}\u0000${id}`) / 0x100000000;
208
+ }
209
+
210
+ /**
211
+ * d3-force's LCG (`lcg.js`: a = 1664525, c = 1013904223, m = 2³²), re-exposed
212
+ * so `seed` actually selects a stream — d3 always builds its own with s = 1,
213
+ * and `simulation.randomSource()` is the documented way to replace it.
214
+ */
215
+ export function lcg(seed: number): () => number {
216
+ let s = Math.trunc(seed) >>> 0;
217
+ return () => (s = (1664525 * s + 1013904223) % 4294967296) / 4294967296;
218
+ }
219
+
220
+ /** Radius that fits `k` siblings `RING_SPACING` apart — see the module header. */
221
+ export function ringRadius(k: number): number {
222
+ return Math.max(CONTAINS_DISTANCE, (RING_SPACING * k) / TAU);
223
+ }
224
+
225
+ interface Structure {
226
+ /** Ids in `model.nodes` order, deduped. */
227
+ ids: string[];
228
+ /** Containment parent of each child (first winning edge, in edge order). */
229
+ parent: Map<string, string>;
230
+ /** Containment children, in edge order. */
231
+ children: Map<string, string[]>;
232
+ /** Nodes with no containment parent — the cluster anchors. */
233
+ roots: string[];
234
+ /** Edges with both endpoints present, no self-loops, deduped. */
235
+ edges: GraphEdge[];
236
+ /** Degree over that filtered edge set, both directions. */
237
+ degree: Map<string, number>;
238
+ }
239
+
240
+ /**
241
+ * Normalise the model into something a simulation can consume: drop self-edges
242
+ * and edges pointing at ids that are not nodes (d3's `forceLink` throws on
243
+ * those), dedupe, and derive the containment forest. Malformed input is the
244
+ * caller's bug, but it must not be the layout's crash.
245
+ */
246
+ function analyse(model: GraphModel): Structure {
247
+ const ids: string[] = [];
248
+ const known = new Set<string>();
249
+ for (const n of model.nodes) {
250
+ if (known.has(n.id)) continue;
251
+ known.add(n.id);
252
+ ids.push(n.id);
253
+ }
254
+
255
+ const edges: GraphEdge[] = [];
256
+ const seen = new Set<string>();
257
+ const parent = new Map<string, string>();
258
+ const children = new Map<string, string[]>();
259
+ const degree = new Map<string, number>();
260
+ for (const e of model.edges) {
261
+ if (e.source === e.target) continue;
262
+ if (!known.has(e.source) || !known.has(e.target)) continue;
263
+ const key = `${e.source}\u0000${e.target}\u0000${e.kind}`;
264
+ if (seen.has(key)) continue;
265
+ seen.add(key);
266
+ edges.push(e);
267
+ degree.set(e.source, (degree.get(e.source) ?? 0) + 1);
268
+ degree.set(e.target, (degree.get(e.target) ?? 0) + 1);
269
+ if (!isContainment(e.kind) || parent.has(e.target)) continue;
270
+ parent.set(e.target, e.source);
271
+ const kids = children.get(e.source);
272
+ if (kids) kids.push(e.target);
273
+ else children.set(e.source, [e.target]);
274
+ }
275
+
276
+ // A containment cycle leaves every member parented, so no id in it is a root
277
+ // and none is reachable from one. `seedPositions` sweeps up the survivors.
278
+ const roots = ids.filter((id) => !parent.has(id));
279
+ return { ids, parent, children, roots, edges, degree };
280
+ }
281
+
282
+ /**
283
+ * Arc budget for one cluster: its own diameter plus the inter-cluster gap.
284
+ * Proportional allocation matters here — a 60-child hub and a 3-child node
285
+ * must not receive the same slice of the circle.
286
+ */
287
+ function arcShare(id: string, children: ReadonlyMap<string, string[]>, gap: number): number {
288
+ return 2 * ringRadius(children.get(id)?.length ?? 0) + gap;
289
+ }
290
+
291
+ /**
292
+ * Radius of the ring the top-level cluster anchors sit on. Disc-packing on a
293
+ * circle, derived — not tuned.
294
+ *
295
+ * Anchors get arc *proportional to their share* (see {@link seedPositions}),
296
+ * so adjacent anchors i and i+1 are `2π·(sᵢ + sᵢ₊₁) / (2·Σs)` apart and the
297
+ * chord between them is `2R·sin` of half that. Requiring the chord to clear
298
+ * both clusters — exactly `(sᵢ + sᵢ₊₁) / 2`, since each share is a diameter
299
+ * plus the gap — gives R for that pair:
300
+ *
301
+ * 2R·sin(π·s / (2·Σs)) ≥ s / 2 ⇒ R ≥ s / (4·sin(π·s / (2·Σs))) , s = sᵢ + sᵢ₊₁
302
+ *
303
+ * Take the max over adjacent pairs, wrapping. The sine's argument is at most
304
+ * π/2 (attained only at n = 2, where s = Σs), so it never folds back.
305
+ */
306
+ function rootRingRadius(shares: readonly number[], total: number): number {
307
+ let radius = 0;
308
+ shares.forEach((share, i) => {
309
+ const pair = share + (shares[(i + 1) % shares.length] as number);
310
+ const need = pair / (4 * Math.sin((Math.PI * pair) / (2 * total)));
311
+ if (need > radius) radius = need;
312
+ });
313
+ return radius;
314
+ }
315
+
316
+ /**
317
+ * Angles for the root ring: each anchor at the centre of its own arc slice,
318
+ * offset by {@link ROOT_RING_PHASE}, and nudged by a hash so two equal-sized
319
+ * clusters never land on an identical angle after rounding.
320
+ */
321
+ function rootAngles(roots: readonly string[], shares: readonly number[], total: number): number[] {
322
+ let acc = 0;
323
+ return shares.map((share, i) => {
324
+ const angle = ROOT_RING_PHASE + TAU * ((acc + share / 2) / total) + (hashUnit(roots[i] as string, 3) - 0.5) * 0.05;
325
+ acc += share;
326
+ return angle;
327
+ });
328
+ }
329
+
330
+ /**
331
+ * Deterministic initial placement (§7.3).
332
+ *
333
+ * Roots are spread around a ring whose arc is allocated in proportion to each
334
+ * cluster's own footprint, so the 60-child hub is not handed the same slice as
335
+ * a 3-child node. Children go on a ring around their parent at ~70 % of the
336
+ * radius the link force will settle them at — near equilibrium, so 300 ticks
337
+ * is plenty — at an angle taken from a **hash of the child's own id**. Never
338
+ * the parent's exact point: exact co-location was one of the four mechanisms
339
+ * behind the retired viewer's vertical line, and a hash is the cheapest way to
340
+ * guarantee two siblings never start on top of each other.
341
+ */
342
+ export function seedPositions(model: GraphModel, width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT): Map<string, Point> {
343
+ const { ids, children, roots } = analyse(model);
344
+ const out = new Map<string, Point>();
345
+ const cx = width / 2;
346
+ const cy = height / 2;
347
+
348
+ if (roots.length === 1) {
349
+ out.set(roots[0] as string, { x: cx, y: cy });
350
+ } else if (roots.length > 1) {
351
+ const gap = clusterGap(roots, children);
352
+ const shares = roots.map((id) => arcShare(id, children, gap));
353
+ let total = 0;
354
+ for (const s of shares) total += s;
355
+ const ring = rootRingRadius(shares, total);
356
+ const angles = rootAngles(roots, shares, total);
357
+ roots.forEach((id, i) => {
358
+ const angle = angles[i] as number;
359
+ out.set(id, { x: cx + ring * Math.cos(angle), y: cy + ring * Math.sin(angle) });
360
+ });
361
+ }
362
+
363
+ // Breadth-first, so a parent always has a point before its children read it.
364
+ const queue = [...roots];
365
+ for (let head = 0; head < queue.length; head++) {
366
+ const id = queue[head]!;
367
+ const kids = children.get(id);
368
+ if (kids === undefined) continue;
369
+ const origin = out.get(id)!;
370
+ const r = 0.7 * ringRadius(kids.length);
371
+ for (const kid of kids) {
372
+ const angle = TAU * hashUnit(kid, 1);
373
+ // 0.85–1.15 of the ring: two ids that collide in angle still differ here.
374
+ const jitter = 0.85 + 0.3 * hashUnit(kid, 2);
375
+ out.set(kid, { x: origin.x + r * jitter * Math.cos(angle), y: origin.y + r * jitter * Math.sin(angle) });
376
+ queue.push(kid);
377
+ }
378
+ }
379
+
380
+ // Anything a containment cycle kept out of the BFS still needs a point.
381
+ for (const id of ids) {
382
+ if (out.has(id)) continue;
383
+ const angle = TAU * hashUnit(id, 4);
384
+ const r = CONTAINS_DISTANCE * (1 + hashUnit(id, 5));
385
+ out.set(id, { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) });
386
+ }
387
+ return out;
388
+ }
389
+
390
+ /**
391
+ * `forceLink` resolves string endpoints into node objects inside its own
392
+ * `initialize`, which runs before the first tick — so by the time the
393
+ * `distance` and `strength` accessors are called, both endpoints are already
394
+ * `SimNode`s. The declared `string | SimNode` union describes only the
395
+ * *pre-initialize* state, so narrowing it at call time would add a branch that
396
+ * can never be taken.
397
+ */
398
+ function endpointId(endpoint: string | SimNode): string {
399
+ return (endpoint as SimNode).id;
400
+ }
401
+
402
+ /**
403
+ * Lay a graph out. Deterministic: the same model with the same `seed` produces
404
+ * byte-identical output, in Node or the browser.
405
+ *
406
+ * The simulation is stepped **synchronously** — `stop()` then a manual `tick()`
407
+ * loop — so it never touches `requestAnimationFrame` and works headless.
408
+ */
409
+ export function computeLayout(model: GraphModel, options: LayoutOptions = {}): Map<string, Point> {
410
+ const ticks = Math.max(0, Math.trunc(options.ticks ?? DEFAULT_TICKS));
411
+ const seed = options.seed ?? DEFAULT_SEED;
412
+ const width = options.width ?? DEFAULT_WIDTH;
413
+ const height = options.height ?? DEFAULT_HEIGHT;
414
+
415
+ const { ids, children, edges, degree, roots } = analyse(model);
416
+ const out = new Map<string, Point>();
417
+ if (ids.length === 0) return out;
418
+
419
+ const seeds = seedPositions(model, width, height);
420
+ const warm = options.initial;
421
+ const nodes: SimNode[] = ids.map((id) => {
422
+ const fallback = seeds.get(id)!;
423
+ return { id, ...finiteOr(warm?.get(id), fallback), vx: 0, vy: 0 };
424
+ });
425
+
426
+ if (nodes.length > 1) {
427
+ runSimulation(nodes, edges, children, degree, seeds, new Set(roots), { ticks, seed });
428
+ }
429
+
430
+ for (const n of nodes) {
431
+ // Guarded on the way out as well as in: the contract is that no caller
432
+ // ever receives a NaN, and a seeded fallback is always available.
433
+ out.set(n.id, finiteOr(n, seeds.get(n.id) as Point));
434
+ }
435
+ return out;
436
+ }
437
+
438
+ /**
439
+ * `candidate` when both its coordinates are finite, else `fallback`. Applied to
440
+ * warm-start input and to simulation output, so a poisoned position can neither
441
+ * enter the simulation nor leave it.
442
+ */
443
+ function finiteOr(candidate: Point | undefined, fallback: Point): Point {
444
+ if (candidate === undefined) return fallback;
445
+ return {
446
+ x: Number.isFinite(candidate.x) ? candidate.x : fallback.x,
447
+ y: Number.isFinite(candidate.y) ? candidate.y : fallback.y,
448
+ };
449
+ }
450
+
451
+ /** Configure and step the d3 simulation in place. Mutates `nodes`. */
452
+ function runSimulation(
453
+ nodes: SimNode[],
454
+ edges: readonly GraphEdge[],
455
+ children: ReadonlyMap<string, string[]>,
456
+ degree: ReadonlyMap<string, number>,
457
+ seeds: ReadonlyMap<string, Point>,
458
+ roots: ReadonlySet<string>,
459
+ opts: { ticks: number; seed: number },
460
+ ): void {
461
+ const links: SimLink[] = edges.map((e) => ({ source: e.source, target: e.target, kind: e.kind }));
462
+
463
+ const childCount = (id: string): number => children.get(id)?.length ?? 0;
464
+ /** Ring geometry is set by whichever endpoint is the fan-out parent. */
465
+ const fanOut = (l: SimLink): number => Math.max(childCount(endpointId(l.source)), childCount(endpointId(l.target)));
466
+ // Every link endpoint is a node with at least this link incident on it, so
467
+ // `degree` always has it and the floor of 1 is arithmetic, not a fallback.
468
+ const deg = (endpoint: string | SimNode): number => degree.get(endpointId(endpoint)) as number;
469
+ const anchor = (n: SimNode): number => (roots.has(n.id) ? ANCHOR_ROOT : ANCHOR_CHILD);
470
+
471
+ const link = forceLink<SimNode, SimLink>(links)
472
+ .id((n) => n.id)
473
+ .distance((l) => (isContainment(l.kind) ? ringRadius(fanOut(l)) : RELATION_DISTANCE))
474
+ // d3's default `1 / min(degree)` is what stops a degree-60 hub being
475
+ // yanked 60 times a tick. Keep that shape; scale relations down from it.
476
+ .strength((l) => {
477
+ const base = 1 / Math.min(deg(l.source), deg(l.target));
478
+ return isContainment(l.kind) ? base : base * RELATION_STRENGTH_SCALE;
479
+ })
480
+ .iterations(2);
481
+
482
+ const sim = forceSimulation<SimNode>(nodes)
483
+ .randomSource(lcg(opts.seed))
484
+ .force("charge", forceManyBody<SimNode>().strength(CHARGE_STRENGTH))
485
+ .force("link", link)
486
+ .force("collide", forceCollide<SimNode>(COLLIDE_RADIUS).strength(1).iterations(3))
487
+ .force("x", forceX<SimNode>((n) => seeds.get(n.id)!.x).strength(anchor))
488
+ .force("y", forceY<SimNode>((n) => seeds.get(n.id)!.y).strength(anchor))
489
+ .alpha(1)
490
+ .alphaMin(ALPHA_MIN)
491
+ // Reach the same convergence at whatever tick budget the caller asked for.
492
+ .alphaDecay(opts.ticks > 0 ? 1 - Math.pow(ALPHA_MIN, 1 / opts.ticks) : 0)
493
+ .velocityDecay(VELOCITY_DECAY);
494
+
495
+ sim.stop();
496
+ for (let i = 0; i < opts.ticks; i++) sim.tick();
497
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Pure geometry over a laid-out point set (weave-workspace §8).
3
+ *
4
+ * These are the measurements the dynamics gate asserts on, and the same
5
+ * measurements the graph column needs at runtime (`bbox` is what "fit to
6
+ * view" is built from). They live in `src/web/shared` rather than in the
7
+ * test tree so they are covered code, not untested test scaffolding.
8
+ *
9
+ * Isomorphic: no `node:*`, no DOM.
10
+ */
11
+
12
+ export interface Point {
13
+ x: number;
14
+ y: number;
15
+ }
16
+
17
+ export interface BBox {
18
+ minX: number;
19
+ minY: number;
20
+ maxX: number;
21
+ maxY: number;
22
+ w: number;
23
+ h: number;
24
+ cx: number;
25
+ cy: number;
26
+ }
27
+
28
+ /** The empty bounding box: zero-sized at the origin. */
29
+ const EMPTY_BBOX: BBox = { minX: 0, minY: 0, maxX: 0, maxY: 0, w: 0, h: 0, cx: 0, cy: 0 };
30
+
31
+ /**
32
+ * Population variance. Zero for an empty or single-element sample, and — the
33
+ * case that matters — exactly zero for the degenerate "every node on one
34
+ * vertical line" layout that the retired simulation produced.
35
+ */
36
+ export function variance(values: readonly number[]): number {
37
+ const n = values.length;
38
+ if (n < 2) return 0;
39
+ let sum = 0;
40
+ for (const v of values) sum += v;
41
+ const mean = sum / n;
42
+ let acc = 0;
43
+ for (const v of values) {
44
+ const d = v - mean;
45
+ acc += d * d;
46
+ }
47
+ return acc / n;
48
+ }
49
+
50
+ /**
51
+ * Smallest Euclidean distance between any two distinct points. `Infinity` for
52
+ * fewer than two points (vacuously non-overlapping).
53
+ *
54
+ * O(n²). The gate runs it on a few hundred points, which is microseconds; if
55
+ * a caller ever needs it on tens of thousands, sort-and-sweep it then.
56
+ */
57
+ export function minPairwiseDistance(points: readonly Point[]): number {
58
+ let min = Infinity;
59
+ let i = 0;
60
+ // `for…of` over a slice rather than indexing: `noUncheckedIndexedAccess`
61
+ // would otherwise force an `undefined` guard on every access that can never
62
+ // fire, and an untestable branch is worse than a copy of a few hundred refs.
63
+ for (const a of points) {
64
+ i++;
65
+ for (const b of points.slice(i)) {
66
+ const dx = a.x - b.x;
67
+ const dy = a.y - b.y;
68
+ const d = Math.sqrt(dx * dx + dy * dy);
69
+ if (d < min) min = d;
70
+ }
71
+ }
72
+ return min;
73
+ }
74
+
75
+ /** Axis-aligned bounding box, with width/height/centre precomputed. */
76
+ export function bbox(points: readonly Point[]): BBox {
77
+ if (points.length === 0) return EMPTY_BBOX;
78
+ let minX = Infinity;
79
+ let minY = Infinity;
80
+ let maxX = -Infinity;
81
+ let maxY = -Infinity;
82
+ for (const p of points) {
83
+ if (p.x < minX) minX = p.x;
84
+ if (p.x > maxX) maxX = p.x;
85
+ if (p.y < minY) minY = p.y;
86
+ if (p.y > maxY) maxY = p.y;
87
+ }
88
+ return { minX, minY, maxX, maxY, w: maxX - minX, h: maxY - minY, cx: (minX + maxX) / 2, cy: (minY + maxY) / 2 };
89
+ }
90
+
91
+ /**
92
+ * How far apart the cluster anchors ended up: the smallest distance between
93
+ * any two of `ids` in `positions`. Ids with no position are skipped; fewer
94
+ * than two resolvable anchors yields `Infinity`.
95
+ *
96
+ * "The five roots stay distinct" is exactly this number staying large — if two
97
+ * clusters merge, their anchors are the first thing to collide.
98
+ */
99
+ export function clusterSeparation(positions: ReadonlyMap<string, Point>, ids: readonly string[]): number {
100
+ const anchors: Point[] = [];
101
+ for (const id of ids) {
102
+ const p = positions.get(id);
103
+ if (p !== undefined) anchors.push(p);
104
+ }
105
+ return minPairwiseDistance(anchors);
106
+ }
107
+
108
+ /**
109
+ * How many of `sectors` equal angular slices around `center` contain at least
110
+ * one of `points`. Points at the exact centre have no angle and are skipped.
111
+ *
112
+ * This is the difference between "a hub's leaves ring it" (occupancy →
113
+ * `sectors`) and "a hub's leaves fell into a line" (occupancy → 2). A count,
114
+ * not a ratio, because the interesting failure is whole sectors being empty.
115
+ */
116
+ export function angularOccupancy(center: Point, points: readonly Point[], sectors: number): number {
117
+ const hit = new Set<number>();
118
+ const TAU = Math.PI * 2;
119
+ for (const p of points) {
120
+ const dx = p.x - center.x;
121
+ const dy = p.y - center.y;
122
+ if (dx === 0 && dy === 0) continue;
123
+ // atan2 ∈ (-π, π] → [0, 1) → sector index, clamped against the +π edge.
124
+ const unit = (Math.atan2(dy, dx) + Math.PI) / TAU;
125
+ hit.add(Math.min(sectors - 1, Math.floor(unit * sectors)));
126
+ }
127
+ return hit.size;
128
+ }
129
+
130
+ /** True when every coordinate of every point is a finite number. */
131
+ export function allFinite(points: readonly Point[]): boolean {
132
+ for (const p of points) {
133
+ if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) return false;
134
+ }
135
+ return true;
136
+ }