pi-weave 0.3.9 → 0.3.10

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.
@@ -40,9 +40,29 @@ import {
40
40
  graphClick,
41
41
  graphColumnModel,
42
42
  graphCountLabel,
43
+ graphPicture,
43
44
  graphPreview,
44
- hoverHighlight,
45
+ hoverPicture,
45
46
  } from "./column.model";
47
+ import {
48
+ CONNECTED_HEADING,
49
+ LIT_BECAUSE,
50
+ SHEET_CLOSE,
51
+ SHEET_LABEL,
52
+ SHEET_OPEN,
53
+ SHEET_OPEN_SPLIT,
54
+ SPOTLIGHT_CLEAR,
55
+ SPOTLIGHT_CLEAR_HINT,
56
+ SPOTLIGHT_LABEL,
57
+ SPOTLIGHT_LEGEND,
58
+ SPOTLIGHT_PLACEHOLDER,
59
+ connectedMore,
60
+ graphSheet,
61
+ litLabel,
62
+ litOnCanvas,
63
+ spotlightEscapes,
64
+ spotlightOf,
65
+ } from "./spotlight.model";
46
66
  import type { PositionStorage } from "./positions";
47
67
  import type { GraphRenderer, RendererFactory } from "./renderer";
48
68
  import type { TrailGlow } from "./graph.model";
@@ -95,6 +115,28 @@ export interface GraphProps {
95
115
  fit: { current: (() => void) | null };
96
116
  /** Recall-trail glows by node id (`trail/trail.model.ts`). Omitted means none. */
97
117
  trail?: ReadonlyMap<string, TrailGlow>;
118
+ /**
119
+ * The spotlight query (`spotlight.model.ts`), controlled by the shell so
120
+ * other UI — the search palette's "light up in graph" — can set it.
121
+ * Omitted or `""` means no spotlight.
122
+ */
123
+ query?: string;
124
+ /**
125
+ * Node ids the search palette found for `query`, when it lit the graph up.
126
+ * They light alongside the spotlight's own matches; omitted means none.
127
+ */
128
+ found?: readonly string[];
129
+ /**
130
+ * Where the spotlight field writes. **Its presence is what makes this the
131
+ * full graph view**: with it the column shows the floating spotlight field
132
+ * and the side sheet; without it (the context rail's note graph, a narrow
133
+ * column) it keeps the compact preview card it always had.
134
+ */
135
+ onQuery?: (query: string) => void;
136
+ /** The sheet's "Open in split". Omitted hides the button. */
137
+ onOpenSplit?: (id: string) => void;
138
+ /** The shell's clock, for the sheet's "2h ago". Omitted reads the wall clock. */
139
+ now?: number;
98
140
  }
99
141
 
100
142
  const NO_GLOWS: ReadonlyMap<string, TrailGlow> = new Map();
@@ -157,13 +199,25 @@ export function Graph(props: GraphProps) {
157
199
  () => graphColumnModel(props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, groupColors),
158
200
  [props.graph, props.selectedId, view, props.storage, scheme, props.bootFailed, forceKey, groupColors],
159
201
  );
160
- const preview = graphPreview(props.graph, props.previewId);
202
+ // The full graph view has a spotlight and a side sheet; the context rail's
203
+ // note graph keeps the compact card. See `GraphProps.onQuery`.
204
+ const full = props.onQuery !== undefined;
205
+ const query = full ? props.query ?? "" : "";
206
+ const spot = useMemo(() => spotlightOf(props.graph, query, props.found), [props.graph, query, props.found]);
207
+ // Same identity contract as `model` above: the highlight effect keys on the
208
+ // picture's identity, so each step is memoized on exactly what it reads.
209
+ const lit = useMemo(() => litOnCanvas(spot, model.representative), [spot, model.representative]);
210
+ const picture = useMemo(() => graphPicture(model.highlight, props.selectedId, lit), [model.highlight, props.selectedId, lit]);
211
+ const preview = full ? null : graphPreview(props.graph, props.previewId);
212
+ const now = props.now ?? Date.now();
213
+ const sheet = useMemo(() => (full ? graphSheet(props.graph, props.previewId, spot, now) : null), [full, props.graph, props.previewId, spot, now]);
214
+ const field = useRef<HTMLInputElement | null>(null);
161
215
 
162
216
  // Read by the mount-time `onSelect`, which outlives this render.
163
217
  const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
164
218
  trailRef.current = props.trail ?? NO_GLOWS;
165
- const live = useRef({ view, model, onSelect: props.onSelect, selectedId: props.selectedId });
166
- live.current = { view, model, onSelect: props.onSelect, selectedId: props.selectedId };
219
+ const live = useRef({ view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId });
220
+ live.current = { view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId };
167
221
 
168
222
  useEffect(() => {
169
223
  const instance = props.renderer(scheme);
@@ -180,11 +234,13 @@ export function Graph(props: GraphProps) {
180
234
  // state would enter the `model` memo below and re-derive the layout,
181
235
  // the group colours and the whole `RenderGraph` on every pointer move.
182
236
  instance.onHover((id) => {
183
- const { model: current, selectedId } = live.current;
237
+ const { model: current, picture: shown } = live.current;
184
238
  // The *drawn* edges, which are exactly the visible ones the column's own
185
239
  // highlight was computed over — a neighbour inside a collapsed cluster
186
240
  // has already been retargeted onto the cluster standing in for it.
187
- instance.setHighlight(hoverHighlight(current.graph.edges, id, current.highlight), id ?? selectedId);
241
+ // Under a spotlight `hoverPicture` hands the spotlight straight back.
242
+ const next = hoverPicture(current.graph.edges, id, shown);
243
+ instance.setHighlight(next.highlight, next.selectedId, next.style);
188
244
  });
189
245
  instance.onDragStart((id) => {
190
246
  const at = renderer.current?.positions().get(id);
@@ -210,7 +266,8 @@ export function Graph(props: GraphProps) {
210
266
  // the render this effect was created in), so a remount carries whatever
211
267
  // the column is already showing.
212
268
  instance.setGraph(live.current.model.graph);
213
- instance.setHighlight(live.current.model.highlight, live.current.selectedId);
269
+ const shown = live.current.picture;
270
+ instance.setHighlight(shown.highlight, shown.selectedId, shown.style);
214
271
  instance.setTrail(trailRef.current);
215
272
  props.fit.current = () => instance.fit();
216
273
  return () => {
@@ -256,21 +313,93 @@ export function Graph(props: GraphProps) {
256
313
  );
257
314
 
258
315
  useEffect(() => {
259
- renderer.current?.setHighlight(model.highlight, props.selectedId);
260
- }, [model.highlight, props.selectedId]);
316
+ renderer.current?.setHighlight(picture.highlight, picture.selectedId, picture.style);
317
+ }, [picture]);
261
318
 
262
319
  useEffect(() => {
263
320
  renderer.current?.setTrail(trailRef.current);
264
321
  }, [props.trail]);
265
322
 
323
+ // `Esc` clears the spotlight before the shell's document listener sees it —
324
+ // or, when `spotlightEscapes` says no, passes through to "clear selection".
325
+ const escape = (event: KeyboardEvent): void => {
326
+ if (event.key !== "Escape" || !spotlightEscapes(query, event.target === field.current, sheet !== null)) return;
327
+ event.preventDefault();
328
+ props.onQuery?.("");
329
+ };
330
+ const more = sheet === null ? null : connectedMore(sheet.connected.length, sheet.connectedTotal);
331
+
266
332
  return (
267
- <div class="weave-graph">
333
+ <div class="weave-graph" onKeyDown={escape}>
268
334
  {/* Same shape as `ContextRail`'s: the *decision* is `graphEmptyMessage`,
269
335
  and what is left here is whether to render the paragraph it returned. */}
270
336
  {model.empty === null ? null : <p class="weave-graph-empty">{model.empty}</p>}
271
337
  {/* `tabIndex={-1}` is the `⌘3` focus target — see `Note.tsx`'s matching
272
338
  comment. The tree's target is the rows `<ul>`, which has its own. */}
273
339
  <div class="weave-graph-canvas" ref={canvas} role="img" aria-label="Knowledge graph" tabIndex={-1} />
340
+ {!full ? null : (
341
+ <div class={`weave-spotlight${spot === null ? "" : " weave-spotlight-on"}`} role="search">
342
+ <svg class="weave-spotlight-icon" viewBox="0 0 20 20" width={14} height={14} fill="none" stroke="currentColor" stroke-width={1.8} aria-hidden="true">
343
+ <circle cx="8.5" cy="8.5" r="5.5" />
344
+ <path d="m13 13 4 4" />
345
+ </svg>
346
+ <input ref={field} class="weave-spotlight-input" type="search" value={query} placeholder={SPOTLIGHT_PLACEHOLDER}
347
+ aria-label={SPOTLIGHT_LABEL} spellcheck={false} autoComplete="off" onInput={(event) => props.onQuery?.(event.currentTarget.value)} />
348
+ {spot === null ? null : (
349
+ <>
350
+ <span class={`weave-spotlight-count${spot.count === 0 ? " weave-spotlight-none" : ""}`} role="status">{litLabel(spot.count)}</span>
351
+ <button type="button" class="weave-spotlight-clear" title={SPOTLIGHT_CLEAR_HINT} aria-label={SPOTLIGHT_CLEAR_HINT}
352
+ onClick={() => { props.onQuery?.(""); field.current?.focus(); }}>{SPOTLIGHT_CLEAR}</button>
353
+ </>
354
+ )}
355
+ </div>
356
+ )}
357
+ {sheet === null ? null : (
358
+ <aside class="weave-sheet" aria-label={SHEET_LABEL}>
359
+ <button type="button" class="weave-sheet-close" aria-label={SHEET_CLOSE} title={SHEET_CLOSE} onClick={() => props.onSelect(null)}>×</button>
360
+ <p class="weave-sheet-crumb">
361
+ <Icon name={sheet.crumb === "" ? sheet.icon : "module"} class="weave-icon" />
362
+ <span>{sheet.crumb === "" ? sheet.kind : sheet.crumb}</span>
363
+ </p>
364
+ <h2 class="weave-sheet-title">{sheet.title}</h2>
365
+ <p class="weave-sheet-meta">
366
+ {sheet.who === null ? null : <span class={`weave-prov weave-prov-${sheet.who.provenance}`} title={sheet.who.title} aria-hidden="true">{sheet.who.glyph}</span>}
367
+ <span>{sheet.who === null ? sheet.kind : sheet.who.word}</span>
368
+ {sheet.when === "" ? null : <span class="weave-sheet-when">· {sheet.when}</span>}
369
+ </p>
370
+ {sheet.text === null ? null : (
371
+ <div class="weave-sheet-text">
372
+ <span class="weave-sheet-text-head">{sheet.text.heading}</span>
373
+ {sheet.text.parts.map((part) => (part.hit ? <mark class="weave-sheet-mark">{part.text}</mark> : part.text))}
374
+ </div>
375
+ )}
376
+ {sheet.litBecause === null ? null : (
377
+ <p class="weave-sheet-why"><span aria-hidden="true">✳</span>{LIT_BECAUSE} {sheet.litBecause}</p>
378
+ )}
379
+ {sheet.connectedTotal === 0 ? null : (
380
+ <section class="weave-sheet-section" aria-label={CONNECTED_HEADING}>
381
+ <h3 class="weave-sheet-heading">{CONNECTED_HEADING}<span class="weave-sheet-count">{sheet.connectedTotal}</span></h3>
382
+ <ul class="weave-sheet-rows">
383
+ {sheet.connected.map((row) => (
384
+ <li key={row.id}>
385
+ <button type="button" class="weave-sheet-row" title={row.label} aria-label={row.ariaLabel} onClick={() => props.onSelect(row.id)}>
386
+ <span class={`weave-sheet-dot${row.lit ? " weave-sheet-dot-lit" : ""}`} aria-hidden="true" />
387
+ <span class="weave-sheet-row-label">{row.label}</span>
388
+ </button>
389
+ </li>
390
+ ))}
391
+ </ul>
392
+ {more === null ? null : <p class="weave-sheet-more">{more}</p>}
393
+ </section>
394
+ )}
395
+ <div class="weave-sheet-actions">
396
+ <button type="button" class="weave-sheet-open" onClick={() => props.onOpen(sheet.id)}>{SHEET_OPEN}</button>
397
+ {props.onOpenSplit === undefined ? null : (
398
+ <button type="button" class="weave-sheet-split" onClick={() => props.onOpenSplit?.(sheet.id)}>{SHEET_OPEN_SPLIT}</button>
399
+ )}
400
+ </div>
401
+ </aside>
402
+ )}
274
403
  {preview === null ? null : (
275
404
  <aside class="weave-graph-preview" aria-label="Selected graph node">
276
405
  <div class="weave-graph-preview-info">
@@ -290,8 +419,10 @@ export function Graph(props: GraphProps) {
290
419
  </button>
291
420
  <span class="weave-graph-legend">
292
421
  <span class="weave-legend-on">◉ {LEGEND.selected}</span>
293
- <span class="weave-legend-near">● {LEGEND.neighborhood}</span>
294
- <span class="weave-legend-dim">· {LEGEND.dimmed}</span>
422
+ {spot === null
423
+ ? <span class="weave-legend-near">● {LEGEND.neighborhood}</span>
424
+ : <span class="weave-legend-lit">● {SPOTLIGHT_LEGEND.lit}</span>}
425
+ <span class="weave-legend-dim">· {spot === null ? LEGEND.dimmed : SPOTLIGHT_LEGEND.dimmed}</span>
295
426
  </span>
296
427
  </div>
297
428
  <p class="weave-graph-count">{graphCountLabel(model.visible, model.total)}</p>
@@ -46,7 +46,7 @@ import { clusterAggregate, focusNeighborhood } from "../../shared/view";
46
46
  import type { GraphPayload, WireGraphEdge } from "../../shared/wire";
47
47
  import { kindIcon, viewModel } from "../tree/tree.model";
48
48
  import type { IconName } from "../shell/icons.model";
49
- import type { RenderGraph } from "./graph.model";
49
+ import type { HighlightStyle, RenderGraph } from "./graph.model";
50
50
  import { EMPTY_RENDER_GRAPH, renderGraph } from "./graph.model";
51
51
  import { groupNodeColors } from "./groups";
52
52
  import type { PositionStorage } from "./positions";
@@ -208,6 +208,55 @@ export function hoverHighlight(
208
208
  return hoveredId === null ? fallback : focusNeighborhood(hoveredId, edges);
209
209
  }
210
210
 
211
+ // --- the picture: neighbourhood or spotlight ------------------------------------------------
212
+
213
+ /**
214
+ * What the renderer is told to draw: a set, the node it is about, and which
215
+ * treatment the set gets (`graph.model.ts`'s {@link HighlightStyle}).
216
+ *
217
+ * The three travel together because they are one decision — the renderer's
218
+ * `setHighlight(highlight, selectedId, style)` takes exactly this, and a
219
+ * caller that passed them separately could pair a spotlight's set with a
220
+ * neighbourhood's treatment.
221
+ */
222
+ export interface GraphPicture {
223
+ readonly highlight: Set<string> | null;
224
+ readonly selectedId: string | null;
225
+ readonly style: HighlightStyle;
226
+ }
227
+
228
+ /**
229
+ * The column's picture: **a spotlight wins over the selection's neighbourhood**.
230
+ *
231
+ * `lit` is the spotlight's set already mapped onto the canvas
232
+ * (`spotlight.model.ts`'s `litOnCanvas`), or `null` when no query is set — and
233
+ * then the picture is the selection's neighbourhood exactly as it was before
234
+ * the spotlight existed. The selection still rides along under a spotlight,
235
+ * because the node the side sheet is about has to stand out among the lit ones.
236
+ */
237
+ export function graphPicture(neighbourhood: Set<string> | null, selectedId: string | null, lit: Set<string> | null): GraphPicture {
238
+ return lit === null
239
+ ? { highlight: neighbourhood, selectedId, style: "neighbourhood" }
240
+ : { highlight: lit, selectedId, style: "spotlight" };
241
+ }
242
+
243
+ /**
244
+ * The picture while the pointer is over a node.
245
+ *
246
+ * Without a spotlight, {@link hoverHighlight} as ever: hover wins and the
247
+ * hovered node is the subject. **Under a spotlight the lit set holds** — it is
248
+ * something the user typed, and moving the pointer across the canvas to reach
249
+ * a lit node must not keep swapping it for neighbourhoods. Only the subject
250
+ * moves: the hovered node takes the selection's place while the pointer is on
251
+ * it, so a faint dot answers with its full colour and its name (a dimmed
252
+ * node has no label for the hover painter to float), and leaving it hands the
253
+ * emphasis back to the selection.
254
+ */
255
+ export function hoverPicture(edges: readonly WireGraphEdge[], hoveredId: string | null, picture: GraphPicture): GraphPicture {
256
+ if (picture.style === "spotlight") return hoveredId === null ? picture : { ...picture, selectedId: hoveredId };
257
+ return { highlight: hoverHighlight(edges, hoveredId, picture.highlight), selectedId: hoveredId ?? picture.selectedId, style: "neighbourhood" };
258
+ }
259
+
211
260
  // --- the control strip (§1.2) ---------------------------------------------------------------
212
261
 
213
262
  /** The `[fit]` control. Constant, but named here so the component holds no copy. */
@@ -269,6 +318,12 @@ export interface GraphColumnModel {
269
318
  readonly cached: boolean;
270
319
  /** Every cluster in the *unreduced* model — what `[expand]` operates on. */
271
320
  readonly clusters: ReadonlyMap<string, ClusterInfo>;
321
+ /**
322
+ * Hidden id → the visible cluster standing in for it (`clusterAggregate`'s
323
+ * mapping), so a set computed over the whole payload — the spotlight's —
324
+ * can be retargeted onto what is drawn.
325
+ */
326
+ readonly representative: ReadonlyMap<string, string>;
272
327
  /** Visible / total, for the count line. */
273
328
  readonly visible: number;
274
329
  readonly total: number;
@@ -283,6 +338,7 @@ export const EMPTY_COLUMN: GraphColumnModel = {
283
338
  key: "",
284
339
  cached: false,
285
340
  clusters: new Map(),
341
+ representative: new Map(),
286
342
  visible: 0,
287
343
  total: 0,
288
344
  empty: "Loading…",
@@ -351,6 +407,7 @@ export function graphColumnModel(
351
407
  key: layout.key,
352
408
  cached: layout.cached,
353
409
  clusters: reduced.clusters,
410
+ representative: reduced.representative,
354
411
  visible: reduced.nodes.length,
355
412
  total: model.nodes.length,
356
413
  empty: graphEmptyMessage(payload, reduced.nodes.length),
@@ -375,6 +375,8 @@ export interface HoverLabelNode {
375
375
  readonly y: number;
376
376
  readonly size: number;
377
377
  readonly label: string | null;
378
+ /** Set by the spotlight on a node whose label already stands — see {@link hoverLabelPainter}. */
379
+ readonly forceLabel?: boolean;
378
380
  }
379
381
 
380
382
  /** The settings a hover painter reads. Both `GraphSettings` and sigma's satisfy it. */
@@ -431,6 +433,9 @@ export function hoverLabelPainter(
431
433
  // `nodeReducer` blanks the label of everything outside the highlight, and
432
434
  // a node with no name has nothing to float.
433
435
  if (data.label === null || data.label === "") return;
436
+ // A forced label (the spotlight's lit nodes) is already standing beside the
437
+ // disc; floating it again underneath would print the same name twice.
438
+ if (data.forceLabel === true) return;
434
439
  const palette = graphPalette(scheme);
435
440
  context.save();
436
441
  context.font = `${settings.labelSize}px ${settings.labelFont}`;
@@ -670,8 +675,31 @@ export interface NodeDisplayOverride {
670
675
  readonly label?: string | null;
671
676
  readonly zIndex?: number;
672
677
  readonly size?: number;
678
+ /**
679
+ * Draw the label whatever the zoom and the label grid say — sigma's own
680
+ * display attribute. Only the spotlight sets it: a lit node has to name
681
+ * itself, and on a graph over {@link SMALL_GRAPH_NODES} the size threshold
682
+ * would otherwise keep a lit leaf silent.
683
+ */
684
+ readonly forceLabel?: boolean;
673
685
  }
674
686
 
687
+ /**
688
+ * Which picture a highlight set paints.
689
+ *
690
+ * - `neighbourhood` — a selection (or hover) and its one-hop neighbours: the
691
+ * selection is the subject, its links are drawn in the accent.
692
+ * - `spotlight` — the nodes a query lit (`spotlight.model.ts`): every lit node
693
+ * is a subject, grown and labelled, the edges *between* lit nodes are drawn
694
+ * in the highlighter's warm slot, and everything else drops back further
695
+ * than a neighbourhood's cloud does.
696
+ *
697
+ * One set, one fade, two treatments — the spotlight rides the exact
698
+ * `setHighlight` path a selection does instead of a second layer, so the two
699
+ * can never be on screen at once and argue.
700
+ */
701
+ export type HighlightStyle = "neighbourhood" | "spotlight";
702
+
675
703
  /** What sigma's `edgeReducer` may override. */
676
704
  export interface EdgeDisplayOverride {
677
705
  readonly color?: string;
@@ -715,12 +743,15 @@ export function nodeReducer(
715
743
  * about the clock.
716
744
  */
717
745
  progress: number = 1,
746
+ /** Which treatment the set gets — see {@link HighlightStyle}. */
747
+ style: HighlightStyle = "neighbourhood",
718
748
  ): (id: string, data: RenderNode, scheme: GraphTheme) => NodeDisplayOverride {
719
749
  return (id, data, scheme) => {
720
750
  // `progress` at zero is indistinguishable from no highlight, and saying so
721
751
  // here is what lets the fade end by *returning* to the unhighlighted graph
722
752
  // rather than by approaching it.
723
753
  if (highlight === null || progress <= 0) return {};
754
+ if (style === "spotlight") return spotlightNode(highlight, selectedId ?? null, progress, id, data, scheme);
724
755
  if (id === selectedId && highlight.has(id)) {
725
756
  // The subject of the gesture: lifted clear of its own neighbourhood and
726
757
  // grown by a ratio rather than to a fixed radius, so a hub still reads
@@ -743,6 +774,75 @@ export function nodeReducer(
743
774
  };
744
775
  }
745
776
 
777
+ // --- the spotlight treatment (G1) ------------------------------------------------------
778
+
779
+ /**
780
+ * How far an unlit node or edge recedes while a spotlight is up.
781
+ *
782
+ * Further than a neighbourhood's {@link RECESS_STRENGTH}: a selection dims the
783
+ * cloud to *place* a node inside it, where a spotlight asks "where are the
784
+ * things I named?" and the answer is the lit nodes alone — the rest is
785
+ * context for position, faint dots and hairlines and nothing more.
786
+ */
787
+ export const SPOTLIGHT_RECESS = 0.92;
788
+
789
+ /** A lit node's growth — a step past a neighbour's, because every lit node is a subject. */
790
+ export const SPOTLIGHT_GROWTH = 1.4;
791
+
792
+ /** The selected node while a spotlight is up: the one the side sheet is about. */
793
+ export const SPOTLIGHT_SELECTED_GROWTH = 1.7;
794
+
795
+ /**
796
+ * An unlit node shrinks toward a dot as it recedes.
797
+ *
798
+ * Unlike a neighbourhood's cloud (which keeps its size so the graph does not
799
+ * appear to move on a click), the spotlight is a mode the user typed into,
800
+ * and the shrink is what makes "everything else drops back to faint dots"
801
+ * true at a glance. Positions are untouched, so nothing reflows.
802
+ */
803
+ export const SPOTLIGHT_SHRINK = 0.75;
804
+
805
+ /**
806
+ * Above this many lit nodes, labels go back to the size threshold.
807
+ *
808
+ * Forcing every label is right for the handful of matches a query usually
809
+ * names, and wrong for a query like `src` that lights a whole subtree: two
810
+ * hundred forced labels are one unreadable smear. Past the cap the lit nodes
811
+ * still grow and keep full colour; sigma's grid decides which of them speak.
812
+ */
813
+ export const SPOTLIGHT_LABEL_LIMIT = 40;
814
+
815
+ /** The slot an edge between two lit nodes is painted in — the highlighter's warm voice. */
816
+ export const SPOTLIGHT_EDGE_SLOT: ColorSlot = "warning";
817
+
818
+ /** One node under a spotlight. The selection is always a subject, lit or not. */
819
+ function spotlightNode(
820
+ lit: ReadonlySet<string>,
821
+ selectedId: string | null,
822
+ progress: number,
823
+ id: string,
824
+ data: RenderNode,
825
+ scheme: GraphTheme,
826
+ ): NodeDisplayOverride {
827
+ const speaks = progress >= LABEL_DROP_AT;
828
+ if (id === selectedId) {
829
+ return { zIndex: data.zIndex + HIGHLIGHT_Z_LIFT * 2, size: data.size * growth(SPOTLIGHT_SELECTED_GROWTH, progress), forceLabel: speaks };
830
+ }
831
+ if (lit.has(id)) {
832
+ return {
833
+ zIndex: data.zIndex + HIGHLIGHT_Z_LIFT,
834
+ size: data.size * growth(SPOTLIGHT_GROWTH, progress),
835
+ forceLabel: speaks && lit.size <= SPOTLIGHT_LABEL_LIMIT,
836
+ };
837
+ }
838
+ return {
839
+ color: recessColor(data.color, scheme, SPOTLIGHT_RECESS * progress),
840
+ label: speaks ? null : data.label,
841
+ size: data.size * growth(SPOTLIGHT_SHRINK, progress),
842
+ zIndex: 0,
843
+ };
844
+ }
845
+
746
846
  // --- the highlight fade (§7.4) -----------------------------------------------------
747
847
 
748
848
  /**
@@ -856,9 +956,23 @@ export function edgeReducer(
856
956
  selectedId?: string | null,
857
957
  /** The fade's progress — see {@link nodeReducer}'s. */
858
958
  progress: number = 1,
959
+ /** Which treatment the set gets — see {@link HighlightStyle}. */
960
+ style: HighlightStyle = "neighbourhood",
859
961
  ): (key: string, data: RenderEdge, scheme: GraphTheme) => EdgeDisplayOverride {
860
962
  return (_key, data, scheme) => {
861
963
  if (highlight === null || progress <= 0) return {};
964
+ if (style === "spotlight") {
965
+ // Lit only when it joins two lit nodes: the lines *between* matches are
966
+ // what the spotlight is for — how the things you named connect.
967
+ if (!highlight.has(data.source) || !highlight.has(data.target)) {
968
+ return { color: recessColor(data.color, scheme, SPOTLIGHT_RECESS * progress) };
969
+ }
970
+ return {
971
+ zIndex: 2,
972
+ size: data.size * growth(EDGE_PRESENCE * 1.35, progress),
973
+ color: blendHex(data.color, graphPalette(scheme)[SPOTLIGHT_EDGE_SLOT], progress),
974
+ };
975
+ }
862
976
  if (!highlight.has(data.source) || !highlight.has(data.target)) {
863
977
  return { color: recessColor(data.color, scheme, RECESS_STRENGTH * progress) };
864
978
  }
@@ -1,6 +1,6 @@
1
1
  import type { GraphTheme } from "../../shared/themes";
2
2
  import type { Point } from "../../shared/layout";
3
- import type { EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
3
+ import type { EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
4
4
  import { edgeReducer, fadeStep, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
5
5
  import type { ProjectedGraph } from "./project";
6
6
  import { positionsOf, project, syncPositions } from "./project";
@@ -130,6 +130,8 @@ export function sigmaRenderer(
130
130
  let highlight: ReadonlySet<string> | null = null;
131
131
  /** The selection inside that neighbourhood — see `nodeReducer`. */
132
132
  let selected: string | null = null;
133
+ /** Which treatment `highlight` gets — a neighbourhood or a spotlight. */
134
+ let style: HighlightStyle = "neighbourhood";
133
135
  /** Recall-trail glows, layered over the selection — see `trailOverride`. */
134
136
  let trail: ReadonlyMap<string, TrailGlow> = new Map();
135
137
  /**
@@ -176,8 +178,8 @@ export function sigmaRenderer(
176
178
  * until the next unrelated frame.
177
179
  */
178
180
  const applyReducers = (instance: SigmaLike): void => {
179
- const nodes = nodeReducer(highlight, selected, fade);
180
- const edges = edgeReducer(highlight, selected, fade);
181
+ const nodes = nodeReducer(highlight, selected, fade, style);
182
+ const edges = edgeReducer(highlight, selected, fade, style);
181
183
  const glows = trail;
182
184
  instance.setSetting("nodeReducer", (id, data) => {
183
185
  const reduced = { ...data, ...nodes(id, data, scheme) };
@@ -279,10 +281,11 @@ export function sigmaRenderer(
279
281
  sigma?.refresh();
280
282
  },
281
283
 
282
- setHighlight(next: ReadonlySet<string> | null, selectedId: string | null = null) {
284
+ setHighlight(next: ReadonlySet<string> | null, selectedId: string | null = null, nextStyle: HighlightStyle = "neighbourhood") {
283
285
  if (clock === undefined) {
284
286
  highlight = next;
285
287
  selected = selectedId;
288
+ style = nextStyle;
286
289
  fade = next === null ? 0 : 1;
287
290
  fadeTo = fade;
288
291
  if (sigma !== null) applyReducers(sigma);
@@ -297,9 +300,11 @@ export function sigmaRenderer(
297
300
  } else {
298
301
  // A new neighbourhood replaces the old one immediately — a cross-fade
299
302
  // between two of them is a picture of neither — and only the *presence*
300
- // of the highlight is animated.
303
+ // of the highlight is animated. The style swaps with the set; a clear
304
+ // (above) keeps the outgoing style so a spotlight fades out *as* one.
301
305
  highlight = next;
302
306
  selected = selectedId;
307
+ style = nextStyle;
303
308
  fadeTo = 1;
304
309
  }
305
310
  if (sigma !== null) applyReducers(sigma);
@@ -338,8 +343,17 @@ export function sigmaRenderer(
338
343
  // onto that box. `animatedReset` settles when the animation ends. Nothing
339
344
  // awaits a camera move, and a rejection from a camera killed mid-flight
340
345
  // is noise.
341
- sigma?.setCustomBBox(boxOf());
342
- void sigma?.getCamera().animatedReset();
346
+ const instance = sigma;
347
+ if (instance === null) return;
348
+ instance.setCustomBBox(boxOf());
349
+ // Sigma leaves edges out of frames drawn while the camera moves
350
+ // (`hideEdgesOnMove`) and repaints when the camera changes. A reset onto
351
+ // where the camera already is changes nothing, so the frame drawn during
352
+ // it, without edges, stayed on screen until something else repainted.
353
+ // One refresh once it settles draws them back.
354
+ instance.getCamera().animatedReset().then(() => {
355
+ if (sigma === instance) instance.refresh();
356
+ }, () => {});
343
357
  },
344
358
 
345
359
  positions() {