summaryrefslogtreecommitdiff
path: root/tools/spectral_editor/script.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-06 22:53:29 +0100
committerskal <pascal.massimino@gmail.com>2026-02-06 22:53:29 +0100
commit0c98c830b382d66c420524ff395e12164a566dd8 (patch)
treedc80376bd0f0a492b7a74037ae84a926af18bf15 /tools/spectral_editor/script.js
parent64145080cddbc0fe9fec7159e9ffdedca48ae9be (diff)
feat(spectral_editor): Add cursor-centered zoom and pan with mouse wheel
Implemented zoom and pan system for the spectral editor: Core Features: - Viewport offset system (viewportOffsetX, viewportOffsetY) for panning - Three wheel interaction modes: * Ctrl/Cmd + wheel: Cursor-centered zoom (both axes) * Shift + wheel: Horizontal pan * Normal wheel: Vertical pan - Zoom range: 0.5-20.0x horizontal, 0.1-5.0x vertical - Zoom factor: 0.9/1.1 per wheel notch (10% change) Technical Implementation: - Calculate data position under cursor before zoom - Apply zoom to pixelsPerFrame and pixelsPerBin - Adjust viewport offsets to keep cursor position stable - Clamp offsets to valid ranges (0 to max content size) - Updated all coordinate conversion functions (screenToSpectrogram, spectrogramToScreen) - Updated playhead rendering with visibility check - Reset viewport offsets on file load Algorithm (cursor-centered zoom): 1. Calculate frame and frequency under cursor: pos = (screen + offset) / scale 2. Apply zoom: scale *= zoomFactor 3. Adjust offset: offset = pos * scale - screen 4. Clamp offset to [0, maxOffset] This matches the zoom behavior of the timeline editor, adapted for 2D spectrogram display. handoff(Claude): Spectral editor zoom implementation complete
Diffstat (limited to 'tools/spectral_editor/script.js')
-rw-r--r--tools/spectral_editor/script.js74
1 files changed, 69 insertions, 5 deletions
diff --git a/tools/spectral_editor/script.js b/tools/spectral_editor/script.js
index 6c6dd49..024005e 100644
--- a/tools/spectral_editor/script.js
+++ b/tools/spectral_editor/script.js
@@ -30,6 +30,8 @@ const state = {
canvasHeight: 0,
pixelsPerFrame: 2.0, // Zoom level (pixels per frame)
pixelsPerBin: 1.0, // Vertical scale (pixels per frequency bin)
+ viewportOffsetX: 0, // Horizontal pan offset (pixels)
+ viewportOffsetY: 0, // Vertical pan offset (pixels)
// Audio playback
audioContext: null,
@@ -94,6 +96,9 @@ function initCanvas() {
// Mouse hover handlers (for crosshair)
canvas.addEventListener('mousemove', onCanvasHover);
canvas.addEventListener('mouseleave', onCanvasLeave);
+
+ // Mouse wheel: zoom (with Ctrl/Cmd) or pan
+ canvas.addEventListener('wheel', onCanvasWheel, { passive: false });
}
function initUI() {
@@ -404,6 +409,9 @@ function onReferenceLoaded(fileName) {
// Adjust zoom to fit
state.pixelsPerFrame = Math.max(1.0, state.canvasWidth / state.referenceNumFrames);
+ state.pixelsPerBin = 1.0; // Reset vertical scale
+ state.viewportOffsetX = 0; // Reset pan
+ state.viewportOffsetY = 0;
updateCurveUI();
updateUndoRedoButtons();
@@ -849,12 +857,65 @@ function onCanvasLeave(e) {
render();
}
+function onCanvasWheel(e) {
+ e.preventDefault();
+
+ const canvas = e.target;
+ const rect = canvas.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+
+ // Zoom mode: Ctrl/Cmd + wheel
+ if (e.ctrlKey || e.metaKey) {
+ // Calculate frame and frequency under cursor BEFORE zoom
+ const frameUnderCursor = (mouseX + state.viewportOffsetX) / state.pixelsPerFrame;
+ const freqUnderCursor = (mouseY + state.viewportOffsetY) / state.pixelsPerBin;
+
+ // Calculate new zoom level
+ const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Wheel down = zoom out, wheel up = zoom in
+ const oldPixelsPerFrame = state.pixelsPerFrame;
+ const oldPixelsPerBin = state.pixelsPerBin;
+
+ state.pixelsPerFrame = Math.max(0.5, Math.min(20.0, state.pixelsPerFrame * zoomFactor));
+ state.pixelsPerBin = Math.max(0.1, Math.min(5.0, state.pixelsPerBin * zoomFactor));
+
+ // Adjust viewport offset so frame/freq under cursor stays in same screen position
+ // After zoom: new_offset = frame * newPixelsPerFrame - mouseX
+ state.viewportOffsetX = frameUnderCursor * state.pixelsPerFrame - mouseX;
+ state.viewportOffsetY = freqUnderCursor * state.pixelsPerBin - mouseY;
+
+ // Clamp viewport offset to valid range
+ const maxOffsetX = Math.max(0, state.referenceNumFrames * state.pixelsPerFrame - state.canvasWidth);
+ const maxOffsetY = Math.max(0, DCT_SIZE * state.pixelsPerBin - state.canvasHeight);
+ state.viewportOffsetX = Math.max(0, Math.min(maxOffsetX, state.viewportOffsetX));
+ state.viewportOffsetY = Math.max(0, Math.min(maxOffsetY, state.viewportOffsetY));
+
+ render();
+ return;
+ }
+
+ // Pan mode: Shift + wheel (horizontal/vertical pan)
+ if (e.shiftKey) {
+ state.viewportOffsetX += e.deltaY;
+ const maxOffsetX = Math.max(0, state.referenceNumFrames * state.pixelsPerFrame - state.canvasWidth);
+ state.viewportOffsetX = Math.max(0, Math.min(maxOffsetX, state.viewportOffsetX));
+ render();
+ return;
+ }
+
+ // Normal mode: pan vertically
+ state.viewportOffsetY += e.deltaY;
+ const maxOffsetY = Math.max(0, DCT_SIZE * state.pixelsPerBin - state.canvasHeight);
+ state.viewportOffsetY = Math.max(0, Math.min(maxOffsetY, state.viewportOffsetY));
+ render();
+}
+
// ============================================================================
// Coordinate Conversion
// ============================================================================
function screenToSpectrogram(screenX, screenY) {
- const frame = Math.round(screenX / state.pixelsPerFrame);
+ const frame = Math.round((screenX + state.viewportOffsetX) / state.pixelsPerFrame);
let freqHz;
if (USE_LOG_SCALE) {
@@ -881,7 +942,7 @@ function screenToSpectrogram(screenX, screenY) {
}
function spectrogramToScreen(frame, freqHz) {
- const x = frame * state.pixelsPerFrame;
+ const x = frame * state.pixelsPerFrame - state.viewportOffsetX;
let y;
if (USE_LOG_SCALE) {
@@ -891,11 +952,11 @@ function spectrogramToScreen(frame, freqHz) {
const clampedFreq = Math.max(FREQ_MIN, Math.min(FREQ_MAX, freqHz));
const logFreq = Math.log10(clampedFreq);
const normalizedY = (logFreq - logMin) / (logMax - logMin);
- y = state.canvasHeight * (1.0 - normalizedY); // Flip Y back to screen coords
+ y = state.canvasHeight * (1.0 - normalizedY) - state.viewportOffsetY; // Flip Y back to screen coords
} else {
// Linear frequency mapping (old behavior)
const bin = (freqHz / (SAMPLE_RATE / 2)) * state.referenceDctSize;
- y = state.canvasHeight - (bin * state.pixelsPerBin);
+ y = state.canvasHeight - (bin * state.pixelsPerBin) - state.viewportOffsetY;
}
return {x, y};
@@ -943,7 +1004,10 @@ function render() {
function drawPlayhead(ctx) {
if (!state.isPlaying || state.playbackCurrentFrame < 0) return;
- const x = state.playbackCurrentFrame * state.pixelsPerFrame;
+ const x = state.playbackCurrentFrame * state.pixelsPerFrame - state.viewportOffsetX;
+
+ // Only draw if playhead is visible in viewport
+ if (x < 0 || x > state.canvasWidth) return;
// Draw vertical line
ctx.strokeStyle = '#ff3333'; // Bright red