muzika-gromche/Frontend/src/lib/TinelineTicks.ts

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);
}