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.
Files changed (39) hide show
  1. package/README.md +9 -3
  2. package/package.json +2 -2
  3. package/skills/weave-notepad/SKILL.md +13 -4
  4. package/src/core/concurrency.ts +36 -0
  5. package/src/core/frontmatter.ts +53 -0
  6. package/src/core/graph/build.ts +60 -1
  7. package/src/core/graph/model.ts +15 -0
  8. package/src/core/graph/wikilinks.ts +5 -1
  9. package/src/core/index.ts +2 -0
  10. package/src/core/paths.ts +7 -0
  11. package/src/core/sessions.ts +929 -0
  12. package/src/core/summaries.ts +1 -19
  13. package/src/core/vault.ts +264 -16
  14. package/src/core/workspace.ts +3 -3
  15. package/src/pi/index.ts +144 -37
  16. package/src/pi/sessionScan.ts +104 -0
  17. package/src/pi/summarize.ts +24 -4
  18. package/src/pi/tools/noteTool.ts +13 -4
  19. package/src/pi/viewer/tui/branding.ts +8 -7
  20. package/src/pi/viewer/tui/explorer.ts +1 -1
  21. package/src/web/client/dist/app.js +52 -39
  22. package/src/web/client/graph/Graph.tsx +70 -19
  23. package/src/web/client/graph/column.model.ts +11 -70
  24. package/src/web/client/graph/dynamics.ts +176 -0
  25. package/src/web/client/graph/graph.model.ts +10 -2
  26. package/src/web/client/graph/positions.ts +51 -10
  27. package/src/web/client/graph/renderer.ts +61 -1
  28. package/src/web/client/selection.storage.ts +69 -0
  29. package/src/web/client/shell/Header.tsx +11 -1
  30. package/src/web/client/shell/Shell.tsx +17 -0
  31. package/src/web/client/shell/theme.ts +15 -2
  32. package/src/web/client/state.ts +11 -0
  33. package/src/web/client/tree/Tree.tsx +4 -1
  34. package/src/web/client/workspace.ts +69 -5
  35. package/src/web/server/page.ts +2 -0
  36. package/src/web/server/routes.ts +19 -7
  37. package/src/web/shared/graph.ts +7 -0
  38. package/src/web/shared/layout.ts +158 -341
  39. 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, depth: 1 };
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(), depth: 1 };
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
- * Depth 1 is `focusNeighborhood` **exactly** — core's function, the same one
170
- * the context rail's "Related" is built from, reached through the §2.1.1 door
171
- * rather than reimplemented. Deeper is that operation applied again, which is
172
- * the only definition of "2 hops" that cannot disagree with "1 hop" at the
173
- * boundary.
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, depth: Depth): Set<string> | null {
160
+ export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string | null): Set<string> | null {
186
161
  if (selectedId === null) return null;
187
- let frontier = focusNeighborhood(selectedId, edges);
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, state.depth),
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 = 3;
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, hashId } from "../../shared/layout";
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
- * The viewport is **not** a parameter, deliberately. `computeLayout` centres
68
- * its output on the width and height it is given, and sigma's camera fits
69
- * whatever extent it is handed — so feeding the real column width in would
70
- * re-run the simulation on every divider drag to produce a picture the camera
71
- * immediately normalises away. A fixed nominal viewport keeps the layout a
72
- * function of the *graph*, which is what makes it cacheable at all.
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 options: LayoutOptions = { ticks: LAYOUT_TICKS, seed: LAYOUT_SEED };
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
- warm === undefined ? options : { ...options, initial: warm },
122
+ { generatedAt: "", staleness: null, nodes: [...nodes], edges: [...edges], contentDigest: "" },
123
+ options,
83
124
  );
84
125
  }
85
126