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
- renderer.current?.fit();
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
- // 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
- }, () => {});
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() {