/** Geometry in rendered pixels, including the responsive SVG width. */ export function chartTimeAtPixel(pixel: number, duration: number, scale: number, ratio: number, padding: number): number { return Math.max(0, Math.min(duration, duration - (pixel / Math.max(ratio, 0.001) - padding) / scale)); } export function chartPixelAtTime(time: number, duration: number, scale: number, ratio: number, padding: number): number { return (padding + (duration - Math.max(0, Math.min(duration, time))) * scale) * ratio; } /** Fixed-size overview; the final judgement belongs to the last bin. */ export function chartDensity(notes: readonly { timeSeconds: number }[], duration: number, bins = 120): number[] { const counts = Array(bins).fill(0); for (const note of notes) { const index = Math.max(0, Math.min(bins - 1, Math.floor(note.timeSeconds / Math.max(duration, 0.001) * bins))); counts[index]++; } const maximum = Math.max(1, ...counts); return counts.map(count => count / maximum); } export function chartColumnAtTime(time: number, duration: number, secondsPerColumn: number): number { const count = Math.max(1, Math.ceil(duration / secondsPerColumn)); return Math.max(0, Math.min(count - 1, Math.floor(time / secondsPerColumn))); } export function chartColumnContains(time: number, start: number, end: number, duration: number): boolean { return time >= start && (time < end || (time === duration && end >= duration)); } /** Use actual SUS boundaries; silent bars and BPM changes still count. */ export function chartMeasureAtTime(measures: readonly { number: number; timeSeconds: number }[], time: number): number | null { if (measures.length === 0 || time < measures[0].timeSeconds) return null; let low = 0; let high = measures.length - 1; while (low < high) { const middle = Math.ceil((low + high) / 2); if (measures[middle].timeSeconds <= time) low = middle; else high = middle - 1; } return measures[low].number; }