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,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Computing the layout, and keeping it across sessions
|
|
3
|
+
* (weave-workspace §7.3, §11 P3).
|
|
4
|
+
*
|
|
5
|
+
* ## Where layout runs, as built
|
|
6
|
+
*
|
|
7
|
+
* §7.3 plans for the server to precompute positions and ship them in
|
|
8
|
+
* `GraphPayload.positions`. That is **not** what happens: `positions` is
|
|
9
|
+
* `null` by design (§5.3), because `shared/layout.ts` imports `d3-force` and
|
|
10
|
+
* the server tier's npm allowlist is empty. So the client runs the identical
|
|
11
|
+
* `shared/layout` code itself, which is exactly why that module lives in
|
|
12
|
+
* `shared/` and takes no DOM.
|
|
13
|
+
*
|
|
14
|
+
* The consequence §7.3 was trying to avoid — "the graph appears already laid
|
|
15
|
+
* out with no visible settling" — is what {@link loadPositions} is for. A
|
|
16
|
+
* cached layout from the last session is *better* than a server-computed one:
|
|
17
|
+
* it settles instantly **and** it is the arrangement the user already has a
|
|
18
|
+
* mental map of.
|
|
19
|
+
*
|
|
20
|
+
* ## When the simulation re-runs
|
|
21
|
+
*
|
|
22
|
+
* §7.3: "The client re-runs the sim only on user drag or expand/collapse." Not
|
|
23
|
+
* every frame, not on every SSE tick, and not on a resize. {@link layoutFor}
|
|
24
|
+
* is the single entry point and it is called from exactly those places;
|
|
25
|
+
* {@link shouldRelayout} is the rule for the case that is easy to get wrong —
|
|
26
|
+
* a new payload arriving over the wire.
|
|
27
|
+
*
|
|
28
|
+
* ## Tier rules (§2)
|
|
29
|
+
*
|
|
30
|
+
* `src/web/client/**`. `localStorage` arrives as a two-method port, not as the
|
|
31
|
+
* global, for the reason `layout.model.ts`'s `LayoutStorage` records: the real
|
|
32
|
+
* one *throws* in partitioned-storage contexts, and a narrow injected port is
|
|
33
|
+
* a better place for that wrapper than a component. No DOM type is named, so
|
|
34
|
+
* the root `tsconfig.json` project compiles the tests.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { computeLayout } from "../../shared/layout";
|
|
38
|
+
import type { LayoutOptions, Point } from "../../shared/layout";
|
|
39
|
+
import type { WireGraphEdge, WireGraphNode } from "../../shared/wire";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* FNV-1a (32-bit) followed by MurmurHash3's `fmix32` avalanche.
|
|
43
|
+
*
|
|
44
|
+
* The finalizer is not optional: FNV-1a mixes its *low* bits well but its high
|
|
45
|
+
* bits poorly for short, near-identical inputs — and the digest folds the hash
|
|
46
|
+
* into a bounded key, so the high bits are the significant part. Raw FNV-1a
|
|
47
|
+
* over `leaf001…leaf199` put 199 ids into six of twelve sectors; `fmix32`
|
|
48
|
+
* costs four lines and makes every bit depend on every input bit.
|
|
49
|
+
*/
|
|
50
|
+
export function hashId(id: string): number {
|
|
51
|
+
let h = 0x811c9dc5;
|
|
52
|
+
for (let i = 0; i < id.length; i++) {
|
|
53
|
+
h ^= id.charCodeAt(i);
|
|
54
|
+
h = Math.imul(h, 0x01000193);
|
|
55
|
+
}
|
|
56
|
+
h ^= h >>> 16;
|
|
57
|
+
h = Math.imul(h, 0x85ebca6b);
|
|
58
|
+
h ^= h >>> 13;
|
|
59
|
+
h = Math.imul(h, 0xc2b2ae35);
|
|
60
|
+
h ^= h >>> 16;
|
|
61
|
+
return h >>> 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
// --- computing ----------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Tick budget for a layout run.
|
|
69
|
+
*
|
|
70
|
+
* `shared/layout.ts` derives its alpha decay from this, so the simulation
|
|
71
|
+
* reaches the same convergence at any budget — the number buys smoothness of
|
|
72
|
+
* the *path*, not quality of the result. 300 is the value §8's gate asserts
|
|
73
|
+
* against, and matching it means the graph a user sees is the graph the
|
|
74
|
+
* dynamics test proved non-degenerate. A different budget here would make the
|
|
75
|
+
* gate a statement about a layout nobody renders.
|
|
76
|
+
*/
|
|
77
|
+
export const LAYOUT_TICKS = 300;
|
|
78
|
+
|
|
79
|
+
/** The seed. Fixed, so two loads of one vault produce one arrangement. */
|
|
80
|
+
export const LAYOUT_SEED = 1;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Lay out a node/edge set.
|
|
84
|
+
*
|
|
85
|
+
* `warm` is `computeLayout`'s `initial`: positions to start from rather than
|
|
86
|
+
* the deterministic seeding. Passed on a re-run so the picture *moves* to its
|
|
87
|
+
* new arrangement instead of being replaced by an unrelated one — §8 pins that
|
|
88
|
+
* re-running from a settled state is a near-fixed-point, which is the property
|
|
89
|
+
* that makes an expand feel like an expand.
|
|
90
|
+
*
|
|
91
|
+
* **New containment children start at their parent** (d3's own collapse/
|
|
92
|
+
* expand pattern): a newly visible node without a stored position enters the
|
|
93
|
+
* simulation at its parent's warm position, so the relaxation it needs is
|
|
94
|
+
* local — the collide force makes it step out among its siblings — instead of
|
|
95
|
+
* d3's phyllotaxis spiral dropping it across the graph and dragging the whole
|
|
96
|
+
* arrangement along.
|
|
97
|
+
*
|
|
98
|
+
* The viewport is **not** a parameter, deliberately. The layout is centred on
|
|
99
|
+
* the origin by `forceX()`/`forceY()`, and sigma's camera fits whatever extent
|
|
100
|
+
* it is handed — so feeding the real column width in would re-run the
|
|
101
|
+
* simulation on every divider drag to produce a picture the camera
|
|
102
|
+
* immediately normalises away. Keeping the layout a function of the *graph*
|
|
103
|
+
* alone is what makes it cacheable at all.
|
|
104
|
+
*/
|
|
105
|
+
export function layoutFor(
|
|
106
|
+
nodes: readonly WireGraphNode[],
|
|
107
|
+
edges: readonly WireGraphEdge[],
|
|
108
|
+
warm?: ReadonlyMap<string, Point>,
|
|
109
|
+
): Map<string, Point> {
|
|
110
|
+
const seeded = new Map<string, Point>(warm ?? []);
|
|
111
|
+
if (seeded.size > 0) {
|
|
112
|
+
for (const e of edges) {
|
|
113
|
+
if (e.kind !== "contains" && e.kind !== "anchored-at") continue;
|
|
114
|
+
if (seeded.has(e.target)) continue;
|
|
115
|
+
const at = seeded.get(e.source);
|
|
116
|
+
if (at !== undefined) seeded.set(e.target, { x: at.x, y: at.y });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const options: LayoutOptions = { ticks: LAYOUT_TICKS, seed: LAYOUT_SEED, pinWarm: true };
|
|
120
|
+
if (seeded.size > 0) options.initial = seeded;
|
|
121
|
+
return computeLayout(
|
|
122
|
+
{ generatedAt: "", staleness: null, nodes: [...nodes], edges: [...edges], contentDigest: "" },
|
|
123
|
+
options,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- the cache key ---------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A digest of the graph's *shape* — which nodes and edges exist, not where
|
|
131
|
+
* they are or what they say.
|
|
132
|
+
*
|
|
133
|
+
* This is the "repo hash" §11 P3 keys position persistence by, and what it
|
|
134
|
+
* hashes is the whole design. Three properties matter:
|
|
135
|
+
*
|
|
136
|
+
* 1. **Shape only.** A note's title changing, a tag being added, `generatedAt`
|
|
137
|
+
* advancing — none of those move a node, so none of them should throw away
|
|
138
|
+
* a layout the user has built a mental map of. Hashing the payload's
|
|
139
|
+
* `stamp` instead would invalidate on every edit, which is the same as not
|
|
140
|
+
* caching.
|
|
141
|
+
* 2. **Order-independent.** Node and edge order is stable today
|
|
142
|
+
* (`buildGraph` is byte-deterministic), but a cache that silently
|
|
143
|
+
* invalidates when core reorders its output would be a mystery, so the
|
|
144
|
+
* per-element hashes are combined by **XOR and sum** rather than by
|
|
145
|
+
* concatenation. Two graphs with the same elements in a different order
|
|
146
|
+
* share a key, which is correct: the layout does not depend on the order.
|
|
147
|
+
* 3. **Cheap.** One `hashId` per node and per edge, no string building. This
|
|
148
|
+
* runs on every payload arrival.
|
|
149
|
+
*
|
|
150
|
+
* The count is mixed in alongside the accumulators because XOR alone cannot
|
|
151
|
+
* see a duplicate (`x ^ x = 0`) and a sum alone collides too easily.
|
|
152
|
+
*/
|
|
153
|
+
export function graphShapeKey(nodes: readonly WireGraphNode[], edges: readonly WireGraphEdge[]): string {
|
|
154
|
+
let xor = 0;
|
|
155
|
+
let sum = 0;
|
|
156
|
+
for (const node of nodes) {
|
|
157
|
+
const h = hashId(node.id);
|
|
158
|
+
xor ^= h;
|
|
159
|
+
sum = (sum + h) >>> 0;
|
|
160
|
+
}
|
|
161
|
+
for (const edge of edges) {
|
|
162
|
+
const h = hashId(`${edge.source}\u0000${edge.target}\u0000${edge.kind}`);
|
|
163
|
+
xor ^= h;
|
|
164
|
+
sum = (sum + h) >>> 0;
|
|
165
|
+
}
|
|
166
|
+
const parts = [nodes.length, edges.length, xor >>> 0, sum].map((n) => n.toString(16));
|
|
167
|
+
return parts.join("-");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether a new payload needs the simulation re-run.
|
|
172
|
+
*
|
|
173
|
+
* The one decision that is easy to get wrong in both directions. Re-running on
|
|
174
|
+
* every payload makes the graph jump whenever anyone saves a file — the
|
|
175
|
+
* watcher fires, the graph refetches, and a picture the user was reading
|
|
176
|
+
* rearranges itself for no visible reason. Never re-running leaves new nodes
|
|
177
|
+
* unplaced and therefore undrawn (`renderGraph` drops a node with no
|
|
178
|
+
* position), so a note created in Obsidian would never appear.
|
|
179
|
+
*
|
|
180
|
+
* The shape key is exactly the line between those: it changes iff a node or an
|
|
181
|
+
* edge was added or removed, which is precisely when the existing positions
|
|
182
|
+
* are no longer a complete answer.
|
|
183
|
+
*/
|
|
184
|
+
export function shouldRelayout(previous: string | null, next: string): boolean {
|
|
185
|
+
return previous !== next;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- persistence (§7.4, §11 P3) ------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The slice of `Storage` this module uses.
|
|
192
|
+
*
|
|
193
|
+
* Same two-method port as `layout.model.ts`'s `LayoutStorage`, and declared
|
|
194
|
+
* separately rather than imported from it because the two are unrelated
|
|
195
|
+
* concerns that merely happen to share a shape — coupling the graph's cache to
|
|
196
|
+
* the column-width persistence would mean one cannot change without the other.
|
|
197
|
+
*/
|
|
198
|
+
export interface PositionStorage {
|
|
199
|
+
getItem(key: string): string | null;
|
|
200
|
+
setItem(key: string, value: string): void;
|
|
201
|
+
removeItem(key: string): void;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The `localStorage` key. Namespaced and versioned, like the layout's. */
|
|
205
|
+
export const POSITIONS_STORAGE_KEY = "pi-weave.graph.positions.v1";
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Coordinates are rounded to **one** decimal before storage.
|
|
209
|
+
*
|
|
210
|
+
* The layout spans thousands of units, so a tenth of a unit is far below one
|
|
211
|
+
* screen pixel at any zoom a person uses. It roughly halves the stored string
|
|
212
|
+
* and, more usefully, makes a hand-inspected entry readable. The rounding is
|
|
213
|
+
* *not* fed back into the simulation as a warm start's only source — it is a
|
|
214
|
+
* near-fixed-point either way (§8 pins re-running from settled positions moves
|
|
215
|
+
* nothing by more than `CONTAINS_DISTANCE`), and a tenth of a unit is orders
|
|
216
|
+
* of magnitude inside that.
|
|
217
|
+
*/
|
|
218
|
+
function round1(value: number): number {
|
|
219
|
+
return Math.round(value * 10) / 10;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Serialize a layout for storage.
|
|
224
|
+
*
|
|
225
|
+
* `[x, y]` pairs rather than `{x, y}` objects: same information, about 40 %
|
|
226
|
+
* fewer bytes, and `localStorage` is a shared 5 MB budget this is not the only
|
|
227
|
+
* consumer of.
|
|
228
|
+
*/
|
|
229
|
+
export function serializePositions(key: string, positions: ReadonlyMap<string, Point>): string {
|
|
230
|
+
const at: Record<string, [number, number]> = {};
|
|
231
|
+
for (const [id, point] of positions) at[id] = [round1(point.x), round1(point.y)];
|
|
232
|
+
return JSON.stringify({ v: 1, key, at });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Parse stored positions for a shape key, or `null`.
|
|
237
|
+
*
|
|
238
|
+
* Everything here is untrusted: the string is user-editable by construction
|
|
239
|
+
* (it lives in a devtools pane anyone can type into) and it outlives the
|
|
240
|
+
* schema, so a v1 reader will one day meet a v2 entry written by a newer build
|
|
241
|
+
* the user ran yesterday. Both have the same correct answer — return `null`
|
|
242
|
+
* and let the caller lay out from scratch. There is no repair path, because a
|
|
243
|
+
* partially-repaired layout is a bug report that says "my graph is weird
|
|
244
|
+
* sometimes".
|
|
245
|
+
*
|
|
246
|
+
* **A key mismatch is a miss, not an error.** That is the invalidation §11 P3
|
|
247
|
+
* asks for, and it is one comparison: the stored layout describes a graph with
|
|
248
|
+
* different nodes or edges in it, so it cannot be completed and is discarded
|
|
249
|
+
* whole rather than merged. A partial merge would place the old nodes and
|
|
250
|
+
* leave the new ones at the origin, which is the hairball §7.2 exists to
|
|
251
|
+
* prevent.
|
|
252
|
+
*/
|
|
253
|
+
export function deserializePositions(raw: string | null, key: string): Map<string, Point> | null {
|
|
254
|
+
if (raw === null) return null;
|
|
255
|
+
let parsed: unknown;
|
|
256
|
+
try {
|
|
257
|
+
parsed = JSON.parse(raw);
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
262
|
+
|
|
263
|
+
const record = parsed as Record<string, unknown>;
|
|
264
|
+
if (record["v"] !== 1) return null;
|
|
265
|
+
if (record["key"] !== key) return null;
|
|
266
|
+
|
|
267
|
+
const at = record["at"];
|
|
268
|
+
if (typeof at !== "object" || at === null || Array.isArray(at)) return null;
|
|
269
|
+
|
|
270
|
+
const out = new Map<string, Point>();
|
|
271
|
+
for (const [id, value] of Object.entries(at as Record<string, unknown>)) {
|
|
272
|
+
if (!Array.isArray(value) || value.length !== 2) return null;
|
|
273
|
+
const [x, y] = value as [unknown, unknown];
|
|
274
|
+
if (typeof x !== "number" || typeof y !== "number") return null;
|
|
275
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
276
|
+
out.set(id, { x, y });
|
|
277
|
+
}
|
|
278
|
+
// An entry that parsed to nothing is indistinguishable from no entry, and
|
|
279
|
+
// returning an empty map would make the caller warm-start from nowhere while
|
|
280
|
+
// believing it had a cache hit.
|
|
281
|
+
return out.size === 0 ? null : out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Read the cached layout for a shape, or `null`.
|
|
286
|
+
*
|
|
287
|
+
* A throwing `getItem` — Safari private browsing, partitioned storage — is
|
|
288
|
+
* indistinguishable from a miss as far as the graph is concerned, and a
|
|
289
|
+
* workspace that lays out from scratch is not an error worth surfacing.
|
|
290
|
+
*/
|
|
291
|
+
export function loadPositions(storage: PositionStorage, key: string): Map<string, Point> | null {
|
|
292
|
+
let raw: string | null;
|
|
293
|
+
try {
|
|
294
|
+
raw = storage.getItem(POSITIONS_STORAGE_KEY);
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
return deserializePositions(raw, key);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Persist a layout, best-effort. Returns whether it stuck.
|
|
303
|
+
*
|
|
304
|
+
* `setItem` fails on quota exhaustion and in partitioned-storage contexts, and
|
|
305
|
+
* neither is a reason to break a graph that is already on screen — the user's
|
|
306
|
+
* layout is correct, it just does not survive a reload.
|
|
307
|
+
*
|
|
308
|
+
* Only **one** layout is stored, keyed by shape rather than accumulated per
|
|
309
|
+
* shape. A user with three repositories open over a week would otherwise
|
|
310
|
+
* accumulate three multi-hundred-node position maps in a 5 MB budget shared
|
|
311
|
+
* with every other pi-weave key, to save a few hundred milliseconds on a
|
|
312
|
+
* switch back. One slot, last graph wins.
|
|
313
|
+
*/
|
|
314
|
+
export function savePositions(storage: PositionStorage, key: string, positions: ReadonlyMap<string, Point>): boolean {
|
|
315
|
+
try {
|
|
316
|
+
storage.setItem(POSITIONS_STORAGE_KEY, serializePositions(key, positions));
|
|
317
|
+
return true;
|
|
318
|
+
} catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Drop the cache. The escape hatch behind a re-layout control. */
|
|
324
|
+
export function clearPositions(storage: PositionStorage): boolean {
|
|
325
|
+
try {
|
|
326
|
+
storage.removeItem(POSITIONS_STORAGE_KEY);
|
|
327
|
+
return true;
|
|
328
|
+
} catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- the whole resolution -------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/** A layout, and where it came from. */
|
|
336
|
+
export interface ResolvedLayout {
|
|
337
|
+
readonly key: string;
|
|
338
|
+
readonly positions: Map<string, Point>;
|
|
339
|
+
/**
|
|
340
|
+
* `true` when the cache answered and the simulation did not run.
|
|
341
|
+
*
|
|
342
|
+
* Reported rather than inferred so a test can assert the cache is actually
|
|
343
|
+
* being used — a persistence layer that silently always misses is a
|
|
344
|
+
* persistence layer that passes every test about its serializer.
|
|
345
|
+
*/
|
|
346
|
+
readonly cached: boolean;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The layout for a graph: cache first, simulation second.
|
|
351
|
+
*
|
|
352
|
+
* This is the whole of §11 P3's "position persistence in `localStorage` keyed
|
|
353
|
+
* by repo hash", and the order is the point. A hit is instant and is the
|
|
354
|
+
* arrangement the user already knows; a miss pays ~300 ticks once and is then
|
|
355
|
+
* written back, so the second load of any graph is free.
|
|
356
|
+
*
|
|
357
|
+
* A cache hit is **used verbatim**, not re-simulated from. Re-running even
|
|
358
|
+
* from a perfect warm start would move every node slightly (§8 measures the
|
|
359
|
+
* drift as bounded but non-zero), so a user reopening the workspace would
|
|
360
|
+
* watch the graph shuffle for no reason — which is exactly the "reshuffle
|
|
361
|
+
* between sessions" §11 P3 asks to prevent.
|
|
362
|
+
*/
|
|
363
|
+
export function resolveLayout(
|
|
364
|
+
storage: PositionStorage,
|
|
365
|
+
nodes: readonly WireGraphNode[],
|
|
366
|
+
edges: readonly WireGraphEdge[],
|
|
367
|
+
): ResolvedLayout {
|
|
368
|
+
const key = graphShapeKey(nodes, edges);
|
|
369
|
+
const cached = loadPositions(storage, key);
|
|
370
|
+
// A cached map that is missing an id cannot be trusted as complete — the
|
|
371
|
+
// shape key says the node set matches, so a gap means a truncated or
|
|
372
|
+
// hand-edited entry. Fall through to a real layout rather than dropping the
|
|
373
|
+
// node (which is what `renderGraph` would do with a missing position).
|
|
374
|
+
if (cached !== null && nodes.every((node) => cached.has(node.id))) {
|
|
375
|
+
return { key, positions: cached, cached: true };
|
|
376
|
+
}
|
|
377
|
+
const positions = layoutFor(nodes, edges);
|
|
378
|
+
savePositions(storage, key, positions);
|
|
379
|
+
return { key, positions, cached: false };
|
|
380
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `RenderGraph` → graphology instance (weave-workspace §7.1).
|
|
3
|
+
*
|
|
4
|
+
* ```text
|
|
5
|
+
* GraphModel (core, authoritative)
|
|
6
|
+
* │
|
|
7
|
+
* ▼ shared/layout.ts positions
|
|
8
|
+
* ▼ graph/graph.model.ts RenderGraph — every decision already made
|
|
9
|
+
* ▼ graph/project.ts ← you are here
|
|
10
|
+
* graphology instance (render projection only)
|
|
11
|
+
* ▼
|
|
12
|
+
* sigma
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* §7.1 states the rule this file exists to obey: the projection is **never
|
|
16
|
+
* edited and never a second source of truth**. Every mutation flows
|
|
17
|
+
* `GraphModel → projection`; nothing flows back. Concretely that means:
|
|
18
|
+
*
|
|
19
|
+
* - Nothing here reads from the graphology instance in order to decide
|
|
20
|
+
* something. {@link project} writes; {@link syncPositions} writes.
|
|
21
|
+
* - There is no `onNodeUpdated` handler, no drag writing back into a model, no
|
|
22
|
+
* "the graph knows best" path. A user drag re-runs `shared/layout.ts` from
|
|
23
|
+
* the model and re-projects, which is why {@link syncPositions} exists as a
|
|
24
|
+
* *write* rather than a merge.
|
|
25
|
+
* - `graphology` exists solely because sigma consumes it (§0 V3). It is not
|
|
26
|
+
* the client's graph type; `RenderGraph` is.
|
|
27
|
+
*
|
|
28
|
+
* ## Why this file is thin, and testable anyway
|
|
29
|
+
*
|
|
30
|
+
* graphology is a plain in-memory data structure with no DOM dependency — it
|
|
31
|
+
* is `sigma` that needs a canvas. So unlike `renderer.ts`, this module *is*
|
|
32
|
+
* covered by ordinary unit tests, and it stays small only because
|
|
33
|
+
* `graph.model.ts` has already made every decision. What is left is two loops
|
|
34
|
+
* and the argument for `multi: false`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import Graph from "graphology";
|
|
38
|
+
import type { Point } from "../../shared/layout";
|
|
39
|
+
import type { RenderEdge, RenderGraph, RenderNode } from "./graph.model";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Node attributes as sigma reads them.
|
|
43
|
+
*
|
|
44
|
+
* `x`, `y`, `size`, `label`, `color`, `type` and `zIndex` are sigma's own
|
|
45
|
+
* reserved names — it reads them off the node attributes directly. `kind` and
|
|
46
|
+
* `provenance` are ours, carried so the reducers can decide from the node's
|
|
47
|
+
* data rather than by looking anything up.
|
|
48
|
+
*/
|
|
49
|
+
export type ProjectedNode = Omit<RenderNode, "id">;
|
|
50
|
+
|
|
51
|
+
/** Edge attributes as sigma reads them. */
|
|
52
|
+
export type ProjectedEdge = Omit<RenderEdge, "key">;
|
|
53
|
+
|
|
54
|
+
/** The graphology type the whole graph column is stated over. */
|
|
55
|
+
export type ProjectedGraph = Graph<ProjectedNode, ProjectedEdge>;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A fresh, empty projection.
|
|
59
|
+
*
|
|
60
|
+
* `multi: true` and `type: "directed"`, both deliberate.
|
|
61
|
+
*
|
|
62
|
+
* **Multi**, because the model genuinely admits parallel edges. A note can
|
|
63
|
+
* both `links-to` and `mentions` the same module — §4.4's mention pass runs
|
|
64
|
+
* over the body independently of the wikilink pass — and `graph.model.ts`
|
|
65
|
+
* dedupes on `(source, target, kind)`, not on the pair. Under `multi: false`
|
|
66
|
+
* graphology *throws* on the second one, so a simple graph would mean either a
|
|
67
|
+
* crash on real data or silently discarding a relationship, which is precisely
|
|
68
|
+
* the retired viewer's bug: "a link from a hidden file to a visible note
|
|
69
|
+
* simply vanished" (§7.4).
|
|
70
|
+
*
|
|
71
|
+
* The guard that `multi: false` looked like it was providing is not lost.
|
|
72
|
+
* `addDirectedEdgeWithKey` still throws on a duplicate **key**, and
|
|
73
|
+
* `edgeKey` is `(source, target, kind)` — so a genuinely duplicated edge is
|
|
74
|
+
* still a mount-time failure, while two *different* relationships between the
|
|
75
|
+
* same pair are still two edges. The key was always doing that work; `multi`
|
|
76
|
+
* only ever governed the pair.
|
|
77
|
+
*
|
|
78
|
+
* **Directed.** Every edge kind in `WIRE_EDGE_KINDS` has a direction that
|
|
79
|
+
* means something — `contains` is not symmetric, and neither is `mentions`.
|
|
80
|
+
* The renderer draws them as plain lines today, but throwing the direction
|
|
81
|
+
* away at the projection would make an arrowhead a re-derivation later.
|
|
82
|
+
*/
|
|
83
|
+
export function emptyProjection(): ProjectedGraph {
|
|
84
|
+
return new Graph<ProjectedNode, ProjectedEdge>({ multi: true, type: "directed" });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a graphology instance from a {@link RenderGraph}.
|
|
89
|
+
*
|
|
90
|
+
* Total on any `RenderGraph` produced by `renderGraph`, which guarantees the
|
|
91
|
+
* three things graphology throws over: unique node ids, unique edge keys, and
|
|
92
|
+
* both endpoints of every edge present. This function deliberately does *not*
|
|
93
|
+
* re-check them — a second, quieter validation here would let a malformed
|
|
94
|
+
* model reach sigma with some of its edges silently missing, and "the graph
|
|
95
|
+
* drew but three links vanished" is a far worse failure than a stack trace
|
|
96
|
+
* naming the id.
|
|
97
|
+
*
|
|
98
|
+
* `tests/web/client-graph.test.ts` runs the whole §8 repo fixture through
|
|
99
|
+
* `renderGraph` and then through here, so "the invariants hold on the real
|
|
100
|
+
* shape" is an assertion rather than a claim.
|
|
101
|
+
*/
|
|
102
|
+
export function project(model: RenderGraph): ProjectedGraph {
|
|
103
|
+
const graph = emptyProjection();
|
|
104
|
+
for (const node of model.nodes) {
|
|
105
|
+
const { id, ...attributes } = node;
|
|
106
|
+
graph.addNode(id, attributes);
|
|
107
|
+
}
|
|
108
|
+
for (const edge of model.edges) {
|
|
109
|
+
const { key, source, target, ...rest } = edge;
|
|
110
|
+
graph.addDirectedEdgeWithKey(key, source, target, { source, target, ...rest });
|
|
111
|
+
}
|
|
112
|
+
return graph;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Write new positions onto an existing projection.
|
|
117
|
+
*
|
|
118
|
+
* The cheap path for a re-run of the simulation: the node and edge sets have
|
|
119
|
+
* not changed, only where things are, so replacing the whole graph would make
|
|
120
|
+
* sigma rebuild every WebGL buffer and lose the camera. Ids absent from
|
|
121
|
+
* `positions` keep their current coordinates — a partial update is a partial
|
|
122
|
+
* update, not an instruction to move everything else to the origin.
|
|
123
|
+
*
|
|
124
|
+
* Returns how many nodes moved, which is what makes it observable without a
|
|
125
|
+
* renderer.
|
|
126
|
+
*/
|
|
127
|
+
export function syncPositions(graph: ProjectedGraph, positions: ReadonlyMap<string, Point>): number {
|
|
128
|
+
let moved = 0;
|
|
129
|
+
for (const [id, at] of positions) {
|
|
130
|
+
if (!graph.hasNode(id)) continue;
|
|
131
|
+
if (!Number.isFinite(at.x) || !Number.isFinite(at.y)) continue;
|
|
132
|
+
graph.mergeNodeAttributes(id, { x: at.x, y: at.y });
|
|
133
|
+
moved++;
|
|
134
|
+
}
|
|
135
|
+
return moved;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The projection's current positions, as `shared/layout.ts` wants them.
|
|
140
|
+
*
|
|
141
|
+
* This is the one read, and it is not an exception to the "never a second
|
|
142
|
+
* source of truth" rule: it exists so a re-run can **warm-start** from where
|
|
143
|
+
* the graph currently is (`LayoutOptions.initial`), which is what stops the
|
|
144
|
+
* picture jumping on expand/collapse. The values still originated in
|
|
145
|
+
* `computeLayout`; nothing user-authored ever enters the projection.
|
|
146
|
+
*/
|
|
147
|
+
export function positionsOf(graph: ProjectedGraph): Map<string, Point> {
|
|
148
|
+
const out = new Map<string, Point>();
|
|
149
|
+
graph.forEachNode((id, attributes) => {
|
|
150
|
+
out.set(id, { x: attributes.x, y: attributes.y });
|
|
151
|
+
});
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real `new Sigma(...)`, adapted to the renderer's injected port
|
|
3
|
+
* (weave-workspace §7.5, §10).
|
|
4
|
+
*
|
|
5
|
+
* `renderer.ts` deliberately does not import sigma: it is a `.ts` compiled by
|
|
6
|
+
* the **root** `tsconfig.json` whenever a test imports it, and `import Sigma
|
|
7
|
+
* from "sigma"` is a `ReferenceError` there — sigma evaluates
|
|
8
|
+
* `WebGL2RenderingContext.BOOL` at module scope while building its default
|
|
9
|
+
* program table, so the import alone dies outside a browser.
|
|
10
|
+
*
|
|
11
|
+
* Something still has to call the real thing. That is this file, and it is
|
|
12
|
+
* separate so the untestable surface is *one constructor call* rather than the
|
|
13
|
+
* whole renderer: `sigmaRenderer` holds the lifecycle, the reducers and the
|
|
14
|
+
* delegation, and is unit-tested against `SigmaLike` with a recording fake.
|
|
15
|
+
* Identical shape and identical reasoning to `api.dom.ts` for `fetch` and
|
|
16
|
+
* `domEventSource` in `live.ts` for `EventSource`.
|
|
17
|
+
*
|
|
18
|
+
* This module is only reachable from a `.tsx` entry point, so it is compiled
|
|
19
|
+
* exclusively by `tsconfig.web.json` (which has `DOM`) and never pulled into
|
|
20
|
+
* the root project. That is what lets it name sigma at all.
|
|
21
|
+
*
|
|
22
|
+
* ## This is §7.5's swap point
|
|
23
|
+
*
|
|
24
|
+
* If sigma stops being the right answer — §0.2's "revisit only if we
|
|
25
|
+
* measurably exceed sigma's comfort zone (tens of thousands of nodes)" —
|
|
26
|
+
* this file and the {@link SigmaLike} port in `renderer.ts` are what change.
|
|
27
|
+
* Nothing above the seam mentions sigma at all.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import Sigma from "sigma";
|
|
31
|
+
import type { ColorScheme } from "./graph.model";
|
|
32
|
+
import type { GraphRenderer, RendererFactory, SigmaFactory, SigmaLike } from "./renderer";
|
|
33
|
+
import { sigmaRenderer } from "./renderer";
|
|
34
|
+
|
|
35
|
+
/** Sigma's own container parameter type, recovered without naming the DOM. */
|
|
36
|
+
type SigmaContainer = ConstructorParameters<typeof Sigma>[1];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `new Sigma(graph, container, settings)`, as a {@link SigmaFactory}.
|
|
40
|
+
*
|
|
41
|
+
* The real `Sigma` satisfies `SigmaLike` structurally — `on`, `setSetting`,
|
|
42
|
+
* `setGraph`, `refresh`, `getCamera` and `kill` are all present with
|
|
43
|
+
* compatible shapes — so the only work here is the container cast, which is
|
|
44
|
+
* checked against sigma's own declared parameter type rather than against an
|
|
45
|
+
* `HTMLElement` asserted from memory.
|
|
46
|
+
*/
|
|
47
|
+
const createSigma: SigmaFactory = (graph, container, settings) =>
|
|
48
|
+
new Sigma(graph, container as unknown as SigmaContainer, settings) as unknown as SigmaLike;
|
|
49
|
+
|
|
50
|
+
/** The browser's renderer factory. Passed to the graph column by the shell. */
|
|
51
|
+
export const createSigmaRenderer: RendererFactory = (scheme: ColorScheme): GraphRenderer =>
|
|
52
|
+
sigmaRenderer(createSigma, scheme);
|