From c141a239087b84f506f559135cf7a0874a715151 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 09:26:50 +0200 Subject: [PATCH 1/7] Fix viewport recovery during pivot view transitions --- Source/PivotViewer/components/PivotCanvas.tsx | 3 + .../components/pivot/visibility.ts | 116 ++++-------------- .../given/a_transition.ts | 53 ++++++++ ...n_a_transition_card_leaves_the_viewport.ts | 32 +++++ .../when_scrolling_during_a_transition.ts | 28 +++++ ...tioning_with_offscreen_target_positions.ts | 25 ++++ 6 files changed, 165 insertions(+), 92 deletions(-) create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/when_a_transition_card_leaves_the_viewport.ts create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition.ts create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions.ts diff --git a/Source/PivotViewer/components/PivotCanvas.tsx b/Source/PivotViewer/components/PivotCanvas.tsx index 61879143..9926b776 100644 --- a/Source/PivotViewer/components/PivotCanvas.tsx +++ b/Source/PivotViewer/components/PivotCanvas.tsx @@ -152,6 +152,7 @@ export function PivotCanvas({ const lastViewChangeTimeRef = useRef(0); const previousViewModeRef = useRef(viewMode); const prevLayoutRef = useRef(null); + const transitionLayoutRef = useRef(null); const prevGroupingRef = useRef(null); const prevScrollTopRef = useRef(0); const prevScrollLeftRef = useRef(0); @@ -500,6 +501,7 @@ export function PivotCanvas({ const layoutChanged = prevLayoutRef.current !== layout; if (viewModeChanged || groupingChanged || layoutChanged) { + if (layoutChanged) transitionLayoutRef.current = prevLayoutRef.current; isViewTransitionRef.current = true; lastViewChangeTimeRef.current = Date.now(); previousViewModeRef.current = viewMode; @@ -754,6 +756,7 @@ export function PivotCanvas({ ), isViewTransition: isViewTransitionRef.current, viewMode, + prevLayout: transitionLayoutRef.current, prevScrollTop: prevScrollTopRef.current, prevScrollLeft: prevScrollLeftRef.current, }); diff --git a/Source/PivotViewer/components/pivot/visibility.ts b/Source/PivotViewer/components/pivot/visibility.ts index bdfdf3b9..edaf2fc4 100644 --- a/Source/PivotViewer/components/pivot/visibility.ts +++ b/Source/PivotViewer/components/pivot/visibility.ts @@ -34,7 +34,7 @@ export interface SyncParams { } export function syncSpritesToViewport(params: SyncParams) { - const { root, groupsContainer, container, sprites, layout, visibleIds: _visibleIds, items, cardWidth, cardHeight, panX, panY, panDeltaX, panDeltaY, viewportWidth, viewportHeight, createCardSprite, updateCardContent, zoomLevel, isViewTransition, viewMode, prevLayout, prevScrollTop, prevScrollLeft } = params; + const { root, groupsContainer, container, sprites, layout, visibleIds: _visibleIds, items, cardWidth, cardHeight, panX, panY, panDeltaX, panDeltaY, viewportWidth, viewportHeight, createCardSprite, updateCardContent, zoomLevel, isViewTransition, viewMode, prevLayout } = params; if (!root || !container) return; void _visibleIds; @@ -136,43 +136,30 @@ export function syncSpritesToViewport(params: SyncParams) { const inViewportIds: (string | number)[] = []; // Small tolerance in world units to avoid floating-point edge cases when // browser/device zoom or high scroll values produce tiny rounding errors. - // Scale epsilon with invScale so tolerance grows when zoomed out. const worldEpsilon = Math.max(0.5, 0.5 * invScale); - - // Iterate layout positions directly to avoid depending on `visibleIds` - // which may be calculated in a different coordinate space or with - // different assumptions about zoom. Looping the positions map is - // deterministic and uses world coordinates directly. - for (const [id, position] of layout.positions) { - if (!position) continue; - const worldX = position.x; - const worldY = position.y; - const worldCardW = cardWidth; - const worldCardH = cardHeight; - - if ( - worldX + worldCardW >= viewportLeftWorld - worldEpsilon && - worldX <= viewportRightWorld + worldEpsilon && - worldY + worldCardH >= viewportTopWorld - worldEpsilon && - worldY <= viewportBottomWorld + worldEpsilon - ) { + const intersectsViewport = (x: number, y: number) => + x + cardWidth >= viewportLeftWorld - worldEpsilon && + x <= viewportRightWorld + worldEpsilon && + y + cardHeight >= viewportTopWorld - worldEpsilon && + y <= viewportBottomWorld + worldEpsilon; + const addVisibleId = (id: string | number) => { + if (!visibleSet.has(id)) { inViewportIds.push(id); visibleSet.add(id); } - } + }; - // During view transitions, if no cards are visible and we're not animating yet, - // force-add the first few cards from the layout to ensure content appears. - // This prevents a blank screen when switching modes, especially in packaged builds - // where scroll stabilization might be delayed. - if (isViewTransition && inViewportIds.length === 0 && layout.positions.size > 0) { - let count = 0; - for (const [id, position] of layout.positions) { - if (count < 5 && position) { // Add up to 5 cards - inViewportIds.push(id); - visibleSet.add(id); - count++; - } + // Target positions are authoritative, but during a transition cards may + // still be at their old positions while the camera catches up. + for (const [id, position] of layout.positions) { + if (position && intersectsViewport(position.x, position.y)) addVisibleId(id); + } + if (isViewTransition) { + for (const [id, position] of prevLayout?.positions ?? []) { + if (!sprites.has(id) && layout.positions.has(id) && position && intersectsViewport(position.x, position.y)) addVisibleId(id); + } + for (const [id, sprite] of sprites) { + if (layout.positions.has(id) && intersectsViewport(sprite.currentX, sprite.currentY)) addVisibleId(id); } } @@ -200,66 +187,11 @@ export function syncSpritesToViewport(params: SyncParams) { void e; } - // Fallback: if no sprites are calculated as visible (e.g., due to rounding - // or scroll/zoom race conditions), force a handful of cards into view so - // the canvas never renders empty at certain zoom levels. - // When transitioning views, be more aggressive to ensure content appears during the transition - let injectedFallback = false; - const fallbackCount = isViewTransition ? 30 : 12; - if (inViewportIds.length === 0 && layout.positions.size > 0) { - injectedFallback = true; - let count = 0; - for (const [id] of layout.positions) { - inViewportIds.push(id); - visibleSet.add(id); - count++; - if (count >= fallbackCount) break; - } - } - - // If we detect a very large discrepancy between created sprites and the - // computed in-viewport count, that's a signal our culling math may be - // unstable (especially at non-100% zoom). In that case, skip hiding this - // frame as a conservative safeguard to avoid mass disappearing tiles. - // However, disable this safeguard during view transitions to ensure old sprites are cleaned up. - // EXCEPT: During view transitions, if scroll position hasn't stabilized yet (e.g., switching to grouped - // mode triggers a scroll-to-bottom), keep all sprites visible to prevent flickering. - // Check scroll stabilization by comparing current scroll to previous scroll position. - const currentScrollTop = container.scrollTop || 0; - const currentScrollLeft = container.scrollLeft || 0; - const scrollTopDelta = Math.abs(currentScrollTop - (prevScrollTop || currentScrollTop)); - const scrollLeftDelta = Math.abs(currentScrollLeft - (prevScrollLeft || currentScrollLeft)); - const scrollStabilized = scrollTopDelta < 10 && scrollLeftDelta < 10; - const aggressiveCull = !injectedFallback && ( - (!isViewTransition && sprites.size > Math.max(120, Math.ceil(inViewportIds.length * 1.5))) || - (isViewTransition && !scrollStabilized) - ); + // Outside transitions, avoid mass-hiding sprites on a potentially stale viewport calculation. + const aggressiveCull = !isViewTransition && sprites.size > Math.max(120, Math.ceil(inViewportIds.length * 1.5)); for (const [id, sprite] of sprites) { if (!visibleSet.has(id)) { - // If view transition is active, check if this sprite has a valid target in the new layout - // If so, keep it visible and animate it to the new position (even if off-screen) - if (isViewTransition && layout.positions.has(id)) { - const newPos = layout.positions.get(id); - if (newPos) { - sprite.targetX = newPos.x; - sprite.targetY = newPos.y; - - // Trigger animation if not already animating - if (sprite.animationStartTime === undefined) { - sprite.startX = sprite.currentX; - sprite.startY = sprite.currentY; - sprite.animationStartTime = Date.now(); - sprite.animationDelay = Math.random() * 300; - } - - try { if (sprite.container) sprite.container.visible = true; } catch (e) { void e; } - // Don't mark as hidden, so it won't be swept - if ((sprite as unknown as { __lastHiddenAt?: number }).__lastHiddenAt) delete (sprite as unknown as { __lastHiddenAt?: number }).__lastHiddenAt; - continue; - } - } - if (aggressiveCull) { // Keep sprite visible this frame to avoid visual holes try { if (sprite.container) sprite.container.visible = true; } catch (e) { void e; } @@ -270,7 +202,7 @@ export function syncSpritesToViewport(params: SyncParams) { if (sprite.container) { sprite.container.visible = false; } - (sprite as unknown as { __lastHiddenAt: number }).__lastHiddenAt = Date.now(); + (sprite as unknown as { __lastHiddenAt?: number }).__lastHiddenAt ??= Date.now(); } catch (e) { void e; } @@ -335,7 +267,7 @@ export function syncSpritesToViewport(params: SyncParams) { // If view transition, try to find old position to fly in from if (isViewTransition && prevLayout && prevLayout.positions.has(id)) { const oldPos = prevLayout.positions.get(id); - if (oldPos) { + if (oldPos && intersectsViewport(oldPos.x, oldPos.y)) { startX = oldPos.x; startY = oldPos.y; diff --git a/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts b/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts new file mode 100644 index 00000000..91af566d --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts @@ -0,0 +1,53 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import * as PIXI from 'pixi.js'; +import type { LayoutResult } from '../../engine/types'; +import type { CardSprite } from '../../components/pivot/constants'; +import type { SyncParams } from '../../components/pivot/visibility'; + +export class a_transition { + root = new PIXI.Container(); + container = { clientWidth: 200, clientHeight: 200, scrollLeft: 0, scrollTop: 0 } as HTMLDivElement; + sprites = new Map(); + layout: LayoutResult = { + positions: new Map(Array.from({ length: 5 }, (_, index) => [index, { x: 0, y: 15000 + index * 30, groupIndex: 0 }] as const)), + totalWidth: 200, + totalHeight: 50000, + }; + previousLayout: LayoutResult = { positions: new Map(), totalWidth: 200, totalHeight: 50000 }; + createdIds: (number | string)[] = []; + + get params(): SyncParams<{ name: string }> { + return { + root: this.root, + container: this.container, + sprites: this.sprites, + layout: this.layout, + prevLayout: this.previousLayout, + visibleIds: new Uint32Array(), + items: [], + cardWidth: 20, + cardHeight: 20, + panX: 0, + panY: 0, + viewportWidth: 200, + viewportHeight: 200, + zoomLevel: 1, + viewMode: 'collection', + isViewTransition: true, + createCardSprite: (id, x, y) => { + this.createdIds.push(id); + return { + itemId: id, + container: new PIXI.Container(), + currentX: x, + currentY: y, + targetX: x, + targetY: y, + } as CardSprite; + }, + updateCardContent: () => {}, + }; + } +} diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_a_transition_card_leaves_the_viewport.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_a_transition_card_leaves_the_viewport.ts new file mode 100644 index 00000000..81dd7a11 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_a_transition_card_leaves_the_viewport.ts @@ -0,0 +1,32 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import sinon from 'sinon'; +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { clearSpritePool } from '../components/pivot/sprites'; +import { a_transition } from './given/a_transition'; + +describe('when a transition card leaves the viewport', () => { + let context: a_transition; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ now: 1000 }); + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + const sprite = context.params.createCardSprite(6, 10, 16000); + context.sprites.set(6, sprite); + syncSpritesToViewport(context.params); + clock.tick(200); + syncSpritesToViewport(context.params); + }); + + afterEach(() => { + clock.restore(); + clearSpritePool(); + }); + + it('should release the offscreen card instead of keeping it for the entire transition', () => { + context.sprites.has(6).should.be.false; + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition.ts new file mode 100644 index 00000000..feb0f791 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition.ts @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { a_transition } from './given/a_transition'; + +describe('when scrolling during a transition', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.layout.positions.set(7, { x: 10, y: 17000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + context.previousLayout.positions.set(7, { x: 10, y: 1100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + context.container.scrollTop = 1000; + syncSpritesToViewport({ ...context.params, prevScrollTop: 0 }); + }); + + it('should render the card now in the viewport', () => { + context.createdIds.should.contain(7); + }); + + it('should stop displaying the card left behind by the scroll', () => { + context.sprites.get(6)!.container.visible.should.be.false; + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions.ts new file mode 100644 index 00000000..3b6f672e --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { a_transition } from './given/a_transition'; + +describe('when transitioning with offscreen target positions', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + }); + + it('should create the card at its previous on-screen position', () => { + context.createdIds.should.contain(6); + context.sprites.get(6)!.currentY.should.equal(100); + }); + + it('should not create the first offscreen cards', () => { + context.createdIds.should.deep.equal([6]); + }); +}); From 14c0d92b7cf0ae82c007c89e607fabda28d950fb Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 09:37:00 +0200 Subject: [PATCH 2/7] Prevent swept transition cards from reentering at old positions --- Source/PivotViewer/components/PivotCanvas.tsx | 5 +++ .../PivotViewer/components/pivot/animation.ts | 5 ++- .../components/pivot/visibility.ts | 7 ++-- .../given/a_transition.ts | 2 ++ ..._card_is_still_at_its_previous_position.ts | 35 +++++++++++++++++++ 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/when_a_swept_transition_card_is_still_at_its_previous_position.ts diff --git a/Source/PivotViewer/components/PivotCanvas.tsx b/Source/PivotViewer/components/PivotCanvas.tsx index 9926b776..3e131ef8 100644 --- a/Source/PivotViewer/components/PivotCanvas.tsx +++ b/Source/PivotViewer/components/PivotCanvas.tsx @@ -153,6 +153,7 @@ export function PivotCanvas({ const previousViewModeRef = useRef(viewMode); const prevLayoutRef = useRef(null); const transitionLayoutRef = useRef(null); + const transitionSeenIdsRef = useRef>(new Set()); const prevGroupingRef = useRef(null); const prevScrollTopRef = useRef(0); const prevScrollLeftRef = useRef(0); @@ -501,6 +502,7 @@ export function PivotCanvas({ const layoutChanged = prevLayoutRef.current !== layout; if (viewModeChanged || groupingChanged || layoutChanged) { + transitionSeenIdsRef.current.clear(); if (layoutChanged) transitionLayoutRef.current = prevLayoutRef.current; isViewTransitionRef.current = true; lastViewChangeTimeRef.current = Date.now(); @@ -587,6 +589,7 @@ export function PivotCanvas({ ), isViewTransition: isViewTransitionRef.current, prevLayout: prevLayoutRef.current, + transitionSeenIds: transitionSeenIdsRef.current, prevScrollTop: prevScrollTopRef.current, prevScrollLeft: prevScrollLeftRef.current, }); @@ -610,6 +613,7 @@ export function PivotCanvas({ needsRenderRef, spritesRef, isViewTransitionRef, + onTransitionComplete: () => transitionSeenIdsRef.current.clear(), }); }, [ layout, @@ -757,6 +761,7 @@ export function PivotCanvas({ isViewTransition: isViewTransitionRef.current, viewMode, prevLayout: transitionLayoutRef.current, + transitionSeenIds: transitionSeenIdsRef.current, prevScrollTop: prevScrollTopRef.current, prevScrollLeft: prevScrollLeftRef.current, }); diff --git a/Source/PivotViewer/components/pivot/animation.ts b/Source/PivotViewer/components/pivot/animation.ts index 04326b6a..9de85886 100644 --- a/Source/PivotViewer/components/pivot/animation.ts +++ b/Source/PivotViewer/components/pivot/animation.ts @@ -79,15 +79,18 @@ export function startAnimationLoop( needsRenderRef: { current: boolean }; spritesRef: { current: Map }; isViewTransitionRef: { current: boolean }; + onTransitionComplete?: () => void; }, animationSpeed = 0.15, ) { - const { mountedRef, appRef, animationFrameRef, isAnimatingRef, needsRenderRef, spritesRef, isViewTransitionRef } = refs; + const { mountedRef, appRef, animationFrameRef, isAnimatingRef, needsRenderRef, spritesRef, isViewTransitionRef, onTransitionComplete } = refs; const animate = () => { if (!mountedRef.current) return; + const wasTransitioning = isViewTransitionRef.current; const stillAnimating = updatePositions(spritesRef.current, isViewTransitionRef, animationSpeed); + if (wasTransitioning && !isViewTransitionRef.current) onTransitionComplete?.(); if (stillAnimating || needsRenderRef.current) { appRef.current?.renderer.render(appRef.current.stage); diff --git a/Source/PivotViewer/components/pivot/visibility.ts b/Source/PivotViewer/components/pivot/visibility.ts index edaf2fc4..5c645681 100644 --- a/Source/PivotViewer/components/pivot/visibility.ts +++ b/Source/PivotViewer/components/pivot/visibility.ts @@ -29,12 +29,13 @@ export interface SyncParams { isViewTransition?: boolean; viewMode: string; prevLayout?: LayoutResult | null; + transitionSeenIds: Set; prevScrollTop?: number; prevScrollLeft?: number; } export function syncSpritesToViewport(params: SyncParams) { - const { root, groupsContainer, container, sprites, layout, visibleIds: _visibleIds, items, cardWidth, cardHeight, panX, panY, panDeltaX, panDeltaY, viewportWidth, viewportHeight, createCardSprite, updateCardContent, zoomLevel, isViewTransition, viewMode, prevLayout } = params; + const { root, groupsContainer, container, sprites, layout, visibleIds: _visibleIds, items, cardWidth, cardHeight, panX, panY, panDeltaX, panDeltaY, viewportWidth, viewportHeight, createCardSprite, updateCardContent, zoomLevel, isViewTransition, viewMode, prevLayout, transitionSeenIds } = params; if (!root || !container) return; void _visibleIds; @@ -156,9 +157,10 @@ export function syncSpritesToViewport(params: SyncParams) { } if (isViewTransition) { for (const [id, position] of prevLayout?.positions ?? []) { - if (!sprites.has(id) && layout.positions.has(id) && position && intersectsViewport(position.x, position.y)) addVisibleId(id); + if (!sprites.has(id) && !transitionSeenIds.has(id) && layout.positions.has(id) && position && intersectsViewport(position.x, position.y)) addVisibleId(id); } for (const [id, sprite] of sprites) { + transitionSeenIds.add(id); if (layout.positions.has(id) && intersectsViewport(sprite.currentX, sprite.currentY)) addVisibleId(id); } } @@ -287,6 +289,7 @@ export function syncSpritesToViewport(params: SyncParams) { sprite = createCardSprite(id, startX, startY); sprites.set(id, sprite); + if (isViewTransition) transitionSeenIds.add(id); if (sprite.container) { root.addChild(sprite.container); sprite.currentX = startX; diff --git a/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts b/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts index 91af566d..bfd382dd 100644 --- a/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts +++ b/Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts @@ -10,6 +10,7 @@ export class a_transition { root = new PIXI.Container(); container = { clientWidth: 200, clientHeight: 200, scrollLeft: 0, scrollTop: 0 } as HTMLDivElement; sprites = new Map(); + transitionSeenIds = new Set(); layout: LayoutResult = { positions: new Map(Array.from({ length: 5 }, (_, index) => [index, { x: 0, y: 15000 + index * 30, groupIndex: 0 }] as const)), totalWidth: 200, @@ -25,6 +26,7 @@ export class a_transition { sprites: this.sprites, layout: this.layout, prevLayout: this.previousLayout, + transitionSeenIds: this.transitionSeenIds, visibleIds: new Uint32Array(), items: [], cardWidth: 20, diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_a_swept_transition_card_is_still_at_its_previous_position.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_a_swept_transition_card_is_still_at_its_previous_position.ts new file mode 100644 index 00000000..f87b0a72 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_a_swept_transition_card_is_still_at_its_previous_position.ts @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import sinon from 'sinon'; +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { clearSpritePool } from '../components/pivot/sprites'; +import { a_transition } from './given/a_transition'; + +describe('when a swept transition card is still at its previous position', () => { + let context: a_transition; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ now: 1000 }); + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + const sprite = context.sprites.get(6)!; + sprite.currentY = 16000; + syncSpritesToViewport(context.params); + clock.tick(200); + syncSpritesToViewport(context.params); + syncSpritesToViewport(context.params); + }); + + afterEach(() => { + clock.restore(); + clearSpritePool(); + }); + + it('should not recreate the card from its previous position', () => { + context.createdIds.should.deep.equal([6]); + }); +}); From 22b55a48cfe08d778e2898e39f7250a7cb67b7b3 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 09:38:43 +0200 Subject: [PATCH 3/7] Sweep offscreen sprites when pivot animation completes --- Source/PivotViewer/components/PivotCanvas.tsx | 6 ++- .../PivotViewer/components/pivot/animation.ts | 8 +++- .../components/pivot/visibility.ts | 3 +- ...hen_animation_finishes_without_a_scroll.ts | 46 +++++++++++++++++++ 4 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/when_animation_finishes_without_a_scroll.ts diff --git a/Source/PivotViewer/components/PivotCanvas.tsx b/Source/PivotViewer/components/PivotCanvas.tsx index 3e131ef8..48f38497 100644 --- a/Source/PivotViewer/components/PivotCanvas.tsx +++ b/Source/PivotViewer/components/PivotCanvas.tsx @@ -544,7 +544,7 @@ export function PivotCanvas({ const currentScrollTop = parentContainerRef.current?.scrollTop || 0; const currentScrollLeft = parentContainerRef.current?.scrollLeft || 0; - syncSpritesToViewport({ + const syncParams = { root: rootRef.current, groupsContainer: groupsContainerRef.current, container: parentContainerRef.current, @@ -592,7 +592,8 @@ export function PivotCanvas({ transitionSeenIds: transitionSeenIdsRef.current, prevScrollTop: prevScrollTopRef.current, prevScrollLeft: prevScrollLeftRef.current, - }); + }; + syncSpritesToViewport(syncParams); // Update previous scroll position for next frame prevScrollTopRef.current = currentScrollTop; @@ -613,6 +614,7 @@ export function PivotCanvas({ needsRenderRef, spritesRef, isViewTransitionRef, + syncVisibility: () => syncSpritesToViewport({ ...syncParams, isViewTransition: false, sweepImmediately: true }), onTransitionComplete: () => transitionSeenIdsRef.current.clear(), }); }, [ diff --git a/Source/PivotViewer/components/pivot/animation.ts b/Source/PivotViewer/components/pivot/animation.ts index 9de85886..0c0c1359 100644 --- a/Source/PivotViewer/components/pivot/animation.ts +++ b/Source/PivotViewer/components/pivot/animation.ts @@ -80,17 +80,21 @@ export function startAnimationLoop( spritesRef: { current: Map }; isViewTransitionRef: { current: boolean }; onTransitionComplete?: () => void; + syncVisibility?: () => void; }, animationSpeed = 0.15, ) { - const { mountedRef, appRef, animationFrameRef, isAnimatingRef, needsRenderRef, spritesRef, isViewTransitionRef, onTransitionComplete } = refs; + const { mountedRef, appRef, animationFrameRef, isAnimatingRef, needsRenderRef, spritesRef, isViewTransitionRef, onTransitionComplete, syncVisibility } = refs; const animate = () => { if (!mountedRef.current) return; const wasTransitioning = isViewTransitionRef.current; const stillAnimating = updatePositions(spritesRef.current, isViewTransitionRef, animationSpeed); - if (wasTransitioning && !isViewTransitionRef.current) onTransitionComplete?.(); + if (wasTransitioning && !isViewTransitionRef.current) { + syncVisibility?.(); + onTransitionComplete?.(); + } if (stillAnimating || needsRenderRef.current) { appRef.current?.renderer.render(appRef.current.stage); diff --git a/Source/PivotViewer/components/pivot/visibility.ts b/Source/PivotViewer/components/pivot/visibility.ts index 5c645681..47921e93 100644 --- a/Source/PivotViewer/components/pivot/visibility.ts +++ b/Source/PivotViewer/components/pivot/visibility.ts @@ -30,6 +30,7 @@ export interface SyncParams { viewMode: string; prevLayout?: LayoutResult | null; transitionSeenIds: Set; + sweepImmediately?: boolean; prevScrollTop?: number; prevScrollLeft?: number; } @@ -224,7 +225,7 @@ export function syncSpritesToViewport(params: SyncParams) { const now = Date.now(); for (const [id, sprite] of sprites) { const lastHidden = (sprite as unknown as { __lastHiddenAt?: number }).__lastHiddenAt; - if (lastHidden && now - lastHidden > SWEEP_MS) { + if (lastHidden && (params.sweepImmediately || now - lastHidden > SWEEP_MS)) { try { // remove from parent if present if (sprite.container && sprite.container.parent) sprite.container.parent.removeChild(sprite.container); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_animation_finishes_without_a_scroll.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_animation_finishes_without_a_scroll.ts new file mode 100644 index 00000000..099b63c8 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_animation_finishes_without_a_scroll.ts @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import sinon from 'sinon'; +import { startAnimationLoop } from '../components/pivot/animation'; +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { clearSpritePool } from '../components/pivot/sprites'; +import { a_transition } from './given/a_transition'; + +describe('when animation finishes without a scroll', () => { + let context: a_transition; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ now: 1000 }); + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + const sprite = context.sprites.get(6)!; + sprite.animationStartTime = 1000; + sprite.animationDelay = 0; + clock.tick(1000); + const isViewTransitionRef = { current: true }; + startAnimationLoop({ + mountedRef: { current: true }, + appRef: { current: null }, + animationFrameRef: { current: 0 }, + isAnimatingRef: { current: false }, + needsRenderRef: { current: false }, + spritesRef: { current: context.sprites }, + isViewTransitionRef, + onTransitionComplete: () => context.transitionSeenIds.clear(), + syncVisibility: () => syncSpritesToViewport({ ...context.params, isViewTransition: isViewTransitionRef.current, sweepImmediately: true }), + }); + }); + + afterEach(() => { + clock.restore(); + clearSpritePool(); + }); + + it('should release the card that finished offscreen', () => { + context.sprites.has(6).should.be.false; + }); +}); From f59780e936a315e1ccf1a1a0189c5edecec75a7b Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 09:39:57 +0200 Subject: [PATCH 4/7] Reset hidden timestamp on reused pivot sprites --- .../PivotViewer/components/pivot/sprites.ts | 3 ++ .../when_a_pooled_sprite_is_hidden_again.ts | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/when_a_pooled_sprite_is_hidden_again.ts diff --git a/Source/PivotViewer/components/pivot/sprites.ts b/Source/PivotViewer/components/pivot/sprites.ts index 97071302..6af2ffd2 100644 --- a/Source/PivotViewer/components/pivot/sprites.ts +++ b/Source/PivotViewer/components/pivot/sprites.ts @@ -45,6 +45,9 @@ export function createCardSprite( sprite.currentX = x; sprite.currentY = y; + // A recycled sprite must start a new hidden grace period if it leaves the viewport. + delete (sprite as CardSprite & { __lastHiddenAt?: number }).__lastHiddenAt; + // Reset animation state delete sprite.animationStartTime; delete sprite.animationDelay; diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_a_pooled_sprite_is_hidden_again.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_a_pooled_sprite_is_hidden_again.ts new file mode 100644 index 00000000..803e6a89 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_a_pooled_sprite_is_hidden_again.ts @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import sinon from 'sinon'; +import { createCardSprite, destroySprite, clearSpritePool } from '../components/pivot/sprites'; +import { DEFAULT_COLORS } from '../components/pivot/constants'; +import { syncSpritesToViewport } from '../components/pivot/visibility'; +import { a_transition } from './given/a_transition'; + +describe('when a pooled sprite is hidden again', () => { + let context: a_transition; + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers({ now: 1000 }); + context = new a_transition(); + const oldSprite = context.params.createCardSprite(6, 10, 16000); + (oldSprite as unknown as { __lastHiddenAt: number }).__lastHiddenAt = 1000; + destroySprite(oldSprite); + clock.tick(500); + const reusedSprite = createCardSprite( + 7, 10, 16000, [{ name: 'Sample User' }], () => {}, () => {}, + 20, 20, DEFAULT_COLORS, item => ({ title: item.name }), item => item.name, + ); + context.layout.positions.set(7, { x: 10, y: 16000, groupIndex: 0 }); + context.sprites.set(7, reusedSprite); + syncSpritesToViewport(context.params); + }); + + afterEach(() => { + clock.restore(); + clearSpritePool(); + }); + + it('should retain the freshly hidden sprite for the sweep grace period', () => { + context.sprites.has(7).should.be.true; + }); +}); From 0bc2e6fefbde6ffe8e4ba6af4f64a18fc39e255f Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 09:41:39 +0200 Subject: [PATCH 5/7] Cover transition layout wiring on pivot scroll --- Source/PivotViewer/components/PivotCanvas.tsx | 6 ++--- .../components/pivot/syncScrollSprites.ts | 10 +++++++++ .../and_a_card_enters_the_viewport.ts} | 7 ++---- .../and_a_card_leaves_the_viewport.ts | 22 +++++++++++++++++++ ...arget_and_a_previous_on_screen_position.ts | 21 ++++++++++++++++++ ...nd_initial_layout_entries_are_offscreen.ts | 20 +++++++++++++++++ .../with_a_previous_on_screen_position.ts} | 7 ++---- 7 files changed, 80 insertions(+), 13 deletions(-) create mode 100644 Source/PivotViewer/components/pivot/syncScrollSprites.ts rename Source/PivotViewer/for_syncSpritesToViewport/{when_scrolling_during_a_transition.ts => when_scrolling_during_a_transition/and_a_card_enters_the_viewport.ts} (76%) create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_leaves_the_viewport.ts create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/with_an_offscreen_target_and_a_previous_on_screen_position.ts create mode 100644 Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/and_initial_layout_entries_are_offscreen.ts rename Source/PivotViewer/for_syncSpritesToViewport/{when_transitioning_with_offscreen_target_positions.ts => when_transitioning_with_offscreen_target_positions/with_a_previous_on_screen_position.ts} (74%) diff --git a/Source/PivotViewer/components/PivotCanvas.tsx b/Source/PivotViewer/components/PivotCanvas.tsx index 48f38497..e9d480e0 100644 --- a/Source/PivotViewer/components/PivotCanvas.tsx +++ b/Source/PivotViewer/components/PivotCanvas.tsx @@ -16,6 +16,7 @@ import { updateCardContent as updateCardContentExternal, } from './pivot/sprites'; import { syncSpritesToViewport } from './pivot/visibility'; +import { syncScrollSprites } from './pivot/syncScrollSprites'; import { updateGroupBackgrounds as updateGroupBackgroundsExternal, updateHighlight as updateHighlightExternal, @@ -721,7 +722,7 @@ export function PivotCanvas({ // because it encapsulates the logic for conditional vertical alignment (offsetY) // in different view modes. Manually setting position here would overwrite that logic. - syncSpritesToViewport({ + syncScrollSprites({ root: rootRef.current, groupsContainer: groupsContainerRef.current, container: parentContainerRef.current, @@ -762,11 +763,10 @@ export function PivotCanvas({ ), isViewTransition: isViewTransitionRef.current, viewMode, - prevLayout: transitionLayoutRef.current, transitionSeenIds: transitionSeenIdsRef.current, prevScrollTop: prevScrollTopRef.current, prevScrollLeft: prevScrollLeftRef.current, - }); + }, transitionLayoutRef.current); // Update previous scroll position for next frame prevScrollTopRef.current = container.scrollTop || 0; diff --git a/Source/PivotViewer/components/pivot/syncScrollSprites.ts b/Source/PivotViewer/components/pivot/syncScrollSprites.ts new file mode 100644 index 00000000..74c34f4d --- /dev/null +++ b/Source/PivotViewer/components/pivot/syncScrollSprites.ts @@ -0,0 +1,10 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { LayoutResult } from '../../engine/types'; +import { syncSpritesToViewport, type SyncParams } from './visibility'; + +/** Keep the scroll path's transition layout in sync with the normal render path. */ +export function syncScrollSprites(params: Omit, 'prevLayout'>, previousLayout: LayoutResult | null) { + syncSpritesToViewport({ ...params, prevLayout: previousLayout }); +} diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_enters_the_viewport.ts similarity index 76% rename from Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition.ts rename to Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_enters_the_viewport.ts index feb0f791..4f9a6e6b 100644 --- a/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition.ts +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_enters_the_viewport.ts @@ -1,8 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { syncSpritesToViewport } from '../components/pivot/visibility'; -import { a_transition } from './given/a_transition'; +import { syncSpritesToViewport } from '../../components/pivot/visibility'; +import { a_transition } from '../given/a_transition'; describe('when scrolling during a transition', () => { let context: a_transition; @@ -22,7 +22,4 @@ describe('when scrolling during a transition', () => { context.createdIds.should.contain(7); }); - it('should stop displaying the card left behind by the scroll', () => { - context.sprites.get(6)!.container.visible.should.be.false; - }); }); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_leaves_the_viewport.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_leaves_the_viewport.ts new file mode 100644 index 00000000..515644a1 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/and_a_card_leaves_the_viewport.ts @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncSpritesToViewport } from '../../components/pivot/visibility'; +import { a_transition } from '../given/a_transition'; + +describe('when scrolling during a transition and a card leaves the viewport', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + context.container.scrollTop = 1000; + syncSpritesToViewport({ ...context.params, prevScrollTop: 0 }); + }); + + it('should stop displaying the card left behind by the scroll', () => { + context.sprites.get(6)!.container.visible.should.be.false; + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/with_an_offscreen_target_and_a_previous_on_screen_position.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/with_an_offscreen_target_and_a_previous_on_screen_position.ts new file mode 100644 index 00000000..b8818509 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_scrolling_during_a_transition/with_an_offscreen_target_and_a_previous_on_screen_position.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncScrollSprites } from '../../components/pivot/syncScrollSprites'; +import { a_transition } from '../given/a_transition'; + +describe('when scrolling during a transition with an offscreen target and a previous on-screen position', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + const { prevLayout: _previousLayout, ...scrollParams } = context.params; + syncScrollSprites(scrollParams, context.previousLayout); + }); + + it('should create the card at its previous on-screen position', () => { + context.sprites.get(6)!.currentY.should.equal(100); + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/and_initial_layout_entries_are_offscreen.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/and_initial_layout_entries_are_offscreen.ts new file mode 100644 index 00000000..2dbe15a3 --- /dev/null +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/and_initial_layout_entries_are_offscreen.ts @@ -0,0 +1,20 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { syncSpritesToViewport } from '../../components/pivot/visibility'; +import { a_transition } from '../given/a_transition'; + +describe('when transitioning with offscreen target positions and initial layout entries are offscreen', () => { + let context: a_transition; + + beforeEach(() => { + context = new a_transition(); + context.layout.positions.set(6, { x: 10, y: 16000, groupIndex: 0 }); + context.previousLayout.positions.set(6, { x: 10, y: 100, groupIndex: 0 }); + syncSpritesToViewport(context.params); + }); + + it('should not create the first offscreen cards', () => { + context.createdIds.should.deep.equal([6]); + }); +}); diff --git a/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions.ts b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/with_a_previous_on_screen_position.ts similarity index 74% rename from Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions.ts rename to Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/with_a_previous_on_screen_position.ts index 3b6f672e..08eb84f4 100644 --- a/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions.ts +++ b/Source/PivotViewer/for_syncSpritesToViewport/when_transitioning_with_offscreen_target_positions/with_a_previous_on_screen_position.ts @@ -1,8 +1,8 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { syncSpritesToViewport } from '../components/pivot/visibility'; -import { a_transition } from './given/a_transition'; +import { syncSpritesToViewport } from '../../components/pivot/visibility'; +import { a_transition } from '../given/a_transition'; describe('when transitioning with offscreen target positions', () => { let context: a_transition; @@ -19,7 +19,4 @@ describe('when transitioning with offscreen target positions', () => { context.sprites.get(6)!.currentY.should.equal(100); }); - it('should not create the first offscreen cards', () => { - context.createdIds.should.deep.equal([6]); - }); }); From 249f8aab40269fce6c0cff184002599dfab0ee18 Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 09:46:19 +0200 Subject: [PATCH 6/7] Add Chromium viewport coverage for pivot regrouping and scroll --- .../PivotViewportRecovery.stories.tsx | 56 +++++++++++++++++++ .../PivotViewer/components/pivot/animation.ts | 12 ++++ 2 files changed, 68 insertions(+) create mode 100644 Source/PivotViewer/PivotViewportRecovery.stories.tsx diff --git a/Source/PivotViewer/PivotViewportRecovery.stories.tsx b/Source/PivotViewer/PivotViewportRecovery.stories.tsx new file mode 100644 index 00000000..d8f0699a --- /dev/null +++ b/Source/PivotViewer/PivotViewportRecovery.stories.tsx @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; +import { PivotViewer } from './PivotViewer'; + +interface SampleCard { + id: number; + title: string; + group: string; +} + +const cards: SampleCard[] = Array.from({ length: 600 }, (_, index) => ({ + id: index + 1, + title: `Sample Card ${index + 1}`, + group: `Group ${Math.floor(index / 100) + 1}`, +})); + +export default { + title: 'PivotViewer/Viewport Recovery', + component: PivotViewer, +} satisfies Meta; + +type Story = StoryObj; + +type ProbeCanvas = HTMLCanvasElement & { __pivotVisibleCardCount?: () => number }; + +export const RegroupAndScroll: Story = { + render: () => ( +
+ + data={cards} + dimensions={[{ key: 'group', label: 'Group', getValue: card => card.group }]} + filters={[]} + cardRenderer={card => ({ title: card.title })} + getItemId={card => card.id} + /> +
+ ), + play: async ({ canvasElement }) => { + const root = within(canvasElement); + const viewport = canvasElement.querySelector('.pv-viewport') as HTMLDivElement; + await waitFor(() => expect(canvasElement.querySelector('canvas')).not.toBeNull()); + const canvas = canvasElement.querySelector('canvas') as ProbeCanvas; + await waitFor(() => expect(canvas.__pivotVisibleCardCount?.()).toBeGreaterThan(0), { timeout: 8000 }); + await userEvent.click(root.getByRole('button', { name: 'Grouped' })); + // Each group has 100 bottom-up cards, so its earliest layout entries start well + // outside the 440px viewport. Move away from the automatic bottom alignment. + await waitFor(() => expect(viewport.scrollTop).toBeGreaterThan(1200), { timeout: 8000 }); + viewport.scrollTop = 1200; + viewport.dispatchEvent(new Event('scroll')); + await waitFor(() => expect(viewport.scrollTop).toBe(1200)); + await waitFor(() => expect(canvas.__pivotVisibleCardCount?.()).toBeGreaterThan(0), { timeout: 8000 }); + }, +}; diff --git a/Source/PivotViewer/components/pivot/animation.ts b/Source/PivotViewer/components/pivot/animation.ts index 0c0c1359..d3bc5e2b 100644 --- a/Source/PivotViewer/components/pivot/animation.ts +++ b/Source/PivotViewer/components/pivot/animation.ts @@ -86,6 +86,18 @@ export function startAnimationLoop( ) { const { mountedRef, appRef, animationFrameRef, isAnimatingRef, needsRenderRef, spritesRef, isViewTransitionRef, onTransitionComplete, syncVisibility } = refs; + // Expose the rendered Pixi sprite count to the Storybook viewport check. + const canvas = appRef.current?.canvas as (HTMLCanvasElement & { __pivotVisibleCardCount?: () => number }) | undefined; + if (canvas) canvas.__pivotVisibleCardCount = () => { + const root = appRef.current?.stage.children[1]; + if (!root) return 0; + return [...spritesRef.current.values()].filter(sprite => { + const x = sprite.currentX * root.scale.x + root.position.x; + const y = sprite.currentY * root.scale.y + root.position.y; + return sprite.container.visible && x + 180 > 0 && x < canvas.clientWidth && y + 120 > 0 && y < canvas.clientHeight; + }).length; + }; + const animate = () => { if (!mountedRef.current) return; From d908fc1a4e16758d51641162e930b2905ad31c3a Mon Sep 17 00:00:00 2001 From: woksin Date: Sat, 26 Sep 2026 09:48:43 +0200 Subject: [PATCH 7/7] Remove the Chromium viewport story and its canvas test hook The story did not reproduce the #236 blank state on the original code, and it required a test-only function on the shipped Pixi canvas element. The viewport recovery is covered by the syncSpritesToViewport and animation specs. --- .../PivotViewportRecovery.stories.tsx | 56 ------------------- .../PivotViewer/components/pivot/animation.ts | 12 ---- 2 files changed, 68 deletions(-) delete mode 100644 Source/PivotViewer/PivotViewportRecovery.stories.tsx diff --git a/Source/PivotViewer/PivotViewportRecovery.stories.tsx b/Source/PivotViewer/PivotViewportRecovery.stories.tsx deleted file mode 100644 index d8f0699a..00000000 --- a/Source/PivotViewer/PivotViewportRecovery.stories.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Cratis. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -import type { Meta, StoryObj } from '@storybook/react'; -import { expect, userEvent, waitFor, within } from 'storybook/test'; -import { PivotViewer } from './PivotViewer'; - -interface SampleCard { - id: number; - title: string; - group: string; -} - -const cards: SampleCard[] = Array.from({ length: 600 }, (_, index) => ({ - id: index + 1, - title: `Sample Card ${index + 1}`, - group: `Group ${Math.floor(index / 100) + 1}`, -})); - -export default { - title: 'PivotViewer/Viewport Recovery', - component: PivotViewer, -} satisfies Meta; - -type Story = StoryObj; - -type ProbeCanvas = HTMLCanvasElement & { __pivotVisibleCardCount?: () => number }; - -export const RegroupAndScroll: Story = { - render: () => ( -
- - data={cards} - dimensions={[{ key: 'group', label: 'Group', getValue: card => card.group }]} - filters={[]} - cardRenderer={card => ({ title: card.title })} - getItemId={card => card.id} - /> -
- ), - play: async ({ canvasElement }) => { - const root = within(canvasElement); - const viewport = canvasElement.querySelector('.pv-viewport') as HTMLDivElement; - await waitFor(() => expect(canvasElement.querySelector('canvas')).not.toBeNull()); - const canvas = canvasElement.querySelector('canvas') as ProbeCanvas; - await waitFor(() => expect(canvas.__pivotVisibleCardCount?.()).toBeGreaterThan(0), { timeout: 8000 }); - await userEvent.click(root.getByRole('button', { name: 'Grouped' })); - // Each group has 100 bottom-up cards, so its earliest layout entries start well - // outside the 440px viewport. Move away from the automatic bottom alignment. - await waitFor(() => expect(viewport.scrollTop).toBeGreaterThan(1200), { timeout: 8000 }); - viewport.scrollTop = 1200; - viewport.dispatchEvent(new Event('scroll')); - await waitFor(() => expect(viewport.scrollTop).toBe(1200)); - await waitFor(() => expect(canvas.__pivotVisibleCardCount?.()).toBeGreaterThan(0), { timeout: 8000 }); - }, -}; diff --git a/Source/PivotViewer/components/pivot/animation.ts b/Source/PivotViewer/components/pivot/animation.ts index d3bc5e2b..0c0c1359 100644 --- a/Source/PivotViewer/components/pivot/animation.ts +++ b/Source/PivotViewer/components/pivot/animation.ts @@ -86,18 +86,6 @@ export function startAnimationLoop( ) { const { mountedRef, appRef, animationFrameRef, isAnimatingRef, needsRenderRef, spritesRef, isViewTransitionRef, onTransitionComplete, syncVisibility } = refs; - // Expose the rendered Pixi sprite count to the Storybook viewport check. - const canvas = appRef.current?.canvas as (HTMLCanvasElement & { __pivotVisibleCardCount?: () => number }) | undefined; - if (canvas) canvas.__pivotVisibleCardCount = () => { - const root = appRef.current?.stage.children[1]; - if (!root) return 0; - return [...spritesRef.current.values()].filter(sprite => { - const x = sprite.currentX * root.scale.x + root.position.x; - const y = sprite.currentY * root.scale.y + root.position.y; - return sprite.container.visible && x + 180 > 0 && x < canvas.clientWidth && y + 120 > 0 && y < canvas.clientHeight; - }).length; - }; - const animate = () => { if (!mountedRef.current) return;