pi-weave 0.1.7 → 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 +123 -37
- package/package.json +17 -5
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +270 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +97 -5
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +39 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +14 -0
- package/src/core/openInEditor.ts +69 -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/types.ts +40 -0
- package/src/core/vault.ts +739 -57
- 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/core/workspace.ts +3 -3
- package/src/pi/index.ts +248 -48
- 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 +5 -3
- 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 +764 -0
- package/src/web/client/graph/Graph.tsx +209 -0
- package/src/web/client/graph/column.model.ts +372 -0
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +546 -0
- package/src/web/client/graph/positions.ts +380 -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 +339 -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/selection.storage.ts +69 -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 +64 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +210 -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 +490 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +89 -0
- package/src/web/client/tree/Tree.tsx +141 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +278 -0
- package/src/web/server/page.ts +258 -0
- package/src/web/server/routes.ts +987 -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 +213 -0
- package/src/web/shared/layout.ts +314 -0
- package/src/web/shared/logo.ts +11 -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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph shapes as they appear **on the wire** (weave-workspace §5.3).
|
|
3
|
+
*
|
|
4
|
+
* ## Why these are declared here and not imported from core
|
|
5
|
+
*
|
|
6
|
+
* They used to be `import type { GraphModel } from "../../core/graph/model"`,
|
|
7
|
+
* and that single line was an architectural violation with a build failure
|
|
8
|
+
* attached to it.
|
|
9
|
+
*
|
|
10
|
+
* The reasoning that put it there was: "`import type` erases at compile time,
|
|
11
|
+
* so it cannot drag `node:fs` into the browser bundle." That is true, and it
|
|
12
|
+
* is also not the whole story. Type erasure protects the **bundle**; it does
|
|
13
|
+
* nothing for the **typecheck**. To resolve `GraphModel`, TypeScript must load
|
|
14
|
+
* `src/core/graph/model.ts`, which imports `../types`, and the compiler walks
|
|
15
|
+
* that whole graph — under `tsconfig.web.json`, which deliberately has
|
|
16
|
+
* `"types": []` and no node lib, so every `node:fs` in the transitive closure
|
|
17
|
+
* is an error. The client tier was importing core in every sense that
|
|
18
|
+
* mattered except the one the rule was written to check.
|
|
19
|
+
*
|
|
20
|
+
* So the tier rule is now literal rather than aspirational: **nothing under
|
|
21
|
+
* `src/web/shared/` imports `src/core` at all**, not even as a type. That is
|
|
22
|
+
* stricter than the §2 table as written (which permits core types here), and
|
|
23
|
+
* it is the version worth having, because "types only" is a distinction the
|
|
24
|
+
* compiler does not make when resolving a project.
|
|
25
|
+
*
|
|
26
|
+
* ## The contract is deliberately a copy, not an alias
|
|
27
|
+
*
|
|
28
|
+
* This is not duplication for its own sake — it is the wire format being
|
|
29
|
+
* honest about what it is. `GraphModel` is an *internal* core type, free to
|
|
30
|
+
* change shape when core needs it to. What crosses an HTTP boundary is a
|
|
31
|
+
* *contract*, and a contract that silently reshapes itself whenever an
|
|
32
|
+
* internal type is refactored is not a contract. Declaring it separately
|
|
33
|
+
* means a core change that would break the client is a visible, deliberate
|
|
34
|
+
* edit here rather than an invisible one.
|
|
35
|
+
*
|
|
36
|
+
* The obvious risk of a copy is drift, so drift is a **compile error**:
|
|
37
|
+
* `tests/web/wire.contract.test.ts` asserts mutual assignability between
|
|
38
|
+
* every type here and its core counterpart. That test is Node-side, where
|
|
39
|
+
* importing core is legal and free. Add a field to `GraphNode` in core and
|
|
40
|
+
* that test fails to compile until this file agrees — which is exactly the
|
|
41
|
+
* moment a human should be deciding whether the new field belongs on the
|
|
42
|
+
* wire.
|
|
43
|
+
*
|
|
44
|
+
* Isomorphic: no `node:*`, no DOM, no `src/core`, no `src/pi`.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where a piece of knowledge came from. Drives trust display (design §13).
|
|
49
|
+
*
|
|
50
|
+
* Mirrors `NoteSource` in `src/core/types.ts`.
|
|
51
|
+
*/
|
|
52
|
+
export type WireNoteSource = "human" | "agent" | "generated";
|
|
53
|
+
|
|
54
|
+
/** Mirrors `StalenessState` in `src/core/types.ts`. */
|
|
55
|
+
export type WireStalenessState = "missing" | "fresh" | "stale";
|
|
56
|
+
|
|
57
|
+
/** Mirrors `StalenessReport` in `src/core/types.ts`. */
|
|
58
|
+
export interface WireStalenessReport {
|
|
59
|
+
state: WireStalenessState;
|
|
60
|
+
reasons: string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Mirrors `NodeKind` in `src/core/graph/model.ts`. */
|
|
64
|
+
export type WireNodeKind =
|
|
65
|
+
| "vault"
|
|
66
|
+
| "note"
|
|
67
|
+
| "repository"
|
|
68
|
+
| "module"
|
|
69
|
+
| "package"
|
|
70
|
+
| "entryPoint"
|
|
71
|
+
| "gitState"
|
|
72
|
+
| "external"
|
|
73
|
+
| "file";
|
|
74
|
+
|
|
75
|
+
/** Mirrors `EdgeKind` in `src/core/graph/model.ts`. */
|
|
76
|
+
export type WireEdgeKind = "contains" | "anchored-at" | "links-to" | "mentions";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Every node kind. The graph legend and the table tests iterate this.
|
|
80
|
+
*
|
|
81
|
+
* A runtime value, unlike everything else in this file, because a renderer
|
|
82
|
+
* needs to enumerate kinds to build a legend and a `type` cannot be iterated.
|
|
83
|
+
* The contract test pins it against core's `NODE_KINDS` element-for-element,
|
|
84
|
+
* so a kind added to core and not here is a failing test rather than a
|
|
85
|
+
* legend that quietly omits a colour.
|
|
86
|
+
*/
|
|
87
|
+
export const WIRE_NODE_KINDS: readonly WireNodeKind[] = [
|
|
88
|
+
"vault",
|
|
89
|
+
"note",
|
|
90
|
+
"repository",
|
|
91
|
+
"module",
|
|
92
|
+
"package",
|
|
93
|
+
"entryPoint",
|
|
94
|
+
"gitState",
|
|
95
|
+
"external",
|
|
96
|
+
"file",
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** Every edge kind. Same reasoning as {@link WIRE_NODE_KINDS}. */
|
|
100
|
+
export const WIRE_EDGE_KINDS: readonly WireEdgeKind[] = ["contains", "anchored-at", "links-to", "mentions"];
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A single graph node. `id` is stable: derived from slugs and paths only.
|
|
104
|
+
*
|
|
105
|
+
* Mirrors `GraphNode` in `src/core/graph/model.ts`.
|
|
106
|
+
*/
|
|
107
|
+
export interface WireGraphNode {
|
|
108
|
+
id: string;
|
|
109
|
+
kind: WireNodeKind;
|
|
110
|
+
label: string;
|
|
111
|
+
/** Trust provenance for knowledge nodes; `null` for structural nodes. */
|
|
112
|
+
provenance: WireNoteSource | null;
|
|
113
|
+
/** Pre-formatted side-panel payload. Display-only by contract. */
|
|
114
|
+
detail: Record<string, string>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Mirrors `GraphEdge` in `src/core/graph/model.ts`. */
|
|
118
|
+
export interface WireGraphEdge {
|
|
119
|
+
source: string;
|
|
120
|
+
target: string;
|
|
121
|
+
kind: WireEdgeKind;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The authoritative node/edge data, as delivered to the browser.
|
|
126
|
+
*
|
|
127
|
+
* Mirrors `GraphModel` in `src/core/graph/model.ts`, **narrowed by one
|
|
128
|
+
* field**: core's `danglingLinks` (§4.2) is not repeated here.
|
|
129
|
+
*
|
|
130
|
+
* §2.1 allows a wire type to be deliberately narrower than its core
|
|
131
|
+
* counterpart provided the narrowing is *declared* rather than discovered,
|
|
132
|
+
* and this is that declaration. The reason is `GraphPayload` (§5.3): the
|
|
133
|
+
* payload already hoists that index to its own top level as `dangling`, so
|
|
134
|
+
* mirroring it inside `model` as well would put the same map on the wire
|
|
135
|
+
* twice and give the client two places to read one fact from.
|
|
136
|
+
*
|
|
137
|
+
* Two things keep the narrowing honest rather than aspirational.
|
|
138
|
+
* `tests/web/wire.contract.test.ts` asserts
|
|
139
|
+
* `Exact<WireGraphModel, Omit<GraphModel, "danglingLinks">>` — still mutual
|
|
140
|
+
* assignability, merely against an explicitly reduced core type, so a
|
|
141
|
+
* *second* core field added and forgotten still fails to compile. And
|
|
142
|
+
* `toGraphPayload` strips the key at the single point that builds the
|
|
143
|
+
* payload, because TypeScript's structural typing would otherwise let the
|
|
144
|
+
* extra property ride along into `JSON.stringify`.
|
|
145
|
+
*/
|
|
146
|
+
export interface WireGraphModel {
|
|
147
|
+
/**
|
|
148
|
+
* Data-as-of marker derived from inputs (max note `updated` / index stamp),
|
|
149
|
+
* so two builds of unchanged inputs produce byte-identical JSON. That is
|
|
150
|
+
* the property `GraphPayload.stamp` and the `304` path depend on.
|
|
151
|
+
*/
|
|
152
|
+
generatedAt: string;
|
|
153
|
+
staleness: WireStalenessReport | null;
|
|
154
|
+
nodes: WireGraphNode[];
|
|
155
|
+
edges: WireGraphEdge[];
|
|
156
|
+
/**
|
|
157
|
+
* Content fingerprint of the note bodies (see core `GraphModel`). Rides
|
|
158
|
+
* the wire so `GraphPayload.stamp` — hashed over these bytes — moves on a
|
|
159
|
+
* body-only edit, which is what un-dedupes the SSE frame that refetches
|
|
160
|
+
* an open note.
|
|
161
|
+
*/
|
|
162
|
+
contentDigest: string;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The core `GraphModel` keys {@link WireGraphModel} deliberately omits.
|
|
167
|
+
*
|
|
168
|
+
* A runtime value rather than only a comment, for the same reason
|
|
169
|
+
* {@link WIRE_NODE_KINDS} is one: the server has to actually delete these
|
|
170
|
+
* keys before serializing, and a hand-written second list of them at the
|
|
171
|
+
* emit site is a list that drifts. `toGraphPayload` iterates this.
|
|
172
|
+
*/
|
|
173
|
+
export const WIRE_MODEL_OMITTED_KEYS: readonly string[] = ["danglingLinks"];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* One note, read live for the note column.
|
|
177
|
+
*
|
|
178
|
+
* Mirrors `ViewNote` in `src/core/graph/current.ts`.
|
|
179
|
+
*/
|
|
180
|
+
export interface WireViewNote {
|
|
181
|
+
slug: string;
|
|
182
|
+
title: string;
|
|
183
|
+
body: string;
|
|
184
|
+
created: string;
|
|
185
|
+
updated: string;
|
|
186
|
+
tags: string[];
|
|
187
|
+
source: WireNoteSource;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Mirrors `NoteMeta` in `src/core/types.ts`. */
|
|
191
|
+
export interface WireNoteMeta {
|
|
192
|
+
title: string;
|
|
193
|
+
/** ISO-8601 timestamps. */
|
|
194
|
+
created: string;
|
|
195
|
+
updated: string;
|
|
196
|
+
tags: string[];
|
|
197
|
+
source: WireNoteSource;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Mirrors `NoteSummary` in `src/core/types.ts`. */
|
|
201
|
+
export interface WireNoteSummary extends WireNoteMeta {
|
|
202
|
+
/** File-name slug (no extension). Stable identity of the note. */
|
|
203
|
+
slug: string;
|
|
204
|
+
/** Size of the Markdown body in characters. */
|
|
205
|
+
bodyLength: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Mirrors `NoteSearchHit` in `src/core/types.ts`. */
|
|
209
|
+
export interface WireNoteSearchHit {
|
|
210
|
+
summary: WireNoteSummary;
|
|
211
|
+
score: number;
|
|
212
|
+
snippet: string;
|
|
213
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
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 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.
|
|
56
|
+
*
|
|
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.
|
|
61
|
+
*
|
|
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.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from "d3-force";
|
|
75
|
+
import type { Simulation, SimulationLinkDatum, SimulationNodeDatum } from "d3-force";
|
|
76
|
+
import type { WireEdgeKind as EdgeKind, WireGraphEdge as GraphEdge, WireGraphModel as GraphModel } from "./graph";
|
|
77
|
+
import type { Point } from "./metrics";
|
|
78
|
+
|
|
79
|
+
export type { Point } from "./metrics";
|
|
80
|
+
|
|
81
|
+
export interface LayoutOptions {
|
|
82
|
+
/** Simulation steps. Alpha decay is derived from this, so the budget stays meaningful. Default 300. */
|
|
83
|
+
ticks?: number;
|
|
84
|
+
/** Seeds d3's jiggle LCG. Default 1. */
|
|
85
|
+
seed?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Warm-start positions by node id. The client passes current positions when
|
|
88
|
+
* re-running after a drag or an expand so the graph does not jump; the
|
|
89
|
+
* dynamics gate passes coincident points to prove symmetry breaking. Ids
|
|
90
|
+
* absent here start on d3's own deterministic phyllotaxis spiral.
|
|
91
|
+
*/
|
|
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;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Visual node radius in layout units. The renderer must not draw larger than this. */
|
|
104
|
+
export const NODE_RADIUS = 9;
|
|
105
|
+
|
|
106
|
+
/** Collision radius: the node plus breathing room for the leading edge of its label. */
|
|
107
|
+
export const COLLIDE_RADIUS = NODE_RADIUS + 9;
|
|
108
|
+
|
|
109
|
+
/** `links-to` / `mentions` are associative, not structural: longer and weak. */
|
|
110
|
+
const RELATION_DISTANCE = 220;
|
|
111
|
+
|
|
112
|
+
/** Relation edges pull at a fraction of the containment link's strength — decoration, not structure. */
|
|
113
|
+
const RELATION_STRENGTH = 0.05;
|
|
114
|
+
|
|
115
|
+
/** Body repulsion — the force-directed-tree example's own value. */
|
|
116
|
+
const CHARGE_STRENGTH = -50;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The containment tree's spring: rest length and stiffness.
|
|
120
|
+
*
|
|
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.
|
|
129
|
+
*/
|
|
130
|
+
const CONTAINS_REST = 90;
|
|
131
|
+
const CONTAINS_STRENGTH = 0.02;
|
|
132
|
+
|
|
133
|
+
const DEFAULT_TICKS = 300;
|
|
134
|
+
const DEFAULT_SEED = 1;
|
|
135
|
+
|
|
136
|
+
/** d3-force's own alpha floor (`simulation.js`); mirrored so `alphaDecay` can be derived from `ticks`. */
|
|
137
|
+
const ALPHA_MIN = 0.001;
|
|
138
|
+
|
|
139
|
+
interface SimNode extends SimulationNodeDatum {
|
|
140
|
+
id: string;
|
|
141
|
+
x?: number;
|
|
142
|
+
y?: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
interface SimLink extends SimulationLinkDatum<SimNode> {
|
|
146
|
+
source: string | SimNode;
|
|
147
|
+
target: string | SimNode;
|
|
148
|
+
kind: EdgeKind;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Containment edges define the hierarchy; everything else is an association. */
|
|
152
|
+
export function isContainment(kind: EdgeKind): boolean {
|
|
153
|
+
return kind === "contains" || kind === "anchored-at";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* d3-force's LCG (`lcg.js`: a = 1664525, c = 1013904223, m = 2³²), re-exposed
|
|
158
|
+
* so `seed` actually selects a stream — d3 always builds its own with s = 1,
|
|
159
|
+
* and `simulation.randomSource()` is the documented way to replace it.
|
|
160
|
+
*/
|
|
161
|
+
export function lcg(seed: number): () => number {
|
|
162
|
+
let s = Math.trunc(seed) >>> 0;
|
|
163
|
+
return () => (s = (1664525 * s + 1013904223) % 4294967296) / 4294967296;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
interface Structure {
|
|
167
|
+
/** Ids in `model.nodes` order, deduped. */
|
|
168
|
+
ids: string[];
|
|
169
|
+
/** Edges with both endpoints present, no self-loops, deduped. */
|
|
170
|
+
edges: GraphEdge[];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Normalise the model into something a simulation can consume: drop self-edges
|
|
175
|
+
* and edges pointing at ids that are not nodes (d3's `forceLink` throws on
|
|
176
|
+
* those), and dedupe. Malformed input is the caller's bug, but it must not be
|
|
177
|
+
* the layout's crash.
|
|
178
|
+
*/
|
|
179
|
+
function analyse(model: GraphModel): Structure {
|
|
180
|
+
const ids: string[] = [];
|
|
181
|
+
const known = new Set<string>();
|
|
182
|
+
for (const n of model.nodes) {
|
|
183
|
+
if (known.has(n.id)) continue;
|
|
184
|
+
known.add(n.id);
|
|
185
|
+
ids.push(n.id);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const edges: GraphEdge[] = [];
|
|
189
|
+
const seen = new Set<string>();
|
|
190
|
+
for (const e of model.edges) {
|
|
191
|
+
if (e.source === e.target) continue;
|
|
192
|
+
if (!known.has(e.source) || !known.has(e.target)) continue;
|
|
193
|
+
const key = `${e.source}\u0000${e.target}\u0000${e.kind}`;
|
|
194
|
+
if (seen.has(key)) continue;
|
|
195
|
+
seen.add(key);
|
|
196
|
+
edges.push(e);
|
|
197
|
+
}
|
|
198
|
+
return { ids, edges };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Lay a graph out. Deterministic: the same model with the same `seed` produces
|
|
203
|
+
* byte-identical output, in Node or the browser.
|
|
204
|
+
*
|
|
205
|
+
* The simulation is stepped **synchronously** — `stop()` then a manual `tick()`
|
|
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.
|
|
209
|
+
*/
|
|
210
|
+
export function computeLayout(model: GraphModel, options: LayoutOptions = {}): Map<string, Point> {
|
|
211
|
+
const ticks = Math.max(0, Math.trunc(options.ticks ?? DEFAULT_TICKS));
|
|
212
|
+
const seed = options.seed ?? DEFAULT_SEED;
|
|
213
|
+
|
|
214
|
+
const { ids, edges } = analyse(model);
|
|
215
|
+
const out = new Map<string, Point>();
|
|
216
|
+
if (ids.length === 0) return out;
|
|
217
|
+
|
|
218
|
+
const warm = options.initial;
|
|
219
|
+
const nodes: SimNode[] = ids.map((id) => {
|
|
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;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
if (nodes.length > 1) {
|
|
234
|
+
runSimulation(nodes, edges, { ticks, seed, warm: warm !== undefined });
|
|
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; }
|
|
239
|
+
|
|
240
|
+
for (const n of nodes) {
|
|
241
|
+
// Guarded on the way out as well as in: the contract is that no caller
|
|
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 });
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
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;
|
|
262
|
+
}
|
|
263
|
+
|
|
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)
|
|
284
|
+
.id((n) => n.id)
|
|
285
|
+
.distance((l) => (isContainment(l.kind) ? CONTAINS_REST : RELATION_DISTANCE))
|
|
286
|
+
.strength((l) => (isContainment(l.kind) ? CONTAINS_STRENGTH : RELATION_STRENGTH))
|
|
287
|
+
.iterations(2);
|
|
288
|
+
|
|
289
|
+
return forceSimulation<N>(opts.nodes)
|
|
290
|
+
.randomSource(lcg(opts.seed ?? DEFAULT_SEED))
|
|
291
|
+
.force("charge", forceManyBody<N>().strength(CHARGE_STRENGTH))
|
|
292
|
+
.force("link", link)
|
|
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)
|
|
308
|
+
.alphaMin(ALPHA_MIN)
|
|
309
|
+
// Reach the same convergence at whatever tick budget the caller asked for.
|
|
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);
|
|
312
|
+
|
|
313
|
+
for (let i = 0; i < opts.ticks; i++) sim.tick();
|
|
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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";
|
|
@@ -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
|
+
}
|