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.
Files changed (98) hide show
  1. package/README.md +123 -37
  2. package/package.json +17 -5
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/cache/workspace.ts +466 -0
  5. package/src/core/concurrency.ts +36 -0
  6. package/src/core/frontmatter.ts +270 -23
  7. package/src/core/git.ts +19 -0
  8. package/src/core/graph/build.ts +97 -5
  9. package/src/core/graph/current.ts +41 -28
  10. package/src/core/graph/mentions.ts +170 -0
  11. package/src/core/graph/model.ts +39 -0
  12. package/src/core/graph/wikilinks.ts +5 -1
  13. package/src/core/index.ts +14 -0
  14. package/src/core/openInEditor.ts +69 -0
  15. package/src/core/paths.ts +7 -0
  16. package/src/core/sessions.ts +929 -0
  17. package/src/core/summaries.ts +1 -19
  18. package/src/core/types.ts +40 -0
  19. package/src/core/vault.ts +739 -57
  20. package/src/core/view/cluster.ts +262 -0
  21. package/src/core/view/detail.ts +118 -0
  22. package/src/core/view/focus.ts +109 -0
  23. package/src/core/view/health.ts +156 -0
  24. package/src/core/view/index.ts +15 -0
  25. package/src/core/view/links.ts +105 -0
  26. package/src/core/view/time.ts +47 -0
  27. package/src/core/view/tree.ts +269 -0
  28. package/src/core/view/types.ts +39 -0
  29. package/src/core/workspace.ts +3 -3
  30. package/src/pi/index.ts +248 -48
  31. package/src/pi/sessionScan.ts +104 -0
  32. package/src/pi/summarize.ts +24 -4
  33. package/src/pi/tools/noteTool.ts +13 -4
  34. package/src/pi/viewer/tui/branding.ts +8 -7
  35. package/src/pi/viewer/tui/explorer.ts +5 -3
  36. package/src/pi/viewer/tui/model.ts +46 -667
  37. package/src/pi/viewer/tui/openNote.ts +7 -56
  38. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  39. package/src/pi/viewer/web/run.ts +331 -0
  40. package/src/web/client/api.dom.ts +40 -0
  41. package/src/web/client/api.ts +472 -0
  42. package/src/web/client/bootstrap.ts +58 -0
  43. package/src/web/client/context/context.model.ts +313 -0
  44. package/src/web/client/dist/app.js +764 -0
  45. package/src/web/client/graph/Graph.tsx +209 -0
  46. package/src/web/client/graph/column.model.ts +372 -0
  47. package/src/web/client/graph/dynamics.ts +176 -0
  48. package/src/web/client/graph/graph.model.ts +546 -0
  49. package/src/web/client/graph/positions.ts +380 -0
  50. package/src/web/client/graph/project.ts +153 -0
  51. package/src/web/client/graph/renderer.dom.ts +52 -0
  52. package/src/web/client/graph/renderer.ts +339 -0
  53. package/src/web/client/graph/scheme.ts +44 -0
  54. package/src/web/client/live.model.ts +275 -0
  55. package/src/web/client/live.ts +151 -0
  56. package/src/web/client/main.tsx +27 -0
  57. package/src/web/client/note/Editor.tsx +102 -0
  58. package/src/web/client/note/Note.tsx +113 -0
  59. package/src/web/client/note/editor.controller.ts +151 -0
  60. package/src/web/client/note/editor.model.ts +636 -0
  61. package/src/web/client/note/note.model.ts +738 -0
  62. package/src/web/client/search/SearchPalette.tsx +105 -0
  63. package/src/web/client/search/search.model.ts +588 -0
  64. package/src/web/client/search/search.ts +107 -0
  65. package/src/web/client/selection.storage.ts +69 -0
  66. package/src/web/client/shell/Columns.tsx +161 -0
  67. package/src/web/client/shell/ContextRail.tsx +87 -0
  68. package/src/web/client/shell/Divider.tsx +44 -0
  69. package/src/web/client/shell/FocusTrap.tsx +56 -0
  70. package/src/web/client/shell/Header.tsx +64 -0
  71. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  72. package/src/web/client/shell/Shell.tsx +210 -0
  73. package/src/web/client/shell/StatusBar.tsx +28 -0
  74. package/src/web/client/shell/cssvars.ts +70 -0
  75. package/src/web/client/shell/drag.model.ts +170 -0
  76. package/src/web/client/shell/focus.model.ts +100 -0
  77. package/src/web/client/shell/keys.model.ts +453 -0
  78. package/src/web/client/shell/keys.ts +59 -0
  79. package/src/web/client/shell/layout.model.ts +526 -0
  80. package/src/web/client/shell/shell.model.ts +333 -0
  81. package/src/web/client/shell/theme.ts +490 -0
  82. package/src/web/client/shell/viewport.ts +29 -0
  83. package/src/web/client/state.ts +89 -0
  84. package/src/web/client/tree/Tree.tsx +141 -0
  85. package/src/web/client/tree/tree.model.ts +674 -0
  86. package/src/web/client/workspace.ts +278 -0
  87. package/src/web/server/page.ts +258 -0
  88. package/src/web/server/routes.ts +987 -0
  89. package/src/web/server/security.ts +361 -0
  90. package/src/web/server/server.ts +275 -0
  91. package/src/web/server/sse.ts +321 -0
  92. package/src/web/server/watcher.ts +507 -0
  93. package/src/web/shared/graph.ts +213 -0
  94. package/src/web/shared/layout.ts +314 -0
  95. package/src/web/shared/logo.ts +11 -0
  96. package/src/web/shared/metrics.ts +136 -0
  97. package/src/web/shared/view.ts +200 -0
  98. 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 = "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAMKADAAQAAAABAAAAMAAAAADbN2wMAAALxElEQVRoBe1ZCXCU1R3/f+d+u5s9sleyOUlICBAMkQQQgpqBKqAoFQZFEYwWUAShjsco7dg4njRtPQcoXhSxWMGCimIYj1QqeICAcoQrd0iy2c1+u/vdZ9+m0xnFVCKw0+kMb+fb7/vevnnv//v/f//jvQVIYWtsbCn47uTJ3BQuAXgqJzdRM9CVyjVSCkACCUBIpfgAZCqnl5D8GJ5aBCm1ACALiGIqVZRqC7AIAGApRZBSCiUUBVN14/8YAJfAdN1MKU1TagEuwWEgp1T+1EYhRVUMhdNSmgdSagFeEHVJU/RUOkFKAThIWknLZNRUAkhFjKPSg2Muszocl/i8/mLT1M1IpO/rCBvaK/cePXGhwVxAANacYEHlkmEjymeguFNKUhShaToYhgG6rgJuAifL4oHjpxo3xdu/fAkBuSCWuSAAPNljLsvPH/lCusdfSVlIkFUFVCS0CQYkazkMuTGJEUAQJMrMEkTD3TvaWg/dJUab2s7XIsT5TkA6Cifl5434sCCvqEAS4sBzMdAVFdxYOjgxF6ShD6VTkBCjoEkiEKYBAX9WsaGT0yKh8DsAYvx8ZDhPADnW4SXlr+XnDh0WY3tBlgQwDQ10UQe/NQgOygkexoe4E4WE0AdgIkohWskiD440t9/EKX+cbd36PwNgd+ffMyR/2GJZjoOmySAryfITAxNHQkoyiLLYT6U2thHRB0djNPSuAY4lnyVQVLlMEMm9utp3zs59XmnSmeaelmZzgq7KkIjHgY3FQFOUfs4HHYUgawLQuAVspgdZRgcFAWRZFlRFRmMMtJvCMIKiZpyPBX5WHnA4hvkom/N6VVc+T4S/bXU4XCUCokZSaJokUd3JIDBqP4CuWDMIegRaIhIQyIFV1A+GCXbG2n/XVR2NN4AkiYokAE+g4mqapsrZRHSzFDvWPFhQPweAy+HxfZQdLBgtSVxPs8I/gdji1ZA2RVEAl98D106fAjzPw57P9gAb7QQLZQHVjIOAAKYHAjCheiLgyOY7360HmZdQiNWSlilM95c9npeT9yBJMxS0Q013DMYiAPxgQAzaiTOyxt/rcqXPa2tvApLA0zCcmsrQFIVhBmaxW+DJVQ/BroZdEOrugoUL58LB/Y0gCRLKAQZk5GTA0uXz4Z2t28Fuo+GeZTWwc+enIAhC8nerlbFeoagScbqrA3wejx8j3L0C3/XFYAAMygfs9pIyl9O1MhYNQzzGrgj1hrZKsoYbhorzXByurL4Mwt2nYcNL68DvcUKWzwlFQ7OQU8ugagoMLciEHL8Lcd6Etc+sAcxQYEzFJSAl95zID+KcBD294Q2JRGI5G2fB5fI8YnGUlAwGwGAohNMMU4cWc/Sx7KuG2vI8x8Jawjpso81mmZNmRTRBcV9B/PbmFcNL6zfD9voGmFRZDiamIfEwiERZmHbDIojHBQgOLQBJ1UBE1jGQdRTN0AVBXGfIJ+9FAstRlqqgKOttDEU/JQPMRn0/Wc2elUIMkzGEsTlXJXi+XUzEb3K48m7Jy7/0EcOQFUXRhtusNN7dE4aJl08EX4YXWFGFkqJC6Go/jaKO0p+JNeSweYUFkOb3wby5M5AlLPD2W+8ix1ZAkuWEw2FrzM4tu0/RGUaMs8+aOH4NTuIVkkmvBzWe+ClLDFhK+EpmVyL1lbsN/vWOrhM3khbrBo7nZjot+JFA5tBGTdcJUeIhnuDAjixgtzKQGcyCEWWjkBNz0Hz8GIR6QuByu4GiCYhGYuAPeKFg2HCw0BQc2PsN9PR0I+2bIKJ84XQ60BwOFJFIs635cLEO5FCLzb5D4rnZdXJz/ZHPDyxDfNz38pTxn5wJ5kcWmANziGYf9iFGuhbqBN7Js20WXZY/MOSWjZnZZS/abday1o6WQ5KsHHc60rOCmQGcZaMgoeza2dIKXW0toMgyijYYTKwaC5mZGdDe2g4CL0BHczN0oOcYyhcySmrZwWyQUaWXiLEf83w84fP6gjhppWN9B59XJThlc1C23KorJkEw94+KwE+4IT+4tqGh4Qeb7IEsgPmLZ69EaX4egSmfUTR2imacR+OhZi7T5XqPZXvtPT1dNarUvKmoZFqb00ZmtnR2ogJOA4eVAgrlA5Ig+rmvo7LBRB+CJPqppOs6SMixeVRqoAgGOUE/RFi+tbNt9xAgcmbl5Q3ZYrenR4pmVNxUfvVUXzwSzVVwwq243NNNTd/46rSJz53pEwNFIbP3xNtP2OhEFYqRR2UdX6OqYjdtd47ri/bSmirL/iFF3ZfPucfNxkInBIEDK00jpcu7E6KcYHmUlUUZBFlFOcAE3cRAkDTgBBlinAhokxZFkekf6e505AIqJBJsY119faD6uqmGpkiapPE2f0awCJXeB8W+wPOuuNzl23tgChL+2TOFT9JpIADJfmj77v0oup2wEDCpff+bewvGD98IFBWxEDiDwsf9hM8/05WT2xxLJEyaJmm7le7ho9FxkswvS/Dc/dFY/LlonNvSF49vY+Pc6hifWIpC8Dw+dqrS7XIj7zaocJQ1Kqdc0YZJ9PRwT2QJCrOU2+8Jjysd/envp08+FtBbr8RVaF710J2xfqEG+PqRD3x/THG+rUU0PLPtROE39uyMcWxXVycKLZM5Nmoc++dbS1csvnnrRx/vxVE9M8GTkV3qyw6Ee1u/qtOVyB50fajJvZs1Ofw3XQl/sF2OfDtz6W29TYfaF1hp+0IBhdFYLPrI8f31D+x88y+H4qz2G4vV6qXtrqdLKyrJ2b+YcJITbTNDVGjTvu3bf8D778s4kA98/3fIKps1BjOpsa5AuuXIod2vOBStHsXpqr6+SHX1pJrPWaqlINTW8QeU1aZnlZdR+eUlvyU0vY1kLCRptZiMg0F1EkHjBBgUQVQ27W+8dt+2nXYbSW5cNOf2Y8hDTr++7a9MU1vTG2Coh2PRo1WPPrZ1kSpKhkbInz39WM2+Hwh0xstZE5nOmx2YlamPhGWtIOeSxlioqV6K81W+YPbdLN1ZihEWLm/kqId0XT7sLyzo0riEDyfpRlRRNxk4jRJcQiEMtLtXyOt0IHYXlA45Xgiz4tmZubvwU60tkjdQ6khzLZIVExsz8tK9117+6Aw9xDxoIWweTQmNOEPeH72eFYCVIXHJNATAqBzd0Ce7fJmbuGhjKaIRf2Bn9lqAWqNi8WIqXUhvf3/VijXXPFCbyTC2+QppYSinN6wIHI2Z2DTM1Hc4KAPFePygE7PLoUhk7JMvLD+GJPoGyLxWmmb+XpI36kFRkGehAtxiqFo3SgtnPZL5r078H6gthzd345g6iTbk0ThmNuMYE1S4ozWSzNYlhU+Oo7stowRePJ18/qCutnvT75bVaR5fjeTyf6va019WJellKsxyAqcH/jR/7sHTEaVN541MtF/+N4UJc7MiHJ8f8GVUaqZqSlhstAnRisdfXHDWsvqsFkgKdfrQm+3olrwOF1feOLdk4q23B4cVvdawvhZ1AaBN1nBTV79MPpfOqaXdQmQ838OC1euTUbncjHP4nSoeWGF09d2VHLNu3Z3C8kWruTvueMqHXntfe3hD56HOYzWKIsefeWUJOrFIHgMMrp3ViQeapqRqbiVOWyfjJLlTZZwdjMrfAgb/itXmrqJJy0gMx47rUmSXf/pV3m1Lbm655f4X51oY5zgqLh7Red5u8PoXhElngsnkY5i4h6TwaswkGla/sfTrgdb7qb5zApCcsKTqDlTAkLertPNhAsMkq5pYzxDYJ1+9v2bX9xe8fuHKDJr2/nLL6vv+nOxfsOBJryli1zFGcKWN8hbzemiLyLX/6o0dted0OnHOAJLClFy1LEvBiMMYQSgMSHUUgUcoEpOApFmaolkGw3UrYbuR0I3dhKoLlEGmo2LDhTb4HGV4J1Fgv17RYr9+ddvit5LznUs7LwDJBUurby1C1aPW2LC+pbT67jTKTfssFOFFB1lOdJSF6lSKoHCStSgGj0qkMNrDhN97r7b/j7M5U2s9m+tr0XnLxXZRAxc1cFEDFzVwUQPnpoF/AbqLsVZFbswxAAAAAElFTkSuQmCC";
@@ -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
+ }