fix: re-add scroll compensation (efficiently) (#22848)

* fix: re-add scroll compensation (efficient)

* Rename showSkeleton to invisible. Adjust skeleton margins, invisible support.

* Fix faulty logic, simplify

* Calculate ratios and determine compensation strategy: height comp for above/partiality visible, month-scroll comp within a fully visible month.

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Min Idzelis 2025-10-13 22:16:05 -04:00 committed by GitHub
parent e8ca7f235c
commit 146973b072
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 128 additions and 71 deletions

View File

@ -89,10 +89,10 @@
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore; let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let element: HTMLElement | undefined = $state(); let scrollableElement: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state(); let timelineElement: HTMLElement | undefined = $state();
let showSkeleton = $state(true); let invisible = $state(true);
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport. // The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
// Note: There may be multiple months visible within the viewport at any given time. // Note: There may be multiple months visible within the viewport at any given time.
let viewportTopMonthScrollPercent = $state(0); let viewportTopMonthScrollPercent = $state(0);
@ -124,29 +124,22 @@
timelineManager.setLayoutOptions(layoutOptions); timelineManager.setLayoutOptions(layoutOptions);
}); });
const scrollTo = (top: number) => { $effect(() => {
if (element) { timelineManager.scrollableElement = scrollableElement;
element.scrollTo({ top }); });
}
};
const scrollTop = (top: number) => {
if (element) {
element.scrollTop = top;
}
};
const scrollToTop = () => { const scrollToTop = () => {
scrollTo(0); timelineManager.scrollTo(0);
}; };
const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId); const getAssetHeight = (assetId: string, monthGroup: MonthGroup) => monthGroup.findAssetAbsolutePosition(assetId);
const assetIsVisible = (assetTop: number): boolean => { const assetIsVisible = (assetTop: number): boolean => {
if (!element) { if (!scrollableElement) {
return false; return false;
} }
const { clientHeight, scrollTop } = element; const { clientHeight, scrollTop } = scrollableElement;
return assetTop >= scrollTop && assetTop < scrollTop + clientHeight; return assetTop >= scrollTop && assetTop < scrollTop + clientHeight;
}; };
@ -163,8 +156,7 @@
return true; return true;
} }
scrollTo(height); timelineManager.scrollTo(height);
updateSlidingWindow();
return true; return true;
}; };
@ -174,8 +166,7 @@
return false; return false;
} }
const height = getAssetHeight(asset.id, monthGroup); const height = getAssetHeight(asset.id, monthGroup);
scrollTo(height); timelineManager.scrollTo(height);
updateSlidingWindow();
return true; return true;
}; };
@ -189,7 +180,7 @@
// if the asset is not found, scroll to the top // if the asset is not found, scroll to the top
scrollToTop(); scrollToTop();
} }
showSkeleton = false; invisible = false;
}; };
beforeNavigate(() => (timelineManager.suspendTransitions = true)); beforeNavigate(() => (timelineManager.suspendTransitions = true));
@ -216,7 +207,7 @@
} else { } else {
scrollToTop(); scrollToTop();
} }
showSkeleton = false; invisible = false;
}, 500); }, 500);
} }
}; };
@ -230,13 +221,12 @@
const updateIsScrolling = () => (timelineManager.scrolling = true); const updateIsScrolling = () => (timelineManager.scrolling = true);
// note: don't throttle, debounch, or otherwise do this function async - it causes flicker // note: don't throttle, debounch, or otherwise do this function async - it causes flicker
const updateSlidingWindow = () => timelineManager.updateSlidingWindow(element?.scrollTop || 0);
const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height); const topSectionResizeObserver: OnResizeCallback = ({ height }) => (timelineManager.topSectionHeight = height);
onMount(() => { onMount(() => {
if (!enableRouting) { if (!enableRouting) {
showSkeleton = false; invisible = false;
} }
}); });
@ -246,11 +236,13 @@
}; };
const getMaxScroll = () => { const getMaxScroll = () => {
if (!element || !timelineElement) { if (!scrollableElement || !timelineElement) {
return 0; return 0;
} }
return ( return (
timelineManager.topSectionHeight + bottomSectionHeight + (timelineElement.clientHeight - element.clientHeight) timelineManager.topSectionHeight +
bottomSectionHeight +
(timelineElement.clientHeight - scrollableElement.clientHeight)
); );
}; };
@ -260,7 +252,7 @@
const delta = monthGroup.height * monthGroupScrollPercent; const delta = monthGroup.height * monthGroupScrollPercent;
const scrollToTop = (topOffset + delta) * maxScrollPercent; const scrollToTop = (topOffset + delta) * maxScrollPercent;
scrollTop(scrollToTop); timelineManager.scrollTo(scrollToTop);
}; };
// note: don't throttle, debounce, or otherwise make this function async - it causes flicker // note: don't throttle, debounce, or otherwise make this function async - it causes flicker
@ -272,7 +264,7 @@
// edge case - scroll limited due to size of content, must adjust - use use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
const offset = maxScroll * overallScrollPercent; const offset = maxScroll * overallScrollPercent;
scrollTop(offset); timelineManager.scrollTo(offset);
} else { } else {
const monthGroup = timelineManager.months.find( const monthGroup = timelineManager.months.find(
({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month, ({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month,
@ -288,26 +280,26 @@
const handleTimelineScroll = () => { const handleTimelineScroll = () => {
isInLeadOutSection = false; isInLeadOutSection = false;
if (!element) { if (!scrollableElement) {
return; return;
} }
if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { if (timelineManager.timelineHeight < timelineManager.viewportHeight * 2) {
// edge case - scroll limited due to size of content, must adjust - use the overall percent instead // edge case - scroll limited due to size of content, must adjust - use the overall percent instead
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
viewportTopMonth = undefined; viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0; viewportTopMonthScrollPercent = 0;
} else { } else {
let top = element.scrollTop; let top = scrollableElement.scrollTop;
if (top < timelineManager.topSectionHeight) { if (top < timelineManager.topSectionHeight) {
// in the lead-in area // in the lead-in area
viewportTopMonth = undefined; viewportTopMonth = undefined;
viewportTopMonthScrollPercent = 0; viewportTopMonthScrollPercent = 0;
const maxScroll = getMaxScroll(); const maxScroll = getMaxScroll();
timelineScrollPercent = Math.min(1, element.scrollTop / maxScroll); timelineScrollPercent = Math.min(1, scrollableElement.scrollTop / maxScroll);
return; return;
} }
@ -414,7 +406,7 @@
onSelect(asset); onSelect(asset);
if (singleSelect) { if (singleSelect) {
scrollTop(0); timelineManager.scrollTo(0);
return; return;
} }
@ -564,10 +556,10 @@
if (evt.key === 'ArrowUp') { if (evt.key === 'ArrowUp') {
amount = -amount; amount = -amount;
if (shiftKeyIsDown) { if (shiftKeyIsDown) {
element?.scrollBy({ top: amount, behavior: 'smooth' }); scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
} }
} else if (evt.key === 'ArrowDown') { } else if (evt.key === 'ArrowDown') {
element?.scrollBy({ top: amount, behavior: 'smooth' }); scrollableElement?.scrollBy({ top: amount, behavior: 'smooth' });
} }
}} }}
/> />
@ -580,19 +572,19 @@
style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'} style:margin-right={(usingMobileDevice ? 0 : scrubberWidth) + 'px'}
tabindex="-1" tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight} bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())} bind:clientWidth={timelineManager.viewportWidth}
bind:this={element} bind:this={scrollableElement}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())} onscroll={() => (handleTimelineScroll(), timelineManager.updateSlidingWindow(), updateIsScrolling())}
> >
<section <section
bind:this={timelineElement} bind:this={timelineElement}
id="virtual-timeline" id="virtual-timeline"
class:invisible={showSkeleton} class:invisible
style:height={timelineManager.timelineHeight + 'px'} style:height={timelineManager.timelineHeight + 'px'}
> >
<section <section
use:resizeObserver={topSectionResizeObserver} use:resizeObserver={topSectionResizeObserver}
class:invisible={showSkeleton} class:invisible
style:position="absolute" style:position="absolute"
style:left="0" style:left="0"
style:right="0" style:right="0"
@ -615,10 +607,7 @@
style:transform={`translate3d(0,${absoluteHeight}px,0)`} style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%" style:width="100%"
> >
<Skeleton <Skeleton {invisible} height={monthGroup.height} title={monthGroup.monthGroupTitle} />
height={monthGroup.height - monthGroup.timelineManager.headerHeight}
title={monthGroup.monthGroupTitle}
/>
</div> </div>
{:else if display} {:else if display}
<div <div
@ -658,7 +647,7 @@
<Portal target="body"> <Portal target="body">
{#if $showAssetViewer} {#if $showAssetViewer}
<TimelineAssetViewer bind:showSkeleton {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} /> <TimelineAssetViewer bind:invisible {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
{/if} {/if}
</Portal> </Portal>

View File

@ -13,7 +13,7 @@
interface Props { interface Props {
timelineManager: TimelineManager; timelineManager: TimelineManager;
showSkeleton: boolean; invisible: boolean;
withStacked?: boolean; withStacked?: boolean;
isShared?: boolean; isShared?: boolean;
album?: AlbumResponseDto | null; album?: AlbumResponseDto | null;
@ -30,7 +30,7 @@
let { let {
timelineManager, timelineManager,
showSkeleton = $bindable(false), invisible = $bindable(false),
removeAction, removeAction,
withStacked = false, withStacked = false,
isShared = false, isShared = false,
@ -81,7 +81,7 @@
const handleClose = async (asset: { id: string }) => { const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false); assetViewingStore.showAssetViewer(false);
showSkeleton = true; invisible = true;
$gridScrollTarget = { at: asset.id }; $gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget }); await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
}; };

View File

@ -14,7 +14,7 @@
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util'; import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import type { Snippet } from 'svelte'; import { type Snippet } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';

View File

@ -2,24 +2,21 @@
interface Props { interface Props {
height: number; height: number;
title?: string; title?: string;
invisible?: boolean;
} }
let { height = 0, title }: Props = $props(); let { height = 0, title, invisible = false }: Props = $props();
</script> </script>
<div class="overflow-clip" style:height={height + 'px'}> <div class={['overflow-clip', { invisible }]} style:height={height + 'px'}>
{#if title} {#if title}
<div <div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm" class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
> >
{title} {title}
</div> </div>
{/if} {/if}
<div <div class="animate-pulse h-full w-full" data-skeleton="true"></div>
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
style:width="calc(100% - 20px)"
data-skeleton="true"
></div>
</div> </div>
<style> <style>
@ -47,4 +44,7 @@
0s linear 0.1s forwards delayedVisibility, 0s linear 0.1s forwards delayedVisibility,
pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
} }
.invisible [data-skeleton] {
visibility: hidden !important;
}
</style> </style>

View File

@ -4,7 +4,6 @@ import { getTimeBucket } from '@immich/sdk';
import type { MonthGroup } from '../month-group.svelte'; import type { MonthGroup } from '../month-group.svelte';
import type { TimelineManager } from '../timeline-manager.svelte'; import type { TimelineManager } from '../timeline-manager.svelte';
import type { TimelineManagerOptions } from '../types'; import type { TimelineManagerOptions } from '../types';
import { layoutMonthGroup } from './layout-support.svelte';
export async function loadFromTimeBuckets( export async function loadFromTimeBuckets(
timelineManager: TimelineManager, timelineManager: TimelineManager,
@ -55,6 +54,4 @@ export async function loadFromTimeBuckets(
)}`, )}`,
); );
} }
layoutMonthGroup(timelineManager, monthGroup);
} }

View File

@ -36,6 +36,7 @@ export class MonthGroup {
#initialCount: number = 0; #initialCount: number = 0;
#sortOrder: AssetOrder = AssetOrder.Desc; #sortOrder: AssetOrder = AssetOrder.Desc;
percent: number = $state(0);
assetsCount: number = $derived( assetsCount: number = $derived(
this.isLoaded this.isLoaded
@ -241,7 +242,6 @@ export class MonthGroup {
if (this.#height === height) { if (this.#height === height) {
return; return;
} }
let needsIntersectionUpdate = false;
const timelineManager = this.timelineManager; const timelineManager = this.timelineManager;
const index = timelineManager.months.indexOf(this); const index = timelineManager.months.indexOf(this);
const heightDelta = height - this.#height; const heightDelta = height - this.#height;
@ -261,11 +261,21 @@ export class MonthGroup {
const newTop = monthGroup.#top + heightDelta; const newTop = monthGroup.#top + heightDelta;
if (monthGroup.#top !== newTop) { if (monthGroup.#top !== newTop) {
monthGroup.#top = newTop; monthGroup.#top = newTop;
needsIntersectionUpdate = true;
} }
} }
if (needsIntersectionUpdate) { if (!timelineManager.viewportTopMonthIntersection) {
timelineManager.updateIntersections(); return;
}
const { month, monthBottomViewportRatio, viewportTopRatioInMonth } = timelineManager.viewportTopMonthIntersection;
const currentIndex = month ? timelineManager.months.indexOf(month) : -1;
if (!month || currentIndex <= 0 || index > currentIndex) {
return;
}
if (index < currentIndex || monthBottomViewportRatio < 1) {
timelineManager.scrollBy(heightDelta);
} else if (index === currentIndex) {
const scrollTo = this.top + height * viewportTopRatioInMonth;
timelineManager.scrollTo(scrollTo);
} }
} }

View File

@ -4,6 +4,7 @@ import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util'; import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk'; import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory'; import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
import { tick } from 'svelte';
import { TimelineManager } from './timeline-manager.svelte'; import { TimelineManager } from './timeline-manager.svelte';
import type { TimelineAsset } from './types'; import type { TimelineAsset } from './types';
@ -64,11 +65,12 @@ describe('TimelineManager', () => {
sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket])); sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssetsResponse[timeBucket]));
await timelineManager.updateViewport({ width: 1588, height: 1000 }); await timelineManager.updateViewport({ width: 1588, height: 1000 });
await tick();
}); });
it('should load months in viewport', () => { it('should load months in viewport', () => {
expect(sdkMock.getTimeBuckets).toBeCalledTimes(1); expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(3); expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
}); });
it('calculates month height', () => { it('calculates month height', () => {
@ -82,13 +84,13 @@ describe('TimelineManager', () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ year: 2024, month: 3, height: 165.5 }), expect.objectContaining({ year: 2024, month: 3, height: 165.5 }),
expect.objectContaining({ year: 2024, month: 2, height: 11_996 }), expect.objectContaining({ year: 2024, month: 2, height: 11_996 }),
expect.objectContaining({ year: 2024, month: 1, height: 48 }), expect.objectContaining({ year: 2024, month: 1, height: 286 }),
]), ]),
); );
}); });
it('calculates timeline height', () => { it('calculates timeline height', () => {
expect(timelineManager.timelineHeight).toBe(12_209.5); expect(timelineManager.timelineHeight).toBe(12_447.5);
}); });
}); });

View File

@ -5,7 +5,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
import { CancellableTask } from '$lib/utils/cancellable-task'; import { CancellableTask } from '$lib/utils/cancellable-task';
import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
import { debounce, isEqual } from 'lodash-es'; import { clamp, debounce, isEqual } from 'lodash-es';
import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity'; import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte'; import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
@ -37,6 +37,13 @@ import type {
Viewport, Viewport,
} from './types'; } from './types';
type ViewportTopMonthIntersection = {
month: MonthGroup | undefined;
// Where viewport top intersects month (0 = month top, 1 = month bottom)
viewportTopRatioInMonth: number;
// Where month bottom is in viewport (0 = viewport top, 1 = viewport bottom)
monthBottomViewportRatio: number;
};
export class TimelineManager { export class TimelineManager {
isInitialized = $state(false); isInitialized = $state(false);
months: MonthGroup[] = $state([]); months: MonthGroup[] = $state([]);
@ -49,6 +56,8 @@ export class TimelineManager {
scrubberMonths: ScrubberMonth[] = $state([]); scrubberMonths: ScrubberMonth[] = $state([]);
scrubberTimelineHeight: number = $state(0); scrubberTimelineHeight: number = $state(0);
viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
visibleWindow = $derived.by(() => ({ visibleWindow = $derived.by(() => ({
top: this.#scrollTop, top: this.#scrollTop,
bottom: this.#scrollTop + this.viewportHeight, bottom: this.#scrollTop + this.viewportHeight,
@ -85,6 +94,8 @@ export class TimelineManager {
#suspendTransitions = $state(false); #suspendTransitions = $state(false);
#resetScrolling = debounce(() => (this.#scrolling = false), 1000); #resetScrolling = debounce(() => (this.#scrolling = false), 1000);
#resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000); #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
#updatingIntersections = false;
#scrollableElement: HTMLElement | undefined = $state();
constructor() {} constructor() {}
@ -98,6 +109,20 @@ export class TimelineManager {
} }
} }
set scrollableElement(element: HTMLElement | undefined) {
this.#scrollableElement = element;
}
scrollTo(top: number) {
this.#scrollableElement?.scrollTo({ top });
this.updateSlidingWindow();
}
scrollBy(y: number) {
this.#scrollableElement?.scrollBy(0, y);
this.updateSlidingWindow();
}
#setHeaderHeight(value: number) { #setHeaderHeight(value: number) {
if (this.#headerHeight == value) { if (this.#headerHeight == value) {
return false; return false;
@ -161,7 +186,8 @@ export class TimelineManager {
const changed = value !== this.#viewportWidth; const changed = value !== this.#viewportWidth;
this.#viewportWidth = value; this.#viewportWidth = value;
this.suspendTransitions = true; this.suspendTransitions = true;
void this.#updateViewportGeometry(changed); this.#updateViewportGeometry(changed);
this.updateSlidingWindow();
} }
get viewportWidth() { get viewportWidth() {
@ -223,20 +249,52 @@ export class TimelineManager {
this.#websocketSupport = undefined; this.#websocketSupport = undefined;
} }
updateSlidingWindow(scrollTop: number) { updateSlidingWindow() {
const scrollTop = this.#scrollableElement?.scrollTop ?? 0;
if (this.#scrollTop !== scrollTop) { if (this.#scrollTop !== scrollTop) {
this.#scrollTop = scrollTop; this.#scrollTop = scrollTop;
this.updateIntersections(); this.updateIntersections();
} }
} }
#calculateMonthBottomViewportRatio(month: MonthGroup | undefined) {
if (!month) {
return 0;
}
const windowHeight = this.visibleWindow.bottom - this.visibleWindow.top;
const bottomOfMonth = month.top + month.height;
const bottomOfMonthInViewport = bottomOfMonth - this.visibleWindow.top;
return clamp(bottomOfMonthInViewport / windowHeight, 0, 1);
}
#calculateVewportTopRatioInMonth(month: MonthGroup | undefined) {
if (!month) {
return 0;
}
return clamp((this.visibleWindow.top - month.top) / month.height, 0, 1);
}
updateIntersections() { updateIntersections() {
if (!this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) { if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
return; return;
} }
this.#updatingIntersections = true;
for (const month of this.months) { for (const month of this.months) {
updateIntersectionMonthGroup(this, month); updateIntersectionMonthGroup(this, month);
} }
const month = this.months.find((month) => month.actuallyIntersecting);
const viewportTopRatioInMonth = this.#calculateVewportTopRatioInMonth(month);
const monthBottomViewportRatio = this.#calculateMonthBottomViewportRatio(month);
this.viewportTopMonthIntersection = {
month,
monthBottomViewportRatio,
viewportTopRatioInMonth,
};
this.#updatingIntersections = false;
} }
clearDeferredLayout(month: MonthGroup) { clearDeferredLayout(month: MonthGroup) {
@ -368,7 +426,8 @@ export class TimelineManager {
await loadFromTimeBuckets(this, monthGroup, this.#options, signal); await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
}, cancelable); }, cancelable);
if (executionStatus === 'LOADED') { if (executionStatus === 'LOADED') {
updateIntersectionMonthGroup(this, monthGroup); updateGeometry(this, monthGroup, { invalidateHeight: false });
this.updateIntersections();
} }
} }