Fit a song both ways and zoom its time in further (#362)

Fit in the Song tab only set the time, from the very left edge, and
kept the height the tracks had. The time also stopped zooming at the
bound of the Pattern tab, too soon to lay a take down closely.

- fitSong in songView.ts frames the song with 9 % of the lanes free on
  every side. The tracks share out the height in between, held to
  28 to 320 px. A song opened without a kept view fits the same way.
- The time zooms in to 8 px a tick, eight times the bound of the
  Pattern tab. zoomTime takes the bound as a parameter, so the Pattern
  tab keeps its own.

Tests:
- Unit song-edit: 15 green (1 new), the counter-check without the
  margin is red.
- e2e 65: ALL PASSED, 56 checks (1 new). The new check presses Fit
  after the tracks are added and finds room around bar and tracks.
  Adding a track presses Fit first now: a new song opens with its one
  track 320 px tall, so the track added below it lay off the edge.
- Gate: 272 files, 4213 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 07:33:47 +02:00
1 parent 5cb7ef7bed
commit d0b76ec355
5 files changed
+78 -15

No files matched your search

+3 -3
View File
@@ -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 };
}
+6 -8
View File
@@ -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;
+24 -1
View File
@@ -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);