diff options
| author | skal <pascal.massimino@gmail.com> | 2026-02-06 22:53:29 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-02-06 22:53:29 +0100 |
| commit | 0c98c830b382d66c420524ff395e12164a566dd8 (patch) | |
| tree | dc80376bd0f0a492b7a74037ae84a926af18bf15 /tools/spectral_editor | |
| parent | 64145080cddbc0fe9fec7159e9ffdedca48ae9be (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')
| -rw-r--r-- | tools/spectral_editor/script.js | 74 |
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 |
