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.
- package/README.md +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- 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/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- 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 +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -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 +279 -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/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 +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -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 +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -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 +206 -0
- package/src/web/shared/layout.ts +497 -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,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
|
+
}
|