pi-weave 0.3.11 → 0.3.12
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.
|
@@ -154,6 +154,18 @@ export function Graph(props: GraphProps) {
|
|
|
154
154
|
// mount frames the *starting* positions, which a settling simulation then
|
|
155
155
|
// pushes past the edges — a two-note vault opened with clipped circles.
|
|
156
156
|
const fitted = useRef(false);
|
|
157
|
+
// A re-frame asked for while the layout was still moving, done when it rests.
|
|
158
|
+
const reframe = useRef(false);
|
|
159
|
+
/**
|
|
160
|
+
* Frame the graph for where it is shown: the full view fits everything, and
|
|
161
|
+
* the Context card's note graph opens on the open note and its neighbours,
|
|
162
|
+
* since you read it while on that note (`renderer.ts`'s `focus`).
|
|
163
|
+
*/
|
|
164
|
+
const frame = useCallback(() => {
|
|
165
|
+
const { full: whole, model: shown } = live.current;
|
|
166
|
+
if (whole) renderer.current?.fit();
|
|
167
|
+
else renderer.current?.focus(shown.highlight);
|
|
168
|
+
}, []);
|
|
157
169
|
/**
|
|
158
170
|
* Arm the simulation clock — idempotent.
|
|
159
171
|
*
|
|
@@ -171,9 +183,10 @@ export function Graph(props: GraphProps) {
|
|
|
171
183
|
engine.tick();
|
|
172
184
|
// Asleep: let the clock die. The next pin re-arms it.
|
|
173
185
|
if (!engine.awake()) {
|
|
174
|
-
if (!fitted.current) {
|
|
186
|
+
if (!fitted.current || reframe.current) {
|
|
175
187
|
fitted.current = true;
|
|
176
|
-
|
|
188
|
+
reframe.current = false;
|
|
189
|
+
frame();
|
|
177
190
|
}
|
|
178
191
|
return;
|
|
179
192
|
}
|
|
@@ -220,8 +233,8 @@ export function Graph(props: GraphProps) {
|
|
|
220
233
|
// Read by the mount-time `onSelect`, which outlives this render.
|
|
221
234
|
const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
|
|
222
235
|
trailRef.current = props.trail ?? NO_GLOWS;
|
|
223
|
-
const live = useRef({ view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId });
|
|
224
|
-
live.current = { view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId };
|
|
236
|
+
const live = useRef({ view, model, picture, full, onSelect: props.onSelect, selectedId: props.selectedId });
|
|
237
|
+
live.current = { view, model, picture, full, onSelect: props.onSelect, selectedId: props.selectedId };
|
|
225
238
|
|
|
226
239
|
useEffect(() => {
|
|
227
240
|
const instance = props.renderer(scheme);
|
|
@@ -320,6 +333,16 @@ export function Graph(props: GraphProps) {
|
|
|
320
333
|
renderer.current?.setHighlight(picture.highlight, picture.selectedId, picture.style);
|
|
321
334
|
}, [picture]);
|
|
322
335
|
|
|
336
|
+
// Re-frame when another note opens under the Context card's graph, or the
|
|
337
|
+
// graph moves between the card and the full view. A click in the full view
|
|
338
|
+
// leaves the camera where it is. Before the first layout rests, the settle
|
|
339
|
+
// frames it; while the layout is still moving, the frame waits for it.
|
|
340
|
+
useEffect(() => {
|
|
341
|
+
if (!fitted.current) return;
|
|
342
|
+
if (clock.current !== null) reframe.current = true;
|
|
343
|
+
else frame();
|
|
344
|
+
}, [full, full ? null : props.selectedId]);
|
|
345
|
+
|
|
323
346
|
useEffect(() => {
|
|
324
347
|
renderer.current?.setTrail(trailRef.current);
|
|
325
348
|
}, [props.trail]);
|
|
@@ -545,6 +545,47 @@ export function frameBox(points: readonly Point[]): ViewBox | null {
|
|
|
545
545
|
return { x: [minX - padding, maxX + padding], y: [minY - padding, maxY + padding] };
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
+
/** Where sigma's camera looks, in the unit square a {@link ViewBox} is normalized onto. */
|
|
549
|
+
export interface CameraTarget {
|
|
550
|
+
readonly x: number;
|
|
551
|
+
readonly y: number;
|
|
552
|
+
/** Sigma's zoom: 1 shows the whole box, smaller is closer. */
|
|
553
|
+
readonly ratio: number;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Room a focus leaves around what it frames, as a multiple of its span. */
|
|
557
|
+
export const FOCUS_MARGIN = 1.2;
|
|
558
|
+
|
|
559
|
+
/** The smallest span a focus frames, so a note with no links is not blown up to fill the card. */
|
|
560
|
+
export const FOCUS_MIN_SPAN = COLLIDE_RADIUS * 10;
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The camera that frames `points` in a view frozen on `box`.
|
|
564
|
+
*
|
|
565
|
+
* The Context card's note graph is read while you are on a note, so it opens
|
|
566
|
+
* on that note and its neighbours instead of the whole vault. This is the
|
|
567
|
+
* arithmetic. Sigma normalizes the box onto a unit square centred on 0.5 (its
|
|
568
|
+
* longer side becomes 1), and a camera ratio of 1 shows that whole square. So
|
|
569
|
+
* the camera's centre is the points' centre in that square, and its ratio is
|
|
570
|
+
* their padded span as a share of the box. Never above 1: a focus does not
|
|
571
|
+
* zoom out past the fit.
|
|
572
|
+
*
|
|
573
|
+
* `null` when there is nothing to frame: no box, or no finite point.
|
|
574
|
+
*/
|
|
575
|
+
export function focusCamera(box: ViewBox | null, points: readonly Point[]): CameraTarget | null {
|
|
576
|
+
const around = frameBox(points);
|
|
577
|
+
if (box === null || around === null) return null;
|
|
578
|
+
const extent = Math.max(box.x[1] - box.x[0], box.y[1] - box.y[0]);
|
|
579
|
+
const scale = extent > 0 && Number.isFinite(extent) ? extent : 1;
|
|
580
|
+
const span = Math.max(around.x[1] - around.x[0], around.y[1] - around.y[0], FOCUS_MIN_SPAN);
|
|
581
|
+
const centre = (range: readonly [number, number]): number => (range[0] + range[1]) / 2;
|
|
582
|
+
return {
|
|
583
|
+
x: 0.5 + (centre(around.x) - centre(box.x)) / scale,
|
|
584
|
+
y: 0.5 + (centre(around.y) - centre(box.y)) / scale,
|
|
585
|
+
ratio: Math.min(1, (span * FOCUS_MARGIN) / scale),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
548
589
|
/** The empty graph. What the column renders before the first payload lands. */
|
|
549
590
|
export const EMPTY_RENDER_GRAPH: RenderGraph = { nodes: [], edges: [] };
|
|
550
591
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { GraphTheme } from "../../shared/themes";
|
|
2
2
|
import type { Point } from "../../shared/layout";
|
|
3
|
-
import type { EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
|
|
4
|
-
import { edgeReducer, fadeStep, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
|
|
3
|
+
import type { CameraTarget, EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
|
|
4
|
+
import { edgeReducer, fadeStep, focusCamera, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
|
|
5
5
|
import type { ProjectedGraph } from "./project";
|
|
6
6
|
import { positionsOf, project, syncPositions } from "./project";
|
|
7
7
|
|
|
@@ -68,7 +68,7 @@ export interface SigmaLike {
|
|
|
68
68
|
*/
|
|
69
69
|
setCustomBBox(box: ViewBox | null): unknown;
|
|
70
70
|
refresh(): unknown;
|
|
71
|
-
getCamera(): { animatedReset(): Promise<void> };
|
|
71
|
+
getCamera(): { animatedReset(): Promise<void>; animate(state: CameraTarget): Promise<void> };
|
|
72
72
|
kill(): void;
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -169,6 +169,20 @@ export function sigmaRenderer(
|
|
|
169
169
|
/** The box to freeze the view on, from the graph's *current* positions. */
|
|
170
170
|
const boxOf = (): ViewBox | null => frameBox([...positionsOf(graph).values()]);
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Repaint once a camera move lands. Sigma leaves edges out of frames drawn
|
|
174
|
+
* while the camera moves (`hideEdgesOnMove`) and repaints when the camera
|
|
175
|
+
* changes; a move onto where the camera already is changes nothing, so the
|
|
176
|
+
* frame drawn during it, without edges, stayed on screen until something
|
|
177
|
+
* else repainted. A renderer destroyed mid-move paints nothing, and a
|
|
178
|
+
* rejection from a camera killed mid-flight is noise.
|
|
179
|
+
*/
|
|
180
|
+
const settle = (instance: SigmaLike, move: Promise<void>): void => {
|
|
181
|
+
move.then(() => {
|
|
182
|
+
if (sigma === instance) instance.refresh();
|
|
183
|
+
}, () => {});
|
|
184
|
+
};
|
|
185
|
+
|
|
172
186
|
/**
|
|
173
187
|
* Push the current highlight into sigma's reducers (§7.4).
|
|
174
188
|
*
|
|
@@ -346,14 +360,24 @@ export function sigmaRenderer(
|
|
|
346
360
|
const instance = sigma;
|
|
347
361
|
if (instance === null) return;
|
|
348
362
|
instance.setCustomBBox(boxOf());
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
363
|
+
settle(instance, instance.getCamera().animatedReset());
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Frame these nodes instead of the whole graph — the Context card's note
|
|
368
|
+
* graph opening on the note you are reading (`focusCamera`). Re-frames on
|
|
369
|
+
* the current positions first, like `fit`, so the camera and the box
|
|
370
|
+
* agree. Nothing to frame (`null`, or no drawn node among `ids`) is a fit.
|
|
371
|
+
*/
|
|
372
|
+
focus(ids: ReadonlySet<string> | null) {
|
|
373
|
+
const instance = sigma;
|
|
374
|
+
if (instance === null) return;
|
|
375
|
+
const box = boxOf();
|
|
376
|
+
const at = positionsOf(graph);
|
|
377
|
+
const points = [...(ids ?? [])].flatMap((id) => at.get(id) ?? []);
|
|
378
|
+
const target = focusCamera(box, points);
|
|
379
|
+
instance.setCustomBBox(box);
|
|
380
|
+
settle(instance, target === null ? instance.getCamera().animatedReset() : instance.getCamera().animate(target));
|
|
357
381
|
},
|
|
358
382
|
|
|
359
383
|
positions() {
|