pi-weave 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Everything the graph column *decides* (weave-workspace §7, §10).
3
+ *
4
+ * §7.1's pipeline is four stages, and only two of them contain judgement:
5
+ *
6
+ * ```text
7
+ * GraphModel ──▶ shared/layout ──▶ [this module] ──▶ project.ts ──▶ sigma
8
+ * (core) (positions) (what to draw) (graphology) (pixels)
9
+ * ```
10
+ *
11
+ * `layout.ts` decides *where*; this module decides *what* — which nodes and
12
+ * edges survive, what colour and size each gets, what its label reads, and
13
+ * which of them are dimmed when something is selected. `project.ts` is then a
14
+ * loop with no opinions, and `renderer.ts` is a wire.
15
+ *
16
+ * That split is forced rather than chosen. §10 forbids a DOM test environment
17
+ * and sigma needs a real canvas and a WebGL context, so **any branch inside a
18
+ * sigma-touching file is a branch no test can reach**. Every branch the graph
19
+ * column needs therefore lives here, in a module that names no DOM type at all
20
+ * and is covered by ordinary unit tests.
21
+ *
22
+ * ## Tier rules (§2)
23
+ *
24
+ * `src/web/client/**`: `src/web/shared` and browser deps only. View-models
25
+ * arrive through `../../shared/view`, the one sanctioned door onto
26
+ * `src/core/view` (§2.1). This file imports **no npm package** — not even
27
+ * graphology — so it compiles under the root `tsconfig.json` (which has no
28
+ * `DOM` lib) whenever a test imports it.
29
+ *
30
+ * ## Why the palette is duplicated from the stylesheet
31
+ *
32
+ * WebGL cannot read a CSS custom property. `sigma` needs a concrete `#rrggbb`
33
+ * per node, and `getComputedStyle` is both a DOM global and an untestable
34
+ * read. So {@link GRAPH_PALETTE} restates the six theme colours the graph
35
+ * uses, and {@link KIND_SLOT} maps a node kind onto the *same slot vocabulary*
36
+ * the TUI already uses (`src/pi/viewer/tui/theme.ts`'s `kindStyle`) rather
37
+ * than inventing a second one.
38
+ *
39
+ * A copy drifts, so drift is a failing test: `tests/web/client-graph.test.ts`
40
+ * asserts every hex in {@link GRAPH_PALETTE} literally appears in
41
+ * `shell/theme.ts`'s `THEME_CSS`. Change a swatch in the stylesheet and the
42
+ * graph's copy goes red on the same commit.
43
+ */
44
+
45
+ import { COLLIDE_RADIUS, NODE_RADIUS } from "../../shared/layout";
46
+ import type { Point } from "../../shared/layout";
47
+ import { listLabel } from "../../shared/view";
48
+ import type { WireEdgeKind, WireGraphEdge, WireGraphNode, WireNodeKind, WireNoteSource } from "../../shared/wire";
49
+ import { provenanceGlyph } from "../tree/tree.model";
50
+
51
+ // --- the palette ----------------------------------------------------------------
52
+
53
+ /** Which theme colour a thing is painted in. The TUI's slot names, verbatim. */
54
+ export type ColorSlot = "accent" | "success" | "warning" | "dim" | "text" | "muted" | "line";
55
+
56
+ /** Dark or light. Chosen by the shell from `prefers-color-scheme`, never read here. */
57
+ export type ColorScheme = "dark" | "light";
58
+
59
+ /**
60
+ * Slot → hex, per scheme.
61
+ *
62
+ * Every value is copied from `shell/theme.ts`'s `THEME_CSS`:
63
+ * `accent`→`--weave-accent`, `success`→`--weave-ok`, `warning`→`--weave-warn`,
64
+ * `dim`→`--weave-dim`, `text`→`--weave-fg`, `muted`→`--weave-faint`,
65
+ * `line`→`--weave-line-strong`. The dark block is `:root`; the light block is
66
+ * the `prefers-color-scheme: light` override.
67
+ */
68
+ export const GRAPH_PALETTE: Readonly<Record<ColorScheme, Readonly<Record<ColorSlot, string>>>> = {
69
+ dark: {
70
+ accent: "#a48cff",
71
+ success: "#4ade80",
72
+ warning: "#fbbf24",
73
+ dim: "#8f8a9c",
74
+ text: "#e8e6ee",
75
+ muted: "#5d5869",
76
+ line: "#343141",
77
+ },
78
+ light: {
79
+ accent: "#6d4aff",
80
+ success: "#15803d",
81
+ warning: "#b45309",
82
+ dim: "#6f6b66",
83
+ text: "#1c1b19",
84
+ muted: "#9a958e",
85
+ line: "#d2cec8",
86
+ },
87
+ };
88
+
89
+ /**
90
+ * Node kind → colour slot.
91
+ *
92
+ * The same assignment `kindStyle` makes in `src/pi/viewer/tui/theme.ts`, which
93
+ * the client tier may not import. Restated rather than re-derived for the
94
+ * reason `tree.model.ts`'s glyph table gives: someone who has used
95
+ * `/weave-view tui` should recognise the graph, and a second vocabulary is a
96
+ * cost with no benefit. `note` takes `text` there too — a note's identity is
97
+ * carried by its provenance badge, not by its kind.
98
+ */
99
+ export const KIND_SLOT: Readonly<Record<WireNodeKind, ColorSlot>> = {
100
+ vault: "accent",
101
+ note: "text",
102
+ repository: "accent",
103
+ module: "success",
104
+ package: "success",
105
+ entryPoint: "warning",
106
+ gitState: "warning",
107
+ external: "warning",
108
+ file: "dim",
109
+ };
110
+
111
+ /** Edge kind → colour slot. Structure recedes; association is the accent. */
112
+ export const EDGE_SLOT: Readonly<Record<WireEdgeKind, ColorSlot>> = {
113
+ contains: "line",
114
+ "anchored-at": "line",
115
+ "links-to": "accent",
116
+ mentions: "warning",
117
+ };
118
+
119
+ /** The colour a node kind is drawn in. */
120
+ export function kindColor(kind: WireNodeKind, scheme: ColorScheme): string {
121
+ return GRAPH_PALETTE[scheme][KIND_SLOT[kind]];
122
+ }
123
+
124
+ /** The colour an edge kind is drawn in. */
125
+ export function edgeColor(kind: WireEdgeKind, scheme: ColorScheme): string {
126
+ return GRAPH_PALETTE[scheme][EDGE_SLOT[kind]];
127
+ }
128
+
129
+ // --- sizes ------------------------------------------------------------------------
130
+
131
+ /**
132
+ * The smallest a node is ever drawn, in layout units.
133
+ *
134
+ * A leaf still has to be clickable, and sigma's hit test is the drawn radius.
135
+ */
136
+ export const MIN_NODE_SIZE = 3;
137
+
138
+ /**
139
+ * The degree at which a node reaches {@link NODE_RADIUS}.
140
+ *
141
+ * 32 rather than "the maximum degree in this graph": a size that depends on
142
+ * the largest hub would make every other node shrink when one module gains a
143
+ * file, so the same note would render at two sizes on two loads of the same
144
+ * vault. A fixed ceiling keeps size comparable across graphs and across
145
+ * sessions.
146
+ */
147
+ export const DEGREE_AT_MAX_SIZE = 32;
148
+
149
+ /**
150
+ * Node radius from incident-edge degree.
151
+ *
152
+ * **Never larger than `NODE_RADIUS`**, and that ceiling is load-bearing rather
153
+ * than tasteful. `layout.ts` separates nodes with a collision radius of
154
+ * `COLLIDE_RADIUS = NODE_RADIUS + 9` and its header states the contract: *"the
155
+ * renderer must not draw larger than this"*. §8's gate then asserts
156
+ * `minPairwiseDistance > 2 · NODE_RADIUS` on the computed positions — which is
157
+ * a statement about *pixels not overlapping* only for as long as this function
158
+ * honours the same bound. Draw a hub at 24 units and the dynamics gate is
159
+ * still green while the screen is a hairball.
160
+ *
161
+ * Logarithmic, so a 60-child hub reads as bigger than a 6-child module without
162
+ * a 3-node vault becoming invisible next to it.
163
+ */
164
+ export function nodeSize(degree: number): number {
165
+ const d = Number.isFinite(degree) && degree > 0 ? degree : 0;
166
+ const share = Math.min(1, Math.log2(1 + d) / Math.log2(1 + DEGREE_AT_MAX_SIZE));
167
+ return MIN_NODE_SIZE + (NODE_RADIUS - MIN_NODE_SIZE) * share;
168
+ }
169
+
170
+ /** Edge thickness by kind. Containment is scaffolding; a wikilink is content. */
171
+ export const EDGE_SIZE: Readonly<Record<WireEdgeKind, number>> = {
172
+ contains: 0.8,
173
+ "anchored-at": 0.8,
174
+ "links-to": 1.4,
175
+ mentions: 1,
176
+ };
177
+
178
+ // --- labels -------------------------------------------------------------------------
179
+
180
+ /**
181
+ * The label sigma draws.
182
+ *
183
+ * `listLabel` is core's (§3) — the same function the tree column and the
184
+ * context rail call, so a node cannot be named one thing in one column and
185
+ * something else in another. The provenance badge is `tree.model.ts`'s glyph,
186
+ * for the same reason and for AGENTS.md rule 4: agent-written content must
187
+ * never look human-authored, and a filled/half/hollow shape survives
188
+ * greyscale, colour-blindness and a WebGL colour ramp in a way a hue does not.
189
+ *
190
+ * ## Why a badge rather than the ring §7.4 sketches
191
+ *
192
+ * §7.4 lists "ring by provenance" as `node attributes + a custom node program
193
+ * (**only if the default is insufficient**)". A ring needs a bordered node
194
+ * program; sigma v3 ships none, `@sigma/node-border` is a new dependency that
195
+ * §0.1's budget process would have to clear, and hand-writing a WebGL program
196
+ * puts a few hundred untestable lines behind the §10 wall for a visual
197
+ * refinement. The badge carries the same information, in the same vocabulary
198
+ * as the other two columns, at zero bytes.
199
+ */
200
+ export function nodeLabel(node: WireGraphNode): string {
201
+ const badge = provenanceGlyph(node.provenance);
202
+ const label = listLabel(node);
203
+ return badge === "" ? label : `${badge} ${label}`;
204
+ }
205
+
206
+ // --- the render model -------------------------------------------------------------
207
+
208
+ /** A node, resolved to everything the projection needs. No decisions left. */
209
+ export interface RenderNode {
210
+ readonly id: string;
211
+ readonly x: number;
212
+ readonly y: number;
213
+ readonly size: number;
214
+ readonly label: string;
215
+ readonly color: string;
216
+ readonly kind: WireNodeKind;
217
+ readonly provenance: WireNoteSource | null;
218
+ /** Bigger nodes paint last, so a hub is never hidden under its own leaves. */
219
+ readonly zIndex: number;
220
+ /** sigma's node program key. */
221
+ readonly type: "circle";
222
+ }
223
+
224
+ /** An edge, likewise resolved. */
225
+ export interface RenderEdge {
226
+ /** Stable and unique across the edge set — see {@link edgeKey}. */
227
+ readonly key: string;
228
+ readonly source: string;
229
+ readonly target: string;
230
+ readonly size: number;
231
+ readonly color: string;
232
+ readonly kind: WireEdgeKind;
233
+ readonly zIndex: number;
234
+ readonly type: "line";
235
+ }
236
+
237
+ /**
238
+ * The whole drawable graph.
239
+ *
240
+ * Its invariants are what let `project.ts` be a loop: every {@link RenderEdge}
241
+ * names two distinct nodes that are both in `nodes`, and every `key` and every
242
+ * node `id` is unique. graphology *throws* on a violation of any of those, so
243
+ * a renderer handed a malformed model does not degrade — it dies at mount.
244
+ */
245
+ export interface RenderGraph {
246
+ readonly nodes: readonly RenderNode[];
247
+ readonly edges: readonly RenderEdge[];
248
+ }
249
+
250
+ /** The empty graph. What the column renders before the first payload lands. */
251
+ export const EMPTY_RENDER_GRAPH: RenderGraph = { nodes: [], edges: [] };
252
+
253
+ /**
254
+ * A stable, unique key for an edge.
255
+ *
256
+ * Includes the kind, so a `contains` and a `links-to` between the same pair
257
+ * are two edges rather than a silently dropped one. `\u0000` as the separator
258
+ * because it cannot occur in a node id (ids are slugs and paths).
259
+ */
260
+ export function edgeKey(edge: WireGraphEdge): string {
261
+ return `${edge.source}\u0000${edge.target}\u0000${edge.kind}`;
262
+ }
263
+
264
+ /**
265
+ * Incident-edge degree for every node, in one pass.
266
+ *
267
+ * Not `degreeOf` from `src/core/view/focus` per node: that is O(nodes × edges)
268
+ * and this is O(edges). Core's function answers one node's degree, which is
269
+ * the question the context rail asks; this answers all of them at once, which
270
+ * is the question a renderer asks. Only edges that survive
271
+ * {@link renderGraph}'s filter are counted, so a node whose only edge points
272
+ * at a missing id is correctly a degree-0 leaf rather than a phantom hub.
273
+ */
274
+ export function degrees(edges: readonly WireGraphEdge[], known: ReadonlySet<string>): Map<string, number> {
275
+ const out = new Map<string, number>();
276
+ const seen = new Set<string>();
277
+ for (const edge of edges) {
278
+ if (edge.source === edge.target) continue;
279
+ if (!known.has(edge.source) || !known.has(edge.target)) continue;
280
+ const key = edgeKey(edge);
281
+ if (seen.has(key)) continue;
282
+ seen.add(key);
283
+ out.set(edge.source, (out.get(edge.source) ?? 0) + 1);
284
+ out.set(edge.target, (out.get(edge.target) ?? 0) + 1);
285
+ }
286
+ return out;
287
+ }
288
+
289
+ /**
290
+ * Resolve nodes, edges and positions into a drawable graph.
291
+ *
292
+ * Defensive in exactly the places `layout.ts`'s `analyse` is, and for the same
293
+ * reason: `buildGraph` should never emit a self-edge, a duplicate, an edge to
294
+ * a missing id or a duplicate node id, but a hand-edited or partially-rebuilt
295
+ * `.okf` index can, and the renderer must not be the thing that throws. A
296
+ * degenerate *input* and a degenerate *output* are different failures.
297
+ *
298
+ * A node with no position is dropped rather than placed at the origin.
299
+ * `computeLayout` returns a point for every node it was given, so a miss means
300
+ * the caller laid out a *different* graph than it is now drawing — and a pile
301
+ * of nodes stacked at (0, 0) is the exact hairball §7.2 exists to prevent.
302
+ */
303
+ export function renderGraph(
304
+ nodes: readonly WireGraphNode[],
305
+ edges: readonly WireGraphEdge[],
306
+ positions: ReadonlyMap<string, Point>,
307
+ scheme: ColorScheme,
308
+ ): RenderGraph {
309
+ const placed = new Map<string, WireGraphNode>();
310
+ for (const node of nodes) {
311
+ if (placed.has(node.id)) continue;
312
+ const at = positions.get(node.id);
313
+ if (at === undefined || !Number.isFinite(at.x) || !Number.isFinite(at.y)) continue;
314
+ placed.set(node.id, node);
315
+ }
316
+
317
+ const known = new Set(placed.keys());
318
+ const degree = degrees(edges, known);
319
+
320
+ const out: RenderNode[] = [];
321
+ for (const [id, node] of placed) {
322
+ const at = positions.get(id) as Point;
323
+ const size = nodeSize(degree.get(id) ?? 0);
324
+ out.push({
325
+ id,
326
+ x: at.x,
327
+ y: at.y,
328
+ size,
329
+ label: nodeLabel(node),
330
+ color: kindColor(node.kind, scheme),
331
+ kind: node.kind,
332
+ provenance: node.provenance,
333
+ zIndex: Math.round(size),
334
+ type: "circle",
335
+ });
336
+ }
337
+
338
+ const drawn: RenderEdge[] = [];
339
+ const seen = new Set<string>();
340
+ for (const edge of edges) {
341
+ if (edge.source === edge.target) continue;
342
+ if (!known.has(edge.source) || !known.has(edge.target)) continue;
343
+ const key = edgeKey(edge);
344
+ if (seen.has(key)) continue;
345
+ seen.add(key);
346
+ drawn.push({
347
+ key,
348
+ source: edge.source,
349
+ target: edge.target,
350
+ size: EDGE_SIZE[edge.kind],
351
+ color: edgeColor(edge.kind, scheme),
352
+ kind: edge.kind,
353
+ // Under every node, always: an edge painted over a hub reads as a line
354
+ // through it. Node zIndexes start at `MIN_NODE_SIZE` rounded, i.e. 3.
355
+ zIndex: 0,
356
+ type: "line",
357
+ });
358
+ }
359
+
360
+ return { nodes: out, edges: drawn };
361
+ }
362
+
363
+ // --- the highlight reducers (§7.4) ---------------------------------------------------
364
+
365
+ /** What sigma's `nodeReducer` may override, as far as this module is concerned. */
366
+ export interface NodeDisplayOverride {
367
+ readonly color?: string;
368
+ readonly label?: string | null;
369
+ readonly zIndex?: number;
370
+ readonly size?: number;
371
+ }
372
+
373
+ /** What sigma's `edgeReducer` may override. */
374
+ export interface EdgeDisplayOverride {
375
+ readonly color?: string;
376
+ readonly hidden?: boolean;
377
+ readonly zIndex?: number;
378
+ }
379
+
380
+ /**
381
+ * The colour everything outside the neighbourhood fades to.
382
+ *
383
+ * `muted` in both schemes — the faintest slot that is still a colour rather
384
+ * than the background. Dimming to the background would *delete* the context
385
+ * the highlight exists to place the selection inside.
386
+ */
387
+ export function dimColor(scheme: ColorScheme): string {
388
+ return GRAPH_PALETTE[scheme].muted;
389
+ }
390
+
391
+ /**
392
+ * sigma's `nodeReducer`, as a pure function (§7.4).
393
+ *
394
+ * `highlight` is `null` when nothing is selected, and that is deliberately not
395
+ * the same as an empty set: nothing selected means *everything* renders
396
+ * normally, where an empty neighbourhood (a selection that is not in this
397
+ * graph) means everything is outside it and dims. Collapsing the two would
398
+ * make a stale selection silently blank the column.
399
+ *
400
+ * Nodes outside the neighbourhood keep their position and their size and lose
401
+ * their colour and their label. Hiding them instead would make the graph
402
+ * *move* on selection — sigma's autoscale reframes on the visible extent — and
403
+ * a graph that reflows when you click it is unusable.
404
+ */
405
+ export function nodeReducer(
406
+ highlight: ReadonlySet<string> | null,
407
+ ): (id: string, data: RenderNode, scheme: ColorScheme) => NodeDisplayOverride {
408
+ return (id, data, scheme) => {
409
+ if (highlight === null) return {};
410
+ if (highlight.has(id)) return { zIndex: data.zIndex + HIGHLIGHT_Z_LIFT };
411
+ return { color: dimColor(scheme), label: null, zIndex: 0 };
412
+ };
413
+ }
414
+
415
+ /**
416
+ * How far a highlighted node is lifted above the rest.
417
+ *
418
+ * Above every unhighlighted node's z (which is at most `NODE_RADIUS` rounded)
419
+ * so the neighbourhood paints as one layer rather than interleaved with the
420
+ * cloud it is standing out from.
421
+ */
422
+ export const HIGHLIGHT_Z_LIFT = NODE_RADIUS + 1;
423
+
424
+ /**
425
+ * sigma's `edgeReducer` (§7.4).
426
+ *
427
+ * An edge is inside the neighbourhood only when **both** endpoints are.
428
+ * `focusNeighborhood` returns the selection plus its direct neighbours, so
429
+ * "both endpoints inside" is exactly the set of edges incident on the
430
+ * selection, plus any edge that happens to join two of its neighbours — which
431
+ * is information about the selection's neighbourhood and belongs in it.
432
+ *
433
+ * Outside edges are `hidden`, not dimmed. An edge is one pixel wide: a dimmed
434
+ * one is visually indistinguishable from a drawn one at any zoom where the
435
+ * highlight matters, and the whole point is to empty the canvas around the
436
+ * selection.
437
+ */
438
+ export function edgeReducer(
439
+ highlight: ReadonlySet<string> | null,
440
+ ): (key: string, data: RenderEdge) => EdgeDisplayOverride {
441
+ return (_key, data) => {
442
+ if (highlight === null) return {};
443
+ return highlight.has(data.source) && highlight.has(data.target) ? { zIndex: 1 } : { hidden: true };
444
+ };
445
+ }
446
+
447
+ // --- sigma settings (§7.4) -------------------------------------------------------------
448
+
449
+ /**
450
+ * The sigma settings this column sets, as plain data.
451
+ *
452
+ * Declared here rather than inline at the `new Sigma(...)` call so that the
453
+ * semantic-zoom tuning is a value a test can assert on. A magic number inside
454
+ * an untestable file is a magic number nobody can check against §7.4.
455
+ */
456
+ export interface GraphSettings {
457
+ readonly hideEdgesOnMove: boolean;
458
+ readonly renderEdgeLabels: boolean;
459
+ readonly labelDensity: number;
460
+ readonly labelGridCellSize: number;
461
+ readonly labelRenderedSizeThreshold: number;
462
+ readonly labelFont: string;
463
+ readonly labelSize: number;
464
+ readonly labelColor: { readonly color: string };
465
+ readonly defaultNodeColor: string;
466
+ readonly defaultEdgeColor: string;
467
+ readonly defaultNodeType: "circle";
468
+ readonly defaultEdgeType: "line";
469
+ readonly minEdgeThickness: number;
470
+ readonly zIndex: boolean;
471
+ readonly itemSizesReference: "positions";
472
+ readonly zoomToSizeRatioFunction: (ratio: number) => number;
473
+ readonly stagePadding: number;
474
+ readonly allowInvalidContainer: boolean;
475
+ }
476
+
477
+ /**
478
+ * Semantic zoom: the rendered size below which a label is suppressed (§7.4).
479
+ *
480
+ * Derived, not tuned. {@link nodeSize} maps degree 0 onto
481
+ * {@link MIN_NODE_SIZE} and `DEGREE_AT_MAX_SIZE` onto `NODE_RADIUS`, so a
482
+ * threshold placed at the midpoint of that range means "label the nodes that
483
+ * are structurally significant at this zoom" — hubs first, leaves once you
484
+ * have zoomed in far enough that their rendered size crosses the line.
485
+ */
486
+ export const LABEL_SIZE_THRESHOLD = (MIN_NODE_SIZE + NODE_RADIUS) / 2;
487
+
488
+ /**
489
+ * Label collision grid, in screen pixels.
490
+ *
491
+ * `COLLIDE_RADIUS` is the layout's own answer to "how much room does a node
492
+ * plus the leading edge of its label need", so reusing it keeps the label
493
+ * grid and the simulation talking about the same distance instead of two
494
+ * numbers that drift apart.
495
+ */
496
+ export const LABEL_GRID_CELL_SIZE = COLLIDE_RADIUS * 4;
497
+
498
+ /** How much of the grid may be filled before labels start being dropped. */
499
+ export const LABEL_DENSITY = 1;
500
+
501
+ /** The settings for a scheme. */
502
+ export function graphSettings(scheme: ColorScheme): GraphSettings {
503
+ const palette = GRAPH_PALETTE[scheme];
504
+ return {
505
+ // §7.4. A pan over a few thousand edges is the one interaction that drops
506
+ // frames, and the edges are the part nobody is reading mid-gesture.
507
+ hideEdgesOnMove: true,
508
+ renderEdgeLabels: false,
509
+ labelDensity: LABEL_DENSITY,
510
+ labelGridCellSize: LABEL_GRID_CELL_SIZE,
511
+ labelRenderedSizeThreshold: LABEL_SIZE_THRESHOLD,
512
+ // The shell's own stack, so the graph's labels match every other column.
513
+ labelFont: "inherit",
514
+ labelSize: 11,
515
+ labelColor: { color: palette.text },
516
+ defaultNodeColor: palette.dim,
517
+ defaultEdgeColor: palette.line,
518
+ defaultNodeType: "circle",
519
+ defaultEdgeType: "line",
520
+ minEdgeThickness: 0.6,
521
+ // Required for `RenderNode.zIndex` to mean anything at all.
522
+ zIndex: true,
523
+ // Sizes are in **layout units**, not screen pixels. This is what ties
524
+ // `nodeSize`'s `NODE_RADIUS` ceiling to §8's `minPairwiseDistance`
525
+ // assertion: under the default (`"screen"`) a node keeps its pixel size as
526
+ // you zoom out, so a provably non-overlapping layout still renders as a
527
+ // solid blob at low zoom.
528
+ itemSizesReference: "positions",
529
+ zoomToSizeRatioFunction: (ratio) => ratio,
530
+ stagePadding: COLLIDE_RADIUS,
531
+ // The container is a real element by construction (the renderer is mounted
532
+ // from a `ref`), but sigma also validates that it has a non-zero size —
533
+ // and a column that is behind a `medium` breakpoint toggle legitimately
534
+ // has none until it is revealed. Throwing there would take the whole
535
+ // workspace down over a column the user cannot see.
536
+ allowInvalidContainer: true,
537
+ };
538
+ }