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.
- package/README.md +8 -37
- package/package.json +1 -2
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +6 -7
- package/src/core/graph/current.ts +2 -4
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +3 -3
- package/src/core/index.ts +26 -27
- package/src/core/paths.ts +0 -7
- package/src/core/vault.ts +16 -681
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +1 -1
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -223
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +93 -219
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +21 -63
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +13 -83
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -125
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/shell.model.ts +10 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +33 -157
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +25 -575
- package/src/web/client/tree/tree.model.ts +8 -162
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -563
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -196
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/client/shell/Divider.tsx +0 -44
- package/src/web/client/shell/cssvars.ts +0 -70
- package/src/web/client/shell/drag.model.ts +0 -170
- package/src/web/client/shell/layout.model.ts +0 -500
- package/src/web/client/shell/viewport.ts +0 -29
- package/src/web/server/sse.ts +0 -321
- package/src/web/server/watcher.ts +0 -507
package/src/web/shared/layout.ts
CHANGED
|
@@ -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,
|
|
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
|
|
289
|
-
return () => (
|
|
50
|
+
let state = Math.trunc(seed) >>> 0;
|
|
51
|
+
return () => (state = (1664525 * state + 1013904223) % 4294967296) / 4294967296;
|
|
290
52
|
}
|
|
291
53
|
|
|
292
|
-
interface
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
309
|
-
if (known.has(
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
317
|
-
if (
|
|
318
|
-
|
|
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(
|
|
76
|
+
edges.push(edge);
|
|
323
77
|
}
|
|
324
78
|
return { ids, edges };
|
|
325
79
|
}
|
|
326
80
|
|
|
327
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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 =
|
|
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
|
|
573
|
-
degree.set(
|
|
574
|
-
degree.set(
|
|
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
|
-
|
|
602
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
}
|