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.
- package/README.md +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- 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
|
+
}
|