refactor(web): switch consumers to use day-tier viewport boundaries

TimelineDay.isInOrNearViewport now derives from the firstInOrNearIndex
$state added in the previous commit (true iff first index != -1). This
replaces the old $derived.by that read every asset's isInOrNearViewport
via viewerAssets.some(), removing a per-asset subscription point that
filter() in AssetLayout had been creating for every render.

AssetLayout switches from filterIsInOrNearViewport(viewerAssets) to
viewerAssets.slice(firstInOrNearIndex, lastInOrNearIndex + 1). The slice
expression depends only on the two boundary $state values, not on any
asset's proximity $derived. Reactive churn during scroll collapses to:
boundary indices change → slice recomputes → {#each} reconciles.

Month.svelte passes the new boundary props through. filterIsInOrNearViewport
is still used at the month tier (to filter days) and stays in utils.

Change-Id: If4e30192146f3e987307b1efd7c6d41d6a6a6964
This commit is contained in:
midzelis
2026-05-24 15:45:19 -04:00
parent 6eab14f6a4
commit 5b10ff0eff
3 changed files with 19 additions and 4 deletions
@@ -1,6 +1,5 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { filterIsInOrNearViewport } from '$lib/managers/timeline-manager/utils.svelte';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
@@ -13,6 +12,8 @@
type Props = {
viewerAssets: ViewerAsset[];
firstInOrNearIndex: number;
lastInOrNearIndex: number;
width: number;
height: number;
manager: VirtualScrollManager;
@@ -27,15 +28,27 @@
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
const {
viewerAssets,
firstInOrNearIndex,
lastInOrNearIndex,
width,
height,
manager,
thumbnail,
customThumbnailLayout,
}: Props = $props();
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const visibleViewerAssets = $derived(
firstInOrNearIndex === -1 ? [] : viewerAssets.slice(firstInOrNearIndex, lastInOrNearIndex + 1),
);
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIsInOrNearViewport(viewerAssets) as viewerAsset (viewerAsset.id)}
{#each visibleViewerAssets as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
@@ -101,6 +101,8 @@
<AssetLayout
{manager}
viewerAssets={timelineDay.viewerAssets}
firstInOrNearIndex={timelineDay.firstInOrNearIndex}
lastInOrNearIndex={timelineDay.lastInOrNearIndex}
height={timelineDay.height}
width={timelineDay.width}
{customThumbnailLayout}
@@ -22,12 +22,12 @@ export class TimelineDay {
height = $state(0);
width = $state(0);
isInOrNearViewport = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.isInOrNearViewport));
// Indices into viewerAssets bounding the in-or-near range. -1/-1 means no assets are in-or-near.
// Updated imperatively by updateAssetBoundaries() from updateViewportProximities() and layout().
firstInOrNearIndex = $state(-1);
lastInOrNearIndex = $state(-1);
isInOrNearViewport = $derived(this.firstInOrNearIndex !== -1);
#top: number = $state(0);
#start: number = $state(0);