From 3ffa7c9d29216b7a2439402b414846d9a6dfd653 Mon Sep 17 00:00:00 2001 From: midzelis Date: Sat, 6 Sep 2025 16:00:04 +0000 Subject: [PATCH] use binary search for perf, refactor, improve readability --- .../base-components/base-timeline.svelte | 93 ++++--------------- .../base-components/timeline-month.svelte | 2 +- web/src/lib/utils/timeline-util.ts | 76 +++++++++++++++ 3 files changed, 93 insertions(+), 78 deletions(-) diff --git a/web/src/lib/components/timeline/base-components/base-timeline.svelte b/web/src/lib/components/timeline/base-components/base-timeline.svelte index 1816bd50a5..7fa1464ae2 100644 --- a/web/src/lib/components/timeline/base-components/base-timeline.svelte +++ b/web/src/lib/components/timeline/base-components/base-timeline.svelte @@ -5,7 +5,7 @@ import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte'; - import type { ScrubberListener, TimelineYearMonth } from '$lib/utils/timeline-util'; + import { findMonthAtScrollPosition, type ScrubberListener, type TimelineYearMonth } from '$lib/utils/timeline-util'; import type { Snippet } from 'svelte'; import Scrubber from './scrubber.svelte'; @@ -51,11 +51,7 @@ empty, }: Props = $props(); - // Constants for timeline calculations const VIEWPORT_MULTIPLIER = 2; // Used to determine if timeline is "small" - const SUBPIXEL_TOLERANCE = -1; // Tolerance for scroll position checks - const NEAR_END_THRESHOLD = 0.9999; // Threshold for detecting near-end of month - let isInLeadOutSection = $state(false); // The percentage of scroll through the month that is currently intersecting the top boundary of the viewport. @@ -91,11 +87,6 @@ return timelineManager.timelineHeight < timelineManager.viewportHeight * VIEWPORT_MULTIPLIER; }; - const isNearMonthBoundary = (progress: number) => { - return progress > NEAR_END_THRESHOLD; - }; - - const resetScrubberMonth = () => { viewportTopMonth = undefined; viewportTopMonthScrollPercent = 0; @@ -116,76 +107,24 @@ }; const handleMonthScroll = () => { - const scrollTop = timelineManager.visibleWindow.top; + const scrollPosition = timelineManager.visibleWindow.top; const months = timelineManager.months; const maxScrollPercent = timelineManager.getMaxScrollPercent(); - - // Early exit if no months - if (months.length === 0) { - isInLeadOutSection = true; - timelineScrollPercent = 1; - resetScrubberMonth(); - return; - } - - // Check if we're before the first month (in lead-in) - const firstMonthTop = months[0].top * maxScrollPercent; - if (scrollTop < firstMonthTop - SUBPIXEL_TOLERANCE) { - isInLeadOutSection = true; - timelineScrollPercent = 1; - resetScrubberMonth(); - return; - } - - // Check if we're after the last month (in lead-out) - const lastMonth = months[months.length - 1]; - const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent; - if (scrollTop >= lastMonthBottom - SUBPIXEL_TOLERANCE) { - isInLeadOutSection = true; - timelineScrollPercent = 1; - resetScrubberMonth(); - return; - } - - // Binary search to find the month containing the viewport top - let left = 0; - let right = months.length - 1; - - while (left <= right) { - const mid = Math.floor((left + right) / 2); - const month = months[mid]; - const monthTop = month.top * maxScrollPercent; - const monthBottom = monthTop + month.height * maxScrollPercent; - - if (scrollTop >= monthTop - SUBPIXEL_TOLERANCE && scrollTop < monthBottom - SUBPIXEL_TOLERANCE) { - // Found the month containing the viewport top - viewportTopMonth = month.yearMonth; - const distanceIntoMonth = scrollTop - monthTop; - viewportTopMonthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent)); - - // Handle month boundary edge case - if (isNearMonthBoundary(viewportTopMonthScrollPercent) && mid < months.length - 1) { - viewportTopMonth = months[mid + 1].yearMonth; - viewportTopMonthScrollPercent = 0; - } - - isInLeadOutSection = false; - return; - } - - if (scrollTop < monthTop) { - right = mid - 1; - } else { - left = mid + 1; - } - } - - // Shouldn't reach here, but if we do, we're in lead-out - isInLeadOutSection = true; - timelineScrollPercent = 1; - resetScrubberMonth(); - }; + // Find the month at the current scroll position + const searchResult = findMonthAtScrollPosition(months, scrollPosition, maxScrollPercent); + + if (searchResult) { + viewportTopMonth = searchResult.month; + viewportTopMonthScrollPercent = searchResult.monthScrollPercent; + isInLeadOutSection = false; + } else { + // We're in lead-out section + isInLeadOutSection = true; + timelineScrollPercent = 1; + resetScrubberMonth(); + } + }; const handleOverallPercentScroll = (percent: number, scrollTo?: (offset: number) => void) => { const maxScroll = timelineManager.getMaxScroll(); diff --git a/web/src/lib/components/timeline/base-components/timeline-month.svelte b/web/src/lib/components/timeline/base-components/timeline-month.svelte index 8bfc964bd7..bc3c13afb1 100644 --- a/web/src/lib/components/timeline/base-components/timeline-month.svelte +++ b/web/src/lib/components/timeline/base-components/timeline-month.svelte @@ -121,7 +121,7 @@ {/if} - + {dayGroup.groupTitle} diff --git a/web/src/lib/utils/timeline-util.ts b/web/src/lib/utils/timeline-util.ts index 8d9ebbd257..c35b02b70e 100644 --- a/web/src/lib/utils/timeline-util.ts +++ b/web/src/lib/utils/timeline-util.ts @@ -243,3 +243,79 @@ export function setDifference(setA: Set, setB: Set): SvelteSet { } return result; } + +export interface MonthGroupForSearch { + yearMonth: TimelineYearMonth; + top: number; + height: number; +} + +export interface BinarySearchResult { + month: TimelineYearMonth; + monthScrollPercent: number; +} + +export function findMonthAtScrollPosition( + months: MonthGroupForSearch[], + scrollPosition: number, + maxScrollPercent: number, +): BinarySearchResult | null { + const SUBPIXEL_TOLERANCE = -1; // Tolerance for scroll position checks + const NEAR_END_THRESHOLD = 0.9999; // Threshold for detecting near-end of month + + if (months.length === 0) { + return null; + } + + // Check if we're before the first month + const firstMonthTop = months[0].top * maxScrollPercent; + if (scrollPosition < firstMonthTop - SUBPIXEL_TOLERANCE) { + return null; + } + + // Check if we're after the last month + const lastMonth = months[months.length - 1]; + const lastMonthBottom = (lastMonth.top + lastMonth.height) * maxScrollPercent; + if (scrollPosition >= lastMonthBottom - SUBPIXEL_TOLERANCE) { + return null; + } + + // Binary search to find the month containing the scroll position + let left = 0; + let right = months.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const month = months[mid]; + const monthTop = month.top * maxScrollPercent; + const monthBottom = monthTop + month.height * maxScrollPercent; + + if (scrollPosition >= monthTop - SUBPIXEL_TOLERANCE && scrollPosition < monthBottom - SUBPIXEL_TOLERANCE) { + // Found the month containing the scroll position + const distanceIntoMonth = scrollPosition - monthTop; + let monthScrollPercent = Math.max(0, distanceIntoMonth / (month.height * maxScrollPercent)); + + // Handle month boundary edge case + if (monthScrollPercent > NEAR_END_THRESHOLD && mid < months.length - 1) { + return { + month: months[mid + 1].yearMonth, + monthScrollPercent: 0, + }; + } + + return { + month: month.yearMonth, + monthScrollPercent, + }; + } + + if (scrollPosition < monthTop) { + right = mid - 1; + } else { + left = mid + 1; + } + } + + // Shouldn't reach here, but return null if we do + return null; +}