mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 10:37:11 -04:00 
			
		
		
		
	* fix: navigate to time action * change-date -> DateSelectionModal; use luxon; use handle* for callback fn name * refactor change-date dialogs * Review comments * chore: clean up --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
		
			
				
	
	
		
			622 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			622 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { AssetOrder, getAssetInfo, getTimeBuckets } from '@immich/sdk';
 | |
| 
 | |
| import { authManager } from '$lib/managers/auth-manager.svelte';
 | |
| 
 | |
| import { CancellableTask } from '$lib/utils/cancellable-task';
 | |
| import { toTimelineAsset, type TimelineDateTime, type TimelineYearMonth } from '$lib/utils/timeline-util';
 | |
| 
 | |
| import { clamp, debounce, isEqual } from 'lodash-es';
 | |
| import { SvelteDate, SvelteMap, SvelteSet } from 'svelte/reactivity';
 | |
| 
 | |
| import { updateIntersectionMonthGroup } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
 | |
| import { updateGeometry } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
 | |
| import { loadFromTimeBuckets } from '$lib/managers/timeline-manager/internal/load-support.svelte';
 | |
| import {
 | |
|   addAssetsToMonthGroups,
 | |
|   runAssetOperation,
 | |
| } from '$lib/managers/timeline-manager/internal/operations-support.svelte';
 | |
| import {
 | |
|   findClosestGroupForDate,
 | |
|   findMonthGroupForAsset as findMonthGroupForAssetUtil,
 | |
|   findMonthGroupForDate,
 | |
|   getAssetWithOffset,
 | |
|   getMonthGroupByDate,
 | |
|   retrieveRange as retrieveRangeUtil,
 | |
| } from '$lib/managers/timeline-manager/internal/search-support.svelte';
 | |
| import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websocket-support.svelte';
 | |
| import { DayGroup } from './day-group.svelte';
 | |
| import { isMismatched, updateObject } from './internal/utils.svelte';
 | |
| import { MonthGroup } from './month-group.svelte';
 | |
| import type {
 | |
|   AssetDescriptor,
 | |
|   AssetOperation,
 | |
|   Direction,
 | |
|   ScrubberMonth,
 | |
|   TimelineAsset,
 | |
|   TimelineManagerLayoutOptions,
 | |
|   TimelineManagerOptions,
 | |
|   Viewport,
 | |
| } 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 {
 | |
|   isInitialized = $state(false);
 | |
|   months: MonthGroup[] = $state([]);
 | |
|   topSectionHeight = $state(0);
 | |
|   bottomSectionHeight = $state(60);
 | |
|   assetsHeight = $derived(this.months.reduce((accumulator, b) => accumulator + b.height, 0));
 | |
|   totalViewerHeight = $derived(this.topSectionHeight + this.assetsHeight + this.bottomSectionHeight);
 | |
|   assetCount = $derived(this.months.reduce((accumulator, b) => accumulator + b.assetsCount, 0));
 | |
| 
 | |
|   albumAssets: Set<string> = new SvelteSet();
 | |
| 
 | |
|   scrubberMonths: ScrubberMonth[] = $state([]);
 | |
|   scrubberTimelineHeight: number = $state(0);
 | |
| 
 | |
|   viewportTopMonthIntersection: ViewportTopMonthIntersection | undefined;
 | |
| 
 | |
|   visibleWindow = $derived.by(() => ({
 | |
|     top: this.#scrollTop,
 | |
|     bottom: this.#scrollTop + this.viewportHeight,
 | |
|   }));
 | |
|   limitedScroll = $derived(this.maxScrollPercent < 0.5);
 | |
| 
 | |
|   initTask = new CancellableTask(
 | |
|     () => {
 | |
|       this.isInitialized = true;
 | |
|       if (this.#options.albumId || this.#options.personId) {
 | |
|         return;
 | |
|       }
 | |
|       this.connect();
 | |
|     },
 | |
|     () => {
 | |
|       this.disconnect();
 | |
|       this.isInitialized = false;
 | |
|     },
 | |
|     () => void 0,
 | |
|   );
 | |
| 
 | |
|   static #INIT_OPTIONS = {};
 | |
|   #viewportHeight = $state(0);
 | |
|   #viewportWidth = $state(0);
 | |
|   #scrollTop = $state(0);
 | |
|   #websocketSupport: WebsocketSupport | undefined;
 | |
| 
 | |
|   #rowHeight = $state(235);
 | |
|   #headerHeight = $state(48);
 | |
|   #gap = $state(12);
 | |
| 
 | |
|   #options: TimelineManagerOptions = TimelineManager.#INIT_OPTIONS;
 | |
| 
 | |
|   #scrolling = $state(false);
 | |
|   #suspendTransitions = $state(false);
 | |
|   #resetScrolling = debounce(() => (this.#scrolling = false), 1000);
 | |
|   #resetSuspendTransitions = debounce(() => (this.suspendTransitions = false), 1000);
 | |
|   #updatingIntersections = false;
 | |
|   #scrollableElement: HTMLElement | undefined = $state();
 | |
| 
 | |
|   setLayoutOptions({ headerHeight = 48, rowHeight = 235, gap = 12 }: TimelineManagerLayoutOptions) {
 | |
|     let changed = false;
 | |
|     changed ||= this.#setHeaderHeight(headerHeight);
 | |
|     changed ||= this.#setGap(gap);
 | |
|     changed ||= this.#setRowHeight(rowHeight);
 | |
|     if (changed) {
 | |
|       this.refreshLayout();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   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) {
 | |
|     if (this.#headerHeight == value) {
 | |
|       return false;
 | |
|     }
 | |
|     this.#headerHeight = value;
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   get headerHeight() {
 | |
|     return this.#headerHeight;
 | |
|   }
 | |
| 
 | |
|   #setGap(value: number) {
 | |
|     if (this.#gap == value) {
 | |
|       return false;
 | |
|     }
 | |
|     this.#gap = value;
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   get gap() {
 | |
|     return this.#gap;
 | |
|   }
 | |
| 
 | |
|   #setRowHeight(value: number) {
 | |
|     if (this.#rowHeight == value) {
 | |
|       return false;
 | |
|     }
 | |
|     this.#rowHeight = value;
 | |
|     return true;
 | |
|   }
 | |
| 
 | |
|   get rowHeight() {
 | |
|     return this.#rowHeight;
 | |
|   }
 | |
| 
 | |
|   set scrolling(value: boolean) {
 | |
|     this.#scrolling = value;
 | |
|     if (value) {
 | |
|       this.suspendTransitions = true;
 | |
|       this.#resetScrolling();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   get scrolling() {
 | |
|     return this.#scrolling;
 | |
|   }
 | |
| 
 | |
|   set suspendTransitions(value: boolean) {
 | |
|     this.#suspendTransitions = value;
 | |
|     if (value) {
 | |
|       this.#resetSuspendTransitions();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   get suspendTransitions() {
 | |
|     return this.#suspendTransitions;
 | |
|   }
 | |
| 
 | |
|   set viewportWidth(value: number) {
 | |
|     const changed = value !== this.#viewportWidth;
 | |
|     this.#viewportWidth = value;
 | |
|     this.suspendTransitions = true;
 | |
|     this.#updateViewportGeometry(changed);
 | |
|     this.updateSlidingWindow();
 | |
|   }
 | |
| 
 | |
|   get viewportWidth() {
 | |
|     return this.#viewportWidth;
 | |
|   }
 | |
| 
 | |
|   set viewportHeight(value: number) {
 | |
|     this.#viewportHeight = value;
 | |
|     this.#suspendTransitions = true;
 | |
|     void this.#updateViewportGeometry(false);
 | |
|   }
 | |
| 
 | |
|   get viewportHeight() {
 | |
|     return this.#viewportHeight;
 | |
|   }
 | |
| 
 | |
|   async *assetsIterator(options?: {
 | |
|     startMonthGroup?: MonthGroup;
 | |
|     startDayGroup?: DayGroup;
 | |
|     startAsset?: TimelineAsset;
 | |
|     direction?: Direction;
 | |
|   }) {
 | |
|     const direction = options?.direction ?? 'earlier';
 | |
|     let { startDayGroup, startAsset } = options ?? {};
 | |
|     for (const monthGroup of this.monthGroupIterator({ direction, startMonthGroup: options?.startMonthGroup })) {
 | |
|       await this.loadMonthGroup(monthGroup.yearMonth, { cancelable: false });
 | |
|       yield* monthGroup.assetsIterator({ startDayGroup, startAsset, direction });
 | |
|       startDayGroup = startAsset = undefined;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   *monthGroupIterator(options?: { direction?: Direction; startMonthGroup?: MonthGroup }) {
 | |
|     const isEarlier = options?.direction === 'earlier';
 | |
|     let startIndex = options?.startMonthGroup
 | |
|       ? this.months.indexOf(options.startMonthGroup)
 | |
|       : isEarlier
 | |
|         ? 0
 | |
|         : this.months.length - 1;
 | |
| 
 | |
|     while (startIndex >= 0 && startIndex < this.months.length) {
 | |
|       yield this.months[startIndex];
 | |
|       startIndex += isEarlier ? 1 : -1;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   connect() {
 | |
|     if (this.#websocketSupport) {
 | |
|       throw new Error('TimelineManager already connected');
 | |
|     }
 | |
|     this.#websocketSupport = new WebsocketSupport(this);
 | |
|     this.#websocketSupport.connectWebsocketEvents();
 | |
|   }
 | |
| 
 | |
|   disconnect() {
 | |
|     if (!this.#websocketSupport) {
 | |
|       return;
 | |
|     }
 | |
|     this.#websocketSupport.disconnectWebsocketEvents();
 | |
|     this.#websocketSupport = undefined;
 | |
|   }
 | |
| 
 | |
|   updateSlidingWindow() {
 | |
|     const scrollTop = this.#scrollableElement?.scrollTop ?? 0;
 | |
|     if (this.#scrollTop !== scrollTop) {
 | |
|       this.#scrollTop = scrollTop;
 | |
|       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() {
 | |
|     if (this.#updatingIntersections || !this.isInitialized || this.visibleWindow.bottom === this.visibleWindow.top) {
 | |
|       return;
 | |
|     }
 | |
|     this.#updatingIntersections = true;
 | |
| 
 | |
|     for (const month of this.months) {
 | |
|       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) {
 | |
|     const hasDeferred = month.dayGroups.some((group) => group.deferredLayout);
 | |
|     if (hasDeferred) {
 | |
|       updateGeometry(this, month, { invalidateHeight: true, noDefer: true });
 | |
|       for (const group of month.dayGroups) {
 | |
|         group.deferredLayout = false;
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async #initializeMonthGroups() {
 | |
|     const timebuckets = await getTimeBuckets({
 | |
|       ...authManager.params,
 | |
|       ...this.#options,
 | |
|     });
 | |
| 
 | |
|     this.months = timebuckets.map((timeBucket) => {
 | |
|       const date = new SvelteDate(timeBucket.timeBucket);
 | |
|       return new MonthGroup(
 | |
|         this,
 | |
|         { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 },
 | |
|         timeBucket.count,
 | |
|         this.#options.order,
 | |
|       );
 | |
|     });
 | |
|     this.albumAssets.clear();
 | |
|     this.#updateViewportGeometry(false);
 | |
|   }
 | |
| 
 | |
|   async updateOptions(options: TimelineManagerOptions) {
 | |
|     if (options.deferInit) {
 | |
|       return;
 | |
|     }
 | |
|     if (this.#options !== TimelineManager.#INIT_OPTIONS && isEqual(this.#options, options)) {
 | |
|       return;
 | |
|     }
 | |
|     await this.initTask.reset();
 | |
|     await this.#init(options);
 | |
|     this.#updateViewportGeometry(false);
 | |
|   }
 | |
| 
 | |
|   async #init(options: TimelineManagerOptions) {
 | |
|     this.isInitialized = false;
 | |
|     this.months = [];
 | |
|     this.albumAssets.clear();
 | |
|     await this.initTask.execute(async () => {
 | |
|       this.#options = options;
 | |
|       await this.#initializeMonthGroups();
 | |
|     }, true);
 | |
|   }
 | |
| 
 | |
|   public destroy() {
 | |
|     this.disconnect();
 | |
|     this.isInitialized = false;
 | |
|   }
 | |
| 
 | |
|   async updateViewport(viewport: Viewport) {
 | |
|     if (viewport.height === 0 && viewport.width === 0) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (this.viewportHeight === viewport.height && this.viewportWidth === viewport.width) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (!this.initTask.executed) {
 | |
|       await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
 | |
|     }
 | |
| 
 | |
|     const changedWidth = viewport.width !== this.viewportWidth;
 | |
|     this.viewportHeight = viewport.height;
 | |
|     this.viewportWidth = viewport.width;
 | |
|     this.#updateViewportGeometry(changedWidth);
 | |
|   }
 | |
| 
 | |
|   #updateViewportGeometry(changedWidth: boolean) {
 | |
|     if (!this.isInitialized) {
 | |
|       return;
 | |
|     }
 | |
|     if (this.viewportWidth === 0 || this.viewportHeight === 0) {
 | |
|       return;
 | |
|     }
 | |
|     for (const month of this.months) {
 | |
|       updateGeometry(this, month, { invalidateHeight: changedWidth });
 | |
|     }
 | |
|     this.updateIntersections();
 | |
|     if (changedWidth) {
 | |
|       this.#createScrubberMonths();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   #createScrubberMonths() {
 | |
|     this.scrubberMonths = this.months.map((month) => ({
 | |
|       assetCount: month.assetsCount,
 | |
|       year: month.yearMonth.year,
 | |
|       month: month.yearMonth.month,
 | |
|       title: month.monthGroupTitle,
 | |
|       height: month.height,
 | |
|     }));
 | |
|     this.scrubberTimelineHeight = this.totalViewerHeight;
 | |
|   }
 | |
| 
 | |
|   createLayoutOptions() {
 | |
|     const viewportWidth = this.viewportWidth;
 | |
| 
 | |
|     return {
 | |
|       spacing: 2,
 | |
|       heightTolerance: 0.15,
 | |
|       rowHeight: this.#rowHeight,
 | |
|       rowWidth: Math.floor(viewportWidth),
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   get maxScrollPercent() {
 | |
|     const totalHeight = this.totalViewerHeight;
 | |
|     const max = (totalHeight - this.viewportHeight) / totalHeight;
 | |
|     return max;
 | |
|   }
 | |
| 
 | |
|   get maxScroll() {
 | |
|     return this.totalViewerHeight - this.viewportHeight;
 | |
|   }
 | |
| 
 | |
|   async loadMonthGroup(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }): Promise<void> {
 | |
|     let cancelable = true;
 | |
|     if (options) {
 | |
|       cancelable = options.cancelable;
 | |
|     }
 | |
|     const monthGroup = getMonthGroupByDate(this, yearMonth);
 | |
|     if (!monthGroup) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (monthGroup.loader?.executed) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const executionStatus = await monthGroup.loader?.execute(async (signal: AbortSignal) => {
 | |
|       await loadFromTimeBuckets(this, monthGroup, this.#options, signal);
 | |
|     }, cancelable);
 | |
|     if (executionStatus === 'LOADED') {
 | |
|       updateGeometry(this, monthGroup, { invalidateHeight: false });
 | |
|       this.updateIntersections();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   addAssets(assets: TimelineAsset[]) {
 | |
|     const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
 | |
|     const notUpdated = this.updateAssets(assetsToUpdate);
 | |
|     addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
 | |
|   }
 | |
| 
 | |
|   async findMonthGroupForAsset(id: string) {
 | |
|     if (!this.isInitialized) {
 | |
|       await this.initTask.waitUntilCompletion();
 | |
|     }
 | |
| 
 | |
|     let { monthGroup } = findMonthGroupForAssetUtil(this, id) ?? {};
 | |
|     if (monthGroup) {
 | |
|       return monthGroup;
 | |
|     }
 | |
| 
 | |
|     const response = await getAssetInfo({ ...authManager.params, id }).catch(() => null);
 | |
|     if (!response) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const asset = toTimelineAsset(response);
 | |
|     if (!asset || this.isExcluded(asset)) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     monthGroup = await this.#loadMonthGroupAtTime(asset.localDateTime, { cancelable: false });
 | |
|     if (monthGroup?.findAssetById({ id })) {
 | |
|       return monthGroup;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async #loadMonthGroupAtTime(yearMonth: TimelineYearMonth, options?: { cancelable: boolean }) {
 | |
|     await this.loadMonthGroup(yearMonth, options);
 | |
|     return getMonthGroupByDate(this, yearMonth);
 | |
|   }
 | |
| 
 | |
|   getMonthGroupByAssetId(assetId: string) {
 | |
|     const monthGroupInfo = findMonthGroupForAssetUtil(this, assetId);
 | |
|     return monthGroupInfo?.monthGroup;
 | |
|   }
 | |
| 
 | |
|   // note: the `index` input is expected to be in the range [0, assetCount). This
 | |
|   // value can be passed to make the method deterministic, which is mainly useful
 | |
|   // for testing.
 | |
|   async getRandomAsset(index?: number): Promise<TimelineAsset | undefined> {
 | |
|     const randomAssetIndex = index ?? Math.floor(Math.random() * this.assetCount);
 | |
| 
 | |
|     let accumulatedCount = 0;
 | |
| 
 | |
|     let randomMonth: MonthGroup | undefined = undefined;
 | |
|     for (const month of this.months) {
 | |
|       if (randomAssetIndex < accumulatedCount + month.assetsCount) {
 | |
|         randomMonth = month;
 | |
|         break;
 | |
|       }
 | |
| 
 | |
|       accumulatedCount += month.assetsCount;
 | |
|     }
 | |
|     if (!randomMonth) {
 | |
|       return;
 | |
|     }
 | |
|     await this.loadMonthGroup(randomMonth.yearMonth, { cancelable: false });
 | |
| 
 | |
|     let randomDay: DayGroup | undefined = undefined;
 | |
|     for (const day of randomMonth.dayGroups) {
 | |
|       if (randomAssetIndex < accumulatedCount + day.viewerAssets.length) {
 | |
|         randomDay = day;
 | |
|         break;
 | |
|       }
 | |
| 
 | |
|       accumulatedCount += day.viewerAssets.length;
 | |
|     }
 | |
|     if (!randomDay) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     return randomDay.viewerAssets[randomAssetIndex - accumulatedCount].asset;
 | |
|   }
 | |
| 
 | |
|   updateAssetOperation(ids: string[], operation: AssetOperation) {
 | |
|     runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
 | |
|   }
 | |
| 
 | |
|   updateAssets(assets: TimelineAsset[]) {
 | |
|     const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
 | |
|     const { unprocessedIds } = runAssetOperation(
 | |
|       this,
 | |
|       new SvelteSet(lookup.keys()),
 | |
|       (asset) => {
 | |
|         updateObject(asset, lookup.get(asset.id));
 | |
|         return { remove: false };
 | |
|       },
 | |
|       { order: this.#options.order ?? AssetOrder.Desc },
 | |
|     );
 | |
|     const result: TimelineAsset[] = [];
 | |
|     for (const id of unprocessedIds.values()) {
 | |
|       result.push(lookup.get(id)!);
 | |
|     }
 | |
|     return result;
 | |
|   }
 | |
| 
 | |
|   removeAssets(ids: string[]) {
 | |
|     const { unprocessedIds } = runAssetOperation(
 | |
|       this,
 | |
|       new SvelteSet(ids),
 | |
|       () => {
 | |
|         return { remove: true };
 | |
|       },
 | |
|       { order: this.#options.order ?? AssetOrder.Desc },
 | |
|     );
 | |
|     return [...unprocessedIds];
 | |
|   }
 | |
| 
 | |
|   refreshLayout() {
 | |
|     for (const month of this.months) {
 | |
|       updateGeometry(this, month, { invalidateHeight: true });
 | |
|     }
 | |
|     this.updateIntersections();
 | |
|   }
 | |
| 
 | |
|   getFirstAsset(): TimelineAsset | undefined {
 | |
|     return this.months[0]?.getFirstAsset();
 | |
|   }
 | |
| 
 | |
|   async getLaterAsset(
 | |
|     assetDescriptor: AssetDescriptor,
 | |
|     interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
 | |
|   ): Promise<TimelineAsset | undefined> {
 | |
|     return await getAssetWithOffset(this, assetDescriptor, interval, 'later');
 | |
|   }
 | |
| 
 | |
|   async getEarlierAsset(
 | |
|     assetDescriptor: AssetDescriptor,
 | |
|     interval: 'asset' | 'day' | 'month' | 'year' = 'asset',
 | |
|   ): Promise<TimelineAsset | undefined> {
 | |
|     return await getAssetWithOffset(this, assetDescriptor, interval, 'earlier');
 | |
|   }
 | |
| 
 | |
|   async getClosestAssetToDate(dateTime: TimelineDateTime) {
 | |
|     let monthGroup = findMonthGroupForDate(this, dateTime);
 | |
|     if (!monthGroup) {
 | |
|       // if exact match not found, find closest
 | |
|       monthGroup = findClosestGroupForDate(this.months, dateTime);
 | |
|       if (!monthGroup) {
 | |
|         return;
 | |
|       }
 | |
|     }
 | |
|     await this.loadMonthGroup(dateTime, { cancelable: false });
 | |
|     const asset = monthGroup.findClosest(dateTime);
 | |
|     if (asset) {
 | |
|       return asset;
 | |
|     }
 | |
|     for await (const asset of this.assetsIterator({ startMonthGroup: monthGroup })) {
 | |
|       return asset;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   async retrieveRange(start: AssetDescriptor, end: AssetDescriptor) {
 | |
|     return retrieveRangeUtil(this, start, end);
 | |
|   }
 | |
| 
 | |
|   isExcluded(asset: TimelineAsset) {
 | |
|     return (
 | |
|       isMismatched(this.#options.visibility, asset.visibility) ||
 | |
|       isMismatched(this.#options.isFavorite, asset.isFavorite) ||
 | |
|       isMismatched(this.#options.isTrashed, asset.isTrashed)
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   getAssetOrder() {
 | |
|     return this.#options.order ?? AssetOrder.Desc;
 | |
|   }
 | |
| }
 |