Skip to content

Commit c44241b

Browse files
committed
fix(graph): review fixes for entrance shift, zoom inset, and edge diff
- Apply entranceShift to special (hovered/selected) nodes so they don't snap to final position during entrance animation - Account for insetTop in zoom degenerate case (single-point graph with chrome would center behind the title) - Extract shared centroid helper in entrance.ts to avoid duplicate O(n) passes - Use multiset counting for enteringEdgeCount, consistent with the edgeSetsEqual fix for duplicate edges Claude-Session: https://claude.ai/code/session_016rS7hi2g4bSzRBfnD1p2ji
1 parent 616630c commit c44241b

4 files changed

Lines changed: 46 additions & 30 deletions

File tree

packages/vanilla/src/graph/canvas-renderer.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -796,13 +796,16 @@ export class GraphCanvasRenderer {
796796
const radius = baseRadius * hoverScale;
797797
// brighten() switches at the scale midpoint (>1.075 of the 1→1.15 range).
798798
const brightened = isHovered && hoverScale >= 1.075;
799+
const s = entranceShift(node);
800+
const nx = node.x + s.x;
801+
const ny = node.y + s.y;
799802

800803
ctx.globalAlpha = dimmed ? SEARCH_NON_MATCH_ALPHA : 1;
801804

802805
// Glow for special nodes
803806
if (showGlow && !dimmed) {
804807
ctx.beginPath();
805-
ctx.arc(node.x, node.y, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
808+
ctx.arc(nx, ny, radius * GLOW_RADIUS_MULTIPLIER, 0, TWO_PI);
806809
ctx.fillStyle = node.fill;
807810
ctx.globalAlpha = GLOW_ALPHA;
808811
ctx.fill();
@@ -811,7 +814,7 @@ export class GraphCanvasRenderer {
811814

812815
// Fill
813816
ctx.beginPath();
814-
ctx.arc(node.x, node.y, radius, 0, TWO_PI);
817+
ctx.arc(nx, ny, radius, 0, TWO_PI);
815818
ctx.fillStyle = brightened ? brighten(node.fill) : node.fill;
816819
ctx.fill();
817820

@@ -823,7 +826,7 @@ export class GraphCanvasRenderer {
823826
// Selection ring
824827
if (isSelected) {
825828
ctx.beginPath();
826-
ctx.arc(node.x, node.y, radius + 3, 0, TWO_PI);
829+
ctx.arc(nx, ny, radius + 3, 0, TWO_PI);
827830
ctx.strokeStyle = theme.colors.categorical[0] ?? '#3b82f6';
828831
ctx.lineWidth = 2;
829832
ctx.stroke();

packages/vanilla/src/graph/entrance.ts

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,9 @@ export const ENTRANCE_STAGGER_MAX_NODES = 3000;
4646
/** How far (px, graph space) a node starts from its final spot during the pop. */
4747
export const ENTRANCE_DRIFT_PX = 16;
4848

49-
/**
50-
* Stagger rank for each node: distance from the layout centroid, ascending, so
51-
* the reveal ripples outward from the center of the graph. Index-order stagger
52-
* reads as arbitrary shimmer; centroid-radial order reads as structure. Warmup
53-
* means positions are near-final when this runs, so the ranking is stable.
54-
*/
55-
export function entranceOrder(
56-
nodes: Array<{ id: string; x: number; y: number }>,
57-
): Map<string, number> {
58-
const rank = new Map<string, number>();
59-
if (nodes.length === 0) return rank;
49+
type XYNode = { id: string; x: number; y: number };
50+
51+
function centroid(nodes: XYNode[]): { cx: number; cy: number } {
6052
let cx = 0;
6153
let cy = 0;
6254
for (const n of nodes) {
@@ -65,6 +57,19 @@ export function entranceOrder(
6557
}
6658
cx /= nodes.length;
6759
cy /= nodes.length;
60+
return { cx, cy };
61+
}
62+
63+
/**
64+
* Stagger rank for each node: distance from the layout centroid, ascending, so
65+
* the reveal ripples outward from the center of the graph. Index-order stagger
66+
* reads as arbitrary shimmer; centroid-radial order reads as structure. Warmup
67+
* means positions are near-final when this runs, so the ranking is stable.
68+
*/
69+
export function entranceOrder(nodes: XYNode[]): Map<string, number> {
70+
const rank = new Map<string, number>();
71+
if (nodes.length === 0) return rank;
72+
const { cx, cy } = centroid(nodes);
6873
const sorted = [...nodes].sort((a, b) => {
6974
const da = (a.x - cx) * (a.x - cx) + (a.y - cy) * (a.y - cy);
7075
const db = (b.x - cx) * (b.x - cx) + (b.y - cy) * (b.y - cy);
@@ -77,23 +82,16 @@ export function entranceOrder(
7782
/**
7883
* Per-node drift offset: a unit vector pointing away from the layout centroid,
7984
* scaled to ENTRANCE_DRIFT_PX. Nodes pop in slightly outside their final spot
80-
* and converge inward — the whole graph reads as breathing in. Deterministic
81-
* (pure function of positions), zero-safe at the centroid.
85+
* and converge inward. Deterministic (pure function of positions), zero-safe
86+
* at the centroid.
8287
*/
8388
export function entranceOffsets(
84-
nodes: Array<{ id: string; x: number; y: number }>,
89+
nodes: XYNode[],
8590
dist: number = ENTRANCE_DRIFT_PX,
8691
): Map<string, { x: number; y: number }> {
8792
const offsets = new Map<string, { x: number; y: number }>();
8893
if (nodes.length === 0) return offsets;
89-
let cx = 0;
90-
let cy = 0;
91-
for (const n of nodes) {
92-
cx += n.x;
93-
cy += n.y;
94-
}
95-
cx /= nodes.length;
96-
cy /= nodes.length;
94+
const { cx, cy } = centroid(nodes);
9795
for (const n of nodes) {
9896
const dx = n.x - cx;
9997
const dy = n.y - cy;

packages/vanilla/src/graph/update-diff.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,21 @@ export function diffGraphUpdate(
116116
const exitingNodes = prevNodes.filter((n) => !nextIds.has(n.id));
117117
const nextEdgeKeys = new Set(next.edges.map((e) => `${e.source} ${e.target}`));
118118
const exitingEdges = prevEdges.filter((e) => !nextEdgeKeys.has(`${e.source} ${e.target}`));
119-
const prevEdgeKeys = new Set(prevEdges.map((e) => `${e.source} ${e.target}`));
119+
const prevEdgeCounts = new Map<string, number>();
120+
for (const e of prevEdges) {
121+
const k = `${e.source} ${e.target}`;
122+
prevEdgeCounts.set(k, (prevEdgeCounts.get(k) ?? 0) + 1);
123+
}
120124
let enteringEdgeCount = 0;
125+
const remainingPrev = new Map(prevEdgeCounts);
121126
for (const e of next.edges) {
122-
if (!prevEdgeKeys.has(`${e.source} ${e.target}`)) enteringEdgeCount++;
127+
const k = `${e.source} ${e.target}`;
128+
const c = remainingPrev.get(k);
129+
if (c && c > 0) {
130+
remainingPrev.set(k, c - 1);
131+
} else {
132+
enteringEdgeCount++;
133+
}
123134
}
124135

125136
// Visual-only: identical node AND edge id sets AND equal simulationConfig.

packages/vanilla/src/graph/zoom.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,14 @@ export class ZoomTransform {
8989
let graphH = maxY - minY;
9090

9191
if (graphW === 0 && graphH === 0) {
92-
// All nodes at the same point; just center
92+
// All nodes at the same point; center within the area below the inset
9393
return {
94-
transform: new ZoomTransform(canvasW / 2 - minX, canvasH / 2 - minY, 1),
95-
contentHeight: padding * 2,
94+
transform: new ZoomTransform(
95+
canvasW / 2 - minX,
96+
insetTop + (canvasH - insetTop) / 2 - minY,
97+
1,
98+
),
99+
contentHeight: padding * 2 + insetTop,
96100
};
97101
}
98102

0 commit comments

Comments
 (0)