pi-weave 0.1.21 → 0.1.23

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.
@@ -42,6 +42,7 @@ import {
42
42
  graphClick,
43
43
  graphColumnModel,
44
44
  graphCountLabel,
45
+ hoverHighlight,
45
46
  } from "./column.model";
46
47
  import type { PositionStorage } from "./positions";
47
48
  import type { GraphRenderer, RendererFactory } from "./renderer";
@@ -181,6 +182,20 @@ export function Graph(props: GraphProps) {
181
182
  setState(next.state);
182
183
  live.current.onSelect(next.selectedId);
183
184
  });
185
+ // Hover drives the *same* reducers a click does (§7.4): the pointer and
186
+ // the selection ask one question, and `hoverHighlight` answers it with the
187
+ // selection's own picture the moment the pointer leaves.
188
+ //
189
+ // Straight to the renderer, deliberately not through `useState`: hover
190
+ // state would enter the `model` memo below and re-derive the layout,
191
+ // the group colours and the whole `RenderGraph` on every pointer move.
192
+ instance.onHover((id) => {
193
+ const { model: current, selectedId } = live.current;
194
+ // The *drawn* edges, which are exactly the visible ones the column's own
195
+ // highlight was computed over — a neighbour inside a collapsed cluster
196
+ // has already been retargeted onto the cluster standing in for it.
197
+ instance.setHighlight(hoverHighlight(current.graph.edges, id, current.highlight), id ?? selectedId);
198
+ });
184
199
  instance.onDragStart((id) => {
185
200
  const at = renderer.current?.positions().get(id);
186
201
  if (at) {
@@ -41,8 +41,8 @@
41
41
 
42
42
  import type { Point } from "../../shared/layout";
43
43
  import type { ClusterAggregate, ClusterInfo, ViewGraphModel } from "../../shared/view";
44
- import { clusterAggregate, degreeOf, focusNeighborhood } from "../../shared/view";
45
- import type { GraphPayload, WireGraphEdge, WireGraphNode } from "../../shared/wire";
44
+ import { clusterAggregate, focusNeighborhood } from "../../shared/view";
45
+ import type { GraphPayload, WireGraphEdge } from "../../shared/wire";
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";
@@ -52,6 +52,20 @@ import { resolveLayout } from "./positions";
52
52
 
53
53
  // --- the view state ------------------------------------------------------------------
54
54
 
55
+ /**
56
+ * The canvas draws whatever the user has expanded — there is no note cap.
57
+ *
58
+ * Clustering is the bound: a collapsed folder is one node standing in for its
59
+ * whole subtree, and `initialGraphView` opens the roots rather than the tree,
60
+ * so the default frame stays small at any vault size. A bound on the payload
61
+ * instead would decide visibility by a note's position in the list, which is
62
+ * unrelated to what the user is looking at and hides notes inside folders
63
+ * they explicitly opened.
64
+ *
65
+ * If a vault ever does overwhelm the layout, bound the *expansion* (how much
66
+ * one expand reveals) or the simulation itself.
67
+ */
68
+
55
69
  /** The graph column's state. Owned by the column; never on the context bus. */
56
70
  export interface GraphViewState {
57
71
  /**
@@ -151,6 +165,31 @@ export function highlightFor(edges: readonly WireGraphEdge[], selectedId: string
151
165
  return focusNeighborhood(selectedId, edges);
152
166
  }
153
167
 
168
+ /**
169
+ * The highlight while the pointer is over a node — **hover wins, selection is
170
+ * what it falls back to**.
171
+ *
172
+ * Obsidian's gesture, and the reason it is the same function as
173
+ * {@link highlightFor} rather than a second visual language: hovering asks
174
+ * exactly the question clicking asks ("what is one hop from here?"), so the
175
+ * two must produce the same set and reach the same reducers. Anything else
176
+ * drifts into a graph where the pointer and the click disagree about what
177
+ * "related" means.
178
+ *
179
+ * `fallback` is the selection's highlight, precomputed by the column — passed
180
+ * rather than recomputed so leaving a node restores the selection's picture
181
+ * without the caller having to remember what it was. `null` hovered and
182
+ * `null` fallback is nothing selected and nothing hovered, which the reducers
183
+ * read as "render everything normally".
184
+ */
185
+ export function hoverHighlight(
186
+ edges: readonly WireGraphEdge[],
187
+ hoveredId: string | null,
188
+ fallback: Set<string> | null,
189
+ ): Set<string> | null {
190
+ return hoveredId === null ? fallback : focusNeighborhood(hoveredId, edges);
191
+ }
192
+
154
193
  // --- the control strip (§1.2) ---------------------------------------------------------------
155
194
 
156
195
  /** The `[fit]` control. Constant, but named here so the component holds no copy. */
@@ -340,38 +379,15 @@ export function graphClick(state: GraphViewState, clusters: ReadonlyMap<string,
340
379
  return { state: toggleCluster(state, id), selectedId: id };
341
380
  }
342
381
 
343
- // --- tooltips ---------------------------------------------------------------------------------
344
-
345
- /**
346
- * What a node says when it stands in for a collapsed subtree.
347
- *
348
- * `ClusterInfo.members` is exactly the hidden descendants this cluster is
349
- * currently representing not `descendants`, which overlaps freely between a
350
- * cluster and its ancestors and would report the same file under both
351
- * `src/core` and `repository`. `members` partitions the hidden set across the
352
- * visible clusters, so the counts add up to the number of nodes that are not
353
- * on screen.
354
- */
355
- export function clusterBadge(cluster: ClusterInfo | undefined): string | null {
356
- if (cluster === undefined || cluster.members.length === 0) return null;
357
- return cluster.members.length === 1 ? "1 hidden" : `${cluster.members.length} hidden`;
358
- }
359
-
360
- /**
361
- * A node's hover text: its label, its degree, and what it is standing in for.
362
- *
363
- * `degreeOf` is core's, reached through the §2.1.1 door. The bulk `degrees`
364
- * pass in `graph.model.ts` is a different algorithm for a different question
365
- * (every node at once, O(edges)); this is one node, which is what a tooltip
366
- * asks.
382
+ /*
383
+ * There were two tooltip builders here — `nodeTooltip` (label · N links · N
384
+ * hidden) and `clusterBadge`. Nothing ever rendered either one; they were the
385
+ * remains of a hover pass that never reached the canvas, and the hover
386
+ * gesture now answers the same question by *lighting the neighbourhood* and
387
+ * floating the node's own name (see `graph.model.ts`'s `drawHoverLabel`),
388
+ * which says "N links" by showing them. Deleted rather than left as covered
389
+ * dead code.
367
390
  */
368
- export function nodeTooltip(node: WireGraphNode, edges: readonly WireGraphEdge[], cluster: ClusterInfo | undefined): string {
369
- const degree = degreeOf(node.id, edges);
370
- const parts = [node.label, degree === 1 ? "1 link" : `${degree} links`];
371
- const badge = clusterBadge(cluster);
372
- if (badge !== null) parts.push(badge);
373
- return parts.join(" · ");
374
- }
375
391
 
376
392
  /** Positions keyed by id, for a caller warm-starting a re-run. */
377
393
  export type LayoutSnapshot = ReadonlyMap<string, Point>;
@@ -373,6 +373,113 @@ export function nodeLabel(node: WireGraphNode): string {
373
373
  return truncateLabel(badge === "" ? label : `${badge} ${label}`);
374
374
  }
375
375
 
376
+ // --- the hover label (§7.4) ---------------------------------------------------------
377
+
378
+ /**
379
+ * The 2D canvas slice {@link hoverLabelPainter} writes to.
380
+ *
381
+ * Structural for the same reason `RenderContainer` is (see `renderer.ts`): a
382
+ * test importing this module drags it into the root `tsconfig.json` project,
383
+ * which has no `DOM` lib, so naming `CanvasRenderingContext2D` here would
384
+ * break the typecheck for every core test. Every member is used below, so the
385
+ * port cannot grow something nothing calls — and because it is a plain
386
+ * object shape, the painter is covered by an ordinary unit test against a
387
+ * recording fake rather than sitting behind §10's canvas wall.
388
+ */
389
+ export interface HoverLabelContext {
390
+ font: string;
391
+ /**
392
+ * `unknown` rather than `string`, and only because the real thing is
393
+ * `string | CanvasGradient | CanvasPattern` — naming either of those DOM
394
+ * types here is what this port exists to avoid, and narrowing to `string`
395
+ * makes `CanvasRenderingContext2D` fail to satisfy the port. Written to, never
396
+ * read, so nothing downstream has to widen.
397
+ */
398
+ fillStyle: unknown;
399
+ textAlign: string;
400
+ shadowColor: string;
401
+ shadowBlur: number;
402
+ save(): void;
403
+ restore(): void;
404
+ fillText(text: string, x: number, y: number): void;
405
+ }
406
+
407
+ /** The node attributes sigma hands a hover painter, narrowed to what is read. */
408
+ export interface HoverLabelNode {
409
+ readonly x: number;
410
+ readonly y: number;
411
+ readonly size: number;
412
+ readonly label: string | null;
413
+ }
414
+
415
+ /** The settings a hover painter reads. Both `GraphSettings` and sigma's satisfy it. */
416
+ export interface HoverLabelSettings {
417
+ readonly labelSize: number;
418
+ readonly labelFont: string;
419
+ }
420
+
421
+ /**
422
+ * The gap between the bottom of the node and the cap height of its name, in
423
+ * screen pixels.
424
+ *
425
+ * Far enough that the text is not touching the disc at the sizes the ramp
426
+ * produces (leaf 6 → hub 18 layout units), close enough that the name still
427
+ * reads as belonging to *that* node rather than floating between two of them.
428
+ */
429
+ export const HOVER_LABEL_GAP = 6;
430
+
431
+ /**
432
+ * How far the hover label's shadow spreads, in pixels.
433
+ *
434
+ * Not decoration — legibility. The label is drawn over whatever the graph
435
+ * happens to have under the node (edges, a receded cloud, another label), and
436
+ * a ground-coloured blur is what separates the glyphs from that without
437
+ * putting an opaque box on the canvas. Sigma's own `drawDiscNodeHover` draws
438
+ * the box instead, hardcoded `#FFF`, which is why it is replaced rather than
439
+ * configured.
440
+ */
441
+ export const HOVER_LABEL_SHADOW = 6;
442
+
443
+ /**
444
+ * Sigma's `defaultDrawNodeHover`, as a scheme-bound pure function (§7.4).
445
+ *
446
+ * Obsidian's gesture, which is the reference: the hovered node's name floats
447
+ * **centred underneath it**, in the theme's own text colour, with no
448
+ * container. The alternative shipped by sigma is a white rounded box with the
449
+ * text to the node's *right* — wrong colour in both schemes, and wrong place
450
+ * when the neighbourhood highlight has just grown the node the label is
451
+ * naming.
452
+ *
453
+ * Being the hover painter also fixes a second thing for free: sigma draws this
454
+ * regardless of `labelRenderedSizeThreshold`, so a leaf too small to carry a
455
+ * standing label still answers the pointer with its name.
456
+ *
457
+ * `save`/`restore` around the whole thing because `textAlign`, the shadow and
458
+ * the fill are shared canvas state — sigma reuses the same context for every
459
+ * other label in the frame, and a leaked `textAlign: "center"` would silently
460
+ * re-align them all.
461
+ */
462
+ export function hoverLabelPainter(
463
+ scheme: ColorScheme,
464
+ ): (context: HoverLabelContext, data: HoverLabelNode, settings: HoverLabelSettings) => void {
465
+ return (context, data, settings) => {
466
+ // `nodeReducer` blanks the label of everything outside the highlight, and
467
+ // a node with no name has nothing to float.
468
+ if (data.label === null || data.label === "") return;
469
+ const palette = GRAPH_PALETTE[scheme];
470
+ context.save();
471
+ context.font = `${settings.labelSize}px ${settings.labelFont}`;
472
+ context.textAlign = "center";
473
+ context.shadowColor = palette.ground;
474
+ context.shadowBlur = HOVER_LABEL_SHADOW;
475
+ context.fillStyle = palette.text;
476
+ // `data.size` is already the *scaled* radius sigma is about to draw, so the
477
+ // baseline clears the disc at every zoom rather than at one of them.
478
+ context.fillText(data.label, data.x, data.y + data.size + settings.labelSize + HOVER_LABEL_GAP);
479
+ context.restore();
480
+ };
481
+ }
482
+
376
483
  // --- the render model -------------------------------------------------------------
377
484
 
378
485
  /** A node, resolved to everything the projection needs. No decisions left. */
@@ -632,14 +739,23 @@ export function nodeReducer(
632
739
  * only a neighbourhood keeps the previous two-tier behaviour.
633
740
  */
634
741
  selectedId?: string | null,
742
+ /**
743
+ * How far the highlight has faded in, 0 → 1. Defaults to 1, so a caller
744
+ * that does not animate gets the finished picture and nothing has to know
745
+ * about the clock.
746
+ */
747
+ progress: number = 1,
635
748
  ): (id: string, data: RenderNode, scheme: ColorScheme) => NodeDisplayOverride {
636
749
  return (id, data, scheme) => {
637
- if (highlight === null) return {};
750
+ // `progress` at zero is indistinguishable from no highlight, and saying so
751
+ // here is what lets the fade end by *returning* to the unhighlighted graph
752
+ // rather than by approaching it.
753
+ if (highlight === null || progress <= 0) return {};
638
754
  if (id === selectedId && highlight.has(id)) {
639
755
  // The subject of the gesture: lifted clear of its own neighbourhood and
640
756
  // grown by a ratio rather than to a fixed radius, so a hub still reads
641
757
  // 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 };
758
+ return { zIndex: data.zIndex + HIGHLIGHT_Z_LIFT * 2, size: data.size * growth(SELECTED_GROWTH, progress) };
643
759
  }
644
760
  // A connected node: its own group colour at full strength, lifted above
645
761
  // the cloud, and grown a little. The step between "connected" and
@@ -648,11 +764,81 @@ export function nodeReducer(
648
764
  // being named — the difference matters most on a big canvas, where the
649
765
  // receded cloud is far off-screen and the only thing visible is a
650
766
  // 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 };
652
- return { color: recessColor(data.color, scheme), label: null, zIndex: 0 };
767
+ if (highlight.has(id)) return { zIndex: data.zIndex + HIGHLIGHT_Z_LIFT, size: data.size * growth(NEIGHBOUR_GROWTH, progress) };
768
+ return {
769
+ color: recessColor(data.color, scheme, RECESS_STRENGTH * progress),
770
+ label: progress >= LABEL_DROP_AT ? null : data.label,
771
+ zIndex: 0,
772
+ };
653
773
  };
654
774
  }
655
775
 
776
+ // --- the highlight fade (§7.4) -----------------------------------------------------
777
+
778
+ /**
779
+ * The time constant of the highlight fade, in milliseconds.
780
+ *
781
+ * The highlight used to arrive and leave in **one frame**, and the frame that
782
+ * hurt was the *leaving* one: 85 % of the canvas snapping from recessed back
783
+ * to full contrast reads as the whole graph flashing, which is louder than
784
+ * the dimming it is undoing. Sliding the recession in and out is what turns
785
+ * two states into one gesture.
786
+ *
787
+ * A time *constant* rather than a duration because {@link fadeStep} is an
788
+ * exponential approach, not a linear ramp — progress covers 63 % of the
789
+ * remaining distance every `TAU` ms, so the motion is ease-out by
790
+ * construction with no easing curve to pick, and it is frame-rate independent
791
+ * for free. 60 ms puts the visible settle at roughly 200 ms, which is the
792
+ * range a hover can afford: slower and pointing at a node feels unanswered.
793
+ */
794
+ export const HIGHLIGHT_FADE_TAU = 60;
795
+
796
+ /**
797
+ * How close to the target counts as arrived.
798
+ *
799
+ * An exponential approach never actually lands, so without a snap the clock
800
+ * would run forever repainting changes below a rounding error. At 0.004 the
801
+ * remaining recession is under half a colour step on an 8-bit channel —
802
+ * invisible, and about five time constants in.
803
+ */
804
+ export const FADE_EPSILON = 0.004;
805
+
806
+ /**
807
+ * Advance a fade toward its target. Pure; the clock is the caller's.
808
+ *
809
+ * `progress` is how *present* the highlight is: 0 renders exactly as no
810
+ * highlight at all, 1 is the full treatment. The target is therefore always
811
+ * one of those two — what fades is the presence of the effect, never the
812
+ * membership of the set. A neighbourhood that changes while the highlight is
813
+ * already up (hover moves from one node to the next) swaps instantly and
814
+ * stays at 1, because a cross-fade between two neighbourhoods is a picture of
815
+ * neither.
816
+ *
817
+ * A backgrounded tab hands back a multi-second `elapsedMs`, and the
818
+ * exponential handles it correctly by arriving: `exp(-big)` is 0.
819
+ */
820
+ export function fadeStep(progress: number, target: number, elapsedMs: number): number {
821
+ const elapsed = Number.isFinite(elapsedMs) && elapsedMs > 0 ? elapsedMs : 0;
822
+ const next = target + (progress - target) * Math.exp(-elapsed / HIGHLIGHT_FADE_TAU);
823
+ return Math.abs(target - next) < FADE_EPSILON ? target : next;
824
+ }
825
+
826
+ /** Interpolate a size multiplier by the fade's progress. 0 → untouched, 1 → `to`. */
827
+ function growth(to: number, progress: number): number {
828
+ return 1 + (to - 1) * progress;
829
+ }
830
+
831
+ /**
832
+ * The fade point at which the cloud's labels are dropped.
833
+ *
834
+ * Text is the one thing on this canvas that cannot fade — sigma draws labels
835
+ * opaque, and there is no per-label alpha to ramp. So the drop is a step, and
836
+ * the least visible place to put a step is the middle of the fade, where the
837
+ * cloud is already half receded and the eye is following the neighbourhood
838
+ * rather than the text it is losing.
839
+ */
840
+ export const LABEL_DROP_AT = 0.5;
841
+
656
842
  /**
657
843
  * How much the selected node grows, as a size multiplier.
658
844
  *
@@ -698,11 +884,13 @@ export function edgeReducer(
698
884
  highlight: ReadonlySet<string> | null,
699
885
  /** The selection, so an edge *touching* it outranks one merely near it. */
700
886
  selectedId?: string | null,
887
+ /** The fade's progress — see {@link nodeReducer}'s. */
888
+ progress: number = 1,
701
889
  ): (key: string, data: RenderEdge, scheme: ColorScheme) => EdgeDisplayOverride {
702
890
  return (_key, data, scheme) => {
703
- if (highlight === null) return {};
891
+ if (highlight === null || progress <= 0) return {};
704
892
  if (!highlight.has(data.source) || !highlight.has(data.target)) {
705
- return { color: recessColor(data.color, scheme) };
893
+ return { color: recessColor(data.color, scheme, RECESS_STRENGTH * progress) };
706
894
  }
707
895
  // Incident on the selection itself — these are the node's actual links,
708
896
  // and they are what "connected" means drawn. Painted in the accent so a
@@ -710,11 +898,17 @@ export function edgeReducer(
710
898
  // for as long as the selection stands, and thickened a step beyond the
711
899
  // neighbourhood's own edges.
712
900
  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 };
901
+ return {
902
+ zIndex: 2,
903
+ size: data.size * growth(EDGE_PRESENCE * 1.35, progress),
904
+ // Blended rather than switched, so the link arrives *as* the accent
905
+ // instead of flicking to it a frame before the rest of the fade.
906
+ color: blendHex(data.color, GRAPH_PALETTE[scheme].accent, progress),
907
+ };
714
908
  }
715
909
  // Between two neighbours, but not touching the selection: real context,
716
910
  // one step quieter.
717
- return { zIndex: 1, size: data.size * EDGE_PRESENCE };
911
+ return { zIndex: 1, size: data.size * growth(EDGE_PRESENCE, progress) };
718
912
  };
719
913
  }
720
914
 
@@ -747,6 +941,8 @@ export interface GraphSettings {
747
941
  readonly zoomToSizeRatioFunction: (ratio: number) => number;
748
942
  readonly stagePadding: number;
749
943
  readonly allowInvalidContainer: boolean;
944
+ /** Ours, not sigma's white box — see {@link hoverLabelPainter}. */
945
+ readonly defaultDrawNodeHover: (context: HoverLabelContext, data: HoverLabelNode, settings: HoverLabelSettings) => void;
750
946
  }
751
947
 
752
948
  /**
@@ -809,6 +1005,7 @@ export function graphSettings(scheme: ColorScheme): GraphSettings {
809
1005
  itemSizesReference: "positions",
810
1006
  zoomToSizeRatioFunction: (ratio) => ratio,
811
1007
  stagePadding: COLLIDE_RADIUS,
1008
+ defaultDrawNodeHover: hoverLabelPainter(scheme),
812
1009
  // The container is a real element by construction (the renderer is mounted
813
1010
  // from a `ref`), but sigma also validates that it has a non-zero size —
814
1011
  // and a column that is behind a `medium` breakpoint toggle legitimately
@@ -7,10 +7,10 @@
7
7
  * `FORCES` pulls apart: a **depth-1 containment branch**.
8
8
  *
9
9
  * ```text
10
- * vault ← a root: its own group
11
- * ├── vfolder:sessions ← group A, with all 323 descendants
12
- * ├── vfolder:manager-digest ← group B, with all 159
13
- * └── note:loose ← no branch of its own: joins the root's group
10
+ * vault ← a root: its own group
11
+ * ├── vfolder:projects ← group A, with all its descendants
12
+ * ├── vfolder:archive ← group B, with all of its own
13
+ * └── note:loose ← no branch of its own: joins the root's group
14
14
  * ```
15
15
  *
16
16
  * Depth 1 exactly, for the reason the retired `bigBranches` used it: deeper
@@ -2,7 +2,7 @@
2
2
  import Sigma from "sigma";
3
3
  import type { ColorScheme } from "./graph.model";
4
4
  import type { GraphRenderer, RendererFactory, SigmaLike } from "./renderer";
5
- import { sigmaRenderer } from "./renderer";
5
+ import { rafClock, sigmaRenderer } from "./renderer";
6
6
 
7
7
  /** Sigma's own container parameter type, recovered without naming the DOM. */
8
8
  type SigmaContainer = ConstructorParameters<typeof Sigma>[1];
@@ -13,4 +13,8 @@ export const createSigmaRenderer: RendererFactory = (scheme: ColorScheme): Graph
13
13
  (graph, container, settings) =>
14
14
  new Sigma(graph, container as unknown as SigmaContainer, settings) as unknown as SigmaLike,
15
15
  scheme,
16
+ // The highlight fade's clock. `window` is named here rather than in
17
+ // `renderer.ts` for the same reason `new Sigma` is: that module must stay
18
+ // compilable without a `DOM` lib.
19
+ rafClock(window),
16
20
  );
@@ -1,6 +1,6 @@
1
1
  import type { Point } from "../../shared/layout";
2
2
  import type { ColorScheme, EdgeDisplayOverride, GraphSettings, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, ViewBox } from "./graph.model";
3
- import { edgeReducer, frameBox, graphSettings, nodeReducer } from "./graph.model";
3
+ import { edgeReducer, fadeStep, frameBox, graphSettings, nodeReducer } from "./graph.model";
4
4
  import type { ProjectedGraph } from "./project";
5
5
  import { positionsOf, project, syncPositions } from "./project";
6
6
 
@@ -46,6 +46,8 @@ export interface SigmaLike {
46
46
  on(event: "clickNode", handler: (payload: { node: string }) => void): unknown;
47
47
  on(event: "clickStage", handler: () => void): unknown;
48
48
  on(event: "downNode", handler: (payload: { node: string }) => void): unknown;
49
+ on(event: "enterNode", handler: (payload: { node: string }) => void): unknown;
50
+ on(event: "leaveNode", handler: () => void): unknown;
49
51
  on(event: "moveBody", handler: (payload: { event: { x: number; y: number }; preventSigmaDefault(): void }) => void): unknown;
50
52
  on(event: "upNode" | "upStage", handler: () => void): unknown;
51
53
  viewportToGraph(position: { x: number; y: number }): Point;
@@ -83,16 +85,63 @@ export interface SigmaLike {
83
85
  * every node colour and re-projecting — a second code path for a case nobody
84
86
  * hits.
85
87
  */
88
+ /**
89
+ * The animation clock, as a port.
90
+ *
91
+ * `requestAnimationFrame` and `performance.now` are browser globals, and this
92
+ * module is compiled by the root `tsconfig.json` (no `DOM` lib) whenever a
93
+ * test imports it — the same constraint `RenderContainer` exists for. Injected
94
+ * rather than imported, so the fade's *timing* is driven by a fake in a test
95
+ * and by the browser in the browser, and the whole ramp is ordinary covered
96
+ * code instead of frames nobody can step.
97
+ */
98
+ export interface FrameClock {
99
+ now(): number;
100
+ request(step: () => void): number;
101
+ cancel(handle: number): void;
102
+ }
103
+
104
+ /** The browser's clock. The one place the two globals are named. */
105
+ export const rafClock = (host: {
106
+ requestAnimationFrame(cb: (t: number) => void): number;
107
+ cancelAnimationFrame(h: number): void;
108
+ performance: { now(): number };
109
+ }): FrameClock => ({
110
+ now: () => host.performance.now(),
111
+ request: (step) => host.requestAnimationFrame(() => step()),
112
+ cancel: (handle) => host.cancelAnimationFrame(handle),
113
+ });
114
+
86
115
  export function sigmaRenderer(
87
116
  create: (graph: ProjectedGraph, container: RenderContainer, settings: GraphSettings) => SigmaLike,
88
117
  scheme: ColorScheme,
118
+ /**
119
+ * The fade clock, or omitted for no animation at all — in which case every
120
+ * highlight change lands at full strength on the next frame, which is the
121
+ * behaviour before the fade existed.
122
+ */
123
+ clock?: FrameClock,
89
124
  ) {
90
125
  let sigma: SigmaLike | null = null;
91
126
  let graph: ProjectedGraph = project({ nodes: [], edges: [] });
92
127
  let highlight: ReadonlySet<string> | null = null;
93
128
  /** The selection inside that neighbourhood — see `nodeReducer`. */
94
129
  let selected: string | null = null;
130
+ /**
131
+ * How present the highlight currently *looks*, and where it is heading.
132
+ *
133
+ * Two numbers rather than one because the fade has to survive its own end:
134
+ * clearing `highlight` the instant the pointer leaves would leave nothing
135
+ * to fade *out of*, which is exactly the flash the fade exists to remove.
136
+ * So a clear sets `fadeTo = 0` and keeps the outgoing set on screen until
137
+ * the ramp reaches it — see `setHighlight`.
138
+ */
139
+ let fade = 0;
140
+ let fadeTo = 0;
141
+ let frame: number | null = null;
142
+ let lastAt = 0;
95
143
  let select: (id: string | null) => void = () => {};
144
+ let hover: (id: string | null) => void = () => {};
96
145
  let dragStart: (id: string) => void = () => {};
97
146
  let dragMove: (id: string, at: Point) => void = () => {};
98
147
  let dragEnd: (id: string) => void = () => {};
@@ -121,12 +170,38 @@ export function sigmaRenderer(
121
170
  * until the next unrelated frame.
122
171
  */
123
172
  const applyReducers = (instance: SigmaLike): void => {
124
- const nodes = nodeReducer(highlight, selected);
125
- const edges = edgeReducer(highlight, selected);
173
+ const nodes = nodeReducer(highlight, selected, fade);
174
+ const edges = edgeReducer(highlight, selected, fade);
126
175
  instance.setSetting("nodeReducer", (id, data) => ({ ...data, ...nodes(id, data, scheme) }));
127
176
  instance.setSetting("edgeReducer", (key, data) => ({ ...data, ...edges(key, data, scheme) }));
128
177
  };
129
178
 
179
+ /**
180
+ * Run the fade toward `fadeTo`, one frame at a time. Idempotent — the same
181
+ * self-terminating-clock shape `Graph.tsx` uses for the simulation, and for
182
+ * the same reason: a graph that has finished moving must cost zero frames.
183
+ */
184
+ const armFade = (): void => {
185
+ if (clock === undefined || frame !== null) return;
186
+ lastAt = clock.now();
187
+ const step = (): void => {
188
+ frame = null;
189
+ const at = clock.now();
190
+ fade = fadeStep(fade, fadeTo, at - lastAt);
191
+ lastAt = at;
192
+ if (sigma !== null) applyReducers(sigma);
193
+ // Arrived at zero: the set was only being held so it had something to
194
+ // fade out of (see `fade`/`fadeTo`), and keeping it would make a later
195
+ // unrelated repaint dim the graph with a stale neighbourhood.
196
+ if (fade === fadeTo) {
197
+ if (fade === 0) highlight = null;
198
+ return;
199
+ }
200
+ frame = clock.request(step);
201
+ };
202
+ frame = clock.request(step);
203
+ };
204
+
130
205
  return {
131
206
  mount(container: RenderContainer) {
132
207
  if (sigma !== null) return;
@@ -153,6 +228,12 @@ export function sigmaRenderer(
153
228
  dragMove(dragging, instance.viewportToGraph({ x: payload.event.x, y: payload.event.y }));
154
229
  }
155
230
  });
231
+ // Hover is reported outward, not interpreted here: which nodes light up
232
+ // is `column.model.ts`'s `hoverHighlight`, the same way a click's meaning
233
+ // is `graphClick`'s. Sigma emits `leaveNode` on its own when the pointer
234
+ // crosses straight from one node to another, so there is no state to keep.
235
+ instance.on("enterNode", ({ node }) => hover(node));
236
+ instance.on("leaveNode", () => hover(null));
156
237
  instance.on("upNode", () => endDrag(dragging));
157
238
  instance.on("upStage", () => endDrag(null));
158
239
  applyReducers(instance);
@@ -180,15 +261,40 @@ export function sigmaRenderer(
180
261
  },
181
262
 
182
263
  setHighlight(next: ReadonlySet<string> | null, selectedId: string | null = null) {
183
- highlight = next;
184
- selected = selectedId;
264
+ if (clock === undefined) {
265
+ highlight = next;
266
+ selected = selectedId;
267
+ fade = next === null ? 0 : 1;
268
+ fadeTo = fade;
269
+ if (sigma !== null) applyReducers(sigma);
270
+ return;
271
+ }
272
+ if (next === null) {
273
+ // Fade *out of* the set that is on screen rather than dropping it: the
274
+ // drop is the flash. `highlight` is cleared by the ramp on arrival.
275
+ fadeTo = 0;
276
+ // Nothing was showing, so there is nothing to fade and no frame to run.
277
+ if (highlight === null) return;
278
+ } else {
279
+ // A new neighbourhood replaces the old one immediately — a cross-fade
280
+ // between two of them is a picture of neither — and only the *presence*
281
+ // of the highlight is animated.
282
+ highlight = next;
283
+ selected = selectedId;
284
+ fadeTo = 1;
285
+ }
185
286
  if (sigma !== null) applyReducers(sigma);
287
+ if (fade !== fadeTo) armFade();
186
288
  },
187
289
 
188
290
  onSelect(handler: (id: string | null) => void) {
189
291
  select = handler;
190
292
  },
191
293
 
294
+ onHover(handler: (id: string | null) => void) {
295
+ hover = handler;
296
+ },
297
+
192
298
  onDragStart(handler: (id: string) => void) {
193
299
  dragStart = handler;
194
300
  },
@@ -215,6 +321,10 @@ export function sigmaRenderer(
215
321
  return positionsOf(graph);
216
322
  },
217
323
  destroy() {
324
+ if (frame !== null) {
325
+ clock?.cancel(frame);
326
+ frame = null;
327
+ }
218
328
  sigma?.kill();
219
329
  sigma = null;
220
330
  },