refactor(web): rename intersecting/actuallyIntersecting to nearby/intersecting

Change-Id: Id6f63247441fe290e7732e489f73e8c16a6a6964
This commit is contained in:
midzelis
2026-03-14 14:08:41 +00:00
parent 1e0459dc2d
commit 3563d2c51c
12 changed files with 170 additions and 113 deletions

View File

@@ -34,7 +34,7 @@
thumbnailSize?: number; thumbnailSize?: number;
thumbnailWidth?: number; thumbnailWidth?: number;
thumbnailHeight?: number; thumbnailHeight?: number;
actuallyIntersecting?: boolean; intersecting?: boolean;
selected?: boolean; selected?: boolean;
selectionCandidate?: boolean; selectionCandidate?: boolean;
disabled?: boolean; disabled?: boolean;
@@ -57,7 +57,7 @@
thumbnailSize = undefined, thumbnailSize = undefined,
thumbnailWidth = undefined, thumbnailWidth = undefined,
thumbnailHeight = undefined, thumbnailHeight = undefined,
actuallyIntersecting = true, intersecting = true,
selected = false, selected = false,
selectionCandidate = false, selectionCandidate = false,
disabled = false, disabled = false,
@@ -89,7 +89,7 @@
$effect(() => { $effect(() => {
if (loaded && !loadedEffectRan) { if (loaded && !loadedEffectRan) {
loadedEffectRan = true; loadedEffectRan = true;
if (!actuallyIntersecting) { if (!intersecting) {
skipFade = true; skipFade = true;
} }
} }

View File

@@ -14,7 +14,6 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { TUNABLES } from '$lib/utils/tunables';
import { deleteAssets } from '$lib/utils/actions'; import { deleteAssets } from '$lib/utils/actions';
import { import {
archiveAssets, archiveAssets,
@@ -28,6 +27,7 @@
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils'; import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util'; import { isTimelineAsset, toTimelineAsset } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk'; import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui'; import { modalManager } from '@immich/ui';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
@@ -82,14 +82,14 @@
return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`; return `top: ${geo.getTop(i)}px; left: ${geo.getLeft(i)}px; width: ${geo.getWidth(i)}px; height: ${geo.getHeight(i)}px;`;
}; };
const isIntersecting = (i: number) => { const isRenderable = (i: number) => {
const geo = geometry; const geo = geometry;
const window = slidingWindow; const window = slidingWindow;
const top = geo.getTop(i); const top = geo.getTop(i);
return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top; return top + pageHeaderOffset < window.bottom && top + geo.getHeight(i) > window.top;
}; };
const isActuallyIntersecting = (i: number) => { const isIntersecting = (i: number) => {
const geo = geometry; const geo = geometry;
const top = geo.getTop(i) + pageHeaderOffset; const top = geo.getTop(i) + pageHeaderOffset;
const bottom = top + geo.getHeight(i); const bottom = top + geo.getHeight(i);
@@ -387,7 +387,7 @@
style:width={geometry.containerWidth + 'px'} style:width={geometry.containerWidth + 'px'}
> >
{#each assets as asset, i (asset.id + '-' + i)} {#each assets as asset, i (asset.id + '-' + i)}
{#if isIntersecting(i)} {#if isRenderable(i)}
{@const currentAsset = toTimelineAsset(asset)} {@const currentAsset = toTimelineAsset(asset)}
<div class="absolute" style:overflow="clip" style={getStyle(i)}> <div class="absolute" style:overflow="clip" style={getStyle(i)}>
<Thumbnail <Thumbnail
@@ -405,7 +405,7 @@
asset={currentAsset} asset={currentAsset}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)} selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
actuallyIntersecting={isActuallyIntersecting(i)} intersecting={isIntersecting(i)}
thumbnailWidth={geometry.getWidth(i)} thumbnailWidth={geometry.getWidth(i)}
thumbnailHeight={geometry.getHeight(i)} thumbnailHeight={geometry.getHeight(i)}
/> />

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { filterRenderable } from '$lib/managers/timeline-manager/utils.svelte';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte'; import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
@@ -20,7 +21,7 @@
{ {
asset: TimelineAsset; asset: TimelineAsset;
position: CommonPosition; position: CommonPosition;
actuallyIntersecting: boolean; intersecting: boolean;
}, },
] ]
>; >;
@@ -31,18 +32,14 @@
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150); const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
</script> </script>
<!-- Image grid --> <!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}> <div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)} {#each filterRenderable(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!} {@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!} {@const asset = viewerAsset.asset!}
{@const actuallyIntersecting = viewerAsset.actuallyIntersecting!} {@const intersecting = viewerAsset.intersecting!}
<!-- note: don't remove data-asset-id - its used by web e2e tests --> <!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div <div
@@ -55,7 +52,7 @@
out:scale|global={{ start: 0.1, duration: scaleDuration }} out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }} animate:flip={{ duration: transitionDuration }}
> >
{@render thumbnail({ asset, position, actuallyIntersecting })} {@render thumbnail({ asset, position, intersecting })}
{@render customThumbnailLayout?.(asset)} {@render customThumbnailLayout?.(asset)}
</div> </div>
{/each} {/each}

View File

@@ -3,7 +3,7 @@
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte'; import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte'; import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte'; import { assetsSnapshot, filterRenderable } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte'; import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload'; import { uploadAssetsStore } from '$lib/stores/upload';
@@ -21,7 +21,7 @@
position: CommonPosition; position: CommonPosition;
dayGroup: DayGroup; dayGroup: DayGroup;
groupIndex: number; groupIndex: number;
actuallyIntersecting: boolean; intersecting: boolean;
}, },
] ]
>; >;
@@ -47,10 +47,6 @@
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150); const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
const getDayGroupFullDate = (dayGroup: DayGroup): string => { const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth; const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({ const date = fromTimelinePlainDate({
@@ -62,7 +58,7 @@
}; };
</script> </script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)} {#each filterRenderable(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)} {@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<section <section
@@ -109,8 +105,8 @@
width={dayGroup.width} width={dayGroup.width}
{customThumbnailLayout} {customThumbnailLayout}
> >
{#snippet thumbnail({ asset, position, actuallyIntersecting })} {#snippet thumbnail({ asset, position, intersecting })}
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex, actuallyIntersecting })} {@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex, intersecting })}
{/snippet} {/snippet}
</AssetLayout> </AssetLayout>
</section> </section>

View File

@@ -645,7 +645,7 @@
</section> </section>
{#each timelineManager.months as monthGroup (monthGroup.viewId)} {#each timelineManager.months as monthGroup (monthGroup.viewId)}
{@const display = monthGroup.intersecting} {@const renderable = monthGroup.renderable}
{@const absoluteHeight = monthGroup.top} {@const absoluteHeight = monthGroup.top}
{#if !monthGroup.isLoaded} {#if !monthGroup.isLoaded}
@@ -657,7 +657,7 @@
> >
<Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} /> <Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} />
</div> </div>
{:else if display} {:else if renderable}
<div <div
class="month-group" class="month-group"
style:height={monthGroup.height + 'px'} style:height={monthGroup.height + 'px'}
@@ -673,7 +673,7 @@
manager={timelineManager} manager={timelineManager}
onDayGroupSelect={handleGroupSelect} onDayGroupSelect={handleGroupSelect}
> >
{#snippet thumbnail({ asset, position, dayGroup, groupIndex, actuallyIntersecting })} {#snippet thumbnail({ asset, position, dayGroup, groupIndex, intersecting })}
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)} {@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
{@const isAssetSelected = {@const isAssetSelected =
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)} assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
@@ -684,7 +684,7 @@
{asset} {asset}
{albumUsers} {albumUsers}
{groupIndex} {groupIndex}
{actuallyIntersecting} {intersecting}
onClick={(asset) => { onClick={(asset) => {
if (typeof onThumbnailClick === 'function') { if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick); onThumbnailClick(asset, timelineManager, dayGroup, _onClick);

View File

@@ -18,7 +18,7 @@ export class DayGroup {
height = $state(0); height = $state(0);
width = $state(0); width = $state(0);
intersecting = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.intersecting)); renderable = $derived.by(() => this.viewerAssets.some((viewAsset) => viewAsset.renderable));
#top: number = $state(0); #top: number = $state(0);
#start: number = $state(0); #start: number = $state(0);
@@ -137,7 +137,7 @@ export class DayGroup {
} }
layout(options: CommonLayoutOptions, noDefer: boolean) { layout(options: CommonLayoutOptions, noDefer: boolean) {
if (!noDefer && !this.monthGroup.intersecting && !this.monthGroup.timelineManager.isScrollingOnLoad) { if (!noDefer && !this.monthGroup.renderable && !this.monthGroup.timelineManager.isScrollingOnLoad) {
this.#deferredLayout = true; this.#deferredLayout = true;
return; return;
} }

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { TimelineManager } from '../timeline-manager.svelte'; import type { TimelineManager } from '../timeline-manager.svelte';
import { Intersection, calculateViewerAssetIntersecting, isIntersecting } from './intersection-support.svelte'; import {
IntersectionFlags,
calculateViewerAssetIntersecting,
isIntersecting,
isRenderable,
isVisible,
} from './intersection-support.svelte';
function createMockTimelineManager(windowTop: number, windowBottom: number, headerHeight: number = 0): TimelineManager { function createMockTimelineManager(windowTop: number, windowBottom: number, headerHeight: number = 0): TimelineManager {
return { return {
@@ -51,45 +57,87 @@ describe('calculateViewerAssetIntersecting', () => {
// viewport 0-1000, no header, default expand margins 500/500 // viewport 0-1000, no header, default expand margins 500/500
const manager = createMockTimelineManager(0, 1000); const manager = createMockTimelineManager(0, 1000);
it('should return ACTUAL when asset is within viewport', () => { it('should return VISIBLE when asset is within viewport', () => {
expect(calculateViewerAssetIntersecting(manager, 100, 50)).toBe(Intersection.ACTUAL); expect(calculateViewerAssetIntersecting(manager, 100, 50)).toBe(IntersectionFlags.VISIBLE);
}); });
it('should return ACTUAL when asset is at viewport top edge', () => { it('should return VISIBLE when asset is at viewport top edge', () => {
expect(calculateViewerAssetIntersecting(manager, 0, 50)).toBe(Intersection.ACTUAL); expect(calculateViewerAssetIntersecting(manager, 0, 50)).toBe(IntersectionFlags.VISIBLE);
}); });
it('should return ACTUAL when asset partially overlaps viewport bottom', () => { it('should return VISIBLE when asset partially overlaps viewport bottom', () => {
expect(calculateViewerAssetIntersecting(manager, 980, 50)).toBe(Intersection.ACTUAL); expect(calculateViewerAssetIntersecting(manager, 980, 50)).toBe(IntersectionFlags.VISIBLE);
}); });
it('should return PRE when asset is just above viewport within expand margin', () => { it('should return NEARBY when asset is just above viewport within expand margin', () => {
expect(calculateViewerAssetIntersecting(manager, -200, 50)).toBe(Intersection.PRE); expect(calculateViewerAssetIntersecting(manager, -200, 50)).toBe(IntersectionFlags.NEARBY);
}); });
it('should return PRE when asset is just below viewport within expand margin', () => { it('should return NEARBY when asset is just below viewport within expand margin', () => {
expect(calculateViewerAssetIntersecting(manager, 1200, 50)).toBe(Intersection.PRE); expect(calculateViewerAssetIntersecting(manager, 1200, 50)).toBe(IntersectionFlags.NEARBY);
}); });
it('should return NONE when asset is far above viewport', () => { it('should return NONE when asset is far above viewport', () => {
expect(calculateViewerAssetIntersecting(manager, -1000, 50)).toBe(Intersection.NONE); expect(calculateViewerAssetIntersecting(manager, -1000, 50)).toBe(IntersectionFlags.NONE);
}); });
it('should return NONE when asset is far below viewport', () => { it('should return NONE when asset is far below viewport', () => {
expect(calculateViewerAssetIntersecting(manager, 2000, 50)).toBe(Intersection.NONE); expect(calculateViewerAssetIntersecting(manager, 2000, 50)).toBe(IntersectionFlags.NONE);
}); });
it('should account for header height in viewport bounds', () => { it('should account for header height in viewport bounds', () => {
const managerWithHeader = createMockTimelineManager(100, 500, 50); const managerWithHeader = createMockTimelineManager(100, 500, 50);
// viewport effectively becomes (100-50)=50 to (500+50)=550 // viewport effectively becomes (100-50)=50 to (500+50)=550
// asset at 40-90 overlaps the effective viewport // asset at 40-90 overlaps the effective viewport
expect(calculateViewerAssetIntersecting(managerWithHeader, 40, 50)).toBe(Intersection.ACTUAL); expect(calculateViewerAssetIntersecting(managerWithHeader, 40, 50)).toBe(IntersectionFlags.VISIBLE);
}); });
it('should return PRE not ACTUAL for asset outside viewport but within header-adjusted expand', () => { it('should return NEARBY not VISIBLE for asset outside viewport but within header-adjusted expand', () => {
const managerWithHeader = createMockTimelineManager(100, 500, 50); const managerWithHeader = createMockTimelineManager(100, 500, 50);
// effective viewport: 50-550, expand: 500 each way -> -450 to 1050 // effective viewport: 50-550, expand: 500 each way -> -450 to 1050
// asset at -400 to -350 is outside viewport but within expand // asset at -400 to -350 is outside viewport but within expand
expect(calculateViewerAssetIntersecting(managerWithHeader, -400, 50)).toBe(Intersection.PRE); expect(calculateViewerAssetIntersecting(managerWithHeader, -400, 50)).toBe(IntersectionFlags.NEARBY);
});
});
describe('Intersection flags', () => {
it('RENDERABLE should be NEARBY | VISIBLE', () => {
expect(IntersectionFlags.RENDERABLE).toBe(IntersectionFlags.NEARBY | IntersectionFlags.VISIBLE);
});
});
describe('isVisible', () => {
it('should return true for VISIBLE', () => {
expect(isVisible(IntersectionFlags.VISIBLE)).toBe(true);
});
it('should return true for RENDERABLE', () => {
expect(isVisible(IntersectionFlags.RENDERABLE)).toBe(true);
});
it('should return false for NEARBY', () => {
expect(isVisible(IntersectionFlags.NEARBY)).toBe(false);
});
it('should return false for NONE', () => {
expect(isVisible(IntersectionFlags.NONE)).toBe(false);
});
});
describe('isRenderable', () => {
it('should return true for VISIBLE', () => {
expect(isRenderable(IntersectionFlags.VISIBLE)).toBe(true);
});
it('should return true for NEARBY', () => {
expect(isRenderable(IntersectionFlags.NEARBY)).toBe(true);
});
it('should return true for RENDERABLE', () => {
expect(isRenderable(IntersectionFlags.RENDERABLE)).toBe(true);
});
it('should return false for NONE', () => {
expect(isRenderable(IntersectionFlags.NONE)).toBe(false);
}); });
}); });

View File

@@ -1,3 +1,4 @@
/* eslint-disable unicorn/prefer-math-trunc */
import { TUNABLES } from '$lib/utils/tunables'; import { TUNABLES } from '$lib/utils/tunables';
import type { MonthGroup } from '../month-group.svelte'; import type { MonthGroup } from '../month-group.svelte';
import { TimelineManager } from '../timeline-manager.svelte'; import { TimelineManager } from '../timeline-manager.svelte';
@@ -7,75 +8,72 @@ const {
} = TUNABLES; } = TUNABLES;
/** /**
* General function to check if a rectangular region intersects with a window. * General function to check if a rectangular region intersects with another regtangular region.
*/ */
export function isIntersecting(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) { export function isIntersecting(regionTop: number, regionBottom: number, otherTop: number, otherBottom: number) {
return ( return (
(regionTop >= windowTop && regionTop < windowBottom) || (regionTop >= otherTop && regionTop < otherBottom) ||
(regionBottom >= windowTop && regionBottom < windowBottom) || (regionBottom >= otherTop && regionBottom < otherBottom) ||
(regionTop < windowTop && regionBottom >= windowBottom) (regionTop < otherTop && regionBottom >= otherBottom)
); );
} }
const NEARBY = 1 << 0;
const VISIBLE = 1 << 1;
export const IntersectionFlags = {
NONE: 0,
NEARBY,
VISIBLE,
RENDERABLE: NEARBY | VISIBLE,
} as const;
export type IntersectionFlag = (typeof IntersectionFlags)[keyof typeof IntersectionFlags];
export function isVisible(flag: number): boolean {
return (flag & IntersectionFlags.VISIBLE) !== 0;
}
export function isRenderable(flag: number): boolean {
return (flag & IntersectionFlags.RENDERABLE) !== 0;
}
function calculateIntersection(regionTop: number, regionBottom: number, windowTop: number, windowBottom: number) {
if (regionBottom < windowTop - INTERSECTION_EXPAND_TOP || regionTop >= windowBottom + INTERSECTION_EXPAND_BOTTOM) {
return IntersectionFlags.NONE;
}
if (regionBottom < windowTop || regionTop >= windowBottom) {
return IntersectionFlags.NEARBY;
}
return IntersectionFlags.VISIBLE;
}
export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) { export function updateIntersectionMonthGroup(timelineManager: TimelineManager, month: MonthGroup) {
const monthGroupTop = month.top; const intersection = calculateIntersection(
const monthGroupBottom = monthGroupTop + month.height; month.top,
const windowTop = timelineManager.visibleWindow.top; month.top + month.height,
const windowBottom = timelineManager.visibleWindow.bottom; timelineManager.visibleWindow.top,
timelineManager.visibleWindow.bottom,
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.intersection = intersection;
month.actuallyIntersecting = actuallyIntersecting; if (isRenderable(intersection)) {
if (intersecting) {
timelineManager.clearDeferredLayout(month); timelineManager.clearDeferredLayout(month);
} }
} }
// Bit flags for intersection state
export const Intersection = {
NONE: 0,
PRE: 1,
ACTUAL: 3, // includes PRE (both bits set)
} as const;
/**
* Returns a numeric flag: NONE (0), PRE (1, within expanded margin only), or ACTUAL (3, truly visible).
*/
export function calculateViewerAssetIntersecting( export function calculateViewerAssetIntersecting(
timelineManager: TimelineManager, timelineManager: TimelineManager,
positionTop: number, positionTop: number,
positionHeight: number, positionHeight: number,
) { ) {
const positionBottom = positionTop + positionHeight;
const headerHeight = timelineManager.headerHeight; const headerHeight = timelineManager.headerHeight;
const windowTop = timelineManager.visibleWindow.top - headerHeight; return calculateIntersection(
const windowBottom = timelineManager.visibleWindow.bottom + headerHeight;
if (isIntersecting(positionTop, positionBottom, windowTop, windowBottom)) {
return Intersection.ACTUAL;
}
if (
isIntersecting(
positionTop, positionTop,
positionBottom, positionTop + positionHeight,
windowTop - INTERSECTION_EXPAND_TOP, timelineManager.visibleWindow.top - headerHeight,
windowBottom + INTERSECTION_EXPAND_BOTTOM, timelineManager.visibleWindow.bottom + headerHeight,
) );
) {
return Intersection.PRE;
}
return Intersection.NONE;
} }

View File

@@ -17,6 +17,12 @@ import {
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import {
IntersectionFlags,
isRenderable,
isVisible,
type IntersectionFlag,
} from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte'; import { DayGroup } from './day-group.svelte';
import { GroupInsertionCache } from './group-insertion-cache.svelte'; import { GroupInsertionCache } from './group-insertion-cache.svelte';
@@ -25,8 +31,7 @@ import type { AssetDescriptor, Direction, MoveAsset, TimelineAsset } from './typ
import { ViewerAsset } from './viewer-asset.svelte'; import { ViewerAsset } from './viewer-asset.svelte';
export class MonthGroup { export class MonthGroup {
#intersecting: boolean = $state(false); #intersection: IntersectionFlag = $state(IntersectionFlags.NONE);
actuallyIntersecting: boolean = $state(false);
isLoaded: boolean = $state(false); isLoaded: boolean = $state(false);
dayGroups: DayGroup[] = $state([]); dayGroups: DayGroup[] = $state([]);
readonly timelineManager: TimelineManager; readonly timelineManager: TimelineManager;
@@ -78,21 +83,25 @@ export class MonthGroup {
} }
} }
set intersecting(newValue: boolean) { set intersection(newValue: IntersectionFlag) {
const old = this.#intersecting; const old = this.#intersection;
if (old === newValue) { if (old === newValue) {
return; return;
} }
this.#intersecting = newValue; this.#intersection = newValue;
if (newValue) { if (isRenderable(newValue)) {
void this.timelineManager.loadMonthGroup(this.yearMonth); void this.timelineManager.loadMonthGroup(this.yearMonth);
} else { } else {
this.cancel(); this.cancel();
} }
} }
get renderable() {
return isRenderable(this.#intersection);
}
get intersecting() { get intersecting() {
return this.#intersecting; return isVisible(this.#intersection);
} }
get lastDayGroup() { get lastDayGroup() {

View File

@@ -208,7 +208,7 @@ export class TimelineManager extends VirtualScrollManager {
updateIntersectionMonthGroup(this, month); updateIntersectionMonthGroup(this, month);
} }
const month = this.months.find((month) => month.actuallyIntersecting); const month = this.months.find((month) => month.intersecting);
const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month); const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month);
const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month); const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month);

View File

@@ -2,3 +2,7 @@ import type { TimelineAsset } from './types';
export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset); export const assetSnapshot = (asset: TimelineAsset): TimelineAsset => $state.snapshot(asset);
export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset)); export const assetsSnapshot = (assets: TimelineAsset[]) => assets.map((asset) => $state.snapshot(asset));
export function filterRenderable<T extends { renderable: boolean }>(items: T[]) {
return items.filter(({ renderable }) => renderable);
}

View File

@@ -1,7 +1,12 @@
import type { CommonPosition } from '$lib/utils/layout-utils'; import type { CommonPosition } from '$lib/utils/layout-utils';
import type { DayGroup } from './day-group.svelte'; import type { DayGroup } from './day-group.svelte';
import { Intersection, calculateViewerAssetIntersecting } from './internal/intersection-support.svelte'; import {
IntersectionFlags,
calculateViewerAssetIntersecting,
isRenderable,
isVisible,
} from './internal/intersection-support.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
export class ViewerAsset { export class ViewerAsset {
@@ -9,7 +14,7 @@ export class ViewerAsset {
#intersection = $derived.by(() => { #intersection = $derived.by(() => {
if (!this.position) { if (!this.position) {
return Intersection.NONE; return IntersectionFlags.NONE;
} }
const store = this.#group.monthGroup.timelineManager; const store = this.#group.monthGroup.timelineManager;
@@ -18,12 +23,12 @@ export class ViewerAsset {
return calculateViewerAssetIntersecting(store, positionTop, this.position.height); return calculateViewerAssetIntersecting(store, positionTop, this.position.height);
}); });
get intersecting() { get renderable() {
return this.#intersection !== Intersection.NONE; return isRenderable(this.#intersection);
} }
get actuallyIntersecting() { get intersecting() {
return this.#intersection === Intersection.ACTUAL; return isVisible(this.#intersection);
} }
position: CommonPosition | undefined = $state.raw(); position: CommonPosition | undefined = $state.raw();