147 lines
4.3 KiB
TypeScript
147 lines
4.3 KiB
TypeScript
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<Px>,
|
|
duration: MaybeRefOrGetter<Seconds>,
|
|
): ComputedRef<Seconds> {
|
|
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<Px>,
|
|
duration: MaybeRefOrGetter<Beats>,
|
|
): ComputedRef<Beats> {
|
|
return computed(() =>
|
|
findOptimalTickInterval(toValue(width), toValue(duration))
|
|
);
|
|
}
|
|
|
|
export interface TicksBounds<T extends AnyTime> {
|
|
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<T extends AnyTime>(
|
|
interval: T,
|
|
viewportIn: T,
|
|
viewportOut: T,
|
|
): TicksBounds<T> {
|
|
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<T extends AnyTime>(
|
|
interval: MaybeRefOrGetter<T>,
|
|
viewportIn: MaybeRefOrGetter<T>,
|
|
viewportOut: MaybeRefOrGetter<T>,
|
|
): Readonly<ShallowRef<TicksBounds<T>>> {
|
|
// only trigger when really needed.
|
|
const ticksBounds = shallowRef<TicksBounds<T>>({
|
|
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);
|
|
}
|