diff --git a/web/app.css b/web/app.css index f9924e9..b8b4287 100644 --- a/web/app.css +++ b/web/app.css @@ -15,7 +15,9 @@ --error: #ef5350; --ok: #81c784; --radius: 6px; - --mono: ui-monospace, "Cascadia Mono", "Source Code Pro", Menlo, Consolas, monospace; + /* M PLUS 1 Code (the desktop app's font) where installed, else the system UI font. */ + --font: "M PLUS 1 Code", system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif; + --mono: "M PLUS 1 Code", ui-monospace, "Cascadia Mono", "Source Code Pro", Menlo, Consolas, monospace; } * { box-sizing: border-box; } @@ -30,7 +32,7 @@ html, body { height: 100%; background: var(--bg); color: var(--text); - font: 13px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", sans-serif; + font: 13px/1.45 var(--font); overflow: hidden; } @@ -154,6 +156,7 @@ select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px s text-overflow: ellipsis; font-size: 12px; } +.file-lufs { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } .file-remove { flex: none; border: 0; @@ -177,6 +180,8 @@ select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px s .metadata dt { color: var(--muted); } .metadata dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } .metadata dd.muted { color: var(--muted); } +.metadata .group { margin-top: 7px; } +.metadata dd.strong { font-weight: 600; } /* ---- reference lines ---- */ @@ -196,10 +201,7 @@ select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px s .plot-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 24px; } .plot-head h1 { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); } -.legend { display: flex; flex-wrap: wrap; gap: 10px; font-size: 11.5px; color: var(--muted); } -.legend .item { display: flex; align-items: center; gap: 5px; } -.legend .swatch { width: 9px; height: 9px; border-radius: 2px; } -.legend .annotation { font-family: var(--mono); color: var(--text); } +.plot-hint { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .plot-host { position: relative; @@ -212,7 +214,30 @@ select:focus-visible, .btn:focus-visible, .toggle:focus-visible { outline: 2px s overflow: hidden; } .plot-host .uplot { position: absolute; inset: 0; width: 100% !important; height: 100% !important; } -.plot-host canvas.heatmap { position: absolute; inset: 0; width: 100%; height: 100%; cursor: crosshair; } +.plot-host canvas.heatmap { position: absolute; inset: 0; width: 100%; height: 100%; } + +/* In-plot legend, like pyqtgraph's: swatches per track plus scalar readouts. + pointer-events: none so zoom and pan work straight through it. */ +.plot-legend { + position: absolute; + z-index: 5; + pointer-events: none; + display: flex; + flex-direction: column; + gap: 2px; + max-width: 46%; + padding: 6px 9px; + background: rgba(13, 15, 20, .82); + border: 1px solid var(--line); + border-radius: 4px; + font-size: 11.5px; +} +.plot-legend .entry { display: flex; align-items: center; gap: 7px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.plot-legend .swatch { width: 14px; height: 3px; border-radius: 1px; flex: none; } +.plot-legend .note { color: var(--muted); padding-left: 21px; font-variant-numeric: tabular-nums; } + +body.dragging-pan, body.dragging-pan * { cursor: grabbing !important; user-select: none; } +body.dragging-ref, body.dragging-ref * { cursor: ns-resize !important; user-select: none; } .placeholder { position: absolute; diff --git a/web/app.js b/web/app.js index aa7b490..93307c0 100644 --- a/web/app.js +++ b/web/app.js @@ -10,7 +10,8 @@ * The roles mirror the desktop app: this module is MainWindow plus * AnalysisResultsManager (track list, overlay set, per-metric result cache, * background prefetch), and the renderer half is AudioVisualizationWidget - * (dataset colour cycle, lin/log, draggable reference lines, overlay). + * (dataset colours, in-plot legend, lin/log, wheel zoom with per-axis + * modifiers, drag-to-pan, draggable reference lines, overlay). */ import { decodeFile } from './decode.js'; @@ -18,7 +19,7 @@ import { METRICS, METRIC_BY_ID, DEFAULT_METRIC_ID, applyRelativeX } from './metr 'use strict'; -// ---- palette --------------------------------------------------------------- +// ---- palette & fonts --------------------------------------------------------- // Same job as `dataset_color` on the desktop: a song keeps one colour wherever // it appears, and overlaid tracks stay distinguishable. Tuned for a dark plot. const PALETTE = [ @@ -27,7 +28,18 @@ const PALETTE = [ '#a1887f', '#e57373', '#64b5f6', '#aed581', ]; -const AUDIO_EXTENSIONS = /\.(wav|mp3|flac|ogg|oga|m4a|aiff?|opus)$/i; +// The UI prefers M PLUS 1 Code (the desktop app's font) and falls back to the +// system UI font where it isn't installed. The stack lives in app.css; canvas +// and uPlot text read it from there so everything uses the same family. +const FONT = getComputedStyle(document.documentElement).getPropertyValue('--font').trim() + || 'system-ui, sans-serif'; +const font = (px) => `${px}px ${FONT}`; + +const AUDIO_EXTENSIONS = /\.(wav|mp3|flac|ogg|oga|m4a|aiff?|aifc|opus)$/i; + +// Loudness figures shown in the metadata panel and file list come from these, +// so they are prefetched before the other metrics. +const PREFETCH_FIRST = ['lufs', 'true_peak']; const state = { metric: DEFAULT_METRIC_ID, @@ -43,6 +55,10 @@ const state = { refs: [], payload: null, // last built plot payload uplot: null, + heatmapGeom: null, // geometry of the spectrogram last drawn (for interaction) + // User zoom/pan window in data units; null = the metric's default framing. + // Survives redraws and overlay changes; reset when the units change. + zoom: { x: null, y: null }, busy: false, refreshAgain: false, results: {}, // file_id -> {metric_id: computed data} @@ -80,6 +96,19 @@ function fmtSeconds(ms) { return ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms)} ms`; } +/** Decimal places needed to print multiples of `step` exactly. */ +function stepDigits(step) { + if (!(step > 0)) return 2; + const text = String(Number(step.toPrecision(6))); + if (text.includes('e-')) return Math.min(8, Number(text.split('e-')[1])); + return Math.min(8, (text.split('.')[1] || '').length); +} + +function formatTick(value, step) { + const v = Math.abs(value) < step * 1e-6 ? 0 : value; + return v.toFixed(stepDigits(step)); +} + function paletteFor(fileId) { const i = state.files.findIndex((f) => f.file_id === fileId); return PALETTE[(i < 0 ? 0 : i) % PALETTE.length]; @@ -89,10 +118,18 @@ function fileById(fileId) { return state.files.find((f) => f.file_id === fileId); } +function resultFor(fileId, metricId) { + return state.results[fileId] && state.results[fileId][metricId]; +} + function currentRefs() { return state.refs; } +function resetZoom() { + state.zoom = { x: null, y: null }; +} + // ---- analysis worker ------------------------------------------------------- // One worker, one metric at a time. Jobs the user is waiting on (the metric on // screen) jump ahead of background prefetch, like the desktop's PrefetchWorker @@ -105,7 +142,7 @@ let running = null; let reqSeq = 0; function requestMetric(trackId, metricId, { background = false } = {}) { - const cached = state.results[trackId] && state.results[trackId][metricId]; + const cached = resultFor(trackId, metricId); if (cached) return Promise.resolve(cached); const key = `${trackId}:${metricId}`; @@ -150,6 +187,7 @@ worker.onmessage = (event) => { if (state.results[msg.trackId]) { state.results[msg.trackId][msg.metricId] = msg.data; state.timings[msg.trackId][msg.metricId] = msg.ms; + if (PREFETCH_FIRST.includes(msg.metricId)) renderFileList(); if (msg.trackId === state.selected) renderMetadata(); } job.resolve(msg.data); @@ -165,8 +203,9 @@ worker.onerror = (event) => { }; function prefetch(trackId) { - METRICS.forEach((m) => { - if (m.id !== state.metric) requestMetric(trackId, m.id, { background: true }).catch(() => {}); + const order = [...PREFETCH_FIRST, ...METRICS.map((m) => m.id).filter((id) => !PREFETCH_FIRST.includes(id))]; + order.forEach((id) => { + if (id !== state.metric) requestMetric(trackId, id, { background: true }).catch(() => {}); }); } @@ -188,7 +227,7 @@ function forgetTrack(fileId) { if (state.selected === fileId) state.selected = state.files.length ? state.files[0].file_id : null; } -// ---- file list ------------------------------------------------------------- +// ---- file list & metadata ---------------------------------------------------- function populateMetricSelect() { const select = el('metric-select'); @@ -230,13 +269,21 @@ function renderFileList() { const name = document.createElement('span'); name.className = 'file-name'; name.textContent = f.name; - name.title = `${f.song_name}\n${(f.format || 'audio').toUpperCase()} · ${(f.size / 1048576).toFixed(1)} MB · held in this tab only`; + name.title = f.song_name; + + const loudness = document.createElement('span'); + loudness.className = 'file-lufs'; + const lufs = resultFor(f.file_id, 'lufs'); + if (lufs && Number.isFinite(lufs.integrated)) { + loudness.textContent = lufs.integrated.toFixed(1); + loudness.title = 'Integrated loudness (LUFS)'; + } const remove = document.createElement('button'); remove.className = 'file-remove'; remove.type = 'button'; remove.textContent = '×'; - remove.title = 'Forget this track (discards its audio and analysis)'; + remove.title = 'Remove this track'; remove.addEventListener('click', async (ev) => { ev.stopPropagation(); forgetTrack(f.file_id); @@ -245,7 +292,7 @@ function renderFileList() { await refreshPlot(); }); - li.append(check, swatch, name, remove); + li.append(check, swatch, name, loudness, remove); li.addEventListener('click', () => { state.selected = f.file_id; renderFileList(); @@ -265,32 +312,38 @@ function renderMetadata() { const dl = el('metadata'); dl.innerHTML = ''; const file = fileById(state.selected); - let rows = [['Track', 'nothing selected']]; - if (file) { - const computed = METRICS - .filter((m) => state.results[file.file_id] && state.results[file.file_id][m.id]) - .map((m) => m.displayName); - rows = [ - ['Track', file.song_name], - ['Length', fmtDuration(file.duration)], - ['Sample rate', `${(file.sample_rate / 1000).toFixed(1)} kHz${file.native_rate_known ? '' : ' (header not recognised — resampled)'}`], - ['Channels', file.channels === 1 ? 'mono' : `${file.channels} (analysed as mono)`], - ['Max amplitude', fmt(file.max_amplitude, 3)], - ['Avg amplitude', fmt(file.avg_amplitude, 3)], - ['Format', `${(file.format || '?').toUpperCase()}, ${(file.size / 1048576).toFixed(1)} MB`], - ['Audio held by', 'this tab only (memory)'], - ['Metrics computed', computed.length ? computed.join(', ') : '—'], - ]; + + const addRow = (label, value, className = '') => { + const dt = document.createElement('dt'); + dt.textContent = label; + const dd = document.createElement('dd'); + dd.textContent = value; + if (className) { dt.className = className; dd.className = className; } + dl.append(dt, dd); + }; + + if (!file) { + addRow('Track', 'nothing selected', 'muted'); + return; } - rows.forEach(([k, v]) => { - const dt = document.createElement('dt'); - dt.textContent = k; - const dd = document.createElement('dd'); - dd.textContent = v; - if (!file) dd.className = 'muted'; - dl.append(dt, dd); - }); + const lufs = resultFor(file.file_id, 'lufs'); + const tp = resultFor(file.file_id, 'true_peak'); + const pending = '…'; + const channels = file.channels === 1 ? 'Mono' : file.channels === 2 ? 'Stereo' : `${file.channels} channels`; + + addRow('Track', file.song_name); + addRow('Length', fmtDuration(file.duration)); + addRow('Sample rate', `${(file.sample_rate / 1000).toFixed(1)} kHz${file.native_rate_known ? '' : ' (assumed)'}`); + addRow('Channels', channels); + addRow('Format', `${(file.format || '?').toUpperCase()} · ${(file.size / 1048576).toFixed(1)} MB`); + addRow('Integrated', + !lufs ? pending : Number.isFinite(lufs.integrated) ? `${lufs.integrated.toFixed(1)} LUFS` : 'below gate', + 'group strong'); + addRow('Loudness range', !lufs ? pending : Number.isFinite(lufs.lra) ? `${lufs.lra.toFixed(1)} LU` : '—'); + addRow('True peak', !tp ? pending : tp.integrated_tp_db > -200 ? `${tp.integrated_tp_db.toFixed(2)} dBTP` : '—'); + addRow('Max amplitude', fmt(file.max_amplitude, 3), 'group'); + addRow('Avg amplitude', fmt(file.avg_amplitude, 3)); } // ---- reference lines ------------------------------------------------------- @@ -313,20 +366,20 @@ function renderRefList() { const color = document.createElement('input'); color.type = 'color'; color.value = /^#[0-9a-f]{6}$/i.test(ref.color) ? ref.color : '#ffb74d'; - color.addEventListener('input', () => { ref.color = color.value; redrawRefs(); }); + color.addEventListener('input', () => { ref.color = color.value; redrawLines(); }); const value = document.createElement('input'); value.type = 'number'; value.step = '0.1'; - value.value = ref.value; + value.value = ref.value.toFixed(2); value.title = 'Position on the value axis'; - value.addEventListener('change', () => { ref.value = parseFloat(value.value) || 0; redrawRefs(); }); + value.addEventListener('change', () => { ref.value = parseFloat(value.value) || 0; redrawLines(); }); const label = document.createElement('input'); label.type = 'text'; label.value = ref.label || ''; label.placeholder = 'tag'; - label.addEventListener('change', () => { ref.label = label.value; redrawRefs(); }); + label.addEventListener('change', () => { ref.label = label.value; redrawLines(); }); const remove = document.createElement('button'); remove.className = 'ref-remove'; @@ -335,7 +388,7 @@ function renderRefList() { remove.addEventListener('click', () => { currentRefs().splice(index, 1); renderRefList(); - redrawRefs(); + redrawLines(); }); li.append(color, value, label, remove); @@ -343,6 +396,19 @@ function renderRefList() { }); } +function syncRefInputs() { + const rows = document.querySelectorAll('#ref-list .ref-row'); + currentRefs().forEach((ref, i) => { + const input = rows[i] && rows[i].querySelector('input[type=number]'); + if (input && document.activeElement !== input) input.value = ref.value.toFixed(2); + }); +} + +/** Repaint reference/guide lines without rebuilding the plot. */ +function redrawLines() { + if (state.uplot) state.uplot.redraw(false, false); +} + // ---- plotting -------------------------------------------------------------- function yLogActive() { @@ -371,7 +437,21 @@ function themeColor(color) { return color; } -function buildUplotData(payload, colorFor) { +function dashFor(style) { + if (style === 'solid') return null; + if (style === 'dot') return [2, 3]; + return [6, 4]; +} + +function hexToRgba(hex, alpha) { + const h = hex.replace('#', ''); + const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; + const n = parseInt(full, 16); + const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; + return `rgba(${r},${g},${b},${alpha})`; +} + +function buildUplotData(payload) { // uPlot needs one shared x array and one column per series. Series may have // different x arrays (overlaid tracks of different lengths), so they are all // resampled onto the union of their own x values. For absolute time that is @@ -413,132 +493,176 @@ function buildUplotData(payload, colorFor) { const data = [x]; const series = []; - const constant = (v) => new Array(x.length).fill(v); datasets.forEach((d) => { - const colour = colorFor(d); + const colour = paletteFor(d.file_id); - d.payload.segments.forEach((seg, si) => { - const single = datasets.length === 1; - const tag = seg.label || 'band'; + d.payload.segments.forEach((seg) => { data.push(resample(seg.x, seg.lo)); data.push(resample(seg.x, seg.hi)); - // Bands draw as two lines; only the second gets a legend entry so the - // pair reads as one dataset. Styling differs by type: an RMS power fill - // is a solid area from zero, a waveform envelope is a bright line pair. + // Bands draw as two lines. Styling differs by type: an RMS power fill is + // a solid area from zero, a waveform envelope is a bright line pair. let loMin = 0; for (const v of seg.lo) if (v !== null && v < loMin) loMin = v; const isEnvelope = loMin < 0; - series.push({ - label: single ? (si === 0 ? tag : '') : (si === 0 ? `${d.label}: ${tag}` : ''), - stroke: colour, width: isEnvelope ? 1 : 1.4, - fill: isEnvelope ? null : hexToRgba(colour, 0.22), - _ref: null, - }); - series.push({ - label: '', - stroke: colour, width: isEnvelope ? 1 : 1.4, - fill: isEnvelope ? hexToRgba(colour, 0.28) : null, - _ref: null, - }); + series.push({ stroke: colour, width: isEnvelope ? 1 : 1.4, fill: isEnvelope ? null : hexToRgba(colour, 0.22) }); + series.push({ stroke: colour, width: isEnvelope ? 1 : 1.4, fill: isEnvelope ? hexToRgba(colour, 0.28) : null }); }); d.payload.series.forEach((line) => { - const single = datasets.length === 1; data.push(resample(line.x, line.y)); - series.push({ - label: single ? line.label : `${d.label}: ${line.label}`, - stroke: line.color || colour, - width: line.width || 1.4, - fill: null, - _ref: null, - }); - }); - }); - - // The metric's own reference lines (-14 LUFS, integrated loudness, 0 dBTP…). - // Identical lines from several tracks are drawn once; per-track lines such as - // "Integrated" are prefixed with the track when more than one is drawn. - const seen = new Set(); - datasets.forEach((d) => { - (d.payload.hlines || []).forEach((hl) => { - const key = `${hl.y}|${hl.label}`; - if (seen.has(key)) return; - seen.add(key); - data.push(constant(hl.y)); - series.push({ - label: datasets.length > 1 && /^Integrated/.test(hl.label) ? `${d.label}: ${hl.label}` : hl.label, - stroke: themeColor(hl.color), - width: hl.width || 1, - dash: dashFor(hl.style), - fill: null, - _ref: null, - }); - }); - }); - - // User reference lines become extra series with filled values, so uPlot draws - // them as part of the same resampled grid instead of an annotation overlay. - const axis = payload.axes || {}; - currentRefs().forEach((ref) => { - const lo = axis.y_range ? axis.y_range[0] : 0; - data.push(constant(axis.y_log ? Math.max(ref.value, lo) : ref.value)); - series.push({ - label: ref.label || `${ref.value}`, - stroke: ref.color || '#ffb74d', - width: 1, - dash: dashFor(ref.style), - fill: null, - _ref: ref, + series.push({ stroke: line.color || colour, width: line.width || 1.4, fill: null }); }); }); return { x, data, series }; } -function dashFor(style) { - if (style === 'solid') return null; - if (style === 'dot') return [2, 3]; - return [6, 4]; -} - -function hexToRgba(hex, alpha) { - const h = hex.replace('#', ''); - const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h; - const n = parseInt(full, 16); - const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255; - return `rgba(${r},${g},${b},${alpha})`; -} - -function renderLegend(payload) { - const box = el('legend'); - box.innerHTML = ''; +/** + * Horizontal lines to paint over the data: the metric's own guides + * (-14 LUFS, 0 dBTP, …) and the user's reference lines. + * + * Guides identical across tracks are drawn once; per-track guides (integrated + * loudness) take the track's colour when several tracks are overlaid. + */ +function collectLines(payload) { + const lines = []; + if (!payload) return lines; + const multi = payload.datasets.length > 1; + const seen = new Set(); payload.datasets.forEach((d) => { - const item = document.createElement('span'); - item.className = 'item'; - const sw = document.createElement('span'); - sw.className = 'swatch'; - sw.style.background = paletteFor(d.file_id); - const text = document.createElement('span'); - text.textContent = d.label; - item.append(sw, text); - box.appendChild(item); + (d.payload.hlines || []).forEach((hl) => { + const key = `${hl.y}|${hl.label}`; + if (!hl.perTrack && seen.has(key)) return; + seen.add(key); + lines.push({ + value: hl.y, + color: hl.perTrack && multi ? paletteFor(d.file_id) : themeColor(hl.color), + width: hl.width || 1, + dash: dashFor(hl.style), + label: hl.label, + side: 'right', + }); + }); + }); + currentRefs().forEach((ref) => { + lines.push({ + value: ref.value, + color: ref.color || '#ffb74d', + width: 1.4, + dash: dashFor(ref.style), + label: ref.label || ref.value.toFixed(2), + side: 'left', + handle: true, + }); + }); + return lines; +} + +/** uPlot draw hook: paint `lines` over the plot area, labels riding on them. */ +function drawPlotLines(u, lines) { + const { left, top, width, height } = u.bbox; // canvas pixels + const visible = lines + .map((ln) => ({ ...ln, y: Math.round(u.valToPos(ln.value, 'y', true)) + 0.5 })) + .filter((ln) => ln.y >= top - 1 && ln.y <= top + height + 1); + if (!visible.length) return; + const ctx = u.ctx; + const ratio = u.over.clientWidth ? width / u.over.clientWidth : 1; + + ctx.save(); + ctx.beginPath(); + ctx.rect(left, top, width, height); + ctx.clip(); + + // Every line first, then every label, so no label is struck through by a + // line drawn after it. + visible.forEach((ln) => { + ctx.strokeStyle = ln.color; + ctx.lineWidth = ln.width * ratio; + ctx.setLineDash((ln.dash || []).map((v) => v * ratio)); + ctx.beginPath(); + ctx.moveTo(left, ln.y); + ctx.lineTo(left + width, ln.y); + ctx.stroke(); + ctx.setLineDash([]); + if (ln.handle) { + ctx.fillStyle = ln.color; + ctx.beginPath(); + ctx.moveTo(left, ln.y - 6 * ratio); + ctx.lineTo(left + 11 * ratio, ln.y); + ctx.lineTo(left, ln.y + 6 * ratio); + ctx.closePath(); + ctx.fill(); + } }); - const notes = new Set(); - payload.datasets.forEach((d) => (d.payload.annotations || []).forEach((n) => notes.add( - payload.datasets.length > 1 ? `${d.label}: ${n}` : n))); - notes.forEach((n) => { - const item = document.createElement('span'); - item.className = 'item annotation'; - item.textContent = n; - box.appendChild(item); + // uPlot leaves its axis text alignment on the shared context; set our own. + ctx.font = font(10.5 * ratio); + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + const placed = { left: [], right: [] }; + const pad = 3 * ratio; + const h = 15 * ratio; + visible.forEach((ln) => { + if (!ln.label) return; + const tw = ctx.measureText(ln.label).width; + const x = ln.side === 'left' ? left + 16 * ratio : left + width - tw - 10 * ratio; + // Above the line, unless that would leave the plot; nudge past labels + // already placed on the same side so near-coincident lines stay legible. + let ty = ln.y - h - 2 * ratio < top ? ln.y + 2 * ratio : ln.y - h - 2 * ratio; + for (const other of placed[ln.side]) { + if (Math.abs(other - ty) < h) ty = other + (ty >= other ? h : -h); + } + placed[ln.side].push(ty); + ctx.fillStyle = 'rgba(13, 15, 20, 0.8)'; + ctx.fillRect(x - pad, ty, tw + 2 * pad, h); + ctx.fillStyle = ln.color; + ctx.fillText(ln.label, x, ty + h / 2); }); + ctx.restore(); +} + +/** The in-plot legend: a swatch per track (or series) plus scalar readouts. */ +function renderPlotLegend(payload, position) { + const box = el('plot-legend'); + box.innerHTML = ''; + const multi = payload.datasets.length > 1; + const add = (text, colour) => { + const entry = document.createElement('div'); + entry.className = colour ? 'entry' : 'entry note'; + if (colour) { + const swatch = document.createElement('span'); + swatch.className = 'swatch'; + swatch.style.background = colour; + entry.append(swatch); + } + const label = document.createElement('span'); + label.textContent = text; + entry.append(label); + box.append(entry); + }; + + payload.datasets.forEach((d) => { + const colour = paletteFor(d.file_id); + const labels = d.payload.heatmap + ? [] + : [...d.payload.segments, ...d.payload.series].map((s) => s.label).filter(Boolean); + if (multi || !labels.length) add(d.label, colour); + else labels.forEach((l) => add(l, colour)); + }); + payload.datasets.forEach((d) => (d.payload.annotations || []).forEach((note) => { + add(multi ? `${d.label}: ${note}` : note, null); + })); + + Object.assign(box.style, { top: 'auto', left: 'auto', right: 'auto' }, position); + box.hidden = !box.childElementCount; } function destroyPlot() { if (state.uplot) { state.uplot.destroy(); state.uplot = null; } el('heatmap').hidden = true; + el('plot-legend').hidden = true; + state.heatmapGeom = null; const host = el('plot-host'); Array.from(host.querySelectorAll('.uplot')).forEach((n) => n.remove()); } @@ -549,105 +673,113 @@ function plotSize() { return [Math.max(320, Math.floor(rect.width)), Math.max(180, Math.floor(rect.height))]; } -function buildAxes(axes) { - if (!axes) return { scales: {}, axes: [] }; - const yLog = yLogActive(); - const xLog = state.xLog === null ? !!axes.x_log : state.xLog; +function padRange(min, max) { + if (!(Number.isFinite(min) && Number.isFinite(max))) return [0, 1]; + if (min === max) return [min - 1, max + 1]; + const pad = (max - min) * 0.05; + return [min - pad, max + pad]; +} +function buildAxes(payload) { + const axes = payload.axes; + const yLog = yLogActive(); + + // Frame the union of the tracks' own spans, so overlaid tracks of different + // lengths don't clip to whichever happens to be first. + const spans = payload.datasets.map((d) => d.payload.axes.x_range).filter(Boolean); + const xDefault = spans.length + ? [Math.min(...spans.map((s) => s[0])), Math.max(...spans.map((s) => s[1]))] + : null; + + // Range functions consult the zoom window first, so a redraw (new track, + // overlay toggle, resize) keeps whatever the user zoomed or panned to. const scales = { - x: { time: false, log: xLog ? 10 : false }, - y: { log: yLog ? 10 : false }, + x: { time: false, range: (u, min, max) => state.zoom.x || xDefault || padRange(min, max) }, + y: { + distr: yLog ? 3 : 1, + log: 10, + range: (u, min, max) => state.zoom.y || axes.y_range || padRange(min, max), + }, }; - // Explicit ranges: the metric's own defaults, but only for the axes it frames. - // Letting uPlot auto-range a log axis over a series whose values start at 0 - // produces an unreadable scale, so ranged axes are applied as given. - if (axes.x_range) scales.x.range = [axes.x_range[0], axes.x_range[1]]; - if (axes.y_range) { - scales.y.range = (u, min, max) => (axes.y_range ? axes.y_range : [min, max]); - } else if (yLog) { - const bounds = state.payload.datasets - .map((d) => d.payload.y_bounds) - .filter(Boolean); - if (bounds.length) { - const lo = Math.min(...bounds.map((b) => Math.abs(b[0]) || 1e-3)); - const hi = Math.max(...bounds.map((b) => b[1])); - scales.y.range = () => [Math.max(lo, 1e-9), hi]; - } + const axisStyle = { + stroke: '#8b93a3', + grid: { stroke: 'rgba(255,255,255,.07)', width: 1 }, + ticks: { stroke: 'rgba(255,255,255,.12)' }, + font: font(11), + labelFont: font(11), + labelSize: 16, + }; + return { + scales, + axes: [ + { ...axisStyle, label: axes.x_label, size: 40, values: (u, vals, i, space, incr) => vals.map((v) => formatTick(v, incr)) }, + { ...axisStyle, label: axes.y_label, size: 58, values: (u, vals, i, space, incr) => vals.map((v) => formatTick(v, incr)) }, + ], + }; +} + +function drawPayload(payload) { + destroyPlot(); + el('placeholder').hidden = payload.datasets.length > 0; + refreshYLogButton(); + + const dataset = payload.datasets[0]; + if (!dataset) return; + if (dataset.payload.heatmap) { + renderHeatmap(payload); + return; } - const yAxis = { - stroke: '#8b93a3', - grid: { stroke: 'rgba(255,255,255,.07)', width: 1 }, - ticks: { stroke: 'rgba(255,255,255,.12)' }, - font: '11px system-ui, sans-serif', - label: axes.y_label, - labelFont: '11px system-ui, sans-serif', - labelSize: 16, - size: 58, - values: (u, vals) => formatTicks(vals, yLog), - }; - const xAxis = { - stroke: '#8b93a3', - grid: { stroke: 'rgba(255,255,255,.07)', width: 1 }, - ticks: { stroke: 'rgba(255,255,255,.12)' }, - font: '11px system-ui, sans-serif', - label: axes.x_label, - labelFont: '11px system-ui, sans-serif', - labelSize: 16, - size: 40, - values: (u, vals) => formatTicks(vals, xLog), - }; - - return { scales, axes: [xAxis, yAxis] }; -} - -function formatTicks(vals, isLog) { - return vals.map((v) => { - if (v === null || v === undefined) return ''; - if (isLog) { - if (v >= 1000) return `${(v / 1000).toFixed(v >= 10000 ? 0 : 1)}k`; - if (v >= 1) return `${Math.round(v)}`; - return v.toPrecision(1); - } - const abs = Math.abs(v); - if (abs >= 1000) return `${(v / 1000).toFixed(1)}k`; - if (abs >= 100) return v.toFixed(0); - if (abs >= 1) return v.toFixed(1); - if (abs === 0) return '0'; - return v.toFixed(2); - }); -} - -/** Draw the linear-row STFT onto a canvas, mapping rows to pixels in y. */ -function renderHeatmap(payload) { - const hm = payload.datasets[0].payload.heatmap; - const canvas = el('heatmap'); + const built = buildUplotData(payload); + if (!built) return; + const { scales, axes } = buildAxes(payload); const [w, h] = plotSize(); - const dpr = window.devicePixelRatio || 1; - canvas.hidden = false; - canvas.width = Math.floor(w * dpr); - canvas.height = Math.floor(h * dpr); - canvas.style.width = `${w}px`; - canvas.style.height = `${h}px`; - const ctx = canvas.getContext('2d'); - const padLeft = 58, padBottom = 40, padTop = 8, padRight = 10; - const plotW = Math.max(1, w - padLeft - padRight); - const plotH = Math.max(1, h - padTop - padBottom); + const u = new uPlot({ + width: w, + height: h, + padding: [10, 14, 6, 6], + cursor: { + drag: { x: false, y: false, setScale: false }, + points: { show: false }, + // Double-click is ours (reset to the metric's framing), not uPlot's autoscale. + bind: { dblclick: () => () => {} }, + }, + legend: { show: false }, + scales, + axes, + series: [{}, ...built.series.map((s) => ({ + stroke: s.stroke, width: s.width, fill: s.fill || undefined, points: { show: false }, + }))], + hooks: { + draw: [(plot) => drawPlotLines(plot, collectLines(state.payload))], + // uPlot sizes its plot area after construction, so the legend is placed + // once that layout exists (and again on every resize). + ready: [(plot) => { attachInteractions(plot.root, uplotAdapter(plot)); placeLegend(plot); }], + setSize: [placeLegend], + }, + }, built.data, el('plot-host')); + state.uplot = u; - const axes = payload.axes || {}; - const xRange = axes.x_range || [hm.x[0], hm.x[hm.x.length - 1]]; - const yRange = axes.y_range || [Math.max(hm.y[0], 1e-6), hm.y[hm.y.length - 1]]; - const yLog = yLogActive(); + function placeLegend(plot) { + renderPlotLegend(payload, { + top: `${plot.over.offsetTop + 10}px`, + right: `${plot.root.clientWidth - plot.over.offsetLeft - plot.over.clientWidth + 10}px`, + }); + } +} +// ---- spectrogram ------------------------------------------------------------- + +const heatmapImages = new WeakMap(); // z array -> offscreen canvas, one pixel per cell + +function heatmapImage(hm) { + let src = heatmapImages.get(hm.z); + if (src) return src; const { rows, cols } = hm; const lut = buildMagmaLut(256); - - // One offscreen pixel per STFT cell, then one scaled drawImage: the browser's - // bilinear scale is the interpolation, which keeps this O(cells) in JS and - // lets the GPU do the resampling for the display size. - const src = document.createElement('canvas'); + src = document.createElement('canvas'); src.width = cols; src.height = rows; const sctx = src.getContext('2d'); @@ -658,103 +790,183 @@ function renderHeatmap(payload) { const base = r * cols; for (let c = 0; c < cols; c++) { const t = (hm.z[base + c] - hm.z_min) / span; - const idx = Math.max(0, Math.min(255, Math.round(t * 255))); - const rgb = lut[idx]; + const rgb = lut[Math.max(0, Math.min(255, Math.round(t * 255)))]; const p = (base + c) * 4; pixels[p] = rgb[0]; pixels[p + 1] = rgb[1]; pixels[p + 2] = rgb[2]; pixels[p + 3] = 255; } } sctx.putImageData(img, 0, 0); - - // Rows are linear in frequency, so a linear y axis is a single stretch. A log - // axis needs row-by-row drawing: for each destination scanline work out which - // source row's frequency band it covers and blit that single row, expanded. - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.save(); - ctx.scale(dpr, dpr); - - if (!yLog) { - // z row 0 is the lowest frequency; canvas y=0 is the top, so flip by scaling. - ctx.save(); - ctx.translate(padLeft, padTop + plotH); - ctx.scale(1, -1); - ctx.imageSmoothingEnabled = true; - ctx.drawImage(src, 0, 0, cols, rows, 0, 0, plotW, plotH); - ctx.restore(); - } else { - const yLo = Math.max(yRange[0], hm.y0); - const yHi = yRange[1]; - const logLo = Math.log10(yLo); - const logHi = Math.log10(yHi); - const dy = hm.dy; - for (let py = 0; py < plotH; py++) { - // canvas top = high frequency - const freqHi = Math.pow(10, logHi - (py / plotH) * (logHi - logLo)); - const freqLo = Math.pow(10, logHi - ((py + 1) / plotH) * (logHi - logLo)); - let rHi = Math.round((freqHi - hm.y0) / dy) - 1; - let rLo = Math.round((freqLo - hm.y0) / dy); - rHi = Math.max(0, Math.min(rows - 1, rHi)); - rLo = Math.max(0, Math.min(rows - 1, rLo)); - const srcRow = Math.max(0, Math.min(rows - 1, Math.floor((rHi + rLo) / 2))); - ctx.drawImage(src, 0, srcRow, cols, 1, padLeft, padTop + py, plotW, 1); - } - } - - drawHeatmapAxes(ctx, { padLeft, padTop, plotW, plotH, xRange, yRange, yLog, hm, axes }); - ctx.restore(); + heatmapImages.set(hm.z, src); + return src; } -function drawHeatmapAxes(ctx, o) { - const { padLeft, padTop, plotW, plotH, xRange, yRange, yLog, hm, axes } = o; - ctx.font = '11px system-ui, sans-serif'; +/** Keep a view window inside `full` (in the axis's own lin/log domain). */ +function clampRange(range, full, log) { + const toD = (v) => (log ? Math.log10(v) : v); + const fromD = (v) => (log ? 10 ** v : v); + let a = toD(range[0]); + let b = toD(range[1]); + const fa = toD(full[0]); + const fb = toD(full[1]); + const minSpan = (fb - fa) * 1e-3; + if (b - a >= fb - fa) return [full[0], full[1]]; + if (b - a < minSpan) { const c = (a + b) / 2; a = c - minSpan / 2; b = c + minSpan / 2; } + if (a < fa) { b += fa - a; a = fa; } + if (b > fb) { a -= b - fb; b = fb; } + return [fromD(a), fromD(b)]; +} + +function heatmapGeometry(payload) { + const hm = payload.datasets[0].payload.heatmap; + const axes = payload.axes || {}; + const [w, h] = plotSize(); + const padLeft = 58, padBottom = 40, padTop = 10, padRight = 14; + const yLog = yLogActive(); + const xFull = axes.x_range || [hm.x[0], hm.x[hm.x.length - 1]]; + const yFull = axes.y_range || [hm.y[0], hm.y[hm.y.length - 1]]; + return { + hm, axes, w, h, padLeft, padTop, yLog, xFull, yFull, + plotW: Math.max(1, w - padLeft - padRight), + plotH: Math.max(1, h - padTop - padBottom), + xView: clampRange(state.zoom.x || xFull, xFull, false), + yView: clampRange(state.zoom.y || yFull, yFull, yLog), + }; +} + +/** Draw the visible part of the linear-row STFT, mapping rows to pixels in y. */ +function renderHeatmap(payload) { + const g = heatmapGeometry(payload); + state.heatmapGeom = g; + const { hm, w, h, padLeft, padTop, plotW, plotH, xView, yView, yLog } = g; + + const canvas = el('heatmap'); + const dpr = window.devicePixelRatio || 1; + canvas.hidden = false; + if (canvas.width !== Math.floor(w * dpr) || canvas.height !== Math.floor(h * dpr)) { + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + } + const ctx = canvas.getContext('2d'); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const src = heatmapImage(hm); + const { rows, cols } = hm; + + // Time -> source column coordinate: the track's time span covers [0, cols]. + const t0 = hm.x[0]; + const tSpan = (hm.x[cols - 1] - t0) || 1; + const toCol = (t) => ((t - t0) / tSpan) * cols; + const fromCol = (c) => t0 + (c / cols) * tSpan; + const xPix = (t) => padLeft + ((t - xView[0]) / (xView[1] - xView[0])) * plotW; + const cA = Math.max(0, toCol(xView[0])); + const cB = Math.min(cols, toCol(xView[1])); + const dxA = xPix(fromCol(cA)); + const dxB = xPix(fromCol(cB)); + + ctx.save(); + ctx.beginPath(); + ctx.rect(padLeft, padTop, plotW, plotH); + ctx.clip(); + ctx.imageSmoothingEnabled = true; + if (cB > cA) { + if (!yLog) { + // Rows are linear in frequency: one stretched blit of the visible block. + // Row r is centred on y0 + r*dy; image row 0 (lowest bin) goes at the bottom. + const toRow = (f) => (f - hm.y0) / hm.dy + 0.5; + const fromRow = (r) => hm.y0 + (r - 0.5) * hm.dy; + const yPix = (f) => padTop + plotH * (1 - (f - yView[0]) / (yView[1] - yView[0])); + const rA = Math.max(0, toRow(yView[0])); + const rB = Math.min(rows, toRow(yView[1])); + if (rB > rA) { + const top = yPix(fromRow(rB)); + const bottom = yPix(fromRow(rA)); + ctx.save(); + ctx.translate(0, top + bottom); + ctx.scale(1, -1); + ctx.drawImage(src, cA, rA, cB - cA, rB - rA, dxA, top, dxB - dxA, bottom - top); + ctx.restore(); + } + } else { + // Log axis: each scanline shows the row at its frequency. + const la = Math.log10(yView[0]); + const lb = Math.log10(yView[1]); + for (let py = 0; py < plotH; py++) { + const f = 10 ** (lb - ((py + 0.5) / plotH) * (lb - la)); + const r = Math.floor((f - hm.y0) / hm.dy + 0.5); + if (r < 0 || r >= rows) continue; + ctx.drawImage(src, cA, r, cB - cA, 1, dxA, padTop + py, dxB - dxA, 1); + } + } + } + ctx.restore(); + + drawHeatmapAxes(ctx, g); + drawColorbar(ctx, g); + renderPlotLegend(payload, { top: `${padTop + 10}px`, left: `${padLeft + 10}px` }); +} + +function niceTicks(a, b, target) { + const span = b - a; + if (!(span > 0)) return [a]; + const raw = span / Math.max(1, target); + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw * 0.999); + const ticks = []; + for (let i = Math.ceil(a / step - 1e-9); i * step <= b + step * 1e-9; i++) ticks.push(i * step); + return ticks; +} + +function logTicks(a, b) { + const ticks = []; + for (let e = Math.floor(Math.log10(a)); e <= Math.ceil(Math.log10(b)); e++) { + for (const m of [1, 2, 5]) { + const v = m * 10 ** e; + if (v >= a && v <= b) ticks.push(v); + } + } + return ticks.length >= 3 ? ticks : niceTicks(a, b, 5); +} + +function fmtHz(v) { + if (v >= 1000) return `${Number((v / 1000).toPrecision(4))}k`; + return `${Number(v.toPrecision(4))}`; +} + +function drawHeatmapAxes(ctx, g) { + const { padLeft, padTop, plotW, plotH, xView, yView, yLog, axes } = g; + ctx.font = font(11); ctx.strokeStyle = 'rgba(255,255,255,.12)'; ctx.fillStyle = '#8b93a3'; - // y ticks: 1-2-5 per decade on a log axis, ~6 even steps on a linear one. - const yTicks = []; - if (yLog) { - for (let e = Math.floor(Math.log10(Math.max(yRange[0], 1))); e <= Math.log10(yRange[1]); e++) { - const f = Math.pow(10, e); - for (const m of [1, 2, 5]) { - const v = m * f; - if (v >= yRange[0] && v <= yRange[1]) yTicks.push(v); - } - } - } else { - const n = 5; - for (let i = 0; i <= n; i++) yTicks.push(yRange[0] + (yRange[1] - yRange[0]) * i / n); - } const yPos = (v) => { - if (yLog) { - const t = (Math.log10(v) - Math.log10(Math.max(yRange[0], 1e-6))) / - (Math.log10(yRange[1]) - Math.log10(Math.max(yRange[0], 1e-6))); - return padTop + plotH * (1 - t); - } - const t = (v - yRange[0]) / ((yRange[1] - yRange[0]) || 1); + const t = yLog + ? (Math.log10(v) - Math.log10(yView[0])) / (Math.log10(yView[1]) - Math.log10(yView[0])) + : (v - yView[0]) / (yView[1] - yView[0]); return padTop + plotH * (1 - t); }; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - yTicks.forEach((v) => { + (yLog ? logTicks(yView[0], yView[1]) : niceTicks(yView[0], yView[1], 6)).forEach((v) => { const y = yPos(v); if (y < padTop - 1 || y > padTop + plotH + 1) return; ctx.beginPath(); ctx.moveTo(padLeft - 4, y); ctx.lineTo(padLeft, y); ctx.stroke(); - ctx.fillText(v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${Math.round(v)}`, padLeft - 7, y); + ctx.fillText(fmtHz(v), padLeft - 7, y); }); - ctx.textAlign = 'center'; + const xTicks = niceTicks(xView[0], xView[1], Math.max(3, Math.floor(plotW / 110))); + const xStep = xTicks.length > 1 ? xTicks[1] - xTicks[0] : 1; ctx.textBaseline = 'top'; - const n = 6; - for (let i = 0; i <= n; i++) { - const t = i / n; - const x = padLeft + plotW * t; - const v = xRange[0] + (xRange[1] - xRange[0]) * t; + xTicks.forEach((v) => { + const x = padLeft + ((v - xView[0]) / (xView[1] - xView[0])) * plotW; + if (x < padLeft - 1 || x > padLeft + plotW + 1) return; ctx.beginPath(); ctx.moveTo(x, padTop + plotH); ctx.lineTo(x, padTop + plotH + 4); ctx.stroke(); - ctx.textAlign = i === n ? 'right' : 'center'; - ctx.fillText(v.toFixed(1), x, padTop + plotH + 7); - } + ctx.textAlign = x > padLeft + plotW - 24 ? 'right' : 'center'; + ctx.fillText(formatTick(v, xStep), x, padTop + plotH + 7); + }); - ctx.fillStyle = '#8b93a3'; ctx.textAlign = 'center'; ctx.fillText(axes.x_label || 'Time (seconds)', padLeft + plotW / 2, padTop + plotH + 22); ctx.save(); @@ -763,26 +975,27 @@ function drawHeatmapAxes(ctx, o) { ctx.textBaseline = 'middle'; ctx.fillText(axes.y_label || 'Frequency (Hz)', 0, 0); ctx.restore(); - - drawColorbar(ctx, hm); } -function drawColorbar(ctx, hm) { - // A compact colourbar in the top-right of the plot area — the desktop app - // gets this free from pyqtgraph; here it is drawn as a 12 px strip. - const x = el('plot-host').clientWidth - 74; - const y = 14, w = 12, h = 96; +function drawColorbar(ctx, g) { + // A compact colourbar in the plot's top-right corner — the desktop app gets + // this free from pyqtgraph; here it is a 12 px strip on a dimmed backing. + const { hm, padLeft, padTop, plotW } = g; + const w = 12, h = 96; + const x = padLeft + plotW - 58; + const y = padTop + 10; const lut = buildMagmaLut(64); + ctx.fillStyle = 'rgba(13, 15, 20, 0.7)'; + ctx.fillRect(x - 6, y - 6, 58, h + 12); for (let i = 0; i < h; i++) { - const t = 1 - i / (h - 1); - const [r, g, b] = lut[Math.round(t * 63)]; - ctx.fillStyle = `rgb(${r},${g},${b})`; + const [r, gr, b] = lut[Math.round((1 - i / (h - 1)) * 63)]; + ctx.fillStyle = `rgb(${r},${gr},${b})`; ctx.fillRect(x, y + i, w, 1); } ctx.strokeStyle = 'rgba(255,255,255,.25)'; ctx.strokeRect(x, y, w, h); ctx.fillStyle = '#8b93a3'; - ctx.font = '10px system-ui, sans-serif'; + ctx.font = font(10); ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(`${hm.z_max.toFixed(0)} dB`, x + w + 4, y + 4); @@ -817,117 +1030,186 @@ function buildMagmaLut(n) { return lut; } -function redrawRefs() { - if (state.payload) drawPayload(state.payload); +// ---- zoom, pan & line dragging ------------------------------------------------- +// The desktop ViewBox semantics: scroll zooms both axes around the cursor, +// Ctrl+scroll zooms time only, Shift+scroll the value axis only, and scrolling +// over an axis zooms just that axis. Dragging the plot pans; dragging near a +// reference line (or its triangle handle) moves the line. Both update live. +// Double-click returns to the metric's own framing. + +const toDomain = (v, log) => (log ? Math.log10(v) : v); +const fromDomain = (v, log) => (log ? 10 ** v : v); + +function zoomRange(range, frac, factor, log) { + const a = toDomain(range[0], log); + const b = toDomain(range[1], log); + const anchor = a + frac * (b - a); + return [fromDomain(anchor - (anchor - a) * factor, log), fromDomain(anchor + (b - anchor) * factor, log)]; } -function drawPayload(payload) { - destroyPlot(); - el('placeholder').hidden = payload.datasets.length > 0; - refreshYLogButton(); +function shiftRange(range, frac, log) { + const a = toDomain(range[0], log); + const b = toDomain(range[1], log); + const d = frac * (b - a); + return [fromDomain(a + d, log), fromDomain(b + d, log)]; +} - const dataset = payload.datasets[0]; - if (dataset && dataset.payload.heatmap) { - renderHeatmap(payload); - return; - } +/** 'plot', 'x' (under the plot), 'y' (left of it) or null. */ +function regionAt(e, rect) { + if (!rect) return null; + const fx = (e.clientX - rect.left) / rect.width; + const fy = (e.clientY - rect.top) / rect.height; + const inX = fx >= 0 && fx <= 1; + const inY = fy >= 0 && fy <= 1; + if (inX && inY) return 'plot'; + if (inY && fx < 0) return 'y'; + if (inX && fy > 1) return 'x'; + return null; +} - const built = buildUplotData(payload, (d) => paletteFor(d.file_id)); - if (!built) return; +let drag = null; +let pendingMove = null; +let moveFrame = 0; - const { scales, axes } = buildAxes(payload.axes); - const [w, h] = plotSize(); +function attachInteractions(target, adapter) { + target.addEventListener('wheel', (e) => { + const rect = adapter.rect(); + const region = regionAt(e, rect); + if (!region) return; + e.preventDefault(); + let zoomX = region !== 'y'; + let zoomY = region !== 'x'; + if (region === 'plot' && e.ctrlKey !== e.shiftKey) { + zoomX = e.ctrlKey; + zoomY = e.shiftKey; + } + // Shift+wheel arrives as horizontal scroll in most browsers. + let delta = e.deltaY || e.deltaX; + if (e.deltaMode === 1) delta *= 16; + else if (e.deltaMode === 2) delta *= rect.height; + const factor = Math.exp(Math.max(-250, Math.min(250, delta)) * 0.002); + const cur = adapter.ranges(); + const fx = (e.clientX - rect.left) / rect.width; + const fy = (e.clientY - rect.top) / rect.height; + adapter.apply( + zoomX ? zoomRange(cur.x, fx, factor, adapter.log.x) : null, + zoomY ? zoomRange(cur.y, 1 - fy, factor, adapter.log.y) : null, + ); + }, { passive: false }); - const seriesSpec = [{ label: 'x', value: (u, v) => (v == null ? '--' : v.toFixed(2)) }]; - built.series.forEach((s) => { - seriesSpec.push({ - label: s.label, - stroke: s.stroke, - width: s.width, - fill: s.fill || undefined, - dash: s.dash || undefined, - points: { show: false }, - value: (u, v) => (v == null ? '--' : v.toFixed(3)), - }); + target.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + const rect = adapter.rect(); + if (regionAt(e, rect) !== 'plot') return; + const ref = adapter.hitRef ? adapter.hitRef(e.clientY) : null; + const cur = adapter.ranges(); + drag = ref + ? { kind: 'ref', adapter, ref } + : { kind: 'pan', adapter, rect, startX: e.clientX, startY: e.clientY, x: cur.x, y: cur.y }; + document.body.classList.add(ref ? 'dragging-ref' : 'dragging-pan'); + e.preventDefault(); }); - state.uplot = new uPlot({ - width: w, - height: h, - padding: [8, 10, 8, 0], - cursor: { - drag: { x: true, y: false, setScale: false }, - sync: { key: 'ujm' }, - points: { size: 6 }, - }, - legend: { show: true, live: true }, - scales, - axes, - series: seriesSpec, - plugins: [refLineDragPlugin(built.series)], - }, built.data, el('plot-host')); + target.addEventListener('dblclick', (e) => { + if (!regionAt(e, adapter.rect())) return; + e.preventDefault(); + adapter.reset(); + }); - el('placeholder').hidden = true; + target.addEventListener('mousemove', (e) => { + if (drag) return; + const region = regionAt(e, adapter.rect()); + const nearRef = region === 'plot' && adapter.hitRef && adapter.hitRef(e.clientY); + target.style.cursor = nearRef ? 'ns-resize' : region === 'plot' ? 'grab' : ''; + }); } -/** Drag a reference line vertically: the desktop app's draggable HLine. */ -function refLineDragPlugin(seriesList) { +function applyDrag(e) { + const a = drag.adapter; + if (drag.kind === 'ref') { + drag.ref.value = a.valueAtY(e.clientY); + syncRefInputs(); + a.redrawLines(); + return; + } + const fx = (e.clientX - drag.startX) / drag.rect.width; + const fy = (e.clientY - drag.startY) / drag.rect.height; + a.apply(shiftRange(drag.x, -fx, a.log.x), shiftRange(drag.y, fy, a.log.y)); +} + +window.addEventListener('mousemove', (e) => { + if (!drag) return; + pendingMove = e; + if (!moveFrame) { + moveFrame = requestAnimationFrame(() => { + moveFrame = 0; + if (drag && pendingMove) applyDrag(pendingMove); + }); + } +}); + +window.addEventListener('mouseup', () => { + if (!drag) return; + if (pendingMove) applyDrag(pendingMove); // land exactly where the button was released + drag = null; + pendingMove = null; + document.body.classList.remove('dragging-ref', 'dragging-pan'); +}); + +function uplotAdapter(u) { + const overTop = () => u.over.getBoundingClientRect().top; return { - hooks: { - ready: (u) => { - const over = u.over; - let dragging = null; - const valueAt = (frac) => { - const yMin = u.scales.y.min, yMax = u.scales.y.max; - return u.scales.y.log - ? Math.pow(10, Math.log10(yMax) - frac * (Math.log10(yMax) - Math.log10(yMin))) - : yMax - frac * (yMax - yMin); - }; - - over.addEventListener('mousedown', (e) => { - if (e.button !== 0) return; - const { top, height } = over.getBoundingClientRect(); - const target = valueAt((e.clientY - top) / height); - const yMin = u.scales.y.min, yMax = u.scales.y.max; - - let best = null, bestDist = Infinity; - seriesList.forEach((s) => { - if (!s._ref) return; - const dist = Math.abs(s._ref.value - target) / ((yMax - yMin) || 1); - if (dist < bestDist) { bestDist = dist; best = s._ref; } - }); - // Only grab a line you actually clicked near (6 % of the axis). - if (!best || bestDist > 0.06) return; - dragging = best; - over.style.cursor = 'ns-resize'; - e.preventDefault(); - }); - - window.addEventListener('mousemove', (e) => { - if (!dragging) return; - const { top, height } = over.getBoundingClientRect(); - dragging.value = valueAt(Math.min(1, Math.max(0, (e.clientY - top) / height))); - syncRefInputs(); - }); - - window.addEventListener('mouseup', () => { - if (!dragging) return; - dragging = null; - over.style.cursor = ''; - redrawRefs(); - }); - }, + log: { x: false, y: u.scales.y.distr === 3 }, + rect: () => u.over.getBoundingClientRect(), + ranges: () => ({ x: [u.scales.x.min, u.scales.x.max], y: [u.scales.y.min, u.scales.y.max] }), + apply: (x, y) => { + if (x) state.zoom.x = x; + if (y) state.zoom.y = y; + u.batch(() => { + if (x) u.setScale('x', { min: x[0], max: x[1] }); + if (y) u.setScale('y', { min: y[0], max: y[1] }); + }); }, + reset: () => { + resetZoom(); + drawPayload(state.payload); + }, + hitRef: (clientY) => { + const top = overTop(); + let best = null; + let bestDist = 7; + currentRefs().forEach((ref) => { + const d = Math.abs(u.valToPos(ref.value, 'y') + top - clientY); + if (d < bestDist) { bestDist = d; best = ref; } + }); + return best; + }, + valueAtY: (clientY) => u.posToVal(clientY - overTop(), 'y'), + redrawLines: () => u.redraw(false, false), }; } -function syncRefInputs() { - const rows = document.querySelectorAll('#ref-list .ref-row'); - currentRefs().forEach((ref, i) => { - const input = rows[i] && rows[i].querySelector('input[type=number]'); - if (input && document.activeElement !== input) input.value = ref.value.toFixed(2); - }); -} +const heatmapAdapter = { + get log() { return { x: false, y: !!(state.heatmapGeom && state.heatmapGeom.yLog) }; }, + rect: () => { + const g = state.heatmapGeom; + if (!g) return null; + const r = el('heatmap').getBoundingClientRect(); + return { left: r.left + g.padLeft, top: r.top + g.padTop, width: g.plotW, height: g.plotH }; + }, + ranges: () => ({ x: state.heatmapGeom.xView, y: state.heatmapGeom.yView }), + apply: (x, y) => { + const g = state.heatmapGeom; + if (x) state.zoom.x = clampRange(x, g.xFull, false); + if (y) state.zoom.y = clampRange(y, g.yFull, g.yLog); + renderHeatmap(state.payload); + }, + reset: () => { + resetZoom(); + renderHeatmap(state.payload); + }, + hitRef: null, +}; // ---- orchestration --------------------------------------------------------- @@ -942,7 +1224,7 @@ function buildPlotPayload() { const warnings = []; state.overlay.map(fileById).filter(Boolean).forEach((f, index) => { - const data = state.results[f.file_id] && state.results[f.file_id][metric.id]; + const data = resultFor(f.file_id, metric.id); if (!data) { warnings.push(`${f.song_name}: ${metric.displayName} not available`); return; @@ -984,7 +1266,7 @@ async function refreshPlot() { const failures = []; for (const fileId of state.overlay) { const f = fileById(fileId); - if (!f || (state.results[fileId] && state.results[fileId][metric.id])) continue; + if (!f || resultFor(fileId, metric.id)) continue; setStatus(`Computing ${metric.displayName} — ${f.name}…`, 'working'); try { await requestMetric(fileId, metric.id); @@ -999,7 +1281,6 @@ async function refreshPlot() { el('plot-title').textContent = payload.datasets.length ? `${payload.metric_name}${payload.datasets.length > 1 ? ` — ${payload.datasets.length} tracks` : ` — ${payload.datasets[0].label}`}` : `${payload.metric_name} — nothing selected`; - renderLegend(payload); drawPayload(payload); const computeMs = payload.datasets @@ -1012,7 +1293,7 @@ async function refreshPlot() { const warnings = failures.concat(payload.warnings); if (warnings.length) setStatus(warnings.join(' · '), 'warn'); - else if (!state.files.length) setStatus('Drop audio files to begin — they never leave this tab'); + else if (!state.files.length) setStatus('Drop audio files to begin'); else if (!payload.datasets.length) setStatus('Tick a file to draw it', 'warn'); else setStatus('Ready'); } catch (err) { @@ -1099,17 +1380,20 @@ function wireEvents() { state.refs = refsFor(state.metric); state.yLog = null; state.xLog = null; + resetZoom(); renderRefList(); await refreshPlot(); }); el('x-mode-select').addEventListener('change', async (e) => { state.xMode = e.target.value; + state.zoom.x = null; await refreshPlot(); }); el('toggle-y-log').addEventListener('click', async () => { state.yLog = !yLogActive(); + state.zoom.y = null; await refreshPlot(); }); @@ -1124,28 +1408,28 @@ function wireEvents() { el('add-ref').addEventListener('click', () => { const refs = currentRefs(); + const u = state.uplot; const axis = (state.payload && state.payload.axes) || {}; - const range = axis.y_range || [0, 1]; - const centre = axis.y_log - ? Math.pow(10, (Math.log10(Math.max(range[0], 1e-3)) + Math.log10(range[1])) / 2) - : (range[0] + range[1]) / 2; + const range = u ? [u.scales.y.min, u.scales.y.max] : axis.y_range || [0, 1]; refs.push({ - value: Number(centre.toFixed(2)), + value: Number(((range[0] + range[1]) / 2).toFixed(2)), color: PALETTE[(refs.length + 3) % PALETTE.length], style: 'dash', label: '', }); renderRefList(); - redrawRefs(); + redrawLines(); }); el('clear-refs').addEventListener('click', () => { state.refsByMetric[state.metric] = []; state.refs = state.refsByMetric[state.metric]; renderRefList(); - redrawRefs(); + redrawLines(); }); + attachInteractions(el('heatmap'), heatmapAdapter); + // Drag & drop onto the window, like the desktop app's setAcceptDrops(True). const overlay = el('drop-overlay'); let dragDepth = 0; @@ -1184,7 +1468,7 @@ function boot() { renderFileList(); renderMetadata(); renderRefList(); - setStatus('Drop audio files to begin — they never leave this tab'); + setStatus('Drop audio files to begin'); } boot(); @@ -1193,5 +1477,5 @@ boot(); window.__ujm = { state, ready: () => !running && !queue.length, - results: (fileId, metricId) => state.results[fileId] && state.results[fileId][metricId], + results: (fileId, metricId) => resultFor(fileId, metricId), }; diff --git a/web/index.html b/web/index.html index d4f0272..7c59b5d 100644 --- a/web/index.html +++ b/web/index.html @@ -32,7 +32,7 @@
- + @@ -67,7 +67,7 @@

No track loaded

-
+
Scroll to zoom · Ctrl: time only · Shift: values only · drag to pan · double-click to reset
@@ -79,6 +79,7 @@ and closing the tab discards it. Analysis runs at the file's native sample rate.

+
diff --git a/web/metrics.js b/web/metrics.js index 215d930..e549b08 100644 --- a/web/metrics.js +++ b/web/metrics.js @@ -69,6 +69,7 @@ export const METRICS = [ hlines.push({ y: data.integrated, label: `Integrated: ${data.integrated.toFixed(1)} LUFS`, color: '#e76f51', style: 'dash', width: 1.5, + perTrack: true, // differs per track: coloured by track when overlaid }); } if (Number.isFinite(data.lra)) annotations.push(`LRA: ${data.lra.toFixed(1)} LU`);