diff --git a/src/editor/music/timeView.ts b/src/editor/music/timeView.ts index abdaec6c..2c8b0535 100644 --- a/src/editor/music/timeView.ts +++ b/src/editor/music/timeView.ts @@ -30,9 +30,9 @@ 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); +/** Zooms about `x`, so the tick under the pointer stays under it, up to `most` pixels a tick. */ +export function zoomTime(view: TimeView, factor: number, x: number, most = MAX_PX_PER_TICK): TimeView { + const pxPerTick = clamp(view.pxPerTick * factor, MIN_PX_PER_TICK, most); const tick = xToTick(view, x); return { panX: x - tick * pxPerTick, pxPerTick }; } diff --git a/src/editor/song/SongTimeline.ts b/src/editor/song/SongTimeline.ts index bb379408..9fe7cab9 100644 --- a/src/editor/song/SongTimeline.ts +++ b/src/editor/song/SongTimeline.ts @@ -62,7 +62,6 @@ import { TimeRuler } from '../music/TimeRuler'; import { clampTimePan, draggedLoop, - fitTime, LOOP_STRIP, RULER_HEIGHT, rulerPartAt, @@ -105,6 +104,7 @@ import { HEAD_BOXES, HEAD_WIDTH, EDGE_REACH, + fitSong, type HeadPart, headPartAt, heldSongView, @@ -121,6 +121,7 @@ import { SLIDERS_MIN_HEIGHT, sliderShare, sliderValue, + SONG_MAX_PX_PER_TICK, type SongView, TRACK_HEIGHT, trackAtY, @@ -346,7 +347,7 @@ export class SongTimeline extends Widget { this.redraw(); } - /** The whole song across, from its first track down. */ + /** The whole song across and all its tracks down, with some room around. */ fit(): void { const asset = this.asset(); if (!asset || this.lanesWidth <= 0 || this.lanesHeight <= 0) { @@ -354,11 +355,8 @@ export class SongTimeline extends Widget { return; } this.needsFit = false; - this.view = { - ...fitTime(songLength(asset), this.lanesWidth), - panY: 0, - trackHeight: this.view.trackHeight, - }; + const count = this.shownTracks(asset).length; + this.view = fitSong(songLength(asset), count, this.lanesWidth, this.lanesHeight); this.redraw(); } @@ -1266,7 +1264,7 @@ export class SongTimeline extends Widget { let view: SongView; if (area === 'heads') view = zoomTracks(this.view, factor, g.y); else if (area === 'ruler' || area === 'lanes') - view = { ...this.view, ...zoomTime(this.view, factor, g.x) }; + view = { ...this.view, ...zoomTime(this.view, factor, g.x, SONG_MAX_PX_PER_TICK) }; else return; this.view = clampTracks(clampTimePan(view, this.lanesWidth), asset.tracks.length, this.lanesHeight); this.needsFit = false; diff --git a/src/editor/song/songView.ts b/src/editor/song/songView.ts index cc3887e7..a30d7fae 100644 --- a/src/editor/song/songView.ts +++ b/src/editor/song/songView.ts @@ -1,7 +1,7 @@ import type { SongItem, SongTrack } from '../../core/music/song'; import type { Vec2 } from '../../core/model/Properties'; import { clamp } from '../../ui'; -import { type TimeView, clampRowPan, tickToX, xToTick } from '../music/timeView'; +import { MIN_PX_PER_TICK, type TimeView, clampRowPan, tickToX, xToTick } from '../music/timeView'; /** * The geometry of the Song tab (#334), apart from Pixi so a test can drive it: the tracks stacked @@ -57,6 +57,29 @@ export function nearestTrack(view: SongView, count: number, y: number): number { return clamp(Math.floor((y - view.panY) / view.trackHeight), 0, Math.max(0, count - 1)); } +/** + * The song zooms in eight times as far as a pattern: a quarter is then 3840 pixels wide, so a take + * can be laid down to the millisecond. + */ +export const SONG_MAX_PX_PER_TICK = 8; +/** What Fit leaves free on each side of the song, as a share of the lanes' width and height. */ +export const FIT_MARGIN = 0.09; + +/** + * The whole song in the lanes with some room around it: its length across and its tracks down, each + * as tall as they share out, within the heights a track may have. + */ +export function fitSong(length: number, count: number, width: number, height: number): SongView { + const side = width * FIT_MARGIN; + const top = height * FIT_MARGIN; + return { + panX: side, + pxPerTick: clamp((width - 2 * side) / Math.max(1, length), MIN_PX_PER_TICK, SONG_MAX_PX_PER_TICK), + panY: top, + trackHeight: clamp((height - 2 * top) / Math.max(1, count), MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT), + }; +} + /** Zooms the tracks about `y`, so the track under the pointer stays under it. */ export function zoomTracks(view: SongView, factor: number, y: number): SongView { const trackHeight = clamp(view.trackHeight * factor, MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT); diff --git a/tests/editor/song-edit.test.ts b/tests/editor/song-edit.test.ts index 3c7cd673..3ab535ec 100644 --- a/tests/editor/song-edit.test.ts +++ b/tests/editor/song-edit.test.ts @@ -17,6 +17,8 @@ import { } from '../../src/editor/song/songEdit'; import { clampTracks, + FIT_MARGIN, + fitSong, HEAD_BOXES, headPartAt, itemAt, @@ -28,11 +30,13 @@ import { peakOver, SLIDERS_MIN_HEIGHT, sliderValue, + SONG_MAX_PX_PER_TICK, type SongView, TRACK_HEIGHT, trackAtY, zoomTracks, } from '../../src/editor/song/songView'; +import { MAX_PX_PER_TICK, tickToX, zoomTime } from '../../src/editor/music/timeView'; const BAR = 1920; const TRACKS = ['t1', 't2', 't3']; @@ -222,6 +226,23 @@ describe('where things are in the Song tab (#334)', () => { }); }); + it('fits the song across and its tracks down with room around, and zooms in further than a pattern', () => { + // 4 bars and 4 tracks in 1000 x 500: 90 pixels free left and right, 45 above and below. + const fitted = fitSong(4 * BAR, 4, 1000, 500); + expect(fitted.panX).toBeCloseTo(1000 * FIT_MARGIN); + expect(fitted.panY).toBeCloseTo(500 * FIT_MARGIN); + expect(tickToX(fitted, 4 * BAR)).toBeCloseTo(1000 * (1 - FIT_MARGIN)); + expect(fitted.panY + 4 * fitted.trackHeight).toBeCloseTo(500 * (1 - FIT_MARGIN)); + // A track keeps the heights it may have, however many or few there are. + expect(fitSong(BAR, 1, 1000, 2000).trackHeight).toBe(MAX_TRACK_HEIGHT); + expect(fitSong(BAR, 60, 1000, 500).trackHeight).toBe(MIN_TRACK_HEIGHT); + // The wheel goes past where a pattern stops, up to the song's own bound. + const deep = zoomTime({ panX: 0, pxPerTick: 1 }, 100, 0, SONG_MAX_PX_PER_TICK); + expect(SONG_MAX_PX_PER_TICK).toBeGreaterThan(MAX_PX_PER_TICK); + expect(deep.pxPerTick).toBe(SONG_MAX_PX_PER_TICK); + expect(fitSong(1, 1, 1000, 500).pxPerTick).toBe(SONG_MAX_PX_PER_TICK); + }); + it('marks where an item starts over inside it, from as far in as it starts', () => { expect(loopSeams({ tick: BAR, length: 3 * BAR }, BAR, 0)).toEqual([2 * BAR, 3 * BAR]); expect(loopSeams({ tick: BAR, length: 2 * BAR }, BAR, 480)).toEqual([2 * BAR - 480, 3 * BAR - 480]); diff --git a/tools/e2e/65-song.mjs b/tools/e2e/65-song.mjs index e7f13281..5cd1ed4f 100644 --- a/tools/e2e/65-song.mjs +++ b/tools/e2e/65-song.mjs @@ -133,8 +133,13 @@ export async function song(t) { ); const id = created.songs[0]?.id; const model = () => json(`__gm.song.model(${JSON.stringify(id)})`); - /** Adds a track under the last one through its head's menu: there is no other way to. */ + /** + * Adds a track under the last one through its head's menu: there is no other way to. Fit first: + * the tracks a fit shares the height out to leave the one added last below the edge. + */ const addTrackBelowLast = async () => { + const fit = await json('__gm.song.fitAt()'); + await click(fit.x, fit.y); const count = (await model()).tracks.length; const head = await json(`__gm.song.headAt(${count - 1}, 'name')`); await click(head.x, head.y, 'right'); @@ -145,10 +150,26 @@ export async function song(t) { for (let i = 0; i < 3; i++) await addTrackBelowLast(); const tracks = (await model()).tracks.map((track) => track.id); - // 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars. - // A pattern dragged onto the second track lands on the bar before the pointer, a bar long. + // 65a-ii. Fit takes the song across and its four tracks down, with some room on every side: the + // bar starts right of the heads and ends left of the edge, the last track ends above it. + const fitButton = await json('__gm.song.fitAt()'); + await click(fitButton.x, fitButton.y); const fitted = await json('__gm.song.view()'); const area = await json('__gm.song.rect()'); + const fitEnds = await json(`[__gm.song.laneAt(0, 0), __gm.song.laneAt(${BAR}, 3)]`); + const bottom = fitEnds[1].y + fitted.trackHeight / 2; + check( + 'Fit leaves room around the song: across the bar and down all four tracks', + fitted.panX > 0 && + fitted.panY > 0 && + fitEnds[1].x < area.x + area.w - 20 && + bottom < area.y + area.h - 20 && + bottom > area.y + area.h * 0.8, + JSON.stringify({ fitted, area, fitEnds }), + ); + + // 65b. An empty song is a bar long and fits it across; the wheel takes the view out to eight bars. + // A pattern dragged onto the second track lands on the bar before the pointer, a bar long. for (let i = 0; i < 30; i++) { const far = await json(`__gm.song.laneAt(${8 * BAR}, 0)`); if (far.x < area.x + area.w) break;