Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .agents/inbox/2026-10-27-lod-title-refresh-must-not-own-camera.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# LOD title refresh must not interrupt the owning camera transition

Date: 2026-10-27. Issue #205. The symptom index already covered explicit camera
input cancelling automatic framing and renderer ownership; neither addressed an
overview-exit title refresh interrupting the active user animation.

## Evidence

CSS PR #199 CI run `40591493014` failed the desktop semantic-LOD case at camera
scale `1.1735656098434863` while expecting `1.2`. Its retained trace shows this
was after the test clock resumed; it was not an unadvanced-clock failure.

A new production-renderer regression starts at `1.1`, requests the existing
120 ms minimap zoom to `1.2`, and advances twelve 16 ms frames. It explicitly
observes overview exit above `1.15` before the target is reached. On the original
runtime, `npm run qc:lod -- --grep 1440 --trace=retain-on-failure` failed with
`1.1767031700493764` instead of `1.2`. The trace is retained under the owned
`/tmp/graphdone-resolution-20261027/lod-transition-negative-artifacts` directory.

The corrected runtime passes both 301-node/370-edge desktop and phone LOD
journeys in 3.3 minutes with one worker and zero retries. Existing membership,
label geometry, selected-item navigation, graph switching and zero-position-write
checks remain. Build, typecheck and lint pass (existing lint warnings remain);
web tests pass 346/346 and the harness passes 175/175. Exact-head review and CI
remain required before integration; source-only evidence is not a live deployment.

## Cause and corrective rule

The overview-exit effect reapplied the current camera through
`zoomBehavior.transform` to make formerly hidden title elements measurable.
D3's selection form of that API interrupts the selection's active transition.
Thus a presentation refresh cancelled the same animation that crossed the LOD
threshold, saving an intermediate camera position.

Refresh source-title geometry directly using the live camera scale after source
elements become visible. Scope the selection to the source `.main-graph-group`, retain
the same-graph transition guard, and respect simplified rendering. Do not emit a
camera command merely to refresh presentation. Camera transitions belong to the
navigation operation that initiated them.

## A live renderer selector needs its own observation

Follow-up review found the initial title-only change used `.main-group`, while
both renderer initializers create `.main-graph-group`. The effect was therefore
a no-op. Animated zoom still passed because later frames refitted the titles.
An initial fixed-camera geometry-only check also passed; neither result proved
the explicit refresh actually ran. These passing-but-insufficient controls are
retained rather than presented as a negative control.

The strengthened fixed-camera Overview→Items phase instruments the selected
real SVG title's native `getBBox` method, retaining its actual geometry result.
It requires a visible measurement, bounded title geometry, an identical camera
and zero minimap camera publications, with the animation clock paused. The
incorrect selector fails with zero visible measurements. With the actual source
selector, both desktop and phone cases pass in 4.2 minutes with zero retries;
the source selector
must be taken from the actual renderer. Do not infer presentation work occurred
merely because an animation reached its target.

## Limits

This corrects overview-to-item transition ownership; it does not implement the
remaining multilevel grouping, bounded 1k rendering or progressive 10k loading
milestones. The browser regression uses owned synthetic graphs and preserves the
original interaction deadlines, required assertions and strict fixture cleanup.
1 change: 1 addition & 0 deletions .agents/issues/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

| Symptom | Canonical lesson and regression |
| --- | --- |
| 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 |
| 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` |
| 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 |
| 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 |
Expand Down
7 changes: 5 additions & 2 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4316,8 +4316,11 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
// that would persist an unfitted/default (or previous graph's) camera.
const previous = previousOverview.current;
previousOverview.current = { active: semanticOverviewActive, graphId: currentGraph?.id };
if (previous.active && previous.graphId === currentGraph?.id && !semanticOverviewActive && svgRef.current && zoomBehaviorRef.current) {
d3.select(svgRef.current).call(zoomBehaviorRef.current.transform, d3.zoomTransform(svgRef.current));
if (previous.active && previous.graphId === currentGraph?.id && !semanticOverviewActive && svgRef.current && !simplifiedRef.current) {
const k = d3.zoomTransform(svgRef.current).k;
d3.select(svgRef.current).select('.main-graph-group')
.selectAll<SVGGElement, unknown>('.node-title-group')
.attr('transform', function() { return fittedTitleTransform(this, k); });
}
}, [semanticOverviewActive, currentGraph?.id]);

Expand Down
66 changes: 66 additions & 0 deletions tests/e2e/graph/semantic-lod.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,72 @@ for (const width of [1440, 390])
(element as SVGSVGElement & { __zoom: { k: number } }).__zoom.k
)).toBeCloseTo(0.5);
}
await zoomToTitle(1.1, true);
await page.evaluate(({ x, y }) => {
(window as unknown as { miniMapZoom: (x: number, y: number, k: number) => void }).miniMapZoom(x, y, 1.2);
}, { x: longTitleItem.positionX, y: longTitleItem.positionY });
let exitedBeforeTarget = false;
for (let frame = 0; frame < 12; frame++) {
await page.clock.runFor(16);
const k = await svg.evaluate(element =>
(element as SVGSVGElement & { __zoom: { k: number } }).__zoom.k
);
if (k > 1.15 && k < 1.2 && await overview.getAttribute('data-active') === 'false') exitedBeforeTarget = true;
}
expect(exitedBeforeTarget).toBe(true);
expect(await svg.evaluate(element =>
(element as SVGSVGElement & { __zoom: { k: number } }).__zoom.k
)).toBeCloseTo(1.2);
await expect(overview).toHaveAttribute('data-active', 'false');
await zoomToTitle(0.5, true);
await expect(overview).toHaveAttribute('data-active', 'true');
const fixedCamera = await svg.evaluate(element => {
const camera = (element as SVGSVGElement & { __zoom: { x: number; y: number; k: number } }).__zoom;
return { x: camera.x, y: camera.y, k: camera.k };
});
await page.evaluate(id => {
const target = window as any;
const node = Array.from(document.querySelectorAll('.main-graph-group .node')).find(element => (element as any).__data__?.id === id)!;
const title = node.querySelector('.node-title-group') as SVGGElement;
const measure = title.getBBox.bind(title);
target.__lodVisibleTitleMeasurements = 0;
target.__lodRestoreTitleMeasurement = () => { delete (title as any).getBBox; };
title.getBBox = () => {
if (!title.closest('[data-overview="true"]')) target.__lodVisibleTitleMeasurements++;
return measure();
};
target.__lodCameraRefreshCount = 0;
target.__lodOriginalViewportUpdate = target.updateMiniMapViewport;
target.updateMiniMapViewport = (...args: unknown[]) => {
target.__lodCameraRefreshCount++;
return target.__lodOriginalViewportUpdate?.(...args);
};
}, seeded.nodeIds[8]);
await overview.getByRole('combobox', { name: 'Graph detail' }).selectOption('items');
await expect(overview).toHaveAttribute('data-active', 'false');
await expect.poll(() => svg.locator('.main-graph-group .node').evaluateAll((elements, id) => {
const node = elements.find(element => (element as Element & { __data__?: { id: string } }).__data__?.id === id)!;
const title = node.querySelector('.node-title-group')!.getBoundingClientRect();
const card = node.querySelector('.node-bg')!.getBoundingClientRect();
return title.width > 0 && title.left >= card.left && title.right <= card.right && title.top >= card.top && title.bottom <= card.bottom;
}, seeded.nodeIds[8])).toBe(true);
expect(await svg.evaluate(element => {
const camera = (element as SVGSVGElement & { __zoom: { x: number; y: number; k: number } }).__zoom;
return { x: camera.x, y: camera.y, k: camera.k };
})).toEqual(fixedCamera);
await expect.poll(() => page.evaluate(() => (window as any).__lodVisibleTitleMeasurements)).toBeGreaterThan(0);
expect(await page.evaluate(() => (window as any).__lodCameraRefreshCount)).toBe(0);
await page.evaluate(() => {
const target = window as any;
target.updateMiniMapViewport = target.__lodOriginalViewportUpdate;
delete target.__lodOriginalViewportUpdate;
delete target.__lodCameraRefreshCount;
target.__lodRestoreTitleMeasurement();
delete target.__lodRestoreTitleMeasurement;
delete target.__lodVisibleTitleMeasurements;
});
await overview.getByRole('combobox', { name: 'Graph detail' }).selectOption('auto');
await expect(overview).toHaveAttribute('data-active', 'true');
await page.clock.resume();
const geometry = await overviewGeometry();
await info.attach('overview-geometry.json', {
Expand Down
Loading