From c1e4b565ab3960cd60212ab79a68d7e06b90c6de Mon Sep 17 00:00:00 2001 From: Min Idzelis Date: Sun, 24 May 2026 15:41:22 -0400 Subject: [PATCH] refactor(web): replace per-asset viewport proximity with day-tier active indices Binary search on asset positions replaces per-ViewerAsset $derived proximity tracking. Reactive churn during scroll reduces from O(N) per-asset deriveds to O(log N) per-day binary search. Change-Id: Ib4bdaec5df4801d1347f41bbabd607956a6a6964 --- .../components/timeline/AssetLayout.svelte | 21 +++++-- web/src/lib/components/timeline/Month.svelte | 2 + .../internal/intersection-support.svelte.ts | 14 ----- .../timeline-manager/timeline-day.svelte.ts | 59 +++++++++++++++---- .../timeline-manager.svelte.ts | 5 ++ .../timeline-manager/timeline-month.svelte.ts | 2 +- .../timeline-manager/viewer-asset.svelte.ts | 26 +------- 7 files changed, 73 insertions(+), 56 deletions(-) diff --git a/web/src/lib/components/timeline/AssetLayout.svelte b/web/src/lib/components/timeline/AssetLayout.svelte index 88f03ef46a..d67e47aea8 100644 --- a/web/src/lib/components/timeline/AssetLayout.svelte +++ b/web/src/lib/components/timeline/AssetLayout.svelte @@ -12,6 +12,8 @@ type Props = { viewerAssets: ViewerAsset[]; + firstActiveIndex: number; + lastActiveIndex: number; width: number; height: number; manager: VirtualScrollManager; @@ -26,18 +28,27 @@ customThumbnailLayout?: Snippet<[asset: TimelineAsset]>; }; - const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props(); + const { + viewerAssets, + firstActiveIndex, + lastActiveIndex, + width, + height, + manager, + thumbnail, + customThumbnailLayout, + }: Props = $props(); const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); - - const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport)); - const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport)); + const visibleViewerAssets = $derived( + firstActiveIndex === -1 ? [] : viewerAssets.slice(firstActiveIndex, lastActiveIndex + 1), + );
- {#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)} + {#each visibleViewerAssets as viewerAsset (viewerAsset.id)} {@const position = viewerAsset.position!} {@const asset = viewerAsset.asset!} diff --git a/web/src/lib/components/timeline/Month.svelte b/web/src/lib/components/timeline/Month.svelte index 3f4b9b0661..7d1b223bfc 100644 --- a/web/src/lib/components/timeline/Month.svelte +++ b/web/src/lib/components/timeline/Month.svelte @@ -101,6 +101,8 @@ number): number { + let lo = 0; + let hi = assets.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (key(assets[mid].position!) < target) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; +} + export class TimelineDay { readonly timelineMonth: TimelineMonth; readonly index: number; @@ -18,12 +37,16 @@ export class TimelineDay { height = $state(0); width = $state(0); + // Assets in or near the viewport; active assets should be added to the DOM. + firstActiveIndex = $state(-1); + lastActiveIndex = $state(-1); + isInOrNearViewport = $derived(this.firstActiveIndex !== -1); + #top: number = $state(0); #start: number = $state(0); #row = $state(0); #col = $state(0); #deferredLayout = false; - #lastInOrNearViewport = -1; constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) { this.index = index; @@ -149,18 +172,32 @@ export class TimelineDay { for (let i = 0; i < this.viewerAssets.length; i++) { this.viewerAssets[i].position = geometry.getPosition(i); } + this.updateAssetBoundaries(); + } + + updateAssetBoundaries() { + const manager = this.timelineMonth.timelineManager; + const visibleWindow = manager.visibleWindow; + if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) { + this.firstActiveIndex = -1; + this.lastActiveIndex = -1; + return; + } + + const dayOffset = this.absoluteTimelineDayTop; + const headerHeight = manager.headerHeight; + const expandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset; + const expandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset; + + const first = lowerBound(this.viewerAssets, expandedTop, (p) => p.top + p.height); + const last = lowerBound(this.viewerAssets, expandedBottom, (p) => p.top) - 1; + + const hasActive = last >= first && first < this.viewerAssets.length; + this.firstActiveIndex = hasActive ? first : -1; + this.lastActiveIndex = hasActive ? last : -1; } get absoluteTimelineDayTop() { return this.timelineMonth.top + this.#top; } - - get isInOrNearViewport() { - if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) { - return true; - } - - this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport); - return this.#lastInOrNearViewport !== -1; - } } diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 32e6d90d1c..5ac2deaa54 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -214,6 +214,11 @@ export class TimelineManager extends VirtualScrollManager { for (const month of this.months) { updateTimelineMonthViewportProximity(this, month); + if (month.isInOrNearViewport && month.isLoaded) { + for (const day of month.timelineDays) { + day.updateAssetBoundaries(); + } + } } const month = this.months.find((month) => month.isInViewport); diff --git a/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts index bee53151f7..d45c6203cd 100644 --- a/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-month.svelte.ts @@ -254,7 +254,7 @@ export class TimelineMonth { addContext.newTimelineDays.add(timelineDay); } - const viewerAsset = new ViewerAsset(timelineDay, timelineAsset); + const viewerAsset = new ViewerAsset(timelineAsset); timelineDay.viewerAssets.push(viewerAsset); addContext.changedTimelineDays.add(timelineDay); } diff --git a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts index 0179dd9e74..3a4ed4545c 100644 --- a/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts +++ b/web/src/lib/managers/timeline-manager/viewer-asset.svelte.ts @@ -1,36 +1,12 @@ import type { CommonPosition } from '$lib/utils/layout-utils'; -import { - ViewportProximity, - calculateViewerAssetViewportProximity, - isInOrNearViewport, -} from './internal/intersection-support.svelte'; -import type { TimelineDay } from './timeline-day.svelte'; import type { TimelineAsset } from './types'; export class ViewerAsset { - readonly #group: TimelineDay; - - #viewportProximity = $derived.by(() => { - if (!this.position) { - return ViewportProximity.FarFromViewport; - } - - const store = this.#group.timelineMonth.timelineManager; - const positionTop = this.#group.absoluteTimelineDayTop + this.position.top; - - return calculateViewerAssetViewportProximity(store, positionTop, this.position.height); - }); - - get isInOrNearViewport() { - return isInOrNearViewport(this.#viewportProximity); - } - position: CommonPosition | undefined = $state.raw(); asset: TimelineAsset = $state() as TimelineAsset; id: string = $derived(this.asset.id); - constructor(group: TimelineDay, asset: TimelineAsset) { - this.#group = group; + constructor(asset: TimelineAsset) { this.asset = asset; } }