diff --git a/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte b/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte index ddcc66904f..7c9d5eeed0 100644 --- a/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte +++ b/web/src/lib/components/timeline/base-components/base-timeline-viewer.svelte @@ -292,7 +292,7 @@ {/if} {/each} - +
monthsLength + 1; // +1 for lead-out + + let isInLeadOutSection = $state(false); + // 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. + let viewportTopMonthScrollPercent = $state(0); + let viewportTopMonth: TimelineYearMonth | undefined = $state(undefined); + let timelineScrollPercent: number = $state(0); let scrubberWidth: number = $state(0); // note: don't throttle, debounce, or otherwise make this function async - it causes flicker // this function updates the scrubber position based on the current scroll position in the timeline const handleTimelineScroll = () => { - leadOut = false; + isInLeadOutSection = false; // Handle small timeline edge case: scroll limited due to size of content if (isSmallTimeline()) { @@ -80,111 +98,131 @@ }; const isSmallTimeline = () => { - return timelineManager.timelineHeight < timelineManager.viewportHeight * 2; + return timelineManager.timelineHeight < timelineManager.viewportHeight * VIEWPORT_MULTIPLIER; + }; + + const isNearMonthBoundary = (progress: number) => { + return progress > NEAR_END_THRESHOLD; + }; + + const canAdvanceToNextMonth = (currentIndex: number, monthsLength: number) => { + return currentIndex + 1 < monthsLength - 1; }; const resetScrubberMonth = () => { - scrubberMonth = undefined; - scrubberMonthPercent = 0; + viewportTopMonth = undefined; + viewportTopMonthScrollPercent = 0; + }; + + const calculateTimelineScrollPercent = () => { + const maxScroll = timelineManager.getMaxScroll(); + timelineScrollPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll); + resetScrubberMonth(); }; const handleSmallTimelineScroll = () => { - const maxScroll = timelineManager.getMaxScroll(); - scrubOverallPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll); - resetScrubberMonth(); + calculateTimelineScrollPercent(); }; const handleLeadInScroll = () => { - const maxScroll = timelineManager.getMaxScroll(); - scrubOverallPercent = Math.min(1, timelineManager.visibleWindow.top / maxScroll); - resetScrubberMonth(); + calculateTimelineScrollPercent(); }; const handleMonthScroll = () => { const monthsLength = timelineManager.months.length; const maxScrollPercent = timelineManager.getMaxScrollPercent(); - let top = timelineManager.visibleWindow.top; - let found = false; + let remainingScrollDistance = timelineManager.visibleWindow.top; + // Tracks if we found the month intersecting the viewport top + let foundIntersectingMonth = false; - for (let i = -1; i < monthsLength + 1; i++) { - const monthData = getMonthData(i); - const next = top - monthData.height * maxScrollPercent; + for (let i = MONTH_LOOP_START; i < getMonthLoopEnd(monthsLength); i++) { + const monthSection = getMonthSection(i); + const nextRemainingDistance = remainingScrollDistance - monthSection.height * maxScrollPercent; // Check if we're in this month (with subpixel tolerance) - if (next < -1 && monthData.monthGroup) { - scrubberMonth = monthData.monthGroup; + if (nextRemainingDistance < SUBPIXEL_TOLERANCE && monthSection.monthGroup) { + viewportTopMonth = monthSection.monthGroup; - // Calculate month percentage - scrubberMonthPercent = Math.max(0, top / (monthData.height * maxScrollPercent)); + // Calculate how far we've scrolled into this month as a percentage + viewportTopMonthScrollPercent = Math.max(0, remainingScrollDistance / (monthSection.height * maxScrollPercent)); // Handle rounding errors (and/or subpixel tolerance) - // advance to next month if almost at end - if (scrubberMonthPercent > 0.9999 && i + 1 < monthsLength - 1) { - scrubberMonth = timelineManager.months[i + 1].yearMonth; - scrubberMonthPercent = 0; + if (isNearMonthBoundary(viewportTopMonthScrollPercent) && canAdvanceToNextMonth(i, monthsLength)) { + viewportTopMonth = timelineManager.months[i + 1].yearMonth; + viewportTopMonthScrollPercent = 0; } - found = true; + foundIntersectingMonth = true; break; } - top = next; + remainingScrollDistance = nextRemainingDistance; } - if (!found) { - leadOut = true; - scrubOverallPercent = 1; + if (!foundIntersectingMonth) { + isInLeadOutSection = true; + timelineScrollPercent = 1; resetScrubberMonth(); } }; - const getMonthData = (index: number) => { + const getMonthSection = (index: number): MonthSection => { const monthsLength = timelineManager.months.length; - if (index === -1) { - // lead-in + if (index === MONTH_LOOP_START) { return { + type: 'lead-in', height: timelineManager.topSectionHeight, monthGroup: undefined, }; } if (index === monthsLength) { - // lead-out return { + type: 'lead-out', height: timelineManager.bottomSectionHeight, monthGroup: undefined, }; } - // normal month return { + type: 'month', height: timelineManager.months[index].height, monthGroup: timelineManager.months[index].yearMonth, }; }; + const handleOverallPercentScroll = (percent: number, scrollTo?: (offset: number) => void) => { + const maxScroll = timelineManager.getMaxScroll(); + const offset = maxScroll * percent; + scrollTo?.(offset); + }; + + const findMonthGroup = (target: TimelineYearMonth) => { + return timelineManager.months.find( + ({ yearMonth }) => yearMonth.year === target.year && yearMonth.month === target.month, + ); + }; + // note: don't throttle, debounce, or otherwise make this function async - it causes flicker // this function scrolls the timeline to the specified month group and offset, based on scrubber interaction const onScrub: ScrubberListener = (scrubberData) => { const { scrubberMonth, overallScrollPercent, scrubberMonthScrollPercent, scrollToFunction } = scrubberData; - if (!scrubberMonth || timelineManager.timelineHeight < timelineManager.viewportHeight * 2) { - // edge case - scroll limited due to size of content, must adjust - use use the overall percent instead - const maxScroll = timelineManager.getMaxScroll(); - const offset = maxScroll * overallScrollPercent; - scrollToFunction?.(offset); + // Handle edge case or no month selected + if (!scrubberMonth || isSmallTimeline()) { + handleOverallPercentScroll(overallScrollPercent, scrollToFunction); return; } - const monthGroup = timelineManager.months.find( - ({ yearMonth: { year, month } }) => year === scrubberMonth.year && month === scrubberMonth.month, - ); - if (!monthGroup) { - return; + + // Find and scroll to the selected month + const monthGroup = findMonthGroup(scrubberMonth); + if (monthGroup) { + scrollToPositionWithinMonth(monthGroup, scrubberMonthScrollPercent, scrollToFunction); } - scrollToMonthGroupAndOffset(monthGroup, scrubberMonthScrollPercent, scrollToFunction); }; - const scrollToMonthGroupAndOffset = ( + const scrollToPositionWithinMonth = ( monthGroup: MonthGroup, monthGroupScrollPercent: number, handleScrollTop?: (top: number) => void, @@ -218,18 +256,18 @@ {empty} {handleTimelineScroll} > - {#snippet header(scrollTo)} + {#snippet header(scrollToFunction)} {#if timelineManager.months.length > 0} onScrub({ ...args, scrollToFunction: scrollTo })} + {isInLeadOutSection} + {timelineScrollPercent} + {viewportTopMonthScrollPercent} + {viewportTopMonth} + onScrub={(scrubberData) => onScrub({ ...scrubberData, scrollToFunction })} bind:scrubberWidth /> {/if} diff --git a/web/src/lib/components/timeline/base-components/scrubber.svelte b/web/src/lib/components/timeline/base-components/scrubber.svelte index 66b24d553a..4ee5fdab76 100644 --- a/web/src/lib/components/timeline/base-components/scrubber.svelte +++ b/web/src/lib/components/timeline/base-components/scrubber.svelte @@ -4,7 +4,7 @@ import type { ScrubberMonth } from '$lib/managers/timeline-manager/types'; import { mobileDevice } from '$lib/stores/mobile-device.svelte'; import { getTabbable } from '$lib/utils/focus-util'; - import { type ScrubberListener } from '$lib/utils/timeline-util'; + import { type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; import { mdiPlay } from '@mdi/js'; import { clamp } from 'lodash-es'; import { onMount } from 'svelte'; @@ -15,10 +15,10 @@ timelineBottomOffset?: number; height?: number; timelineManager: TimelineManager; - scrubOverallPercent?: number; - scrubberMonthPercent?: number; - scrubberMonth?: { year: number; month: number }; - leadOut?: boolean; + timelineScrollPercent?: number; + viewportTopMonthScrollPercent?: number; + viewportTopMonth?: TimelineYearMonth; + isInLeadOutSection?: boolean; scrubberWidth?: number; onScrub?: ScrubberListener; onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void; @@ -31,10 +31,10 @@ timelineBottomOffset = 0, height = 0, timelineManager, - scrubOverallPercent = 0, - scrubberMonthPercent = 0, - scrubberMonth = undefined, - leadOut = false, + timelineScrollPercent = 0, + viewportTopMonthScrollPercent = 0, + viewportTopMonth = undefined, + isInLeadOutSection = false, onScrub = undefined, onScrubKeyDown = undefined, startScrub = undefined, @@ -100,7 +100,7 @@ offset += scrubberMonthPercent * relativeBottomOffset; } return offset; - } else if (leadOut) { + } else if (isInLeadOutSection) { let offset = relativeTopOffset; for (const segment of segments) { offset += segment.height; @@ -111,7 +111,9 @@ return scrubOverallPercent * (height - (PADDING_TOP + PADDING_BOTTOM)); } }; - let scrollY = $derived(toScrollFromMonthGroupPercentage(scrubberMonth, scrubberMonthPercent, scrubOverallPercent)); + let scrollY = $derived( + toScrollFromMonthGroupPercentage(viewportTopMonth, viewportTopMonthScrollPercent, timelineScrollPercent), + ); let timelineFullHeight = $derived(timelineManager.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset); let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight)); let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));