pi-weave 0.1.8 → 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 (39) hide show
  1. package/README.md +9 -3
  2. package/package.json +2 -2
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/concurrency.ts +36 -0
  5. package/src/core/frontmatter.ts +53 -0
  6. package/src/core/graph/build.ts +60 -1
  7. package/src/core/graph/model.ts +15 -0
  8. package/src/core/graph/wikilinks.ts +5 -1
  9. package/src/core/index.ts +2 -0
  10. package/src/core/paths.ts +7 -0
  11. package/src/core/sessions.ts +929 -0
  12. package/src/core/summaries.ts +1 -19
  13. package/src/core/vault.ts +264 -16
  14. package/src/core/workspace.ts +3 -3
  15. package/src/pi/index.ts +144 -37
  16. package/src/pi/sessionScan.ts +104 -0
  17. package/src/pi/summarize.ts +24 -4
  18. package/src/pi/tools/noteTool.ts +13 -4
  19. package/src/pi/viewer/tui/branding.ts +8 -7
  20. package/src/pi/viewer/tui/explorer.ts +1 -1
  21. package/src/web/client/dist/app.js +52 -39
  22. package/src/web/client/graph/Graph.tsx +70 -19
  23. package/src/web/client/graph/column.model.ts +11 -70
  24. package/src/web/client/graph/dynamics.ts +176 -0
  25. package/src/web/client/graph/graph.model.ts +10 -2
  26. package/src/web/client/graph/positions.ts +51 -10
  27. package/src/web/client/graph/renderer.ts +61 -1
  28. package/src/web/client/selection.storage.ts +69 -0
  29. package/src/web/client/shell/Header.tsx +11 -1
  30. package/src/web/client/shell/Shell.tsx +17 -0
  31. package/src/web/client/shell/theme.ts +15 -2
  32. package/src/web/client/state.ts +11 -0
  33. package/src/web/client/tree/Tree.tsx +4 -1
  34. package/src/web/client/workspace.ts +69 -5
  35. package/src/web/server/page.ts +2 -0
  36. package/src/web/server/routes.ts +19 -7
  37. package/src/web/shared/graph.ts +7 -0
  38. package/src/web/shared/layout.ts +158 -341
  39. package/src/web/shared/logo.ts +11 -0
@@ -15,38 +15,64 @@
15
15
  * type graph into `tsconfig.web.json` — the identical failure `wire.ts` hit.
16
16
  * Fixed here pre-emptively rather than left as a tripwire for P3.
17
17
  *
18
- * ## Why d3-force (§7.2)
18
+ * ## The recipe — d3's own force-directed tree, not ours
19
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.
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.
27
47
  *
28
- * ## The four failure mechanisms, and what answers each
48
+ * Two deviations from the example, both stated rather than hidden:
29
49
  *
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) |
50
+ * 1. **`forceCollide`** — the example draws 3.5-pixel dots with no labels; we
51
+ * draw 9-unit nodes with zoomed labels, so nodes keep a collision radius
52
+ * (`NODE_RADIUS + label room`) and siblings never overlap.
53
+ * 2. **Relation edges** (`links-to` / `mentions`) are not part of the tree.
54
+ * They ride along at a longer distance and a fraction of the strength, so
55
+ * they decorate the structure instead of distorting it.
36
56
  *
37
- * ## Ring sizing — the one non-obvious formula
57
+ * `forceX()`/`forceY()` (d3 defaults: target 0, no accessor) replace the
58
+ * seed-anchored gravity — they pull every component toward the origin, which
59
+ * is the no-component-escapes-to-infinity guarantee the anchors existed for,
60
+ * and they mean a released drag needs no anchor bookkeeping at all.
38
61
  *
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.
62
+ * ## Why d3-force (§7.2)
63
+ *
64
+ * The retired simulation collapsed to a vertical line because repulsion and
65
+ * collision derive their direction as `dx / d`: once two nodes share an `x`,
66
+ * the x-component of the push is exactly zero forever, and damping freezes it
67
+ * there. d3-force injects `jiggle()` on exactly that zero (`manyBody.js`,
68
+ * `collide.js`, `link.js`), drawn from a **seeded** LCG — so we get symmetry
69
+ * breaking *and* reproducibility, which is what makes §8 a stable CI gate
70
+ * rather than a flaky one. d3 also fills nodes without positions on a
71
+ * deterministic phyllotaxis spiral, so cold starts need no invented seeding.
46
72
  */
47
73
 
48
74
  import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from "d3-force";
49
- import type { SimulationLinkDatum, SimulationNodeDatum } from "d3-force";
75
+ import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from "d3-force";
50
76
  import type { WireEdgeKind as EdgeKind, WireGraphEdge as GraphEdge, WireGraphModel as GraphModel } from "./graph";
51
77
  import type { Point } from "./metrics";
52
78
 
@@ -57,17 +83,21 @@ export interface LayoutOptions {
57
83
  ticks?: number;
58
84
  /** Seeds d3's jiggle LCG. Default 1. */
59
85
  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
86
  /**
65
87
  * Warm-start positions by node id. The client passes current positions when
66
88
  * re-running after a drag or an expand so the graph does not jump; the
67
89
  * dynamics gate passes coincident points to prove symmetry breaking. Ids
68
- * absent here fall back to {@link seedPositions}.
90
+ * absent here start on d3's own deterministic phyllotaxis spiral.
69
91
  */
70
92
  initial?: ReadonlyMap<string, Point>;
93
+ /**
94
+ * Hold the warm-started nodes in place while the simulation integrates the
95
+ * newcomers (d3's `fx`/`fy`). The collapse/expand pattern: an expand must
96
+ * hand its new children to the layout without shoving everything else out
97
+ * of the way — the existing arrangement is the user's, and the collide
98
+ * force packs the newcomers around it. Ignored without `initial`.
99
+ */
100
+ pinWarm?: boolean;
71
101
  }
72
102
 
73
103
  /** Visual node radius in layout units. The renderer must not draw larger than this. */
@@ -76,94 +106,40 @@ export const NODE_RADIUS = 9;
76
106
  /** Collision radius: the node plus breathing room for the leading edge of its label. */
77
107
  export const COLLIDE_RADIUS = NODE_RADIUS + 9;
78
108
 
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. */
109
+ /** `links-to` / `mentions` are associative, not structural: longer and weak. */
86
110
  const RELATION_DISTANCE = 220;
87
111
 
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
- }
112
+ /** Relation edges pull at a fraction of the containment link's strength — decoration, not structure. */
113
+ const RELATION_STRENGTH = 0.05;
110
114
 
111
- /** Body repulsion. Negative is repulsive; scaled up from d3's -30 for our node sizes. */
112
- const CHARGE_STRENGTH = -180;
115
+ /** Body repulsion — the force-directed-tree example's own value. */
116
+ const CHARGE_STRENGTH = -50;
113
117
 
114
118
  /**
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.
119
+ * The containment tree's spring: rest length and stiffness.
125
120
  *
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.
121
+ * The example's `distance(0).strength(1)` is rigid — correct for 3.5-pixel
122
+ * dots with no collide, and violent here: with a collision radius and a
123
+ * 189-child hub, dragging the hub yanked every child at full strength and the
124
+ * whole tree thrashed (measured: >9000 units of other-node motion per tick on
125
+ * this repository's real graph). Springs with real rest length and low
126
+ * stiffness keep every drag a local ripple while collide still packs the
127
+ * cluster; the shape stays a tree because every node is *in* the tree, not
128
+ * because the links are rigid.
139
129
  */
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;
130
+ const CONTAINS_REST = 90;
131
+ const CONTAINS_STRENGTH = 0.02;
144
132
 
145
133
  const DEFAULT_TICKS = 300;
146
134
  const DEFAULT_SEED = 1;
147
- const DEFAULT_WIDTH = 1280;
148
- const DEFAULT_HEIGHT = 800;
149
135
 
150
136
  /** d3-force's own alpha floor (`simulation.js`); mirrored so `alphaDecay` can be derived from `ticks`. */
151
137
  const ALPHA_MIN = 0.001;
152
138
 
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
139
  interface SimNode extends SimulationNodeDatum {
164
140
  id: string;
165
- x: number;
166
- y: number;
141
+ x?: number;
142
+ y?: number;
167
143
  }
168
144
 
169
145
  interface SimLink extends SimulationLinkDatum<SimNode> {
@@ -173,40 +149,10 @@ interface SimLink extends SimulationLinkDatum<SimNode> {
173
149
  }
174
150
 
175
151
  /** Containment edges define the hierarchy; everything else is an association. */
176
- function isContainment(kind: EdgeKind): boolean {
152
+ export function isContainment(kind: EdgeKind): boolean {
177
153
  return kind === "contains" || kind === "anchored-at";
178
154
  }
179
155
 
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
156
  /**
211
157
  * d3-force's LCG (`lcg.js`: a = 1664525, c = 1013904223, m = 2³²), re-exposed
212
158
  * so `seed` actually selects a stream — d3 always builds its own with s = 1,
@@ -217,31 +163,18 @@ export function lcg(seed: number): () => number {
217
163
  return () => (s = (1664525 * s + 1013904223) % 4294967296) / 4294967296;
218
164
  }
219
165
 
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
166
  interface Structure {
226
167
  /** Ids in `model.nodes` order, deduped. */
227
168
  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
169
  /** Edges with both endpoints present, no self-loops, deduped. */
235
170
  edges: GraphEdge[];
236
- /** Degree over that filtered edge set, both directions. */
237
- degree: Map<string, number>;
238
171
  }
239
172
 
240
173
  /**
241
174
  * Normalise the model into something a simulation can consume: drop self-edges
242
175
  * 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.
176
+ * those), and dedupe. Malformed input is the caller's bug, but it must not be
177
+ * the layout's crash.
245
178
  */
246
179
  function analyse(model: GraphModel): Structure {
247
180
  const ids: string[] = [];
@@ -254,9 +187,6 @@ function analyse(model: GraphModel): Structure {
254
187
 
255
188
  const edges: GraphEdge[] = [];
256
189
  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
190
  for (const e of model.edges) {
261
191
  if (e.source === e.target) continue;
262
192
  if (!known.has(e.source) || !known.has(e.target)) continue;
@@ -264,139 +194,8 @@ function analyse(model: GraphModel): Structure {
264
194
  if (seen.has(key)) continue;
265
195
  seen.add(key);
266
196
  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
197
  }
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;
198
+ return { ids, edges };
400
199
  }
401
200
 
402
201
  /**
@@ -405,93 +204,111 @@ function endpointId(endpoint: string | SimNode): string {
405
204
  *
406
205
  * The simulation is stepped **synchronously** — `stop()` then a manual `tick()`
407
206
  * loop — so it never touches `requestAnimationFrame` and works headless.
207
+ * Warm ids keep their positions; ids without one start on d3's deterministic
208
+ * phyllotaxis spiral, which is why a cold start needs no invented seeding.
408
209
  */
409
210
  export function computeLayout(model: GraphModel, options: LayoutOptions = {}): Map<string, Point> {
410
211
  const ticks = Math.max(0, Math.trunc(options.ticks ?? DEFAULT_TICKS));
411
212
  const seed = options.seed ?? DEFAULT_SEED;
412
- const width = options.width ?? DEFAULT_WIDTH;
413
- const height = options.height ?? DEFAULT_HEIGHT;
414
213
 
415
- const { ids, children, edges, degree, roots } = analyse(model);
214
+ const { ids, edges } = analyse(model);
416
215
  const out = new Map<string, Point>();
417
216
  if (ids.length === 0) return out;
418
217
 
419
- const seeds = seedPositions(model, width, height);
420
218
  const warm = options.initial;
421
219
  const nodes: SimNode[] = ids.map((id) => {
422
- const fallback = seeds.get(id)!;
423
- return { id, ...finiteOr(warm?.get(id), fallback), vx: 0, vy: 0 };
220
+ const at = warm?.get(id);
221
+ const node: SimNode = { id, vx: 0, vy: 0 };
222
+ if (at !== undefined && Number.isFinite(at.x) && Number.isFinite(at.y)) {
223
+ node.x = at.x;
224
+ node.y = at.y;
225
+ if (options.pinWarm === true) {
226
+ node.fx = at.x;
227
+ node.fy = at.y;
228
+ }
229
+ }
230
+ return node;
424
231
  });
425
232
 
426
233
  if (nodes.length > 1) {
427
- runSimulation(nodes, edges, children, degree, seeds, new Set(roots), { ticks, seed });
234
+ runSimulation(nodes, edges, { ticks, seed, warm: warm !== undefined });
428
235
  }
236
+ // A pinned run leaves the pins behind; clear them so the returned map is
237
+ // positions, not a promise to keep standing there forever.
238
+ if (options.pinWarm === true) for (const n of nodes) { n.fx = null; n.fy = null; }
429
239
 
430
240
  for (const n of nodes) {
431
241
  // 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));
242
+ // ever receives a NaN. d3 fills every node during `initialize`, so the
243
+ // fallback is unreachable in practice — but the contract is the contract.
244
+ 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 });
434
245
  }
435
246
  return out;
436
247
  }
437
248
 
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
- };
249
+ export interface ForceSimulationOptions<N> {
250
+ nodes: N[];
251
+ /** String endpoints are resolved by node id; dangling ids must be filtered out by the caller. */
252
+ links: Array<{ source: string | N; target: string | N; kind: EdgeKind }>;
253
+ /**
254
+ * Per-node `forceX`/`forceY` targets, re-read every tick. Absent (or an id
255
+ * missing from the map) targets the origin — d3's own default — which is the
256
+ * no-component-escapes guarantee. A live driver mutates the map on drag
257
+ * release so a dropped node rests where the user put it.
258
+ */
259
+ anchors?: ReadonlyMap<string, Point>;
260
+ /** Seeds d3's jiggle LCG. Default 1 — deterministic in Node and browser. */
261
+ seed?: number;
449
262
  }
450
263
 
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)
264
+ /**
265
+ * The force configuration, as ONE definition shared by the static layout
266
+ * ({@link computeLayout}) and the live driver (`dynamics.ts`).
267
+ *
268
+ * It is d3's force-directed-tree recipe: the containment tree holds children
269
+ * at their parent with full strength, and the arrangement emerges from gentle
270
+ * repulsion and collision; `forceX()`/`forceY()` pull every component toward
271
+ * the origin at d3's default strength, so nothing drifts to infinity.
272
+ *
273
+ * The forces live here once so the static and live equilibria cannot diverge:
274
+ * whatever the static layout settles to is exactly what the live sim holds.
275
+ * The simulation is returned stopped; the driver owns the alpha policy — the
276
+ * static path decays to the alpha floor over its tick budget, the live path
277
+ * re-heats on interaction and sleeps when the floor is reached. Velocity
278
+ * decay stays at d3's own default, exactly like the example.
279
+ */
280
+ export function createForceSimulation<
281
+ N extends SimulationNodeDatum & { id: string },
282
+ >(opts: ForceSimulationOptions<N>): Simulation<N, undefined> {
283
+ const link = forceLink<N, { source: string | N; target: string | N; kind: EdgeKind }>(opts.links)
472
284
  .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
- })
285
+ .distance((l) => (isContainment(l.kind) ? CONTAINS_REST : RELATION_DISTANCE))
286
+ .strength((l) => (isContainment(l.kind) ? CONTAINS_STRENGTH : RELATION_STRENGTH))
480
287
  .iterations(2);
481
288
 
482
- const sim = forceSimulation<SimNode>(nodes)
483
- .randomSource(lcg(opts.seed))
484
- .force("charge", forceManyBody<SimNode>().strength(CHARGE_STRENGTH))
289
+ return forceSimulation<N>(opts.nodes)
290
+ .randomSource(lcg(opts.seed ?? DEFAULT_SEED))
291
+ .force("charge", forceManyBody<N>().strength(CHARGE_STRENGTH))
485
292
  .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)
293
+ .force("collide", forceCollide<N>(COLLIDE_RADIUS).strength(1).iterations(3))
294
+ .force("x", forceX<N>((n) => opts.anchors?.get(n.id)?.x ?? 0))
295
+ .force("y", forceY<N>((n) => opts.anchors?.get(n.id)?.y ?? 0))
296
+ .stop();
297
+ }
298
+
299
+ /** Configure and step the d3 simulation in place. Mutates `nodes`. */
300
+ function runSimulation(nodes: SimNode[], edges: readonly GraphEdge[], opts: { ticks: number; seed: number; warm: boolean }): void {
301
+ const links: SimLink[] = edges.map((e) => ({ source: e.source, target: e.target, kind: e.kind }));
302
+ const sim = createForceSimulation({ nodes, links, seed: opts.seed });
303
+ sim
304
+ // A cold start assembles from d3's phyllotaxis at full alpha; a warm start
305
+ // relaxes what is already on screen — d3's own re-heat value — so the
306
+ // graph does not re-arrange itself under the user on every expand.
307
+ .alpha(opts.warm ? 0.3 : 1)
490
308
  .alphaMin(ALPHA_MIN)
491
309
  // 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);
310
+ // Velocity decay stays at d3's own default — the example sets neither.
311
+ .alphaDecay(opts.ticks > 0 ? 1 - Math.pow(ALPHA_MIN, 1 / opts.ticks) : 0);
494
312
 
495
- sim.stop();
496
313
  for (let i = 0; i < opts.ticks; i++) sim.tick();
497
- }
314
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * logo.ts — the pi-weave brand mark as an inline data URI.
3
+ *
4
+ * The web viewer is a single committed bundle behind a strict CSP
5
+ * (img-src 'self' data:), so the brand mark is an inlined 48×48 PNG —
6
+ * the transparent-background spider derived from docs/pi-weave-logo.png —
7
+ * rather than a new static route. Same pattern as the TUI's branding.ts.
8
+ */
9
+ export const LOGO_MARK_MIME = "image/png";
10
+ /** 48×48 transparent PNG, base64 — crisp at 2× for the 18px header mark. */
11
+ export const LOGO_MARK_B64 = "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAMKADAAQAAAABAAAAMAAAAADbN2wMAAALxElEQVRoBe1ZCXCU1R3/f+d+u5s9sleyOUlICBAMkQQQgpqBKqAoFQZFEYwWUAShjsco7dg4njRtPQcoXhSxWMGCimIYj1QqeICAcoQrd0iy2c1+u/vdZ9+m0xnFVCKw0+kMb+fb7/vevnnv//v/f//jvQVIYWtsbCn47uTJ3BQuAXgqJzdRM9CVyjVSCkACCUBIpfgAZCqnl5D8GJ5aBCm1ACALiGIqVZRqC7AIAGApRZBSCiUUBVN14/8YAJfAdN1MKU1TagEuwWEgp1T+1EYhRVUMhdNSmgdSagFeEHVJU/RUOkFKAThIWknLZNRUAkhFjKPSg2Muszocl/i8/mLT1M1IpO/rCBvaK/cePXGhwVxAANacYEHlkmEjymeguFNKUhShaToYhgG6rgJuAifL4oHjpxo3xdu/fAkBuSCWuSAAPNljLsvPH/lCusdfSVlIkFUFVCS0CQYkazkMuTGJEUAQJMrMEkTD3TvaWg/dJUab2s7XIsT5TkA6Cifl5434sCCvqEAS4sBzMdAVFdxYOjgxF6ShD6VTkBCjoEkiEKYBAX9WsaGT0yKh8DsAYvx8ZDhPADnW4SXlr+XnDh0WY3tBlgQwDQ10UQe/NQgOygkexoe4E4WE0AdgIkohWskiD440t9/EKX+cbd36PwNgd+ffMyR/2GJZjoOmySAryfITAxNHQkoyiLLYT6U2thHRB0djNPSuAY4lnyVQVLlMEMm9utp3zs59XmnSmeaelmZzgq7KkIjHgY3FQFOUfs4HHYUgawLQuAVspgdZRgcFAWRZFlRFRmMMtJvCMIKiZpyPBX5WHnA4hvkom/N6VVc+T4S/bXU4XCUCokZSaJokUd3JIDBqP4CuWDMIegRaIhIQyIFV1A+GCXbG2n/XVR2NN4AkiYokAE+g4mqapsrZRHSzFDvWPFhQPweAy+HxfZQdLBgtSVxPs8I/gdji1ZA2RVEAl98D106fAjzPw57P9gAb7QQLZQHVjIOAAKYHAjCheiLgyOY7360HmZdQiNWSlilM95c9npeT9yBJMxS0Q013DMYiAPxgQAzaiTOyxt/rcqXPa2tvApLA0zCcmsrQFIVhBmaxW+DJVQ/BroZdEOrugoUL58LB/Y0gCRLKAQZk5GTA0uXz4Z2t28Fuo+GeZTWwc+enIAhC8nerlbFeoagScbqrA3wejx8j3L0C3/XFYAAMygfs9pIyl9O1MhYNQzzGrgj1hrZKsoYbhorzXByurL4Mwt2nYcNL68DvcUKWzwlFQ7OQU8ugagoMLciEHL8Lcd6Etc+sAcxQYEzFJSAl95zID+KcBD294Q2JRGI5G2fB5fI8YnGUlAwGwGAohNMMU4cWc/Sx7KuG2vI8x8Jawjpso81mmZNmRTRBcV9B/PbmFcNL6zfD9voGmFRZDiamIfEwiERZmHbDIojHBQgOLQBJ1UBE1jGQdRTN0AVBXGfIJ+9FAstRlqqgKOttDEU/JQPMRn0/Wc2elUIMkzGEsTlXJXi+XUzEb3K48m7Jy7/0EcOQFUXRhtusNN7dE4aJl08EX4YXWFGFkqJC6Go/jaKO0p+JNeSweYUFkOb3wby5M5AlLPD2W+8ix1ZAkuWEw2FrzM4tu0/RGUaMs8+aOH4NTuIVkkmvBzWe+ClLDFhK+EpmVyL1lbsN/vWOrhM3khbrBo7nZjot+JFA5tBGTdcJUeIhnuDAjixgtzKQGcyCEWWjkBNz0Hz8GIR6QuByu4GiCYhGYuAPeKFg2HCw0BQc2PsN9PR0I+2bIKJ84XQ60BwOFJFIs635cLEO5FCLzb5D4rnZdXJz/ZHPDyxDfNz38pTxn5wJ5kcWmANziGYf9iFGuhbqBN7Js20WXZY/MOSWjZnZZS/abday1o6WQ5KsHHc60rOCmQGcZaMgoeza2dIKXW0toMgyijYYTKwaC5mZGdDe2g4CL0BHczN0oOcYyhcySmrZwWyQUaWXiLEf83w84fP6gjhppWN9B59XJThlc1C23KorJkEw94+KwE+4IT+4tqGh4Qeb7IEsgPmLZ69EaX4egSmfUTR2imacR+OhZi7T5XqPZXvtPT1dNarUvKmoZFqb00ZmtnR2ogJOA4eVAgrlA5Ig+rmvo7LBRB+CJPqppOs6SMixeVRqoAgGOUE/RFi+tbNt9xAgcmbl5Q3ZYrenR4pmVNxUfvVUXzwSzVVwwq243NNNTd/46rSJz53pEwNFIbP3xNtP2OhEFYqRR2UdX6OqYjdtd47ri/bSmirL/iFF3ZfPucfNxkInBIEDK00jpcu7E6KcYHmUlUUZBFlFOcAE3cRAkDTgBBlinAhokxZFkekf6e505AIqJBJsY119faD6uqmGpkiapPE2f0awCJXeB8W+wPOuuNzl23tgChL+2TOFT9JpIADJfmj77v0oup2wEDCpff+bewvGD98IFBWxEDiDwsf9hM8/05WT2xxLJEyaJmm7le7ho9FxkswvS/Dc/dFY/LlonNvSF49vY+Pc6hifWIpC8Dw+dqrS7XIj7zaocJQ1Kqdc0YZJ9PRwT2QJCrOU2+8Jjysd/envp08+FtBbr8RVaF710J2xfqEG+PqRD3x/THG+rUU0PLPtROE39uyMcWxXVycKLZM5Nmoc++dbS1csvnnrRx/vxVE9M8GTkV3qyw6Ee1u/qtOVyB50fajJvZs1Ofw3XQl/sF2OfDtz6W29TYfaF1hp+0IBhdFYLPrI8f31D+x88y+H4qz2G4vV6qXtrqdLKyrJ2b+YcJITbTNDVGjTvu3bf8D778s4kA98/3fIKps1BjOpsa5AuuXIod2vOBStHsXpqr6+SHX1pJrPWaqlINTW8QeU1aZnlZdR+eUlvyU0vY1kLCRptZiMg0F1EkHjBBgUQVQ27W+8dt+2nXYbSW5cNOf2Y8hDTr++7a9MU1vTG2Coh2PRo1WPPrZ1kSpKhkbInz39WM2+Hwh0xstZE5nOmx2YlamPhGWtIOeSxlioqV6K81W+YPbdLN1ZihEWLm/kqId0XT7sLyzo0riEDyfpRlRRNxk4jRJcQiEMtLtXyOt0IHYXlA45Xgiz4tmZubvwU60tkjdQ6khzLZIVExsz8tK9117+6Aw9xDxoIWweTQmNOEPeH72eFYCVIXHJNATAqBzd0Ce7fJmbuGhjKaIRf2Bn9lqAWqNi8WIqXUhvf3/VijXXPFCbyTC2+QppYSinN6wIHI2Z2DTM1Hc4KAPFePygE7PLoUhk7JMvLD+GJPoGyLxWmmb+XpI36kFRkGehAtxiqFo3SgtnPZL5r078H6gthzd345g6iTbk0ThmNuMYE1S4ozWSzNYlhU+Oo7stowRePJ18/qCutnvT75bVaR5fjeTyf6va019WJellKsxyAqcH/jR/7sHTEaVN541MtF/+N4UJc7MiHJ8f8GVUaqZqSlhstAnRisdfXHDWsvqsFkgKdfrQm+3olrwOF1feOLdk4q23B4cVvdawvhZ1AaBN1nBTV79MPpfOqaXdQmQ838OC1euTUbncjHP4nSoeWGF09d2VHLNu3Z3C8kWruTvueMqHXntfe3hD56HOYzWKIsefeWUJOrFIHgMMrp3ViQeapqRqbiVOWyfjJLlTZZwdjMrfAgb/itXmrqJJy0gMx47rUmSXf/pV3m1Lbm655f4X51oY5zgqLh7Red5u8PoXhElngsnkY5i4h6TwaswkGla/sfTrgdb7qb5zApCcsKTqDlTAkLertPNhAsMkq5pYzxDYJ1+9v2bX9xe8fuHKDJr2/nLL6vv+nOxfsOBJryli1zFGcKWN8hbzemiLyLX/6o0dted0OnHOAJLClFy1LEvBiMMYQSgMSHUUgUcoEpOApFmaolkGw3UrYbuR0I3dhKoLlEGmo2LDhTb4HGV4J1Fgv17RYr9+ddvit5LznUs7LwDJBUurby1C1aPW2LC+pbT67jTKTfssFOFFB1lOdJSF6lSKoHCStSgGj0qkMNrDhN97r7b/j7M5U2s9m+tr0XnLxXZRAxc1cFEDFzVwUQPnpoF/AbqLsVZFbswxAAAAAElFTkSuQmCC";