pi-weave 0.1.8 → 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 +9 -3
- package/package.json +2 -2
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +53 -0
- package/src/core/graph/build.ts +60 -1
- package/src/core/graph/model.ts +15 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +2 -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/vault.ts +264 -16
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +144 -37
- 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 +1 -1
- package/src/web/client/dist/app.js +52 -39
- package/src/web/client/graph/Graph.tsx +70 -19
- package/src/web/client/graph/column.model.ts +11 -70
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +10 -2
- package/src/web/client/graph/positions.ts +51 -10
- package/src/web/client/graph/renderer.ts +61 -1
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Header.tsx +11 -1
- package/src/web/client/shell/Shell.tsx +17 -0
- package/src/web/client/shell/theme.ts +15 -2
- package/src/web/client/state.ts +11 -0
- package/src/web/client/tree/Tree.tsx +4 -1
- package/src/web/client/workspace.ts +69 -5
- package/src/web/server/page.ts +2 -0
- package/src/web/server/routes.ts +19 -7
- package/src/web/shared/graph.ts +7 -0
- package/src/web/shared/layout.ts +158 -341
- package/src/web/shared/logo.ts +11 -0
|
@@ -29,31 +29,28 @@
|
|
|
29
29
|
* `Shell.tsx` uses for its drag handlers, for the same reason.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
32
|
+
import { useEffect, useMemo, useRef, useState, useCallback } from "preact/hooks";
|
|
33
33
|
import type { GraphPayload } from "../../shared/wire";
|
|
34
34
|
import type { GraphViewState } from "./column.model";
|
|
35
35
|
import {
|
|
36
|
-
DEPTHS,
|
|
37
36
|
FIT_HINT,
|
|
38
37
|
FIT_LABEL,
|
|
39
38
|
LEGEND,
|
|
40
39
|
allExpanded,
|
|
41
|
-
depthHint,
|
|
42
|
-
depthLabel,
|
|
43
40
|
effectiveView,
|
|
44
41
|
expandHint,
|
|
45
42
|
expandLabel,
|
|
46
43
|
graphClick,
|
|
47
44
|
graphColumnModel,
|
|
48
45
|
graphCountLabel,
|
|
49
|
-
parseDepth,
|
|
50
|
-
setDepth,
|
|
51
46
|
toggleExpandAll,
|
|
52
47
|
} from "./column.model";
|
|
53
48
|
import type { PositionStorage } from "./positions";
|
|
54
49
|
import type { GraphRenderer, RendererFactory } from "./renderer";
|
|
55
50
|
import { schemeOf } from "./scheme";
|
|
56
51
|
import type { SchemeHost } from "./scheme";
|
|
52
|
+
import { createGraphSimulation } from "./dynamics";
|
|
53
|
+
import type { GraphSimulation } from "./dynamics";
|
|
57
54
|
|
|
58
55
|
export interface GraphProps {
|
|
59
56
|
graph: GraphPayload | null;
|
|
@@ -81,6 +78,30 @@ export interface GraphProps {
|
|
|
81
78
|
export function Graph(props: GraphProps) {
|
|
82
79
|
const canvas = useRef<HTMLDivElement | null>(null);
|
|
83
80
|
const renderer = useRef<GraphRenderer | null>(null);
|
|
81
|
+
const dynamics = useRef<GraphSimulation | null>(null);
|
|
82
|
+
const clock = useRef<number | null>(null);
|
|
83
|
+
/**
|
|
84
|
+
* Arm the simulation clock — idempotent.
|
|
85
|
+
*
|
|
86
|
+
* The layout is always live; the clock still sleeps whenever the engine's
|
|
87
|
+
* alpha reaches its floor (a graph that is holding still costs zero frames)
|
|
88
|
+
* and re-arms when a drag pins a node or a re-layout hands over a new
|
|
89
|
+
* engine.
|
|
90
|
+
*/
|
|
91
|
+
const armClock = useCallback(() => {
|
|
92
|
+
if (clock.current !== null) return;
|
|
93
|
+
const step = () => {
|
|
94
|
+
clock.current = null;
|
|
95
|
+
const engine = dynamics.current;
|
|
96
|
+
if (engine === null) return;
|
|
97
|
+
engine.tick();
|
|
98
|
+
// Asleep: let the clock die. The next pin re-arms it.
|
|
99
|
+
if (!engine.awake()) return;
|
|
100
|
+
renderer.current?.setPositions(engine.positions());
|
|
101
|
+
clock.current = requestAnimationFrame(step);
|
|
102
|
+
};
|
|
103
|
+
clock.current = requestAnimationFrame(step);
|
|
104
|
+
}, []);
|
|
84
105
|
// `null` means "the user has not touched the expansion" — not "nothing is
|
|
85
106
|
// expanded". `effectiveView` resolves the difference; see its doc comment.
|
|
86
107
|
const [state, setState] = useState<GraphViewState | null>(null);
|
|
@@ -101,6 +122,20 @@ export function Graph(props: GraphProps) {
|
|
|
101
122
|
setState(next.state);
|
|
102
123
|
live.current.onSelect(next.selectedId);
|
|
103
124
|
});
|
|
125
|
+
instance.onDragStart((id) => {
|
|
126
|
+
const at = renderer.current?.positions().get(id);
|
|
127
|
+
if (at) {
|
|
128
|
+
dynamics.current?.pin(id, at);
|
|
129
|
+
// The pin re-heats the engine; if the clock was asleep, wake it.
|
|
130
|
+
armClock();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
instance.onDragMove((id, at) => {
|
|
134
|
+
dynamics.current?.pin(id, at);
|
|
135
|
+
});
|
|
136
|
+
instance.onDragEnd((id) => {
|
|
137
|
+
dynamics.current?.release(id);
|
|
138
|
+
});
|
|
104
139
|
instance.mount(canvas.current ?? { clientWidth: 0, clientHeight: 0 });
|
|
105
140
|
renderer.current = instance;
|
|
106
141
|
props.fit.current = () => instance.fit();
|
|
@@ -117,6 +152,35 @@ export function Graph(props: GraphProps) {
|
|
|
117
152
|
renderer.current?.setGraph(model.graph);
|
|
118
153
|
}, [model.key]);
|
|
119
154
|
|
|
155
|
+
// Live layout, in two effects so pause/resume and re-layout are independent.
|
|
156
|
+
//
|
|
157
|
+
// Effect 1 (keyed on `model.key`) owns the *simulation*: it is created once
|
|
158
|
+
// per graph shape, warm-started from the renderer's current positions so a
|
|
159
|
+
// drag or an expand does not make the graph jump, and disposed when the shape
|
|
160
|
+
// changes. A fresh engine starts at settle alpha — the "alive" answer to an
|
|
161
|
+
// expand — so the clock must arm here if it had gone to sleep.
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
const engine = createGraphSimulation(model.graph, renderer.current?.positions());
|
|
164
|
+
dynamics.current = engine;
|
|
165
|
+
if (engine !== null) armClock();
|
|
166
|
+
return () => {
|
|
167
|
+
dynamics.current = null;
|
|
168
|
+
};
|
|
169
|
+
}, [model.key, armClock]);
|
|
170
|
+
|
|
171
|
+
// Effect 2 owns the clock's unmount cleanup: the engine's own lifecycle is
|
|
172
|
+
// effect 1's, and the step self-terminates whenever the engine settles, so
|
|
173
|
+
// the only teardown left here is a frame that could outlive the component.
|
|
174
|
+
useEffect(
|
|
175
|
+
() => () => {
|
|
176
|
+
if (clock.current !== null) {
|
|
177
|
+
cancelAnimationFrame(clock.current);
|
|
178
|
+
clock.current = null;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
[],
|
|
182
|
+
);
|
|
183
|
+
|
|
120
184
|
useEffect(() => {
|
|
121
185
|
renderer.current?.setHighlight(model.highlight);
|
|
122
186
|
}, [model.highlight]);
|
|
@@ -134,19 +198,6 @@ export function Graph(props: GraphProps) {
|
|
|
134
198
|
<button type="button" class="weave-chip" title={expandHint(everything)} onClick={() => setState(toggleExpandAll(view, model.clusters))}>
|
|
135
199
|
{expandLabel(everything)}
|
|
136
200
|
</button>
|
|
137
|
-
<select
|
|
138
|
-
class="weave-chip weave-depth"
|
|
139
|
-
title={depthHint(view.depth)}
|
|
140
|
-
aria-label="Highlight depth"
|
|
141
|
-
value={String(view.depth)}
|
|
142
|
-
onChange={(event) => setState(setDepth(view, parseDepth(event.currentTarget.value, view.depth)))}
|
|
143
|
-
>
|
|
144
|
-
{DEPTHS.map((depth) => (
|
|
145
|
-
<option key={depth} value={String(depth)}>
|
|
146
|
-
{depthLabel(depth)}
|
|
147
|
-
</option>
|
|
148
|
-
))}
|
|
149
|
-
</select>
|
|
150
201
|
<span class="weave-graph-legend">
|
|
151
202
|
<span class="weave-legend-on">◉ {LEGEND.selected}</span>
|
|
152
203
|
<span class="weave-legend-near">● {LEGEND.neighborhood}</span>
|
|
@@ -51,25 +51,6 @@ import { resolveLayout } from "./positions";
|
|
|
51
51
|
|
|
52
52
|
// --- the view state ------------------------------------------------------------------
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
* How many hops of neighbourhood the highlight covers.
|
|
56
|
-
*
|
|
57
|
-
* The `[depth 1 ▾]` control from the §1.2 mock. 1 is core's
|
|
58
|
-
* `focusNeighborhood` verbatim; 2 and 3 are that set expanded by re-applying
|
|
59
|
-
* it, which is the only definition that cannot disagree with the context
|
|
60
|
-
* rail's at depth 1.
|
|
61
|
-
*
|
|
62
|
-
* Capped at 3 deliberately. On this repository's shape the 60-child hub is two
|
|
63
|
-
* hops from most of the graph, so depth 4 highlights nearly everything and a
|
|
64
|
-
* highlight that covers everything conveys nothing. A number rather than a
|
|
65
|
-
* boolean because "just the neighbours" and "the neighbourhood around this
|
|
66
|
-
* module" are genuinely different questions.
|
|
67
|
-
*/
|
|
68
|
-
export type Depth = 1 | 2 | 3;
|
|
69
|
-
|
|
70
|
-
/** Every depth the control offers, in order. */
|
|
71
|
-
export const DEPTHS: readonly Depth[] = [1, 2, 3];
|
|
72
|
-
|
|
73
54
|
/** The graph column's state. Owned by the column; never on the context bus. */
|
|
74
55
|
export interface GraphViewState {
|
|
75
56
|
/**
|
|
@@ -83,8 +64,6 @@ export interface GraphViewState {
|
|
|
83
64
|
* {@link initialGraphView}.
|
|
84
65
|
*/
|
|
85
66
|
readonly expanded: ReadonlySet<string>;
|
|
86
|
-
/** How far the highlight reaches. */
|
|
87
|
-
readonly depth: Depth;
|
|
88
67
|
}
|
|
89
68
|
|
|
90
69
|
/**
|
|
@@ -112,7 +91,7 @@ export const AUTO_COLLAPSE_ABOVE = 120;
|
|
|
112
91
|
export function initialGraphView(model: ViewGraphModel): GraphViewState {
|
|
113
92
|
const expanded =
|
|
114
93
|
model.nodes.length > AUTO_COLLAPSE_ABOVE ? new Set<string>() : new Set(clusterAggregate(model, new Set()).clusters.keys());
|
|
115
|
-
return { expanded
|
|
94
|
+
return { expanded };
|
|
116
95
|
}
|
|
117
96
|
|
|
118
97
|
/**
|
|
@@ -127,7 +106,7 @@ export function initialGraphView(model: ViewGraphModel): GraphViewState {
|
|
|
127
106
|
*/
|
|
128
107
|
export function effectiveView(payload: GraphPayload | null, state: GraphViewState | null): GraphViewState {
|
|
129
108
|
if (state !== null) return state;
|
|
130
|
-
if (payload === null) return { expanded: new Set()
|
|
109
|
+
if (payload === null) return { expanded: new Set() };
|
|
131
110
|
return initialGraphView(viewModel(payload));
|
|
132
111
|
}
|
|
133
112
|
|
|
@@ -156,21 +135,17 @@ export function collapseAll(state: GraphViewState): GraphViewState {
|
|
|
156
135
|
return { ...state, expanded: new Set() };
|
|
157
136
|
}
|
|
158
137
|
|
|
159
|
-
/** Set the highlight depth. */
|
|
160
|
-
export function setDepth(state: GraphViewState, depth: Depth): GraphViewState {
|
|
161
|
-
return state.depth === depth ? state : { ...state, depth };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
138
|
// --- the highlight (§1.3, §7.4) ---------------------------------------------------------
|
|
165
139
|
|
|
166
140
|
/**
|
|
167
141
|
* The set of ids that stay lit when `selectedId` is selected.
|
|
168
142
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* rather than reimplemented.
|
|
172
|
-
*
|
|
173
|
-
*
|
|
143
|
+
* `focusNeighborhood` **verbatim** — core's function, the same one the
|
|
144
|
+
* context rail's "Related" is built from, reached through the §2.1.1 door
|
|
145
|
+
* rather than reimplemented. (The `[depth 1 ▾]` control once let the user
|
|
146
|
+
* widen this to two and three hops; it read as decoration next to the rail's
|
|
147
|
+
* fixed-depth "Related" and is gone — one definition of "related", one
|
|
148
|
+
* behaviour everywhere.)
|
|
174
149
|
*
|
|
175
150
|
* `null` in, `null` out: nothing selected means no highlight at all, which the
|
|
176
151
|
* reducers treat as "render everything normally" — deliberately not the same
|
|
@@ -182,34 +157,13 @@ export function setDepth(state: GraphViewState, depth: Depth): GraphViewState {
|
|
|
182
157
|
* highlighting the hidden id would light nothing while leaving the cluster
|
|
183
158
|
* that actually represents it dimmed.
|
|
184
159
|
*/
|
|
185
|
-
export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string | null
|
|
160
|
+
export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string | null): Set<string> | null {
|
|
186
161
|
if (selectedId === null) return null;
|
|
187
|
-
|
|
188
|
-
for (let hop = 1; hop < depth; hop++) {
|
|
189
|
-
const next = new Set(frontier);
|
|
190
|
-
for (const id of frontier) for (const neighbour of focusNeighborhood(id, edges)) next.add(neighbour);
|
|
191
|
-
// A neighbourhood that stopped growing has reached its whole connected
|
|
192
|
-
// component, so further hops cannot add anything and the loop is waste.
|
|
193
|
-
if (next.size === frontier.size) break;
|
|
194
|
-
frontier = next;
|
|
195
|
-
}
|
|
196
|
-
return frontier;
|
|
162
|
+
return focusNeighborhood(selectedId, edges);
|
|
197
163
|
}
|
|
198
164
|
|
|
199
165
|
// --- the control strip (§1.2) ---------------------------------------------------------------
|
|
200
166
|
|
|
201
|
-
/** The `[depth 1 ▾]` control's label. */
|
|
202
|
-
export function depthLabel(depth: Depth): string {
|
|
203
|
-
return `depth ${depth}`;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** Its tooltip — says what the number means, not what it is. */
|
|
207
|
-
export function depthHint(depth: Depth): string {
|
|
208
|
-
return depth === 1
|
|
209
|
-
? "highlighting direct neighbours of the selection"
|
|
210
|
-
: `highlighting everything within ${depth} hops of the selection`;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
167
|
/** The `[expand]` control's label, which is really a toggle. */
|
|
214
168
|
export function expandLabel(allExpanded: boolean): string {
|
|
215
169
|
return allExpanded ? "collapse" : "expand";
|
|
@@ -240,19 +194,6 @@ export function toggleExpandAll(state: GraphViewState, clusters: ReadonlyMap<str
|
|
|
240
194
|
return allExpanded(state, clusters) ? collapseAll(state) : expandAll(state, clusters);
|
|
241
195
|
}
|
|
242
196
|
|
|
243
|
-
/**
|
|
244
|
-
* Parse the depth `<select>`'s value.
|
|
245
|
-
*
|
|
246
|
-
* A `<select>` yields a string and the component must not cast it — an
|
|
247
|
-
* `as Depth` on unvalidated input is exactly how a `depth 7` ends up in state
|
|
248
|
-
* and the highlight silently covers the whole graph. Anything unrecognised
|
|
249
|
-
* falls back to the current depth, so a malformed event changes nothing.
|
|
250
|
-
*/
|
|
251
|
-
export function parseDepth(value: string, fallback: Depth): Depth {
|
|
252
|
-
const parsed = Number(value);
|
|
253
|
-
return (DEPTHS as readonly number[]).includes(parsed) ? (parsed as Depth) : fallback;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
197
|
/** The `[fit]` control. Constant, but named here so the component holds no copy. */
|
|
257
198
|
export const FIT_LABEL = "fit";
|
|
258
199
|
export const FIT_HINT = "frame the whole graph";
|
|
@@ -354,7 +295,7 @@ export function graphColumnModel(
|
|
|
354
295
|
|
|
355
296
|
return {
|
|
356
297
|
graph: renderGraph(reduced.nodes, reduced.edges, layout.positions, scheme),
|
|
357
|
-
highlight: highlightFor(reduced.edges, selectedId
|
|
298
|
+
highlight: highlightFor(reduced.edges, selectedId),
|
|
358
299
|
key: layout.key,
|
|
359
300
|
cached: layout.cached,
|
|
360
301
|
clusters: reduced.clusters,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live force dynamics for the graph's current shape — the Obsidian-style
|
|
3
|
+
* "the graph is alive" feel while the user drags.
|
|
4
|
+
*
|
|
5
|
+
* ## One physics, two drivers
|
|
6
|
+
*
|
|
7
|
+
* The static layout (`shared/layout.ts`) and this module now share ONE force
|
|
8
|
+
* configuration: {@link createForceSimulation}. They used to be two
|
|
9
|
+
* independent physics — the static path ring-sized its link distances per
|
|
10
|
+
* fan-out and anchored every node to its seed; this one pulled at a flat 90
|
|
11
|
+
* units toward its own equilibrium. Every graph therefore *visibly re-laid
|
|
12
|
+
* itself* after mount (a 215-node repo graph migrated from a ~1200-unit ring
|
|
13
|
+
* to the bespoke sim's ~300-unit cloud over about thirty seconds), and the
|
|
14
|
+
* constant-alpha loop ticked at 60 fps forever, because a bespoke integrator
|
|
15
|
+
* has no alpha to decay. Sharing the factory fixes both by construction: the
|
|
16
|
+
* warm start is already at the shared equilibrium, so there is nothing to
|
|
17
|
+
* migrate to, and d3's alpha decay is what says "settled".
|
|
18
|
+
*
|
|
19
|
+
* ## The lifecycle
|
|
20
|
+
*
|
|
21
|
+
* A new engine starts at {@link SETTLE_ALPHA} (an expand/collapse lands new
|
|
22
|
+
* nodes already laid out; the brief settle is the "alive" response, not a
|
|
23
|
+
* re-layout) and decays to d3's alpha floor, at which point {@link awake}
|
|
24
|
+
* turns false and the component's clock puts itself to sleep — no frames are
|
|
25
|
+
* spent on a graph that is holding still. `pin` re-heats to
|
|
26
|
+
* {@link DRAG_ALPHA_TARGET} (d3's own drag pattern), so neighbours make room
|
|
27
|
+
* while the user drags; `release` clears the pin AND retargets the node's
|
|
28
|
+
* anchor to its drop point, so a dragged node stays where it was dropped
|
|
29
|
+
* instead of springing back to its seed.
|
|
30
|
+
*
|
|
31
|
+
* ## The §7.2 vertical-line freeze
|
|
32
|
+
*
|
|
33
|
+
* Inherited from d3 itself: `manyBody`, `collide` and `link` all inject a
|
|
34
|
+
* seeded `jiggle` into a zero axis component, so coincident or collinear
|
|
35
|
+
* nodes always acquire a direction. The seeded LCG (`seed: 1`) keeps the
|
|
36
|
+
* whole thing deterministic — the same graph, the same gestures, byte-identical
|
|
37
|
+
* positions — which is what the test gate asserts.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import type { Point } from "../../shared/layout";
|
|
41
|
+
import { createForceSimulation, isContainment } from "../../shared/layout";
|
|
42
|
+
import type { WireEdgeKind } from "../../shared/graph";
|
|
43
|
+
import type { RenderGraph } from "./graph.model";
|
|
44
|
+
|
|
45
|
+
interface SimNode {
|
|
46
|
+
id: string;
|
|
47
|
+
x?: number;
|
|
48
|
+
y?: number;
|
|
49
|
+
vx?: number;
|
|
50
|
+
vy?: number;
|
|
51
|
+
index?: number;
|
|
52
|
+
fx?: number | null;
|
|
53
|
+
fy?: number | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface SimLink {
|
|
57
|
+
source: string | SimNode;
|
|
58
|
+
target: string | SimNode;
|
|
59
|
+
kind: WireEdgeKind;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GraphSimulation {
|
|
63
|
+
tick(): void;
|
|
64
|
+
positions(): Map<string, Point>;
|
|
65
|
+
pin(id: string, at: Point): void;
|
|
66
|
+
release(id: string): void;
|
|
67
|
+
/** `false` once the alpha floor is reached — the graph is holding still, and the clock can sleep. */
|
|
68
|
+
awake(): boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Re-heat on creation (mount, expand, collapse): brief and local, never a re-layout. */
|
|
72
|
+
const SETTLE_ALPHA = 0.06;
|
|
73
|
+
/**
|
|
74
|
+
* The drag alpha target. The balance the user feels: too low and the
|
|
75
|
+
* neighbours read as stiff and lazy while the node moves; too high and the
|
|
76
|
+
* whole tree thrashes (measured: the hub drag peaks at ~950px/s of per-node
|
|
77
|
+
* ripple at 0.3 with rigid links, ~174px/s at 0.2 with the soft springs —
|
|
78
|
+
* lively, and an order of magnitude inside the crazy regime).
|
|
79
|
+
*/
|
|
80
|
+
const DRAG_ALPHA_TARGET = 0.2;
|
|
81
|
+
/** d3-force's alpha floor. */
|
|
82
|
+
const ALPHA_MIN = 0.001;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build a live simulation over a {@link RenderGraph}.
|
|
86
|
+
*
|
|
87
|
+
* `initial`, when given, is the warm start: existing ids keep their current
|
|
88
|
+
* positions (so a drag or an expand does not make the graph jump), and ids it
|
|
89
|
+
* does not name fall back to the graph's own laid-out positions. Those warm
|
|
90
|
+
* positions are also the anchor targets, so the graph rests exactly where the
|
|
91
|
+
* layout left it.
|
|
92
|
+
*
|
|
93
|
+
* Returns `null` for an empty graph — there is nothing to simulate, and an
|
|
94
|
+
* empty `Map` every frame is work for nothing.
|
|
95
|
+
*/
|
|
96
|
+
export function createGraphSimulation(graph: RenderGraph, initial?: ReadonlyMap<string, Point>): GraphSimulation | null {
|
|
97
|
+
if (graph.nodes.length === 0) return null;
|
|
98
|
+
|
|
99
|
+
const nodes: SimNode[] = graph.nodes.map((node) => {
|
|
100
|
+
const at = initial?.get(node.id);
|
|
101
|
+
return {
|
|
102
|
+
id: node.id,
|
|
103
|
+
x: at !== undefined && Number.isFinite(at.x) ? at.x : node.x,
|
|
104
|
+
y: at !== undefined && Number.isFinite(at.y) ? at.y : node.y,
|
|
105
|
+
vx: 0,
|
|
106
|
+
vy: 0,
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
110
|
+
|
|
111
|
+
// Dangling endpoints and self-edges are a degenerate *input*; forceLink
|
|
112
|
+
// would throw on them, so they are dropped here and the layout's own
|
|
113
|
+
// `pathologicalGraph` fixture remains the gate for degenerate *outputs*.
|
|
114
|
+
const seen = new Set<string>();
|
|
115
|
+
const links: SimLink[] = [];
|
|
116
|
+
for (const edge of graph.edges) {
|
|
117
|
+
if (edge.source === edge.target) continue;
|
|
118
|
+
if (!byId.has(edge.source) || !byId.has(edge.target)) continue;
|
|
119
|
+
const key = `${edge.source}\u0000${edge.target}\u0000${edge.kind}`;
|
|
120
|
+
if (seen.has(key)) continue;
|
|
121
|
+
seen.add(key);
|
|
122
|
+
links.push({ source: edge.source, target: edge.target, kind: edge.kind });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// No invented seeding; centre gravity lives in the factory (`forceX()`/
|
|
126
|
+
// `forceY()`). `anchors` starts empty — every node targets the origin — and
|
|
127
|
+
// grows on release: a dropped node's anchor is retargeted to its drop point,
|
|
128
|
+
// so placement sticks (the anchor is five times stronger than the leaf
|
|
129
|
+
// spring, so the node rests where the user left it).
|
|
130
|
+
const anchors = new Map<string, Point>();
|
|
131
|
+
const sim = createForceSimulation({ nodes, links, anchors, seed: 1 })
|
|
132
|
+
.alpha(SETTLE_ALPHA)
|
|
133
|
+
.alphaMin(ALPHA_MIN);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
tick() {
|
|
137
|
+
sim.tick();
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
awake() {
|
|
141
|
+
// A held alpha target is motion about to happen — the clock must stay
|
|
142
|
+
// armed even while alpha itself is still converging up to the target.
|
|
143
|
+
return sim.alphaTarget() > ALPHA_MIN || sim.alpha() > ALPHA_MIN;
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
positions() {
|
|
147
|
+
return new Map(nodes.map((node) => [node.id, { x: node.x ?? 0, y: node.y ?? 0 }]));
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
pin(id, at) {
|
|
151
|
+
const node = byId.get(id);
|
|
152
|
+
if (!node) return;
|
|
153
|
+
// d3's canonical drag, from the force-directed graph example: fix the
|
|
154
|
+
// subject at the pointer and hold the alpha target up so the neighbours
|
|
155
|
+
// make room, then cool on release. A pinned node is immune to every
|
|
156
|
+
// force — the drag must not fight the sim, or the node would shudder
|
|
157
|
+
// under its own neighbours.
|
|
158
|
+
node.fx = at.x;
|
|
159
|
+
node.fy = at.y;
|
|
160
|
+
sim.alphaTarget(DRAG_ALPHA_TARGET);
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
release(id) {
|
|
164
|
+
const node = byId.get(id);
|
|
165
|
+
if (!node) return;
|
|
166
|
+
// Unfix, like d3's `dragended` — and retarget the node's centre-gravity
|
|
167
|
+
// anchor to the drop point, so the graph reads the drop as a placement:
|
|
168
|
+
// the node rests where the user left it instead of gliding back into
|
|
169
|
+
// the tree.
|
|
170
|
+
anchors.set(id, { x: node.x ?? 0, y: node.y ?? 0 });
|
|
171
|
+
node.fx = null;
|
|
172
|
+
node.fy = null;
|
|
173
|
+
sim.alphaTarget(0);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -131,9 +131,13 @@ export function edgeColor(kind: WireEdgeKind, scheme: ColorScheme): string {
|
|
|
131
131
|
/**
|
|
132
132
|
* The smallest a node is ever drawn, in layout units.
|
|
133
133
|
*
|
|
134
|
-
* A leaf still has to be clickable, and sigma's hit test is the drawn radius
|
|
134
|
+
* A leaf still has to be clickable, and sigma's hit test is the drawn radius —
|
|
135
|
+
* at fit zoom a leaf renders at `MIN_NODE_SIZE · cameraCorrection`, so a floor
|
|
136
|
+
* of 6 is what keeps every node inside a pointer's reach at overview zoom.
|
|
137
|
+
* (The d3 gallery's force-directed graphs draw every node the same radius for
|
|
138
|
+
* exactly this reason; size still varies by degree up to the ceiling.)
|
|
135
139
|
*/
|
|
136
|
-
export const MIN_NODE_SIZE =
|
|
140
|
+
export const MIN_NODE_SIZE = 6;
|
|
137
141
|
|
|
138
142
|
/**
|
|
139
143
|
* The degree at which a node reaches {@link NODE_RADIUS}.
|
|
@@ -455,6 +459,7 @@ export function edgeReducer(
|
|
|
455
459
|
*/
|
|
456
460
|
export interface GraphSettings {
|
|
457
461
|
readonly hideEdgesOnMove: boolean;
|
|
462
|
+
readonly enableCameraPanning: boolean;
|
|
458
463
|
readonly renderEdgeLabels: boolean;
|
|
459
464
|
readonly labelDensity: number;
|
|
460
465
|
readonly labelGridCellSize: number;
|
|
@@ -505,6 +510,9 @@ export function graphSettings(scheme: ColorScheme): GraphSettings {
|
|
|
505
510
|
// §7.4. A pan over a few thousand edges is the one interaction that drops
|
|
506
511
|
// frames, and the edges are the part nobody is reading mid-gesture.
|
|
507
512
|
hideEdgesOnMove: true,
|
|
513
|
+
// The renderer toggles this off while a node is being dragged, so the
|
|
514
|
+
// gesture pins the node to the cursor instead of panning the camera.
|
|
515
|
+
enableCameraPanning: true,
|
|
508
516
|
renderEdgeLabels: false,
|
|
509
517
|
labelDensity: LABEL_DENSITY,
|
|
510
518
|
labelGridCellSize: LABEL_GRID_CELL_SIZE,
|
|
@@ -34,10 +34,34 @@
|
|
|
34
34
|
* the root `tsconfig.json` project compiles the tests.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
import { computeLayout
|
|
37
|
+
import { computeLayout } from "../../shared/layout";
|
|
38
38
|
import type { LayoutOptions, Point } from "../../shared/layout";
|
|
39
39
|
import type { WireGraphEdge, WireGraphNode } from "../../shared/wire";
|
|
40
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
|
+
|
|
41
65
|
// --- computing ----------------------------------------------------------------------
|
|
42
66
|
|
|
43
67
|
/**
|
|
@@ -64,22 +88,39 @@ export const LAYOUT_SEED = 1;
|
|
|
64
88
|
* re-running from a settled state is a near-fixed-point, which is the property
|
|
65
89
|
* that makes an expand feel like an expand.
|
|
66
90
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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.
|
|
73
104
|
*/
|
|
74
105
|
export function layoutFor(
|
|
75
106
|
nodes: readonly WireGraphNode[],
|
|
76
107
|
edges: readonly WireGraphEdge[],
|
|
77
108
|
warm?: ReadonlyMap<string, Point>,
|
|
78
109
|
): Map<string, Point> {
|
|
79
|
-
const
|
|
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;
|
|
80
121
|
return computeLayout(
|
|
81
|
-
{ generatedAt: "", staleness: null, nodes: [...nodes], edges: [...edges] },
|
|
82
|
-
|
|
122
|
+
{ generatedAt: "", staleness: null, nodes: [...nodes], edges: [...edges], contentDigest: "" },
|
|
123
|
+
options,
|
|
83
124
|
);
|
|
84
125
|
}
|
|
85
126
|
|