Compare commits

..

3 Commits

Author SHA1 Message Date
midzelis 4b1e7795e2 refactor(web): remove now-unused ViewerAsset proximity $derived
With day-tier boundaries handling viewport classification (previous two
commits), the per-asset #viewportProximity $derived and isInOrNearViewport
getter are no longer read by anyone. Remove them, along with the
calculateViewerAssetViewportProximity helper they used, and the back-pointer
to TimelineDay that only existed to feed the derive.

Net effect at scale: one $derived per ViewerAsset deleted. For a library
with N assets, that's N $derived instances and their dependency-tracking
metadata no longer allocated. The work that those derives did is now
performed lazily, O(log N) per day, by binary search in updateAssetBoundaries.

Change-Id: I77f5eee5a4a5ebb7968e7f87955dcd516a6a6964
2026-05-24 15:51:43 -04:00
midzelis 5b10ff0eff 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
2026-05-24 15:45:19 -04:00
midzelis 6eab14f6a4 refactor(web): add imperative day-tier viewport boundary computation
Adds firstInOrNearIndex / lastInOrNearIndex $state on TimelineDay and an
updateAssetBoundaries() method that locates them via binary search on asset
positions. Wired into both layout() (when positions change) and
updateViewportProximities() (when scroll moves the viewport).

This is purely additive — no consumer reads the new state yet. The existing
ViewerAsset.$derived-based proximity machinery and TimelineDay.isInOrNearViewport
.some() derive continue to work unchanged.

Subsequent commits will (1) switch consumers to use the day-tier boundaries
and (2) remove the now-redundant per-asset $derived.

Change-Id: Ib4bdaec5df4801d1347f41bbabd607956a6a6964
2026-05-24 15:41:22 -04:00
9 changed files with 551 additions and 538 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
FROM builder-cpu AS builder-cuda
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
+444 -491
View File
File diff suppressed because it is too large Load Diff
@@ -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}
@@ -54,16 +54,3 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
}
}
export function calculateViewerAssetViewportProximity(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
const headerHeight = timelineManager.headerHeight;
return calculateViewportProximity(
positionTop,
positionTop + positionHeight,
timelineManager.visibleWindow.top - headerHeight,
timelineManager.visibleWindow.bottom + headerHeight,
);
}
@@ -3,10 +3,15 @@ import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
export class TimelineDay {
readonly timelineMonth: TimelineMonth;
readonly index: number;
@@ -17,7 +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);
@@ -149,6 +159,73 @@ export class TimelineDay {
for (let i = 0; i < this.viewerAssets.length; i++) {
this.viewerAssets[i].position = geometry.getPosition(i);
}
this.updateAssetBoundaries();
}
// Imperatively (re)computes firstInOrNearIndex / lastInOrNearIndex via binary search on
// asset positions. Called from layout() (positions changed) and from
// updateViewportProximities() (viewport changed). Cost: O(log N) per day instead of the
// O(N) per-asset $derived recompute that the reactive equivalent would do.
updateAssetBoundaries() {
const manager = this.timelineMonth.timelineManager;
const visibleWindow = manager.visibleWindow;
if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) {
this.firstInOrNearIndex = -1;
this.lastInOrNearIndex = -1;
return;
}
// Match the asset-level proximity zone from calculateViewerAssetViewportProximity:
// window is expanded by headerHeight on both sides, then by INTERSECTION_EXPAND_*
// for the "near" band. Combined: the in-or-near zone reaches headerHeight + EXPAND
// beyond the visible window on each side.
const headerHeight = manager.headerHeight;
const dayOffset = this.absoluteTimelineDayTop;
const localExpandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset;
const localExpandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset;
// Lower bound: smallest i where asset[i].bottom >= localExpandedTop
// (asset's bottom edge is at or below the in-or-near top boundary).
let lo = 0;
let hi = this.viewerAssets.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
const position = this.viewerAssets[mid].position!;
if (position.top + position.height < localExpandedTop) {
lo = mid + 1;
} else {
hi = mid;
}
}
const firstIdx = lo;
if (firstIdx >= this.viewerAssets.length) {
// Entire day is above the in-or-near zone.
this.firstInOrNearIndex = -1;
this.lastInOrNearIndex = -1;
return;
}
// Upper bound: smallest i where asset[i].top >= localExpandedBottom
// (asset's top edge crosses past the in-or-near bottom boundary).
lo = firstIdx;
hi = this.viewerAssets.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (this.viewerAssets[mid].position!.top >= localExpandedBottom) {
hi = mid;
} else {
lo = mid + 1;
}
}
const lastIdx = lo - 1;
if (lastIdx < firstIdx) {
this.firstInOrNearIndex = -1;
this.lastInOrNearIndex = -1;
} else {
this.firstInOrNearIndex = firstIdx;
this.lastInOrNearIndex = lastIdx;
}
}
get absoluteTimelineDayTop() {
@@ -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);
@@ -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);
}
@@ -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;
}
}