feat(studio): adapt timeline ruler density

This commit is contained in:
Miguel Ángel
2026-04-28 17:41:39 -04:00
parent 7ff1734db7
commit 635fca124d
2 changed files with 92 additions and 8 deletions
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
formatTimelineTickLabel,
generateTicks,
getDefaultDroppedTrack,
getTimelineCanvasHeight,
@@ -79,6 +80,20 @@ describe("generateTicks", () => {
expect(major[0]).toBe(0);
}
});
it("uses denser major labels as timeline zoom increases", () => {
const fitTicks = generateTicks(180, 10);
const zoomedTicks = generateTicks(180, 48);
expect(fitTicks.major[1] - fitTicks.major[0]).toBe(15);
expect(zoomedTicks.major[1] - zoomedTicks.major[0]).toBe(5);
expect(zoomedTicks.minor).toContain(1);
expect(zoomedTicks.minor).toContain(4);
});
it("keeps labels readable instead of placing one at every tiny tick", () => {
const { major } = generateTicks(180, 80);
expect(major[1] - major[0]).toBe(2);
});
});
describe("formatTime", () => {
@@ -119,6 +134,20 @@ describe("formatTime", () => {
});
});
describe("formatTimelineTickLabel", () => {
it("uses minute-second labels for normal timeline intervals", () => {
expect(formatTimelineTickLabel(90, 180, 5)).toBe("1:30");
});
it("uses hour labels for long timelines", () => {
expect(formatTimelineTickLabel(3661, 4000, 60)).toBe("1:01:01");
});
it("shows subsecond labels when the major ruler interval is below one second", () => {
expect(formatTimelineTickLabel(1.5, 3, 0.5)).toBe("0:01.5");
});
});
describe("shouldAutoScrollTimeline", () => {
it("never auto-scrolls in fit mode", () => {
expect(shouldAutoScrollTimeline("fit", 1200, 800)).toBe(false);
@@ -88,16 +88,47 @@ function getStyle(tag: string): TrackVisualStyle {
}
/* ── Tick Generation ────────────────────────────────────────────── */
export function generateTicks(duration: number): { major: number[]; minor: number[] } {
function getMajorTickInterval(duration: number, pixelsPerSecond?: number): number {
const zoomIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
if (Number.isFinite(pixelsPerSecond) && (pixelsPerSecond ?? 0) > 0) {
const targetMajorPx = 128;
return (
zoomIntervals.find((interval) => interval * (pixelsPerSecond ?? 0) >= targetMajorPx) ?? 600
);
}
const durationIntervals = [0.25, 0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
return durationIntervals.find((interval) => interval >= target) ?? 60;
}
function getMinorTickInterval(majorInterval: number, pixelsPerSecond?: number): number {
let interval = majorInterval / 2;
if (majorInterval >= 30) interval = majorInterval / 6;
else if (majorInterval >= 15) interval = majorInterval / 3;
else if (majorInterval >= 5) interval = majorInterval / 5;
else if (majorInterval >= 1) interval = majorInterval / 4;
if (
Number.isFinite(pixelsPerSecond) &&
(pixelsPerSecond ?? 0) > 0 &&
interval * (pixelsPerSecond ?? 0) < 20
) {
return Math.max(0.25, majorInterval / 2);
}
return Math.max(0.25, interval);
}
export function generateTicks(
duration: number,
pixelsPerSecond?: number,
): { major: number[]; minor: number[] } {
if (duration <= 0 || !Number.isFinite(duration) || duration > 7200)
return { major: [], minor: [] };
const intervals = [0.5, 1, 2, 5, 10, 15, 30, 60];
const target = duration / 6;
const majorInterval = intervals.find((i) => i >= target) ?? 60;
const minorInterval = Math.max(0.25, majorInterval / 2);
const majorInterval = getMajorTickInterval(duration, pixelsPerSecond);
const minorInterval = getMinorTickInterval(majorInterval, pixelsPerSecond);
const major: number[] = [];
const minor: number[] = [];
const maxTicks = 500; // Safety cap to prevent infinite loop
const maxTicks = 2000; // Safety cap to prevent runaway tick generation
for (
let t = 0;
t <= duration + 0.001 && major.length + minor.length < maxTicks;
@@ -113,6 +144,25 @@ export function generateTicks(duration: number): { major: number[]; minor: numbe
return { major, minor };
}
export function formatTimelineTickLabel(time: number, duration: number, majorInterval: number) {
if (!Number.isFinite(time)) return "0:00";
const safeTime = Math.max(0, time);
if (majorInterval < 1) {
const totalTenths = Math.round(safeTime * 10);
const wholeSeconds = Math.floor(totalTenths / 10);
const tenth = totalTenths % 10;
return `${formatTime(wholeSeconds)}.${tenth}`;
}
if (duration >= 3600 || safeTime >= 3600) {
const totalSeconds = Math.floor(safeTime);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return formatTime(safeTime);
}
export function shouldAutoScrollTimeline(
zoomMode: ZoomMode,
scrollWidth: number,
@@ -956,7 +1006,12 @@ export const Timeline = memo(function Timeline({
cancelAnimationFrame(dragScrollRaf.current);
}, []);
const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
const { major, minor } = useMemo(
() => generateTicks(effectiveDuration, pps),
[effectiveDuration, pps],
);
const majorTickInterval =
major.length >= 2 ? Math.max(0.25, major[1] - major[0]) : effectiveDuration;
const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => {
if (resizingClip?.element.id === element.id) {
@@ -1324,7 +1379,7 @@ export const Timeline = memo(function Timeline({
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
style={{ color: theme.tickText }}
>
{formatTime(t)}
{formatTimelineTickLabel(t, effectiveDuration, majorTickInterval)}
</span>
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
</div>