Skip to content

Commit 0888279

Browse files
authored
Merge pull request #207 from GraphDone/fix/lod-transition-ownership-20261027
Preserve animated zoom across semantic LOD transitions
2 parents 8419292 + b12d083 commit 0888279

4 files changed

Lines changed: 137 additions & 2 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# LOD title refresh must not interrupt the owning camera transition
2+
3+
Date: 2026-10-27. Issue #205. The symptom index already covered explicit camera
4+
input cancelling automatic framing and renderer ownership; neither addressed an
5+
overview-exit title refresh interrupting the active user animation.
6+
7+
## Evidence
8+
9+
CSS PR #199 CI run `40591493014` failed the desktop semantic-LOD case at camera
10+
scale `1.1735656098434863` while expecting `1.2`. Its retained trace shows this
11+
was after the test clock resumed; it was not an unadvanced-clock failure.
12+
13+
A new production-renderer regression starts at `1.1`, requests the existing
14+
120 ms minimap zoom to `1.2`, and advances twelve 16 ms frames. It explicitly
15+
observes overview exit above `1.15` before the target is reached. On the original
16+
runtime, `npm run qc:lod -- --grep 1440 --trace=retain-on-failure` failed with
17+
`1.1767031700493764` instead of `1.2`. The trace is retained under the owned
18+
`/tmp/graphdone-resolution-20261027/lod-transition-negative-artifacts` directory.
19+
20+
The corrected runtime passes both 301-node/370-edge desktop and phone LOD
21+
journeys in 3.3 minutes with one worker and zero retries. Existing membership,
22+
label geometry, selected-item navigation, graph switching and zero-position-write
23+
checks remain. Build, typecheck and lint pass (existing lint warnings remain);
24+
web tests pass 346/346 and the harness passes 175/175. Exact-head review and CI
25+
remain required before integration; source-only evidence is not a live deployment.
26+
27+
## Cause and corrective rule
28+
29+
The overview-exit effect reapplied the current camera through
30+
`zoomBehavior.transform` to make formerly hidden title elements measurable.
31+
D3's selection form of that API interrupts the selection's active transition.
32+
Thus a presentation refresh cancelled the same animation that crossed the LOD
33+
threshold, saving an intermediate camera position.
34+
35+
Refresh source-title geometry directly using the live camera scale after source
36+
elements become visible. Scope the selection to the source `.main-graph-group`, retain
37+
the same-graph transition guard, and respect simplified rendering. Do not emit a
38+
camera command merely to refresh presentation. Camera transitions belong to the
39+
navigation operation that initiated them.
40+
41+
## A live renderer selector needs its own observation
42+
43+
Follow-up review found the initial title-only change used `.main-group`, while
44+
both renderer initializers create `.main-graph-group`. The effect was therefore
45+
a no-op. Animated zoom still passed because later frames refitted the titles.
46+
An initial fixed-camera geometry-only check also passed; neither result proved
47+
the explicit refresh actually ran. These passing-but-insufficient controls are
48+
retained rather than presented as a negative control.
49+
50+
The strengthened fixed-camera Overview→Items phase instruments the selected
51+
real SVG title's native `getBBox` method, retaining its actual geometry result.
52+
It requires a visible measurement, bounded title geometry, an identical camera
53+
and zero minimap camera publications, with the animation clock paused. The
54+
incorrect selector fails with zero visible measurements. With the actual source
55+
selector, both desktop and phone cases pass in 4.2 minutes with zero retries;
56+
the source selector
57+
must be taken from the actual renderer. Do not infer presentation work occurred
58+
merely because an animation reached its target.
59+
60+
## Limits
61+
62+
This corrects overview-to-item transition ownership; it does not implement the
63+
remaining multilevel grouping, bounded 1k rendering or progressive 10k loading
64+
milestones. The browser regression uses owned synthetic graphs and preserves the
65+
original interaction deadlines, required assertions and strict fixture cleanup.

‎.agents/issues/INDEX.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
| Symptom | Canonical lesson and regression |
44
| --- | --- |
5+
| Animated zoom stops near the overview exit threshold instead of reaching its target | [Title refresh must not own the camera](../inbox/2026-10-27-lod-title-refresh-must-not-own-camera.md); frame-controlled `semantic-lod.spec.ts` negative control |
56
| Disposable Git fixture teardown reports ENOTEMPTY under .git after a synchronous commit | [Prevent detached fixture maintenance](../inbox/2026-10-27-disposable-git-fixtures-must-own-maintenance.md); `tests/lib/runner/unified-cli.test.mjs` |
67
| A router major update passes install but path handling or route UI assertions differ | [Validate installed navigation and current UI contracts](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); `routerNavigation.test.tsx`, `router-navigation.spec.ts`, dependency guard |
78
| Status-note text lands in the title and Add remains disabled after quick edit opens | [Delayed autofocus must preserve chosen focus](../inbox/2026-10-27-delayed-autofocus-must-preserve-chosen-focus.md); `QuickEditSurface.test.tsx` controls the pending frame |

‎packages/web/src/components/InteractiveGraphVisualization.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4316,8 +4316,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
43164316
// that would persist an unfitted/default (or previous graph's) camera.
43174317
const previous = previousOverview.current;
43184318
previousOverview.current = { active: semanticOverviewActive, graphId: currentGraph?.id };
4319-
if (previous.active && previous.graphId === currentGraph?.id && !semanticOverviewActive && svgRef.current && zoomBehaviorRef.current) {
4320-
d3.select(svgRef.current).call(zoomBehaviorRef.current.transform, d3.zoomTransform(svgRef.current));
4319+
if (previous.active && previous.graphId === currentGraph?.id && !semanticOverviewActive && svgRef.current && !simplifiedRef.current) {
4320+
const k = d3.zoomTransform(svgRef.current).k;
4321+
d3.select(svgRef.current).select('.main-graph-group')
4322+
.selectAll<SVGGElement, unknown>('.node-title-group')
4323+
.attr('transform', function() { return fittedTitleTransform(this, k); });
43214324
}
43224325
}, [semanticOverviewActive, currentGraph?.id]);
43234326

‎tests/e2e/graph/semantic-lod.spec.ts‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,72 @@ for (const width of [1440, 390])
199199
(element as SVGSVGElement & { __zoom: { k: number } }).__zoom.k
200200
)).toBeCloseTo(0.5);
201201
}
202+
await zoomToTitle(1.1, true);
203+
await page.evaluate(({ x, y }) => {
204+
(window as unknown as { miniMapZoom: (x: number, y: number, k: number) => void }).miniMapZoom(x, y, 1.2);
205+
}, { x: longTitleItem.positionX, y: longTitleItem.positionY });
206+
let exitedBeforeTarget = false;
207+
for (let frame = 0; frame < 12; frame++) {
208+
await page.clock.runFor(16);
209+
const k = await svg.evaluate(element =>
210+
(element as SVGSVGElement & { __zoom: { k: number } }).__zoom.k
211+
);
212+
if (k > 1.15 && k < 1.2 && await overview.getAttribute('data-active') === 'false') exitedBeforeTarget = true;
213+
}
214+
expect(exitedBeforeTarget).toBe(true);
215+
expect(await svg.evaluate(element =>
216+
(element as SVGSVGElement & { __zoom: { k: number } }).__zoom.k
217+
)).toBeCloseTo(1.2);
218+
await expect(overview).toHaveAttribute('data-active', 'false');
219+
await zoomToTitle(0.5, true);
220+
await expect(overview).toHaveAttribute('data-active', 'true');
221+
const fixedCamera = await svg.evaluate(element => {
222+
const camera = (element as SVGSVGElement & { __zoom: { x: number; y: number; k: number } }).__zoom;
223+
return { x: camera.x, y: camera.y, k: camera.k };
224+
});
225+
await page.evaluate(id => {
226+
const target = window as any;
227+
const node = Array.from(document.querySelectorAll('.main-graph-group .node')).find(element => (element as any).__data__?.id === id)!;
228+
const title = node.querySelector('.node-title-group') as SVGGElement;
229+
const measure = title.getBBox.bind(title);
230+
target.__lodVisibleTitleMeasurements = 0;
231+
target.__lodRestoreTitleMeasurement = () => { delete (title as any).getBBox; };
232+
title.getBBox = () => {
233+
if (!title.closest('[data-overview="true"]')) target.__lodVisibleTitleMeasurements++;
234+
return measure();
235+
};
236+
target.__lodCameraRefreshCount = 0;
237+
target.__lodOriginalViewportUpdate = target.updateMiniMapViewport;
238+
target.updateMiniMapViewport = (...args: unknown[]) => {
239+
target.__lodCameraRefreshCount++;
240+
return target.__lodOriginalViewportUpdate?.(...args);
241+
};
242+
}, seeded.nodeIds[8]);
243+
await overview.getByRole('combobox', { name: 'Graph detail' }).selectOption('items');
244+
await expect(overview).toHaveAttribute('data-active', 'false');
245+
await expect.poll(() => svg.locator('.main-graph-group .node').evaluateAll((elements, id) => {
246+
const node = elements.find(element => (element as Element & { __data__?: { id: string } }).__data__?.id === id)!;
247+
const title = node.querySelector('.node-title-group')!.getBoundingClientRect();
248+
const card = node.querySelector('.node-bg')!.getBoundingClientRect();
249+
return title.width > 0 && title.left >= card.left && title.right <= card.right && title.top >= card.top && title.bottom <= card.bottom;
250+
}, seeded.nodeIds[8])).toBe(true);
251+
expect(await svg.evaluate(element => {
252+
const camera = (element as SVGSVGElement & { __zoom: { x: number; y: number; k: number } }).__zoom;
253+
return { x: camera.x, y: camera.y, k: camera.k };
254+
})).toEqual(fixedCamera);
255+
await expect.poll(() => page.evaluate(() => (window as any).__lodVisibleTitleMeasurements)).toBeGreaterThan(0);
256+
expect(await page.evaluate(() => (window as any).__lodCameraRefreshCount)).toBe(0);
257+
await page.evaluate(() => {
258+
const target = window as any;
259+
target.updateMiniMapViewport = target.__lodOriginalViewportUpdate;
260+
delete target.__lodOriginalViewportUpdate;
261+
delete target.__lodCameraRefreshCount;
262+
target.__lodRestoreTitleMeasurement();
263+
delete target.__lodRestoreTitleMeasurement;
264+
delete target.__lodVisibleTitleMeasurements;
265+
});
266+
await overview.getByRole('combobox', { name: 'Graph detail' }).selectOption('auto');
267+
await expect(overview).toHaveAttribute('data-active', 'true');
202268
await page.clock.resume();
203269
const geometry = await overviewGeometry();
204270
await info.attach('overview-geometry.json', {

0 commit comments

Comments
 (0)