diff --git a/.agents/inbox/2026-10-27-lod-title-refresh-must-not-own-camera.md b/.agents/inbox/2026-10-27-lod-title-refresh-must-not-own-camera.md new file mode 100644 index 00000000..b0ad43ee --- /dev/null +++ b/.agents/inbox/2026-10-27-lod-title-refresh-must-not-own-camera.md @@ -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. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index dbbc318a..276c2589 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -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 | diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index 86722b0e..d402473f 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -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('.node-title-group') + .attr('transform', function() { return fittedTitleTransform(this, k); }); } }, [semanticOverviewActive, currentGraph?.id]); diff --git a/tests/e2e/graph/semantic-lod.spec.ts b/tests/e2e/graph/semantic-lod.spec.ts index d0f88249..74decfa2 100644 --- a/tests/e2e/graph/semantic-lod.spec.ts +++ b/tests/e2e/graph/semantic-lod.spec.ts @@ -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', {