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.
@@ -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 type="button" class="weave-chip" title={expandHint(everything)} onClick={() => setState(toggleExpandAll(view, model.clusters))}>
237
- {expandLabel(everything)}
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
- /** Open or close one cluster. Returns a new state; see {@link expandAll}. */
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 `[expand]` control's label, which is really a toggle. */
166
- export function expandLabel(allExpanded: boolean): string {
167
- return allExpanded ? "collapse" : "expand";
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
- * What the `[expand]` control does when pressed.
161
+ * The `[sliders]` control, which shows and hides the tuner panel (§15.7).
184
162
  *
185
- * One function rather than a ternary in the component, for §10's reason: the
186
- * choice between expanding and collapsing is a branch, and a branch in a
187
- * `.tsx` is a branch no test can reach. It is also the *only* place the
188
- * toggle's two halves are paired, so its label and its effect cannot disagree
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 function toggleExpandAll(state: GraphViewState, clusters: ReadonlyMap<string, ClusterInfo>): GraphViewState {
192
- return allExpanded(state, clusters) ? collapseAll(state) : expandAll(state, clusters);
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
- sim.alphaTarget(DRAG_ALPHA_TARGET);
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)) return { zIndex: data.zIndex + HIGHLIGHT_Z_LIFT };
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
- return highlight.has(data.source) && highlight.has(data.target)
658
- ? { zIndex: 1, size: data.size * EDGE_PRESENCE }
659
- : { color: recessColor(data.color, scheme) };
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