fix(web): skip thumbhash fade for offscreen-loaded thumbnails

Thumbnails that finish loading while offscreen (in the 500px pre-load
zone) were still playing the thumbhash fade-out transition, causing
visual flicker when scrolling. This adds an `actuallyIntersecting`
property (zero-margin viewport check) alongside the existing
`intersecting` (500px expanded margins) to distinguish between
pre-loaded and truly visible assets.

- Refactor `calculateViewerAssetIntersecting` to return a numeric flag
  (NONE/PRE/ACTUAL) avoiding object allocation in the hot derived path
- Inline `calculateMonthGroupIntersecting` into
  `updateIntersectionMonthGroup` to avoid intermediate object allocation
- Thread `actuallyIntersecting` through AssetLayout → Month → Timeline
  → Thumbnail snippet chain
- Use `actuallyIntersecting` in Thumbnail to skip the fade when loaded
  offscreen
- Add unit tests for `isIntersecting` and
  `calculateViewerAssetIntersecting`
This commit is contained in:
midzelis
2026-03-10 16:04:00 +00:00
parent 00dae6ac38
commit be3cdd3fa4
8 changed files with 204 additions and 52 deletions

View File

@@ -6,31 +6,8 @@ const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
const actuallyIntersecting = calculateMonthGroupIntersecting(timelineManager, month, 0, 0);
let preIntersecting = false;
if (!actuallyIntersecting) {
preIntersecting = calculateMonthGroupIntersecting(
timelineManager,
month,
INTERSECTION_EXPAND_TOP,
INTERSECTION_EXPAND_BOTTOM,
);
}
month.intersecting = actuallyIntersecting || preIntersecting;
month.actuallyIntersecting = actuallyIntersecting;
if (preIntersecting || actuallyIntersecting) {
timelineManager.clearDeferredLayout(month);
}
}
/**
* General function to check if a rectangular region intersects with a window.
* @param regionTop - Top position of the region to check
* @param regionBottom - Bottom position of the region to check
* @param windowTop - Top position of the window
* @param windowBottom - Bottom position of the window
* @returns true if the region intersects with the window
*/
export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) {
return (
@@ -40,34 +17,65 @@ export function isIntersecting(regionTop: number, regionBottom: number, windowTo
);
}
export function calculateMonthGroupIntersecting(
timelineManager: TimelineManager,
monthGroup: MonthGroup,
expandTop: number,
expandBottom: number,
) {
const monthGroupTop = monthGroup.top;
const monthGroupBottom = monthGroupTop + monthGroup.height;
const topWindow = timelineManager.visibleWindow.top - expandTop;
const bottomWindow = timelineManager.visibleWindow.bottom + expandBottom;
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
const monthGroupTop = month.top;
const monthGroupBottom = monthGroupTop + month.height;
const windowTop = timelineManager.visibleWindow.top;
const windowBottom = timelineManager.visibleWindow.bottom;
return isIntersecting(monthGroupTop, monthGroupBottom, topWindow, bottomWindow);
const actuallyIntersecting = isIntersecting(monthGroupTop, monthGroupBottom, windowTop, windowBottom);
let intersecting = actuallyIntersecting;
if (!actuallyIntersecting) {
intersecting = isIntersecting(
monthGroupTop,
monthGroupBottom,
windowTop - INTERSECTION_EXPAND_TOP,
windowBottom + INTERSECTION_EXPAND_BOTTOM,
);
}
month.intersecting = intersecting;
month.actuallyIntersecting = actuallyIntersecting;
if (intersecting) {
timelineManager.clearDeferredLayout(month);
}
}
// Bit flags for intersection state
export const Intersection = {
NONE: 0,
PRE: 1,
ACTUAL: 3, // includes PRE (both bits set)
} as const;
/**
* Calculate intersection for viewer assets with additional parameters like header height
* Returns a numeric flag: NONE (0), PRE (1, within expanded margin only), or ACTUAL (3, truly visible).
*/
export function calculateViewerAssetIntersecting(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
expandTop: number = INTERSECTION_EXPAND_TOP,
expandBottom: number = INTERSECTION_EXPAND_BOTTOM,
) {
const topWindow = timelineManager.visibleWindow.top - timelineManager.headerHeight - expandTop;
const bottomWindow = timelineManager.visibleWindow.bottom + timelineManager.headerHeight + expandBottom;
const positionBottom = positionTop + positionHeight;
const headerHeight = timelineManager.headerHeight;
const windowTop = timelineManager.visibleWindow.top - headerHeight;
const windowBottom = timelineManager.visibleWindow.bottom + headerHeight;
return isIntersecting(positionTop, positionBottom, topWindow, bottomWindow);
if (isIntersecting(positionTop, positionBottom, windowTop, windowBottom)) {
return Intersection.ACTUAL;
}
if (
isIntersecting(
positionTop,
positionBottom,
windowTop - INTERSECTION_EXPAND_TOP,
windowBottom + INTERSECTION_EXPAND_BOTTOM,
)
) {
return Intersection.PRE;
}
return Intersection.NONE;
}