pi-weave 0.1.19 → 0.1.21
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 +22 -21
- package/package.json +1 -1
- package/skills/weave-explore/SKILL.md +2 -0
- package/skills/weave-notepad/SKILL.md +10 -0
- package/src/core/concurrency.ts +1 -1
- package/src/core/frontmatter.ts +30 -0
- package/src/core/index.ts +25 -1
- package/src/core/paths.ts +1 -0
- package/src/core/sessions.ts +963 -0
- package/src/core/vault.ts +93 -1
- package/src/pi/index.ts +108 -9
- package/src/pi/sessionScan.ts +105 -0
- package/src/pi/summarize.ts +48 -5
- package/src/pi/tools/noteTool.ts +1 -1
- package/src/web/client/dist/app.js +71 -41
- package/src/web/client/graph/ForceTuner.tsx +99 -0
- package/src/web/client/graph/Graph.tsx +77 -16
- package/src/web/client/graph/column.model.ts +35 -49
- package/src/web/client/graph/dynamics.ts +22 -1
- package/src/web/client/graph/graph.model.ts +63 -5
- package/src/web/client/graph/groups.ts +353 -0
- package/src/web/client/graph/positions.ts +14 -13
- package/src/web/client/graph/renderer.ts +6 -3
- package/src/web/client/graph/tuner.model.ts +210 -0
- package/src/web/client/main.tsx +12 -1
- package/src/web/client/shell/Columns.tsx +3 -0
- package/src/web/client/shell/Shell.tsx +7 -0
- package/src/web/client/shell/theme.ts +30 -1
- package/src/web/client/tree/Tree.tsx +126 -13
- package/src/web/client/tree/tree.model.ts +102 -1
- package/src/web/shared/layout.ts +76 -11
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hidden force tuner panel (docs/weave-workspace.md §15.7).
|
|
3
|
+
*
|
|
4
|
+
* Props in, JSX out. Every range, label, parse and clamp comes from
|
|
5
|
+
* `tuner.model.ts`; what is left here is seven `<input type="range">` elements
|
|
6
|
+
* and two buttons. Native range inputs rather than a slider component, for the
|
|
7
|
+
* obvious reason: the platform has had this element since 2011.
|
|
8
|
+
*
|
|
9
|
+
* Rendered only when `Graph.tsx` was handed `tuner` — that is, only when
|
|
10
|
+
* `?sliders=1` was on the URL. See `slidersFlag`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { useState } from "preact/hooks";
|
|
14
|
+
import { FORCES, FORCE_DEFAULTS, setForces } from "../../shared/layout";
|
|
15
|
+
import type { SliderSpec } from "./tuner.model";
|
|
16
|
+
import { FORCE_SLIDERS, HAIRBALL, forcesSnippet, formatValue, isDefault, parseSlider, sliderValue } from "./tuner.model";
|
|
17
|
+
|
|
18
|
+
export interface ForceTunerProps {
|
|
19
|
+
/**
|
|
20
|
+
* Called after every write to `FORCES`, so the column can drop its cached
|
|
21
|
+
* layout and re-run the simulation. The panel never lays out anything
|
|
22
|
+
* itself — it mutates the constants and says so.
|
|
23
|
+
*/
|
|
24
|
+
onChange: () => void;
|
|
25
|
+
/** Whether nodes are coloured by group hue (§15.8). */
|
|
26
|
+
groupColors: boolean;
|
|
27
|
+
/** Toggle that. Owned by the column, which persists it. */
|
|
28
|
+
onGroupColors: (next: boolean) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ForceTuner(props: ForceTunerProps) {
|
|
32
|
+
// The panel's own re-render trigger. `FORCES` is a plain mutable object, so
|
|
33
|
+
// preact cannot observe it; a counter is the whole subscription.
|
|
34
|
+
const [, bump] = useState(0);
|
|
35
|
+
const [copied, setCopied] = useState(false);
|
|
36
|
+
|
|
37
|
+
const apply = (spec: SliderSpec, raw: string): void => {
|
|
38
|
+
setForces({ [spec.key]: parseSlider(spec, raw) });
|
|
39
|
+
setCopied(false);
|
|
40
|
+
bump((n) => n + 1);
|
|
41
|
+
props.onChange();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const applyAll = (next: Partial<typeof FORCES>): void => {
|
|
45
|
+
setForces(next);
|
|
46
|
+
setCopied(false);
|
|
47
|
+
bump((n) => n + 1);
|
|
48
|
+
props.onChange();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div class="weave-tuner">
|
|
53
|
+
<p class="weave-tuner-title">graph forces · ?sliders=1</p>
|
|
54
|
+
<label class="weave-tuner-toggle" title="hue per containment group, shade per kind">
|
|
55
|
+
<input type="checkbox" checked={props.groupColors} onChange={(event) => props.onGroupColors((event.currentTarget as HTMLInputElement).checked)} />
|
|
56
|
+
<span>colour by group</span>
|
|
57
|
+
</label>
|
|
58
|
+
{FORCE_SLIDERS.map((spec) => (
|
|
59
|
+
<label class="weave-tuner-row" key={spec.key} title={spec.hint}>
|
|
60
|
+
<span class="weave-tuner-label">
|
|
61
|
+
{spec.label}
|
|
62
|
+
<b>{formatValue(FORCES[spec.key])}</b>
|
|
63
|
+
</span>
|
|
64
|
+
<input
|
|
65
|
+
type="range"
|
|
66
|
+
min={spec.min}
|
|
67
|
+
max={spec.max}
|
|
68
|
+
step={spec.step}
|
|
69
|
+
value={sliderValue(FORCES, spec)}
|
|
70
|
+
onInput={(event) => apply(spec, (event.currentTarget as HTMLInputElement).value)}
|
|
71
|
+
/>
|
|
72
|
+
</label>
|
|
73
|
+
))}
|
|
74
|
+
<div class="weave-tuner-actions">
|
|
75
|
+
<button type="button" class="weave-chip" disabled={isDefault(FORCES)} onClick={() => applyAll(FORCE_DEFAULTS)}>
|
|
76
|
+
reset
|
|
77
|
+
</button>
|
|
78
|
+
<button type="button" class="weave-chip" title="the pre-tuner constants, for comparison — see HAIRBALL" onClick={() => applyAll(HAIRBALL)}>
|
|
79
|
+
before
|
|
80
|
+
</button>
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
class="weave-chip"
|
|
84
|
+
onClick={() => {
|
|
85
|
+
// Best-effort: `navigator.clipboard` is absent over plain HTTP in
|
|
86
|
+
// some browsers, and the snippet is on screen in the panel anyway.
|
|
87
|
+
void navigator.clipboard?.writeText(forcesSnippet(FORCES)).then(
|
|
88
|
+
() => setCopied(true),
|
|
89
|
+
() => setCopied(false),
|
|
90
|
+
);
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{copied ? "copied ✓" : "copy values"}
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<pre class="weave-tuner-snippet">{forcesSnippet(FORCES)}</pre>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -35,15 +35,13 @@ import type { GraphViewState } from "./column.model";
|
|
|
35
35
|
import {
|
|
36
36
|
FIT_HINT,
|
|
37
37
|
FIT_LABEL,
|
|
38
|
+
FORCES_HINT,
|
|
39
|
+
FORCES_LABEL,
|
|
38
40
|
LEGEND,
|
|
39
|
-
allExpanded,
|
|
40
41
|
effectiveView,
|
|
41
|
-
expandHint,
|
|
42
|
-
expandLabel,
|
|
43
42
|
graphClick,
|
|
44
43
|
graphColumnModel,
|
|
45
44
|
graphCountLabel,
|
|
46
|
-
toggleExpandAll,
|
|
47
45
|
} from "./column.model";
|
|
48
46
|
import type { PositionStorage } from "./positions";
|
|
49
47
|
import type { GraphRenderer, RendererFactory } from "./renderer";
|
|
@@ -52,6 +50,9 @@ import type { SchemeHost } from "./scheme";
|
|
|
52
50
|
import type { ColorScheme } from "./graph.model";
|
|
53
51
|
import { createGraphSimulation } from "./dynamics";
|
|
54
52
|
import type { GraphSimulation } from "./dynamics";
|
|
53
|
+
import { ForceTuner } from "./ForceTuner";
|
|
54
|
+
import { POSITIONS_STORAGE_KEY } from "./positions";
|
|
55
|
+
import { loadGroupColors, saveGroupColors } from "./tuner.model";
|
|
55
56
|
|
|
56
57
|
export interface GraphProps {
|
|
57
58
|
graph: GraphPayload | null;
|
|
@@ -88,6 +89,14 @@ export interface GraphProps {
|
|
|
88
89
|
* keeps the ownership where it is and costs one line at each end.
|
|
89
90
|
*/
|
|
90
91
|
fit: { current: (() => void) | null };
|
|
92
|
+
/**
|
|
93
|
+
* Show the hidden force tuner (`?sliders=1`, docs/weave-workspace.md §15.7).
|
|
94
|
+
*
|
|
95
|
+
* A prop rather than a `location.search` read here, for the same reason
|
|
96
|
+
* `cwd` and `platform` are props: the decision is `slidersFlag`'s, and it is
|
|
97
|
+
* testable where a `location` read is not.
|
|
98
|
+
*/
|
|
99
|
+
tuner?: boolean;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
export function Graph(props: GraphProps) {
|
|
@@ -120,6 +129,30 @@ export function Graph(props: GraphProps) {
|
|
|
120
129
|
// `null` means "the user has not touched the expansion" — not "nothing is
|
|
121
130
|
// expanded". `effectiveView` resolves the difference; see its doc comment.
|
|
122
131
|
const [state, setState] = useState<GraphViewState | null>(null);
|
|
132
|
+
/**
|
|
133
|
+
* Bumped by the tuner after each write to `FORCES`. It enters the model memo
|
|
134
|
+
* below purely as a cache-buster: the graph's *shape* has not changed, so
|
|
135
|
+
* nothing else would recompute, and the whole point is that the same shape
|
|
136
|
+
* now lays out differently.
|
|
137
|
+
*/
|
|
138
|
+
const [forceRev, setForceRev] = useState(0);
|
|
139
|
+
/**
|
|
140
|
+
* Colour nodes by group hue (§15.8). Persisted, because it is a taste the
|
|
141
|
+
* user holds across sessions rather than a per-visit mode, and read through
|
|
142
|
+
* the same `PositionStorage` port the layout cache uses so the column still
|
|
143
|
+
* names no browser global.
|
|
144
|
+
*/
|
|
145
|
+
const [groupColors, setGroupColors] = useState(() => loadGroupColors(props.storage));
|
|
146
|
+
/**
|
|
147
|
+
* Whether the tuner panel is open.
|
|
148
|
+
*
|
|
149
|
+
* Seeded from the `?sliders=1` flag, then owned by the `[sliders]` chip — so
|
|
150
|
+
* the URL is still the way to arrive with it open, and the button is the way
|
|
151
|
+
* to get at it once you are here. The chip is always present: the panel was
|
|
152
|
+
* unreachable without knowing a query string, which is the right gate for a
|
|
153
|
+
* half-built instrument and the wrong one for a finished control.
|
|
154
|
+
*/
|
|
155
|
+
const [tunerOpen, setTunerOpen] = useState(props.tuner === true);
|
|
123
156
|
|
|
124
157
|
// The shell's decision wins; `schemeOf` stays for a host-driven default.
|
|
125
158
|
const scheme = props.scheme ?? schemeOf(props.host);
|
|
@@ -133,14 +166,13 @@ export function Graph(props: GraphProps) {
|
|
|
133
166
|
// graph. Identity is the whole contract; do not switch the effect to
|
|
134
167
|
// comparing set contents, the memo makes comparison unnecessary.
|
|
135
168
|
const model = useMemo(
|
|
136
|
-
() => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed),
|
|
137
|
-
[props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed],
|
|
169
|
+
() => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors),
|
|
170
|
+
[props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceRev, groupColors],
|
|
138
171
|
);
|
|
139
|
-
const everything = allExpanded(view, model.clusters);
|
|
140
172
|
|
|
141
173
|
// Read by the mount-time `onSelect`, which outlives this render.
|
|
142
|
-
const live = useRef({ view, model, onSelect: props.onSelect });
|
|
143
|
-
live.current = { view, model, onSelect: props.onSelect };
|
|
174
|
+
const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId });
|
|
175
|
+
live.current = { view, model, onSelect: props.onSelect, selectedId: props.selectedId };
|
|
144
176
|
|
|
145
177
|
useEffect(() => {
|
|
146
178
|
const instance = props.renderer(scheme);
|
|
@@ -173,7 +205,7 @@ export function Graph(props: GraphProps) {
|
|
|
173
205
|
// the render this effect was created in), so a remount carries whatever
|
|
174
206
|
// the column is already showing.
|
|
175
207
|
instance.setGraph(live.current.model.graph);
|
|
176
|
-
instance.setHighlight(live.current.model.highlight);
|
|
208
|
+
instance.setHighlight(live.current.model.highlight, live.current.selectedId);
|
|
177
209
|
props.fit.current = () => instance.fit();
|
|
178
210
|
return () => {
|
|
179
211
|
instance.destroy();
|
|
@@ -186,7 +218,7 @@ export function Graph(props: GraphProps) {
|
|
|
186
218
|
|
|
187
219
|
useEffect(() => {
|
|
188
220
|
renderer.current?.setGraph(model.graph);
|
|
189
|
-
}, [model.key]);
|
|
221
|
+
}, [model.key, forceRev, groupColors]);
|
|
190
222
|
|
|
191
223
|
// Live layout, in two effects so pause/resume and re-layout are independent.
|
|
192
224
|
//
|
|
@@ -202,7 +234,7 @@ export function Graph(props: GraphProps) {
|
|
|
202
234
|
return () => {
|
|
203
235
|
dynamics.current = null;
|
|
204
236
|
};
|
|
205
|
-
}, [model.key, armClock]);
|
|
237
|
+
}, [model.key, armClock, forceRev]);
|
|
206
238
|
|
|
207
239
|
// Effect 2 owns the clock's unmount cleanup: the engine's own lifecycle is
|
|
208
240
|
// effect 1's, and the step self-terminates whenever the engine settles, so
|
|
@@ -218,8 +250,8 @@ export function Graph(props: GraphProps) {
|
|
|
218
250
|
);
|
|
219
251
|
|
|
220
252
|
useEffect(() => {
|
|
221
|
-
renderer.current?.setHighlight(model.highlight);
|
|
222
|
-
}, [model.highlight]);
|
|
253
|
+
renderer.current?.setHighlight(model.highlight, props.selectedId);
|
|
254
|
+
}, [model.highlight, props.selectedId]);
|
|
223
255
|
|
|
224
256
|
return (
|
|
225
257
|
<div class="weave-graph">
|
|
@@ -229,12 +261,41 @@ export function Graph(props: GraphProps) {
|
|
|
229
261
|
{/* `tabIndex={-1}` is the `⌘3` focus target — see `Note.tsx`'s matching
|
|
230
262
|
comment. The tree's target is the rows `<ul>`, which has its own. */}
|
|
231
263
|
<div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" tabIndex={-1} />
|
|
264
|
+
{tunerOpen ? (
|
|
265
|
+
<ForceTuner
|
|
266
|
+
groupColors={groupColors}
|
|
267
|
+
onGroupColors={(next) => {
|
|
268
|
+
saveGroupColors(props.storage, next);
|
|
269
|
+
setGroupColors(next);
|
|
270
|
+
}}
|
|
271
|
+
onChange={() => {
|
|
272
|
+
// Poison the stored layout rather than reading it: the cache is
|
|
273
|
+
// keyed by graph *shape*, which a force change does not touch, so
|
|
274
|
+
// a hit would hand back the arrangement of the previous constants
|
|
275
|
+
// and the sliders would appear to do nothing. An unparseable entry
|
|
276
|
+
// is a miss by `deserializePositions`' contract, and the two-method
|
|
277
|
+
// `PositionStorage` port has no `removeItem` to call instead.
|
|
278
|
+
try {
|
|
279
|
+
props.storage.setItem(POSITIONS_STORAGE_KEY, "");
|
|
280
|
+
} catch {
|
|
281
|
+
// A storage that refuses writes still lays out; see `savePositions`.
|
|
282
|
+
}
|
|
283
|
+
setForceRev((n) => n + 1);
|
|
284
|
+
}}
|
|
285
|
+
/>
|
|
286
|
+
) : null}
|
|
232
287
|
<div class="weave-graph-controls">
|
|
233
288
|
<button type="button" class="weave-chip" title={FIT_HINT} onClick={() => renderer.current?.fit()}>
|
|
234
289
|
{FIT_LABEL}
|
|
235
290
|
</button>
|
|
236
|
-
<button
|
|
237
|
-
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
class={tunerOpen ? "weave-chip weave-chip-on" : "weave-chip"}
|
|
294
|
+
title={FORCES_HINT}
|
|
295
|
+
aria-pressed={tunerOpen}
|
|
296
|
+
onClick={() => setTunerOpen(!tunerOpen)}
|
|
297
|
+
>
|
|
298
|
+
{FORCES_LABEL}
|
|
238
299
|
</button>
|
|
239
300
|
<span class="weave-graph-legend">
|
|
240
301
|
<span class="weave-legend-on">◉ {LEGEND.selected}</span>
|
|
@@ -46,6 +46,7 @@ import type { GraphPayload, WireGraphEdge, WireGraphNode } from "../../shared/wi
|
|
|
46
46
|
import { viewModel } from "../tree/tree.model";
|
|
47
47
|
import type { ColorScheme, RenderGraph } from "./graph.model";
|
|
48
48
|
import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
|
|
49
|
+
import { groupNodeColors } from "./groups";
|
|
49
50
|
import type { PositionStorage } from "./positions";
|
|
50
51
|
import { resolveLayout } from "./positions";
|
|
51
52
|
|
|
@@ -108,31 +109,21 @@ export function effectiveView(payload: GraphPayload | null, state: GraphViewStat
|
|
|
108
109
|
return initialGraphView(viewModel(payload));
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Open or close one cluster. Returns a new state.
|
|
114
|
+
*
|
|
115
|
+
* The `[expand]` / `[collapse]` control that used to pair with this is gone
|
|
116
|
+
* (§15.10): the graph opens fully expanded, so "expand" was a no-op on arrival
|
|
117
|
+
* and "collapse" threw the whole picture away to show two root nodes. Clicking
|
|
118
|
+
* a collapsed cluster still opens it — that is `graphClick` — and per-level
|
|
119
|
+
* walking is what the tree column is for.
|
|
120
|
+
*/
|
|
112
121
|
export function toggleCluster(state: GraphViewState, id: string): GraphViewState {
|
|
113
122
|
const expanded = new Set(state.expanded);
|
|
114
123
|
if (!expanded.delete(id)) expanded.add(id);
|
|
115
124
|
return { ...state, expanded };
|
|
116
125
|
}
|
|
117
126
|
|
|
118
|
-
/**
|
|
119
|
-
* Open every cluster. The `[expand]` control from the §1.2 mock.
|
|
120
|
-
*
|
|
121
|
-
* `clusters` is `ClusterAggregate.clusters`, which holds **every** node with a
|
|
122
|
-
* containment child whether or not it is currently visible — so one press
|
|
123
|
-
* opens the whole tree rather than one level of it. That is deliberate: the
|
|
124
|
-
* per-level walk is what the tree column is for, and a graph control that
|
|
125
|
-
* needed six presses to show the graph would be a worse version of it.
|
|
126
|
-
*/
|
|
127
|
-
export function expandAll(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): GraphViewState {
|
|
128
|
-
return { ...state, expanded: new Set(clusters.keys()) };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Close every cluster, back to the roots. The other half of `[expand]`. */
|
|
132
|
-
export function collapseAll(state: GraphViewState): GraphViewState {
|
|
133
|
-
return { ...state, expanded: new Set() };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
127
|
// --- the highlight (§1.3, §7.4) ---------------------------------------------------------
|
|
137
128
|
|
|
138
129
|
/**
|
|
@@ -162,39 +153,20 @@ export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string
|
|
|
162
153
|
|
|
163
154
|
// --- the control strip (§1.2) ---------------------------------------------------------------
|
|
164
155
|
|
|
165
|
-
/** The `[
|
|
166
|
-
export
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Its tooltip. */
|
|
171
|
-
export function expandHint(allExpanded: boolean): string {
|
|
172
|
-
return allExpanded ? "collapse every cluster back to the roots" : "expand every cluster";
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Whether every cluster in the graph is currently open. */
|
|
176
|
-
export function allExpanded(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): boolean {
|
|
177
|
-
if (clusters.size === 0) return false;
|
|
178
|
-
for (const id of clusters.keys()) if (!state.expanded.has(id)) return false;
|
|
179
|
-
return true;
|
|
180
|
-
}
|
|
156
|
+
/** The `[fit]` control. Constant, but named here so the component holds no copy. */
|
|
157
|
+
export const FIT_LABEL = "fit";
|
|
158
|
+
export const FIT_HINT = "frame the whole graph";
|
|
181
159
|
|
|
182
160
|
/**
|
|
183
|
-
*
|
|
161
|
+
* The `[sliders]` control, which shows and hides the tuner panel (§15.7).
|
|
184
162
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* — {@link expandLabel} reads the same predicate.
|
|
163
|
+
* Labelled for what the button *opens* rather than for what the panel edits:
|
|
164
|
+
* "forces" names the physics, which is the one thing a reader who has not read
|
|
165
|
+
* `layout.ts` has no word for. The constants keep the `FORCES_` prefix because
|
|
166
|
+
* the module they drive is still the force layout.
|
|
190
167
|
*/
|
|
191
|
-
export
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/** The `[fit]` control. Constant, but named here so the component holds no copy. */
|
|
196
|
-
export const FIT_LABEL = "fit";
|
|
197
|
-
export const FIT_HINT = "frame the whole graph";
|
|
168
|
+
export const FORCES_LABEL = "sliders";
|
|
169
|
+
export const FORCES_HINT = "tune the layout physics and colours";
|
|
198
170
|
|
|
199
171
|
/**
|
|
200
172
|
* The legend under the canvas, from the §1.2 mock:
|
|
@@ -300,6 +272,16 @@ export function graphColumnModel(
|
|
|
300
272
|
// Optional so every existing caller and test keeps its shape; only the
|
|
301
273
|
// shell's boot-failure signal has a reason to pass it.
|
|
302
274
|
bootFailed = false,
|
|
275
|
+
/**
|
|
276
|
+
* Colour nodes by their group's hue rather than by kind (§15.8).
|
|
277
|
+
*
|
|
278
|
+
* A parameter rather than a constant because it is a taste decision the
|
|
279
|
+
* user owns — the kind palette is three greys and an accent, which is calm
|
|
280
|
+
* but says nothing about which blob is which. Defaults to `true`: the
|
|
281
|
+
* grouping is what the forces went to the trouble of separating, so leaving
|
|
282
|
+
* it uncoloured by default would waste the layout.
|
|
283
|
+
*/
|
|
284
|
+
groupColors = true,
|
|
303
285
|
): GraphColumnModel {
|
|
304
286
|
if (payload === null)
|
|
305
287
|
// Identity preserved on the ordinary path (`EMPTY_COLUMN` is compared by
|
|
@@ -311,9 +293,13 @@ export function graphColumnModel(
|
|
|
311
293
|
const vaultOpen = state.expanded.has("vault");
|
|
312
294
|
const edges = vaultOpen ? reduced.edges.filter((edge) => edge.source !== "vault" && edge.target !== "vault") : reduced.edges;
|
|
313
295
|
const layout = resolveLayout(storage, reduced.nodes, edges);
|
|
296
|
+
// Over the *reduced* nodes and the same edges the layout used, so a
|
|
297
|
+
// collapsed cluster is coloured by the group it stands in for rather than
|
|
298
|
+
// by a branch that is not on screen.
|
|
299
|
+
const fills = groupColors ? groupNodeColors(reduced.nodes, edges, scheme) : undefined;
|
|
314
300
|
|
|
315
301
|
return {
|
|
316
|
-
graph: renderGraph(reduced.nodes, edges, layout.positions, scheme),
|
|
302
|
+
graph: renderGraph(reduced.nodes, edges, layout.positions, scheme, fills),
|
|
317
303
|
highlight: highlightFor(edges, selectedId),
|
|
318
304
|
key: layout.key,
|
|
319
305
|
cached: layout.cached,
|
|
@@ -39,6 +39,16 @@ const DRAG_ALPHA_TARGET = 0.2;
|
|
|
39
39
|
/** d3-force's alpha floor. */
|
|
40
40
|
const ALPHA_MIN = 0.001;
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* How far the pin must travel, in layout units, to count as a drag rather
|
|
44
|
+
* than a held press.
|
|
45
|
+
*
|
|
46
|
+
* Half a layout unit is well under one screen pixel at any zoom a person
|
|
47
|
+
* uses, so this cannot swallow a real drag — but it does swallow the
|
|
48
|
+
* identical-coordinate repeats a stationary press produces. See `pin`.
|
|
49
|
+
*/
|
|
50
|
+
const PIN_STILL = 0.5;
|
|
51
|
+
|
|
42
52
|
/**
|
|
43
53
|
* Build a live simulation over a {@link RenderGraph}.
|
|
44
54
|
*
|
|
@@ -111,9 +121,20 @@ export function createGraphSimulation(graph: RenderGraph, initial?: ReadonlyMap<
|
|
|
111
121
|
// make room, then cool on release. A pinned node is immune to every
|
|
112
122
|
// force — the drag must not fight the sim, or the node would shudder
|
|
113
123
|
// under its own neighbours.
|
|
124
|
+
const still = node.fx != null && node.fy != null && Math.abs(node.fx - at.x) < PIN_STILL && Math.abs(node.fy - at.y) < PIN_STILL;
|
|
114
125
|
node.fx = at.x;
|
|
115
126
|
node.fy = at.y;
|
|
116
|
-
|
|
127
|
+
// Heat only while the pointer is actually *travelling*. `alphaTarget` is
|
|
128
|
+
// a floor, not a decay: held above the alpha floor it feeds the system
|
|
129
|
+
// energy forever, so a long press with a still cursor kept every other
|
|
130
|
+
// node swimming (measured: 93 of 94 nodes moving, mean 70 units, never
|
|
131
|
+
// settling) and — once the forces got strong enough to separate groups
|
|
132
|
+
// — orbiting each other. d3's example gets away with the unconditional
|
|
133
|
+
// hold because its `drag` subject only fires on real movement; sigma's
|
|
134
|
+
// `downNode` + `moveBody` pair does not, so the distinction is made
|
|
135
|
+
// here. Releasing the target to 0 while the node stays pinned lets the
|
|
136
|
+
// graph cool *under* the held node, which is what a press should do.
|
|
137
|
+
sim.alphaTarget(still ? 0 : DRAG_ALPHA_TARGET);
|
|
117
138
|
},
|
|
118
139
|
|
|
119
140
|
release(id) {
|
|
@@ -521,6 +521,15 @@ export function renderGraph(
|
|
|
521
521
|
edges: readonly WireGraphEdge[],
|
|
522
522
|
positions: ReadonlyMap<string, Point>,
|
|
523
523
|
scheme: ColorScheme,
|
|
524
|
+
/**
|
|
525
|
+
* Per-node fills that override the kind palette — `groups.ts`'s
|
|
526
|
+
* group-hue assignment, or absent for the kind-only colouring.
|
|
527
|
+
*
|
|
528
|
+
* Injected rather than computed here because it is a *policy* (§15.8's
|
|
529
|
+
* `colors` setting) and this function is the mechanism. An id the map does
|
|
530
|
+
* not name falls back to {@link kindColor}, so a partial map is safe.
|
|
531
|
+
*/
|
|
532
|
+
groupFills?: ReadonlyMap<string, string>,
|
|
524
533
|
): RenderGraph {
|
|
525
534
|
const placed = new Map<string, WireGraphNode>();
|
|
526
535
|
for (const node of nodes) {
|
|
@@ -543,7 +552,7 @@ export function renderGraph(
|
|
|
543
552
|
y: at.y,
|
|
544
553
|
size,
|
|
545
554
|
label: nodeLabel(node),
|
|
546
|
-
color: kindColor(node.kind, scheme),
|
|
555
|
+
color: groupFills?.get(id) ?? kindColor(node.kind, scheme),
|
|
547
556
|
kind: node.kind,
|
|
548
557
|
provenance: node.provenance,
|
|
549
558
|
zIndex: Math.round(size),
|
|
@@ -614,14 +623,50 @@ export interface EdgeDisplayOverride {
|
|
|
614
623
|
*/
|
|
615
624
|
export function nodeReducer(
|
|
616
625
|
highlight: ReadonlySet<string> | null,
|
|
626
|
+
/**
|
|
627
|
+
* The selected node itself, distinguished from its neighbours.
|
|
628
|
+
*
|
|
629
|
+
* `highlight` is `focusNeighborhood`'s answer — the selection *plus* its
|
|
630
|
+
* direct neighbours — and painting the two identically loses the one fact
|
|
631
|
+
* the gesture was about: which node was clicked. Optional, so a caller with
|
|
632
|
+
* only a neighbourhood keeps the previous two-tier behaviour.
|
|
633
|
+
*/
|
|
634
|
+
selectedId?: string | null,
|
|
617
635
|
): (id: string, data: RenderNode, scheme: ColorScheme) => NodeDisplayOverride {
|
|
618
636
|
return (id, data, scheme) => {
|
|
619
637
|
if (highlight === null) return {};
|
|
620
|
-
if (highlight.has(id))
|
|
638
|
+
if (id === selectedId && highlight.has(id)) {
|
|
639
|
+
// The subject of the gesture: lifted clear of its own neighbourhood and
|
|
640
|
+
// grown by a ratio rather than to a fixed radius, so a hub still reads
|
|
641
|
+
// as bigger than the leaf beside it while both read as "this one".
|
|
642
|
+
return { zIndex: data.zIndex + HIGHLIGHT_Z_LIFT * 2, size: data.size * SELECTED_GROWTH };
|
|
643
|
+
}
|
|
644
|
+
// A connected node: its own group colour at full strength, lifted above
|
|
645
|
+
// the cloud, and grown a little. The step between "connected" and
|
|
646
|
+
// "unrelated" used to be carried entirely by the *recession of everything
|
|
647
|
+
// else*, which reads as the graph dimming rather than as a neighbourhood
|
|
648
|
+
// being named — the difference matters most on a big canvas, where the
|
|
649
|
+
// receded cloud is far off-screen and the only thing visible is a
|
|
650
|
+
// neighbourhood that looks exactly like it did before the click.
|
|
651
|
+
if (highlight.has(id)) return { zIndex: data.zIndex + HIGHLIGHT_Z_LIFT, size: data.size * NEIGHBOUR_GROWTH };
|
|
621
652
|
return { color: recessColor(data.color, scheme), label: null, zIndex: 0 };
|
|
622
653
|
};
|
|
623
654
|
}
|
|
624
655
|
|
|
656
|
+
/**
|
|
657
|
+
* How much the selected node grows, as a size multiplier.
|
|
658
|
+
*
|
|
659
|
+
* A ratio, not a fixed radius: the degree ramp's whole job is that a hub is
|
|
660
|
+
* visibly bigger than a leaf, and selecting a leaf must not make it the
|
|
661
|
+
* largest thing on the stage. 1.45× is a clear step at every zoom — sizes are
|
|
662
|
+
* in layout units (`itemSizesReference: "positions"`), so it survives zooming
|
|
663
|
+
* out, unlike a pixel bump.
|
|
664
|
+
*/
|
|
665
|
+
export const SELECTED_GROWTH = 1.45;
|
|
666
|
+
|
|
667
|
+
/** The same idea, one step down, for a directly connected node. */
|
|
668
|
+
export const NEIGHBOUR_GROWTH = 1.15;
|
|
669
|
+
|
|
625
670
|
/**
|
|
626
671
|
* How far a highlighted node is lifted above the rest.
|
|
627
672
|
*
|
|
@@ -651,12 +696,25 @@ export const HIGHLIGHT_Z_LIFT = MAX_NODE_SIZE + 1;
|
|
|
651
696
|
*/
|
|
652
697
|
export function edgeReducer(
|
|
653
698
|
highlight: ReadonlySet<string> | null,
|
|
699
|
+
/** The selection, so an edge *touching* it outranks one merely near it. */
|
|
700
|
+
selectedId?: string | null,
|
|
654
701
|
): (key: string, data: RenderEdge, scheme: ColorScheme) => EdgeDisplayOverride {
|
|
655
702
|
return (_key, data, scheme) => {
|
|
656
703
|
if (highlight === null) return {};
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
704
|
+
if (!highlight.has(data.source) || !highlight.has(data.target)) {
|
|
705
|
+
return { color: recessColor(data.color, scheme) };
|
|
706
|
+
}
|
|
707
|
+
// Incident on the selection itself — these are the node's actual links,
|
|
708
|
+
// and they are what "connected" means drawn. Painted in the accent so a
|
|
709
|
+
// containment hairline joining the selection stops reading as scaffolding
|
|
710
|
+
// for as long as the selection stands, and thickened a step beyond the
|
|
711
|
+
// neighbourhood's own edges.
|
|
712
|
+
if (selectedId != null && (data.source === selectedId || data.target === selectedId)) {
|
|
713
|
+
return { zIndex: 2, size: data.size * EDGE_PRESENCE * 1.35, color: GRAPH_PALETTE[scheme].accent };
|
|
714
|
+
}
|
|
715
|
+
// Between two neighbours, but not touching the selection: real context,
|
|
716
|
+
// one step quieter.
|
|
717
|
+
return { zIndex: 1, size: data.size * EDGE_PRESENCE };
|
|
660
718
|
};
|
|
661
719
|
}
|
|
662
720
|
|