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.
- package/README.md +9 -3
- package/package.json +2 -2
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +53 -0
- package/src/core/graph/build.ts +60 -1
- package/src/core/graph/model.ts +15 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +2 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/vault.ts +264 -16
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +144 -37
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +1 -1
- package/src/web/client/dist/app.js +52 -39
- package/src/web/client/graph/Graph.tsx +70 -19
- package/src/web/client/graph/column.model.ts +11 -70
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +10 -2
- package/src/web/client/graph/positions.ts +51 -10
- package/src/web/client/graph/renderer.ts +61 -1
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Header.tsx +11 -1
- package/src/web/client/shell/Shell.tsx +17 -0
- package/src/web/client/shell/theme.ts +15 -2
- package/src/web/client/state.ts +11 -0
- package/src/web/client/tree/Tree.tsx +4 -1
- package/src/web/client/workspace.ts +69 -5
- package/src/web/server/page.ts +2 -0
- package/src/web/server/routes.ts +19 -7
- package/src/web/shared/graph.ts +7 -0
- package/src/web/shared/layout.ts +158 -341
- package/src/web/shared/logo.ts +11 -0
package/src/web/shared/layout.ts
CHANGED
|
@@ -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
|
-
* ##
|
|
18
|
+
* ## The recipe — d3's own force-directed tree, not ours
|
|
19
19
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
48
|
+
* Two deviations from the example, both stated rather than hidden:
|
|
29
49
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
89
|
-
const
|
|
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
|
|
112
|
-
const CHARGE_STRENGTH = -
|
|
115
|
+
/** Body repulsion — the force-directed-tree example's own value. */
|
|
116
|
+
const CHARGE_STRENGTH = -50;
|
|
113
117
|
|
|
114
118
|
/**
|
|
115
|
-
*
|
|
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
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
166
|
-
y
|
|
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
|
|
244
|
-
*
|
|
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,
|
|
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
|
|
423
|
-
|
|
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,
|
|
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
|
|
433
|
-
|
|
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
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
/**
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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) ?
|
|
474
|
-
|
|
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
|
-
|
|
483
|
-
.randomSource(lcg(opts.seed))
|
|
484
|
-
.force("charge", forceManyBody<
|
|
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<
|
|
487
|
-
.force("x", forceX<
|
|
488
|
-
.force("y", forceY<
|
|
489
|
-
.
|
|
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
|
-
|
|
493
|
-
.
|
|
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 = "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";
|