import type { AnyTime, Beats, Px, Seconds } from "@/lib/units"; import { computed, type ComputedRef, type MaybeRefOrGetter, shallowReadonly, type ShallowRef, shallowRef, toValue, watch, } from "vue"; const TICK_INTERVAL_PX_THRESHOLD = 150; /** * Find such time interval that converted to pixels it won't exceed this threshold. * Start with large intervals, and progressively subdivide it until a suitably small interval is found. * @param width timeline width (including empty space) in pixels * @param duration timeline visual duration (including empty space) in seconds * @returns Visually optimal interval for ticks, in seconds. */ export function findOptimalTickInterval( width: Px, duration: Seconds, ): Seconds { const pxPerSec = Number.isFinite(duration) && duration > 0 ? width / duration : NaN; // If we can't compute a sensible pixels-per-second, fall back to the smallest interval. if (!Number.isFinite(pxPerSec)) return 1; const seconds = TICK_INTERVAL_PX_THRESHOLD / pxPerSec; if (seconds >= 2) { return Math.floor(seconds / 2) * 2; } else { return 1; } } export function useOptimalTickInterval( width: MaybeRefOrGetter, duration: MaybeRefOrGetter, ): ComputedRef { return computed(() => findOptimalTickInterval(toValue(width), toValue(duration)) ); } /** * Find such beats interval that converted to pixels it won't exceed this threshold. * @param width timeline width (including empty space) in pixels * @param duration timeline visual duration (including empty space) in beats * @returns Visually optimal interval for ticks, in beats. */ export function findOptimalBeatTickInterval( width: Px, duration: Beats, ): Beats { const pxPerBeat = Number.isFinite(duration) && duration > 0 ? width / duration : NaN; // If we can't compute a sensible pixels-per-beat, fall back to the smallest interval. if (!Number.isFinite(pxPerBeat)) return 1; const beats = TICK_INTERVAL_PX_THRESHOLD / pxPerBeat; if (beats >= 4) { return Math.floor(beats / 4) * 4; } else { return 1; } } export function useOptimalBeatTickInterval( width: MaybeRefOrGetter, duration: MaybeRefOrGetter, ): ComputedRef { return computed(() => findOptimalTickInterval(toValue(width), toValue(duration)) ); } export interface TicksBounds { tickIn: T; tickOut: T; } /** * Find closest bounds for ticks divisible by `interval` such that they cover the whole viewport. * TickIn is the largest value <= viewportIn that is a multiple of `interval`. * TickOut is the smallest value >= viewportOut that is a multiple of `interval`. * * Notes: * - `interval` is expected to be a positive finite number. If it's invalid, the original viewport bounds are returned. * - Works with fractional intervals and negative times. * * @param interval tick spacing (seconds or beats) * @param viewportIn left/earlier bound of the viewport (seconds or beats) * @param viewportOut right/later bound of the viewport (seconds or beats) * @returns object with { tickIn, tickOut } */ export function findTicksBounds( interval: T, viewportIn: T, viewportOut: T, ): TicksBounds { if (!Number.isFinite(interval) || interval <= 0) { return { tickIn: viewportIn, tickOut: viewportOut }; } // Normalize -0 to 0 for cleanliness const normalize = (v: T) => (Object.is(v, -0) ? 0 as T : v); const tickIn = normalize(Math.floor(viewportIn / interval) * interval as T); const tickOut = normalize(Math.ceil(viewportOut / interval) * interval as T); return { tickIn, tickOut }; } export function useTicksBounds( interval: MaybeRefOrGetter, viewportIn: MaybeRefOrGetter, viewportOut: MaybeRefOrGetter, ): Readonly>> { // only trigger when really needed. const ticksBounds = shallowRef>({ tickIn: toValue(viewportIn), tickOut: toValue(viewportOut), }); watch([interval, viewportIn, viewportOut], () => { const bounds = findTicksBounds( toValue(interval), toValue(viewportIn), toValue(viewportOut), ); if ( bounds.tickIn !== ticksBounds.value.tickIn || bounds.tickOut !== ticksBounds.value.tickOut ) { ticksBounds.value = bounds; } }, { immediate: true }); return shallowReadonly(ticksBounds); }