Skip to content
18 changes: 14 additions & 4 deletions Source/PivotViewer/components/PivotCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -152,6 +153,8 @@ export function PivotCanvas<TItem extends object>({
const lastViewChangeTimeRef = useRef(0);
const previousViewModeRef = useRef<ViewMode>(viewMode);
const prevLayoutRef = useRef<LayoutResult | null>(null);
const transitionLayoutRef = useRef<LayoutResult | null>(null);
const transitionSeenIdsRef = useRef<Set<ItemId>>(new Set());
const prevGroupingRef = useRef<GroupingResult | null>(null);
const prevScrollTopRef = useRef<number>(0);
const prevScrollLeftRef = useRef<number>(0);
Expand Down Expand Up @@ -500,6 +503,8 @@ export function PivotCanvas<TItem extends object>({
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();
previousViewModeRef.current = viewMode;
Expand Down Expand Up @@ -540,7 +545,7 @@ export function PivotCanvas<TItem extends object>({
const currentScrollTop = parentContainerRef.current?.scrollTop || 0;
const currentScrollLeft = parentContainerRef.current?.scrollLeft || 0;

syncSpritesToViewport({
const syncParams = {
root: rootRef.current,
groupsContainer: groupsContainerRef.current,
container: parentContainerRef.current,
Expand Down Expand Up @@ -585,9 +590,11 @@ export function PivotCanvas<TItem extends object>({
),
isViewTransition: isViewTransitionRef.current,
prevLayout: prevLayoutRef.current,
transitionSeenIds: transitionSeenIdsRef.current,
prevScrollTop: prevScrollTopRef.current,
prevScrollLeft: prevScrollLeftRef.current,
});
};
syncSpritesToViewport(syncParams);

// Update previous scroll position for next frame
prevScrollTopRef.current = currentScrollTop;
Expand All @@ -608,6 +615,8 @@ export function PivotCanvas<TItem extends object>({
needsRenderRef,
spritesRef,
isViewTransitionRef,
syncVisibility: () => syncSpritesToViewport({ ...syncParams, isViewTransition: false, sweepImmediately: true }),
onTransitionComplete: () => transitionSeenIdsRef.current.clear(),
});
}, [
layout,
Expand Down Expand Up @@ -713,7 +722,7 @@ export function PivotCanvas<TItem extends object>({
// 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,
Expand Down Expand Up @@ -754,9 +763,10 @@ export function PivotCanvas<TItem extends object>({
),
isViewTransition: isViewTransitionRef.current,
viewMode,
transitionSeenIds: transitionSeenIdsRef.current,
prevScrollTop: prevScrollTopRef.current,
prevScrollLeft: prevScrollLeftRef.current,
});
}, transitionLayoutRef.current);

// Update previous scroll position for next frame
prevScrollTopRef.current = container.scrollTop || 0;
Expand Down
9 changes: 8 additions & 1 deletion Source/PivotViewer/components/pivot/animation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,22 @@ export function startAnimationLoop(
needsRenderRef: { current: boolean };
spritesRef: { current: Map<unknown, CardSprite> };
isViewTransitionRef: { current: boolean };
onTransitionComplete?: () => void;
syncVisibility?: () => void;
},
animationSpeed = 0.15,
) {
const { mountedRef, appRef, animationFrameRef, isAnimatingRef, needsRenderRef, spritesRef, isViewTransitionRef } = 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) {
syncVisibility?.();
onTransitionComplete?.();
}

if (stillAnimating || needsRenderRef.current) {
appRef.current?.renderer.render(appRef.current.stage);
Expand Down
3 changes: 3 additions & 0 deletions Source/PivotViewer/components/pivot/sprites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export function createCardSprite<TItem extends object>(
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;
Expand Down
10 changes: 10 additions & 0 deletions Source/PivotViewer/components/pivot/syncScrollSprites.ts
Original file line number Diff line number Diff line change
@@ -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<TItem>(params: Omit<SyncParams<TItem>, 'prevLayout'>, previousLayout: LayoutResult | null) {
syncSpritesToViewport({ ...params, prevLayout: previousLayout });
}
122 changes: 29 additions & 93 deletions Source/PivotViewer/components/pivot/visibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ export interface SyncParams<TItem> {
isViewTransition?: boolean;
viewMode: string;
prevLayout?: LayoutResult | null;
transitionSeenIds: Set<string | number>;
sweepImmediately?: boolean;
prevScrollTop?: number;
prevScrollLeft?: number;
}

export function syncSpritesToViewport<TItem>(params: SyncParams<TItem>) {
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, transitionSeenIds } = params;
if (!root || !container) return;

void _visibleIds;
Expand Down Expand Up @@ -136,43 +138,31 @@ export function syncSpritesToViewport<TItem>(params: SyncParams<TItem>) {
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) && !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);
}
}

Expand Down Expand Up @@ -200,66 +190,11 @@ export function syncSpritesToViewport<TItem>(params: SyncParams<TItem>) {
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; }
Expand All @@ -270,7 +205,7 @@ export function syncSpritesToViewport<TItem>(params: SyncParams<TItem>) {
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;
}
Expand All @@ -290,7 +225,7 @@ export function syncSpritesToViewport<TItem>(params: SyncParams<TItem>) {
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);
Expand Down Expand Up @@ -335,7 +270,7 @@ export function syncSpritesToViewport<TItem>(params: SyncParams<TItem>) {
// 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;

Expand All @@ -355,6 +290,7 @@ export function syncSpritesToViewport<TItem>(params: SyncParams<TItem>) {

sprite = createCardSprite(id, startX, startY);
sprites.set(id, sprite);
if (isViewTransition) transitionSeenIds.add(id);
if (sprite.container) {
root.addChild(sprite.container);
sprite.currentX = startX;
Expand Down
55 changes: 55 additions & 0 deletions Source/PivotViewer/for_syncSpritesToViewport/given/a_transition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// 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<number | string, CardSprite>();
transitionSeenIds = new Set<number | string>();
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,
transitionSeenIds: this.transitionSeenIds,
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: () => {},
};
}
}
Loading
Loading