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.
- package/README.md +19 -0
- package/package.json +1 -1
- package/skills/weave-notepad/SKILL.md +42 -8
- package/skills/weave-notepad/references/link-repair.md +90 -0
- package/src/core/cache/workspace.ts +7 -13
- package/src/core/graph/build.ts +13 -35
- package/src/core/graph/current.ts +4 -5
- package/src/core/index.ts +18 -0
- package/src/core/links/repair.ts +330 -0
- package/src/core/links/similar.ts +279 -0
- package/src/core/vault.ts +147 -3
- package/src/core/view/health.ts +4 -0
- package/src/pi/tools/noteTool.ts +144 -4
- package/src/web/client/dist/app.js +42 -42
- package/src/web/client/graph/Graph.tsx +15 -0
- package/src/web/client/graph/column.model.ts +49 -33
- package/src/web/client/graph/graph.model.ts +205 -8
- package/src/web/client/graph/groups.ts +4 -4
- package/src/web/client/graph/renderer.dom.ts +5 -1
- package/src/web/client/graph/renderer.ts +115 -5
|
@@ -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,
|
|
45
|
-
import type { GraphPayload, WireGraphEdge
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
11
|
-
* ├── vfolder:
|
|
12
|
-
* ├── vfolder:
|
|
13
|
-
* └── note:loose
|
|
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
|
-
|
|
184
|
-
|
|
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
|
},
|