import { type Meter, ticksPerBar, ticksPerBeat } from '../../core/music/time'; import { clamp } from '../../ui'; /** * The time axis of the piano roll (#333), and of the song tab after it (#334), apart from Pixi so a * test can drive it: ticks run left to right, `x` is measured from the left edge of the area that * shows them. Only across; what is stacked above each other is up to the tab. */ export interface TimeView { /** Pixels from the area's left edge to tick 0. */ panX: number; pxPerTick: number; } export const RULER_HEIGHT = 30; /** The strip along the top of the ruler that holds the loop; a drag there sets it, below moves the start. */ export const LOOP_STRIP = 10; /** A bar of 4/4 from 8 pixels to a 1/32 of 60. */ export const MIN_PX_PER_TICK = 0.004; export const MAX_PX_PER_TICK = 1; const BAR_LABEL_MIN_PX = 36; const BEAT_MIN_PX = 6; const GRID_MIN_PX = 5; export function tickToX(view: TimeView, tick: number): number { return view.panX + tick * view.pxPerTick; } export function xToTick(view: TimeView, x: number): number { return (x - view.panX) / view.pxPerTick; } /** Zooms about `x`, so the tick under the pointer stays under it. */ export function zoomTime(view: TimeView, factor: number, x: number): TimeView { const pxPerTick = clamp(view.pxPerTick * factor, MIN_PX_PER_TICK, MAX_PX_PER_TICK); const tick = xToTick(view, x); return { panX: x - tick * pxPerTick, pxPerTick }; } /** How far right of the area's left edge tick 0 can be panned, as a share of the area's width. */ export const TIME_LEAD = 0.5; /** * Time starts at 0, but it need not stand at the left edge: a pan can take it up to half the area in, * to look at, with nothing to place left of it. */ export function clampTimePan(view: V, width: number): V { const most = Math.max(0, width * TIME_LEAD); return view.panX > most ? { ...view, panX: most } : view; } /** How far past the first or the last row (a note, a track) a pan can go, as a share of the area's height. */ export const ROW_LEAD = 0.5; /** * Down the area as across it: the first row can be panned up to half the height down from the top * edge and the last up to half the height up from the bottom, to look at, with nothing to place there. * Rows that fit with room to spare are not pushed up off the top. */ export function clampRowPan(panY: number, content: number, height: number): number { const lead = Math.max(0, height * ROW_LEAD); return clamp(panY, Math.min(0, height - lead - content), lead); } /** `length` ticks across `width` pixels, from tick 0. */ export function fitTime(length: number, width: number): TimeView { return { panX: 0, pxPerTick: clamp(width / Math.max(1, length), MIN_PX_PER_TICK, MAX_PX_PER_TICK) }; } /** A line across the time: 0 a bar, 1 a beat, 2 a step of the grid. */ export interface TimeLine { tick: number; x: number; level: 0 | 1 | 2; } /** * The lines from `0` to `width` pixels, each tick only at its strongest level. Beats and the grid * are left out where they would stand closer than a few pixels. */ export function timeLines(view: TimeView, meter: Meter, grid: number, width: number): TimeLine[] { const levels = new Map(); const from = Math.max(0, xToTick(view, 0)); const to = xToTick(view, width); const spans: [number, 0 | 1 | 2, number][] = [ [ticksPerBar(meter), 0, 0], [ticksPerBeat(meter), 1, BEAT_MIN_PX], [grid, 2, GRID_MIN_PX], ]; for (const [step, level, minPx] of spans) { if (step * view.pxPerTick < minPx) continue; for (let tick = Math.ceil(from / step) * step; tick <= to; tick += step) if (!levels.has(tick)) levels.set(tick, level); } return [...levels] .map(([tick, level]) => ({ tick, x: tickToX(view, tick), level })) .sort((a, b) => a.tick - b.tick); } export type RulerPart = 'loop' | 'time'; export function rulerPartAt(y: number): RulerPart { return y < LOOP_STRIP ? 'loop' : 'time'; } /** * The loop a drag on the loop strip leaves: from where it was pressed to `at`, or, pressed at an end * of `loop`, that end moved to `at`. Null where the drag spans nothing. */ export function draggedLoop( loop: { start: number; end: number }, edge: 'start' | 'end' | null, from: number, at: number, ): { start: number; end: number } | null { if (edge === 'start') return { start: Math.min(at, loop.end - 1), end: loop.end }; if (edge === 'end') return { start: loop.start, end: Math.max(at, loop.start + 1) }; return at === from ? null : { start: Math.min(at, from), end: Math.max(at, from) }; } /** Every `n`-th bar gets its number on the ruler, `n` a power of two so the numbers never overlap. */ export function barLabelStep(view: TimeView, meter: Meter): number { const barPx = ticksPerBar(meter) * view.pxPerTick; let n = 1; while (n * barPx < BAR_LABEL_MIN_PX && n < 1024) n *= 2; return n; }