diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 730e160..d8a0c9a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -83,7 +83,8 @@ "Bash(touch:*)", "Bash(open http://localhost:5173/debug-multiple.html)", "Bash(open http://localhost:5173/debug-focus-test.html)", - "Bash(open http://localhost:5175/debug.html)" + "Bash(open http://localhost:5175/debug.html)", + "Bash(git add:*)" ] } } \ No newline at end of file diff --git a/Memory_Bank.md b/Memory_Bank.md index 0646024..8474d35 100644 --- a/Memory_Bank.md +++ b/Memory_Bank.md @@ -123,6 +123,45 @@ None **Next Steps:** Ready for pull request creation. Implementation successfully addresses GitHub Issue #126 by eliminating the need to commit version.js changes after local development builds. +## Issue #130 - Fix Manual Harmonics Visibility When Zoomed - 2025-01-08 + +**Task Reference:** GitHub Issue #130 and Task Assignment Prompt Task_Issue_130.md +**Problem:** Manual harmonics were placed at the center of the overall time period instead of the center of the currently visible (zoomed) time period, making them invisible to users when zoomed in. + +**Root Cause Analysis:** +- In `ManualHarmonicModal.js` (lines 88-89), when no cursor position was available, anchor time was calculated using `(state.config.timeMin + state.config.timeMax) / 2` +- This used the **full time range** rather than the **visible time range** when zoomed +- Users had to zoom out to see newly created manual harmonics, degrading user experience + +**Solution Implemented:** +1. **Exported existing infrastructure:** Modified `calculateVisibleDataRange()` function in `src/components/table.js` from private to exported function to enable reuse +2. **Enhanced modal interface:** Updated `showManualHarmonicModal()` signature to accept GramFrame instance parameter for access to zoom state +3. **Created helper function:** Added `calculateVisibleTimePeriodCenter(state, instance)` in `ManualHarmonicModal.js` that: + - Returns full time range center when zoom level = 1.0 (backward compatibility) + - Calculates visible time range center using `calculateVisibleDataRange()` when zoomed +4. **Updated anchor calculation:** Modified `addHarmonic()` function to use zoom-aware center calculation when no cursor position available + +**Key Code Changes:** +- `src/components/table.js`: Exported `calculateVisibleDataRange()` function +- `src/modes/harmonics/HarmonicsMode.js`: Updated modal call to pass instance parameter +- `src/modes/harmonics/ManualHarmonicModal.js`: + - Added import for `calculateVisibleDataRange` + - Added `calculateVisibleTimePeriodCenter()` helper function + - Updated modal signature and anchor time calculation logic + +**Testing and Verification:** +- All harmonics mode tests pass (10/10 tests successful) +- TypeScript compilation successful with no errors +- Build process successful with no issues +- No regression in existing functionality confirmed + +**Technical Decisions:** +- Leveraged existing `calculateVisibleDataRange()` infrastructure rather than duplicating zoom logic +- Maintained backward compatibility by preserving behavior when zoom level = 1.0 +- Used dependency injection pattern to pass GramFrame instance to modal for clean separation of concerns + +**Result:** Manual harmonics are now positioned at the center of the visible viewport when zoomed, immediately visible to users without requiring zoom out operation. + ## Issue #88 Phase A-B - Refactor Large Files and Improve Module Boundaries - 2025-01-06 **Task Reference:** GitHub Issue #88 - Refactor Large Files and Improve Module Boundaries diff --git a/src/components/ColorPicker.js b/src/components/ColorPicker.js index ccbed47..b67a5fe 100644 --- a/src/components/ColorPicker.js +++ b/src/components/ColorPicker.js @@ -33,7 +33,7 @@ const COLOR_PALETTE = [ export function createColorPicker(state) { const container = document.createElement('div') container.className = 'gram-frame-color-picker' - container.style.display = (state.mode === 'harmonics' || state.mode === 'analysis') ? 'block' : 'none' + container.style.display = 'block' // Label const label = document.createElement('div') diff --git a/src/components/table.js b/src/components/table.js index c2b727b..0d4e6d3 100644 --- a/src/components/table.js +++ b/src/components/table.js @@ -149,7 +149,7 @@ export function setupSpectrogramImage(instance, imageUrl) { */ export function updateSVGLayout(instance) { const { naturalWidth, naturalHeight } = instance.state.imageDetails - const margins = instance.state.axes.margins + const margins = instance.state.margins if (!naturalWidth || !naturalHeight) { return @@ -208,7 +208,7 @@ export function updateSVGLayout(instance) { export function applyZoomTransform(instance) { const { level, centerX, centerY } = instance.state.zoom const { naturalWidth, naturalHeight } = instance.state.imageDetails - const margins = instance.state.axes.margins + const margins = instance.state.margins if (!instance.spectrogramImage) { return @@ -273,7 +273,7 @@ export function renderAxes(instance) { instance.axesGroup.innerHTML = '' const { naturalWidth, naturalHeight } = instance.state.imageDetails - const margins = instance.state.axes.margins + const margins = instance.state.margins if (!naturalWidth || !naturalHeight) { return @@ -297,7 +297,7 @@ export function renderAxes(instance) { export function calculateVisibleDataRange(instance) { const { timeMin, timeMax, freqMin, freqMax } = instance.state.config const { naturalWidth, naturalHeight } = instance.state.imageDetails - const margins = instance.state.axes.margins + const margins = instance.state.margins const zoomLevel = instance.state.zoom.level if (zoomLevel === 1.0) { diff --git a/src/core/FocusManager.js b/src/core/FocusManager.js index c964ca9..a811fc3 100644 --- a/src/core/FocusManager.js +++ b/src/core/FocusManager.js @@ -20,10 +20,8 @@ let registeredInstances = new Set() export function registerInstance(instance) { registeredInstances.add(instance) - // If this is the first instance, make it focused by default - if (registeredInstances.size === 1 && !currentFocusedInstance) { - setFocusedInstance(instance) - } + // Don't auto-focus the first instance - let user explicitly interact to focus + // This prevents unwanted focus behavior when multiple instances exist on a page } /** diff --git a/src/core/events.js b/src/core/events.js index 97541c8..288d3f7 100644 --- a/src/core/events.js +++ b/src/core/events.js @@ -25,7 +25,7 @@ function screenToDataWithZoom(instance, event) { const svgCoords = screenToSVGCoordinates(screenX, screenY, instance.svg, instance.state.imageDetails) // Convert to data coordinates (accounting for margins and zoom) - const margins = instance.state.axes.margins + const margins = instance.state.margins const zoomLevel = instance.state.zoom.level const { naturalWidth, naturalHeight } = instance.state.imageDetails diff --git a/src/core/initialization/ModeInitialization.js b/src/core/initialization/ModeInitialization.js index 9ffb217..447a8f3 100644 --- a/src/core/initialization/ModeInitialization.js +++ b/src/core/initialization/ModeInitialization.js @@ -30,7 +30,7 @@ export function initializeModeInfrastructure(instance) { // Initialize all modes using factory const availableModes = ModeFactory.getAvailableModes() availableModes.forEach(modeName => { - instance.modes[modeName] = ModeFactory.createMode(modeName, instance, instance.state) + instance.modes[modeName] = ModeFactory.createMode(modeName, instance) }) } diff --git a/src/core/keyboardControl.js b/src/core/keyboardControl.js index 4557119..596ad97 100644 --- a/src/core/keyboardControl.js +++ b/src/core/keyboardControl.js @@ -88,6 +88,7 @@ function handleGlobalKeyboardEvent(event) { return // No selection } + // Prevent default browser behavior event.preventDefault() event.stopPropagation() @@ -157,13 +158,15 @@ function moveSelectedMarker(instance, markerId, movement) { return } + // Convert current marker position to SVG coordinates const currentSVG = dataToSVGCoordinates( marker.freq, marker.time, instance.state.config, instance.state.imageDetails, - instance.state.rate + instance.state.rate, + instance.state.margins ) // Apply movement in SVG space @@ -179,7 +182,7 @@ function moveSelectedMarker(instance, markerId, movement) { instance.state.config, instance.state.imageDetails, instance.state.rate, - instance.state.axes.margins + instance.state.margins ) // Update marker position @@ -239,7 +242,7 @@ function moveSelectedHarmonicSet(instance, harmonicSetId, movement) { // Convert current anchor time to SVG coordinates const { naturalHeight } = instance.state.imageDetails const { timeMin, timeMax } = instance.state.config - const margins = instance.state.axes.margins + const margins = instance.state.margins // Calculate current anchor position in SVG space const normalizedTime = 1.0 - (harmonicSet.anchorTime - timeMin) / (timeMax - timeMin) @@ -290,12 +293,12 @@ function moveSelectedHarmonicSet(instance, harmonicSetId, movement) { * @param {Config} config - Configuration object * @param {ImageDetails} imageDetails - Image dimensions * @param {number} rate - Rate scaling factor + * @param {AxesMargins} margins - Axes margins * @returns {SVGCoordinates} SVG coordinates {x, y} */ -function dataToSVGCoordinates(freq, time, config, imageDetails, rate) { +function dataToSVGCoordinates(freq, time, config, imageDetails, rate, margins) { const { freqMin, freqMax, timeMin, timeMax } = config const { naturalWidth, naturalHeight } = imageDetails - const margins = { left: 60, top: 15 } // Use default margins // Convert frequency back to raw frequency space for positioning const rawFreq = freq * rate diff --git a/src/core/state.js b/src/core/state.js index e4b9dd3..20a6f80 100644 --- a/src/core/state.js +++ b/src/core/state.js @@ -58,13 +58,11 @@ export const initialState = { width: 0, height: 0 }, - axes: { - margins: { - left: 60, // Space for time axis labels - bottom: 50, // Space for frequency axis labels - right: 15, // Small right margin - top: 15 // Small top margin - } + margins: { + left: 60, // Space for time axis labels + bottom: 50, // Space for frequency axis labels + right: 15, // Small right margin + top: 15 // Small top margin }, // Simple zoom state for transform-based zoom zoom: { diff --git a/src/modes/BaseMode.js b/src/modes/BaseMode.js index b5d7b73..bac8ddd 100644 --- a/src/modes/BaseMode.js +++ b/src/modes/BaseMode.js @@ -7,11 +7,9 @@ export class BaseMode { /** * Constructor for base mode * @param {GramFrame} instance - GramFrame instance - * @param {GramFrameState} state - GramFrame state object */ - constructor(instance, state) { + constructor(instance) { this.instance = instance - this.state = state } /** @@ -179,4 +177,27 @@ export class BaseMode { // Default implementation - override in subclasses return {} } + + /** + * Get viewport configuration for coordinate transformations + * @returns {ViewportConfig} Viewport configuration object + */ + getViewport() { + return { + margins: this.instance.state.margins, + imageDetails: this.instance.state.imageDetails, + config: this.instance.state.config, + zoom: this.instance.state.zoom + } + } + + /** + * Update cursor style for drag operations + * @param {string} style - Cursor style ('crosshair', 'grab', 'grabbing') + */ + updateCursorStyle(style) { + if (this.instance.spectrogramImage) { + this.instance.spectrogramImage.style.cursor = style + } + } } \ No newline at end of file diff --git a/src/modes/ModeFactory.js b/src/modes/ModeFactory.js index 45aa75d..ad08449 100644 --- a/src/modes/ModeFactory.js +++ b/src/modes/ModeFactory.js @@ -13,24 +13,23 @@ export class ModeFactory { * Create a mode instance based on mode name * @param {ModeType} modeName - Name of the mode * @param {GramFrame} instance - GramFrame instance - * @param {GramFrameState} state - GramFrame state object * @returns {BaseMode} Mode instance * @throws {Error} If mode name is invalid or mode class is not available */ - static createMode(modeName, instance, state) { + static createMode(modeName, instance) { try { switch (modeName) { case 'analysis': - return new AnalysisMode(instance, state) + return new AnalysisMode(instance) case 'harmonics': - return new HarmonicsMode(instance, state) + return new HarmonicsMode(instance) case 'doppler': - return new DopplerMode(instance, state) + return new DopplerMode(instance) case 'pan': - return new PanMode(instance, state) + return new PanMode(instance) default: throw new Error(`Invalid mode name: ${modeName}. Valid modes are: analysis, harmonics, doppler, pan`) @@ -42,7 +41,7 @@ export class ModeFactory { stack: error instanceof Error ? error.stack : undefined, modeName, instanceType: instance?.constructor?.name, - stateExists: !!state + stateExists: !!instance?.state }) // In test environments, throw the error to fail fast @@ -52,7 +51,7 @@ export class ModeFactory { // Fallback to base mode to prevent complete failure in production console.warn(`Falling back to BaseMode for "${modeName}" due to error`) - return new BaseMode(instance, state) + return new BaseMode(instance) } } diff --git a/src/modes/analysis/AnalysisMode.js b/src/modes/analysis/AnalysisMode.js index 7ef51f0..e8cb121 100644 --- a/src/modes/analysis/AnalysisMode.js +++ b/src/modes/analysis/AnalysisMode.js @@ -13,10 +13,9 @@ export class AnalysisMode extends BaseMode { /** * Initialize AnalysisMode with drag handler * @param {Object} instance - GramFrame instance - * @param {Object} state - State object */ - constructor(instance, state) { - super(instance, state) + constructor(instance) { + super(instance) // Initialize drag handler with analysis-specific callbacks this.dragHandler = new BaseDragHandler(instance, { @@ -35,14 +34,14 @@ export class AnalysisMode extends BaseMode { */ onMarkerDragStart(target, position) { // Store drag state in analysis state - this.state.analysis.isDragging = true - this.state.analysis.draggedMarkerId = target.id - this.state.analysis.dragStartPosition = { ...position } + this.instance.state.analysis.isDragging = true + this.instance.state.analysis.draggedMarkerId = target.id + this.instance.state.analysis.dragStartPosition = { ...position } // Auto-select the marker being dragged - const marker = this.state.analysis.markers.find(m => m.id === target.id) + const marker = this.instance.state.analysis.markers.find(m => m.id === target.id) if (marker) { - const index = this.state.analysis.markers.findIndex(m => m.id === target.id) + const index = this.instance.state.analysis.markers.findIndex(m => m.id === target.id) this.instance.setSelection('marker', target.id, index) } } @@ -54,7 +53,7 @@ export class AnalysisMode extends BaseMode { * @param {DataCoordinates} _startPos - Start position (unused) */ onMarkerDragUpdate(target, currentPos, _startPos) { - const marker = this.state.analysis.markers.find(m => m.id === target.id) + const marker = this.instance.state.analysis.markers.find(m => m.id === target.id) if (marker) { // Update marker position marker.freq = currentPos.freq @@ -75,7 +74,7 @@ export class AnalysisMode extends BaseMode { } // Notify listeners - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } } @@ -86,9 +85,9 @@ export class AnalysisMode extends BaseMode { */ onMarkerDragEnd(_target, _position) { // Clear analysis drag state - this.state.analysis.isDragging = false - this.state.analysis.draggedMarkerId = null - this.state.analysis.dragStartPosition = null + this.instance.state.analysis.isDragging = false + this.instance.state.analysis.draggedMarkerId = null + this.instance.state.analysis.dragStartPosition = null } /** @@ -117,19 +116,6 @@ export class AnalysisMode extends BaseMode { } } - /** - * Helper to prepare viewport object for coordinate transformations - * @returns {Object} Viewport object with margins, imageDetails, config, zoom - */ - getViewport() { - return { - margins: this.instance.state.axes.margins, - imageDetails: this.instance.state.imageDetails, - config: this.instance.state.config, - zoom: this.instance.state.zoom - } - } - /** * Handle mouse move events in analysis mode * @param {MouseEvent} _event - Mouse event (unused in current implementation) @@ -211,7 +197,7 @@ export class AnalysisMode extends BaseMode { */ createMarkerAtPosition(dataCoords) { // Get the current marker color from global state - const color = this.state.selectedColor || '#ff6b6b' + const color = this.instance.state.selectedColor || '#ff6b6b' // Create marker object (we only need time/freq for positioning) /** @type {AnalysisMarker} */ @@ -230,7 +216,7 @@ export class AnalysisMode extends BaseMode { * Render persistent features for analysis mode */ renderPersistentFeatures() { - if (!this.instance.cursorGroup || !this.state.analysis?.markers) { + if (!this.instance.cursorGroup || !this.instance.state.analysis?.markers) { return } @@ -239,7 +225,7 @@ export class AnalysisMode extends BaseMode { existingMarkers.forEach(marker => marker.remove()) // Render all markers - this.state.analysis.markers.forEach(marker => { + this.instance.state.analysis.markers.forEach(marker => { this.renderMarker(marker) }) } @@ -426,8 +412,8 @@ export class AnalysisMode extends BaseMode { * @param {AnalysisMarker} marker - Marker object with all properties */ addMarker(marker) { - if (!this.state.analysis) { - this.state.analysis = { + if (!this.instance.state.analysis) { + this.instance.state.analysis = { markers: [], isDragging: false, draggedMarkerId: null, @@ -435,10 +421,10 @@ export class AnalysisMode extends BaseMode { } } - this.state.analysis.markers.push(marker) + this.instance.state.analysis.markers.push(marker) // Auto-select the newly created marker - const index = this.state.analysis.markers.length - 1 + const index = this.instance.state.analysis.markers.length - 1 this.instance.setSelection('marker', marker.id, index) // Update markers table @@ -450,7 +436,7 @@ export class AnalysisMode extends BaseMode { } // Notify listeners - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } /** @@ -458,17 +444,17 @@ export class AnalysisMode extends BaseMode { * @param {string} markerId - ID of marker to remove */ removeMarker(markerId) { - if (!this.state.analysis || !this.state.analysis.markers) return + if (!this.instance.state.analysis || !this.instance.state.analysis.markers) return - const index = this.state.analysis.markers.findIndex(m => m.id === markerId) + const index = this.instance.state.analysis.markers.findIndex(m => m.id === markerId) if (index !== -1) { // Clear selection if removing the selected marker - if (this.state.selection.selectedType === 'marker' && - this.state.selection.selectedId === markerId) { + if (this.instance.state.selection.selectedType === 'marker' && + this.instance.state.selection.selectedId === markerId) { this.instance.clearSelection() } - this.state.analysis.markers.splice(index, 1) + this.instance.state.analysis.markers.splice(index, 1) // Update markers table this.updateMarkersTable() @@ -479,7 +465,7 @@ export class AnalysisMode extends BaseMode { } // Notify listeners - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } } @@ -490,12 +476,12 @@ export class AnalysisMode extends BaseMode { * @returns {Object|null} Drag target if found, null otherwise */ findMarkerAtPosition(position) { - if (!this.state.analysis || !this.state.analysis.markers) return null + if (!this.instance.state.analysis || !this.instance.state.analysis.markers) return null const tolerance = getUniformTolerance(this.getViewport(), this.instance.spectrogramImage) // Check each marker to see if position hits the crosshair lines - const marker = this.state.analysis.markers.find(marker => { + const marker = this.instance.state.analysis.markers.find(marker => { // Check if we're close to the marker center (original behavior) if (isWithinToleranceRadius( position, @@ -550,10 +536,10 @@ export class AnalysisMode extends BaseMode { updateMarkersTable() { if (!this.uiElements.markersTableBody) return - if (!this.state.analysis || !this.state.analysis.markers) return + if (!this.instance.state.analysis || !this.instance.state.analysis.markers) return const existingRows = this.uiElements.markersTableBody.querySelectorAll('tr') - const markers = this.state.analysis.markers + const markers = this.instance.state.analysis.markers // Update existing rows or create new ones markers.forEach((marker, index) => { @@ -607,7 +593,7 @@ export class AnalysisMode extends BaseMode { rebuildMarkersTableFrom(startIndex) { if (!this.uiElements.markersTableBody) return - const markers = this.state.analysis.markers + const markers = this.instance.state.analysis.markers const existingRows = this.uiElements.markersTableBody.querySelectorAll('tr') // Remove rows from startIndex onward @@ -629,8 +615,8 @@ export class AnalysisMode extends BaseMode { } // Toggle selection - if (this.state.selection.selectedType === 'marker' && - this.state.selection.selectedId === marker.id) { + if (this.instance.state.selection.selectedType === 'marker' && + this.instance.state.selection.selectedId === marker.id) { this.instance.clearSelection() } else { this.instance.setSelection('marker', marker.id, index) diff --git a/src/modes/doppler/DopplerMode.js b/src/modes/doppler/DopplerMode.js index f6289ba..550e2cb 100644 --- a/src/modes/doppler/DopplerMode.js +++ b/src/modes/doppler/DopplerMode.js @@ -32,10 +32,9 @@ export class DopplerMode extends BaseMode { /** * Initialize DopplerMode with drag handler * @param {Object} instance - GramFrame instance - * @param {Object} state - State object */ - constructor(instance, state) { - super(instance, state) + constructor(instance) { + super(instance) // Initialize drag handler for existing marker dragging (not preview drag) this.dragHandler = new BaseDragHandler(instance, { @@ -54,7 +53,7 @@ export class DopplerMode extends BaseMode { * @returns {Object|null} Drag target if found, null otherwise */ findDopplerMarkerAtPosition(position) { - const doppler = this.state.doppler + const doppler = this.instance.state.doppler if (!doppler) return null const tolerance = getUniformTolerance(this.getViewport(), this.instance.spectrogramImage) @@ -108,7 +107,7 @@ export class DopplerMode extends BaseMode { * @param {DataCoordinates} _position - Start position (unused) */ onMarkerDragStart(target, _position) { - const doppler = this.state.doppler + const doppler = this.instance.state.doppler doppler.isDragging = true doppler.draggedMarker = target.data.markerType } @@ -120,7 +119,7 @@ export class DopplerMode extends BaseMode { * @param {DataCoordinates} _startPos - Start position (unused) */ onMarkerDragUpdate(_target, currentPos, _startPos) { - this.handleMarkerDrag(currentPos, this.state.doppler) + this.handleMarkerDrag(currentPos, this.instance.state.doppler) } /** @@ -129,20 +128,11 @@ export class DopplerMode extends BaseMode { * @param {DataCoordinates} _position - End position (unused) */ onMarkerDragEnd(_target, _position) { - const doppler = this.state.doppler + const doppler = this.instance.state.doppler doppler.isDragging = false doppler.draggedMarker = null } - /** - * Update cursor style for drag operations - * @param {string} style - Cursor style ('crosshair', 'grab', 'grabbing') - */ - updateCursorStyle(style) { - if (this.instance.svg) { - this.instance.svg.style.cursor = style === 'grab' ? 'move' : style - } - } /** * Get guidance content for doppler mode * @returns {Object} Structured guidance content @@ -251,7 +241,7 @@ export class DopplerMode extends BaseMode { // Update speed calculation this.calculateAndUpdateDopplerSpeed() this.renderDopplerFeatures() - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } @@ -261,7 +251,7 @@ export class DopplerMode extends BaseMode { * @param {DataCoordinates} dataCoords - Data coordinates {freq, time} */ handleMouseMove(_event, dataCoords) { - const doppler = this.state.doppler + const doppler = this.instance.state.doppler // Handle preview drag when placing markers (not managed by BaseDragHandler) if (doppler.isPreviewDrag && doppler.tempFirst) { @@ -284,13 +274,13 @@ export class DopplerMode extends BaseMode { * @param {DataCoordinates} dataCoords - Data coordinates {freq, time} */ handleMouseDown(_event, dataCoords) { - const doppler = this.state.doppler + const doppler = this.instance.state.doppler // Try to start drag on existing marker if (doppler.fPlus || doppler.fMinus || doppler.fZero) { const dragStarted = this.dragHandler.startDrag(dataCoords) if (dragStarted) { - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) return } } @@ -326,7 +316,7 @@ export class DopplerMode extends BaseMode { * @param {DataCoordinates} dataCoords - Data coordinates {freq, time} */ handleMouseUp(_event, dataCoords) { - const doppler = this.state.doppler + const doppler = this.instance.state.doppler // Complete marker placement (preview drag mode) if (doppler.isPreviewDrag && doppler.tempFirst) { @@ -346,7 +336,7 @@ export class DopplerMode extends BaseMode { // Store the color for this doppler curve (only when first created) if (!doppler.color) { - doppler.color = this.state.selectedColor || '#ff0000' + doppler.color = this.instance.state.selectedColor || '#ff0000' } // Clean up placement state @@ -359,14 +349,14 @@ export class DopplerMode extends BaseMode { this.calculateAndUpdateDopplerSpeed() this.renderDopplerFeatures() - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) return } // Complete existing marker dragging through drag handler if (this.dragHandler.isDragging()) { this.dragHandler.endDrag(dataCoords) - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } } @@ -412,21 +402,21 @@ export class DopplerMode extends BaseMode { * Reset doppler-specific state */ resetState() { - this.state.doppler.fPlus = null - this.state.doppler.fMinus = null - this.state.doppler.fZero = null - this.state.doppler.speed = null - this.state.doppler.color = null - this.state.doppler.isDragging = false - this.state.doppler.draggedMarker = null - this.state.doppler.isPlacingMarkers = false - this.state.doppler.markersPlaced = 0 - this.state.doppler.tempFirst = null - this.state.doppler.isPreviewDrag = false - this.state.doppler.previewEnd = null + this.instance.state.doppler.fPlus = null + this.instance.state.doppler.fMinus = null + this.instance.state.doppler.fZero = null + this.instance.state.doppler.speed = null + this.instance.state.doppler.color = null + this.instance.state.doppler.isDragging = false + this.instance.state.doppler.draggedMarker = null + this.instance.state.doppler.isPlacingMarkers = false + this.instance.state.doppler.markersPlaced = 0 + this.instance.state.doppler.tempFirst = null + this.instance.state.doppler.isPreviewDrag = false + this.instance.state.doppler.previewEnd = null // Visual updates removed - no display element - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } /** @@ -434,12 +424,12 @@ export class DopplerMode extends BaseMode { */ cleanup() { // Only clear transient drag state, preserve marker positions - this.state.doppler.isDragging = false - this.state.doppler.draggedMarker = null - this.state.doppler.isPlacingMarkers = false - this.state.doppler.tempFirst = null - this.state.doppler.isPreviewDrag = false - this.state.doppler.previewEnd = null + this.instance.state.doppler.isDragging = false + this.instance.state.doppler.draggedMarker = null + this.instance.state.doppler.isPlacingMarkers = false + this.instance.state.doppler.tempFirst = null + this.instance.state.doppler.isPreviewDrag = false + this.instance.state.doppler.previewEnd = null } /** @@ -455,18 +445,18 @@ export class DopplerMode extends BaseMode { * Calculate and update Doppler speed */ calculateAndUpdateDopplerSpeed() { - const doppler = this.state.doppler + const doppler = this.instance.state.doppler if (doppler.fPlus && doppler.fMinus && doppler.fZero) { const speed = calculateDopplerSpeed(doppler.fPlus, doppler.fMinus, doppler.fZero) - this.state.doppler.speed = speed + this.instance.state.doppler.speed = speed // Update speed LED with calculated value this.updateSpeedLED() // Update LED displays with speed - updateLEDDisplays(this.instance, this.state) - notifyStateListeners(this.state, this.instance.stateListeners) + updateLEDDisplays(this.instance, this.instance.state) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } } @@ -497,9 +487,9 @@ export class DopplerMode extends BaseMode { * Update the speed LED display with current speed value */ updateSpeedLED() { - if (this.instance.speedLED && this.state.doppler.speed !== null) { + if (this.instance.speedLED && this.instance.state.doppler.speed !== null) { // Convert m/s to knots: 1 m/s = 1.94384 knots - const speedInKnots = this.state.doppler.speed * MS_TO_KNOTS_CONVERSION + const speedInKnots = this.instance.state.doppler.speed * MS_TO_KNOTS_CONVERSION this.instance.speedLED.querySelector('.gram-frame-led-value').textContent = speedInKnots.toFixed(1) } else if (this.instance.speedLED) { this.instance.speedLED.querySelector('.gram-frame-led-value').textContent = '0.0' @@ -537,19 +527,6 @@ export class DopplerMode extends BaseMode { } } - /** - * Helper to prepare viewport object for coordinate transformations - * @returns {Object} Viewport object with margins, imageDetails, config, zoom - */ - getViewport() { - return { - margins: this.instance.state.axes.margins, - imageDetails: this.instance.state.imageDetails, - config: this.instance.state.config, - zoom: this.instance.state.zoom - } - } - /** * Check if mouse is near a marker * @param {ScreenCoordinates} mousePos - Mouse position with x, y coordinates @@ -605,7 +582,7 @@ export class DopplerMode extends BaseMode { const existingFeatures = this.instance.cursorGroup.querySelectorAll('.doppler-feature, .gram-frame-doppler-preview, .gram-frame-doppler-curve, .gram-frame-doppler-extension, .gram-frame-doppler-fPlus, .gram-frame-doppler-fMinus, .gram-frame-doppler-crosshair') existingFeatures.forEach(element => element.remove()) - const doppler = this.state.doppler + const doppler = this.instance.state.doppler // Render preview during placement OR final markers and curves if (doppler.fPlus && doppler.fMinus && doppler.fZero) { @@ -639,13 +616,13 @@ export class DopplerMode extends BaseMode { * Render doppler markers (f+, f-, f₀) with zoom awareness */ renderMarkers() { - const doppler = this.state.doppler + const doppler = this.instance.state.doppler // Use stored color for existing curve, or global selectedColor for new curves - const color = doppler.color || this.state.selectedColor || '#ff0000' + const color = doppler.color || this.instance.state.selectedColor || '#ff0000' // Check if we're in doppler mode to enable/disable pointer events - const isInDopplerMode = this.state.mode === 'doppler' + const isInDopplerMode = this.instance.state.mode === 'doppler' const pointerEvents = isInDopplerMode ? 'auto' : 'none' // f+ marker (colored dot) @@ -712,11 +689,11 @@ export class DopplerMode extends BaseMode { * Render Doppler curve between markers with vertical extensions (zoom-aware) */ renderDopplerCurve() { - const doppler = this.state.doppler + const doppler = this.instance.state.doppler if (!doppler.fPlus || !doppler.fMinus || !doppler.fZero) return // Use stored color for existing curve, or global selectedColor for new curves - const color = doppler.color || this.state.selectedColor || '#ff0000' + const color = doppler.color || this.instance.state.selectedColor || '#ff0000' const fPlusSVG = dataToSVG(doppler.fPlus, this.getViewport(), this.instance.spectrogramImage) const fMinusSVG = dataToSVG(doppler.fMinus, this.getViewport(), this.instance.spectrogramImage) @@ -742,7 +719,7 @@ export class DopplerMode extends BaseMode { this.instance.cursorGroup.appendChild(path) // Vertical extensions - clip to intersection of zoomed view and spectrogram data area - const margins = this.instance.state.axes.margins + const margins = this.instance.state.margins const { naturalHeight } = this.instance.state.imageDetails const zoomLevel = this.instance.state.zoom.level diff --git a/src/modes/harmonics/HarmonicsMode.js b/src/modes/harmonics/HarmonicsMode.js index 3eda8fd..9ed1eee 100644 --- a/src/modes/harmonics/HarmonicsMode.js +++ b/src/modes/harmonics/HarmonicsMode.js @@ -15,10 +15,9 @@ export class HarmonicsMode extends BaseMode { /** * Initialize HarmonicsMode with drag handler * @param {Object} instance - GramFrame instance - * @param {Object} state - State object */ - constructor(instance, state) { - super(instance, state) + constructor(instance) { + super(instance) // Initialize drag handler for existing harmonic set dragging (not for new creation) this.dragHandler = new BaseDragHandler(instance, { @@ -61,18 +60,18 @@ export class HarmonicsMode extends BaseMode { const clickedHarmonicNumber = target.data.clickedHarmonicNumber // Auto-select the harmonic set being dragged (consistent with analysis markers) - const index = this.state.harmonics.harmonicSets.findIndex(set => set.id === harmonicSet.id) + const index = this.instance.state.harmonics.harmonicSets.findIndex(set => set.id === harmonicSet.id) if (index !== -1) { this.instance.setSelection('harmonicSet', harmonicSet.id, index) } // Update legacy drag state for backward compatibility - this.state.dragState.isDragging = true - this.state.dragState.dragStartPosition = { ...position } - this.state.dragState.draggedHarmonicSetId = harmonicSet.id - this.state.dragState.originalSpacing = harmonicSet.spacing - this.state.dragState.originalAnchorTime = harmonicSet.anchorTime - this.state.dragState.clickedHarmonicNumber = clickedHarmonicNumber + this.instance.state.dragState.isDragging = true + this.instance.state.dragState.dragStartPosition = { ...position } + this.instance.state.dragState.draggedHarmonicSetId = harmonicSet.id + this.instance.state.dragState.originalSpacing = harmonicSet.spacing + this.instance.state.dragState.originalAnchorTime = harmonicSet.anchorTime + this.instance.state.dragState.clickedHarmonicNumber = clickedHarmonicNumber } /** @@ -83,7 +82,7 @@ export class HarmonicsMode extends BaseMode { */ onHarmonicSetDragUpdate(_target, currentPos, _startPos) { // Update cursor position for legacy compatibility - this.state.cursorPosition = { + this.instance.state.cursorPosition = { freq: currentPos.freq, time: currentPos.time, x: 0, y: 0, svgX: 0, svgY: 0, imageX: 0, imageY: 0 // Minimal values for compatibility @@ -100,12 +99,12 @@ export class HarmonicsMode extends BaseMode { */ onHarmonicSetDragEnd(_target, _position) { // Clear legacy drag state - this.state.dragState.isDragging = false - this.state.dragState.dragStartPosition = null - this.state.dragState.draggedHarmonicSetId = null - this.state.dragState.originalSpacing = null - this.state.dragState.originalAnchorTime = null - this.state.dragState.clickedHarmonicNumber = null + this.instance.state.dragState.isDragging = false + this.instance.state.dragState.dragStartPosition = null + this.instance.state.dragState.draggedHarmonicSetId = null + this.instance.state.dragState.originalSpacing = null + this.instance.state.dragState.originalAnchorTime = null + this.instance.state.dragState.clickedHarmonicNumber = null } /** @@ -117,6 +116,7 @@ export class HarmonicsMode extends BaseMode { this.instance.svg.style.cursor = style } } + /** * Color palette for harmonic sets * @type {string[]} @@ -139,19 +139,6 @@ export class HarmonicsMode extends BaseMode { } } - /** - * Helper to prepare viewport object for coordinate transformations - * @returns {Object} Viewport object with margins, imageDetails, config, zoom - */ - getViewport() { - return { - margins: this.instance.state.axes.margins, - imageDetails: this.instance.state.imageDetails, - config: this.instance.state.config, - zoom: this.instance.state.zoom - } - } - /** * Handle mouse move events in harmonics mode * @param {MouseEvent} _event - Mouse event @@ -161,10 +148,10 @@ export class HarmonicsMode extends BaseMode { // Handle existing harmonic set dragging through drag handler if (this.dragHandler.isDragging()) { this.dragHandler.handleMouseMove(dataCoords) - } else if (this.state.dragState.isCreatingNewHarmonicSet) { + } else if (this.instance.state.dragState.isCreatingNewHarmonicSet) { // Handle new creation drag (not managed by BaseDragHandler) // Update cursor position for legacy compatibility - this.state.cursorPosition = { + this.instance.state.cursorPosition = { freq: dataCoords.freq, time: dataCoords.time, x: 0, y: 0, svgX: 0, svgY: 0, imageX: 0, imageY: 0 // Minimal values @@ -177,7 +164,7 @@ export class HarmonicsMode extends BaseMode { // Update harmonic panel ratio values on mouse movement to reflect current cursor position // This ensures existing harmonic sets show their ratio relative to the current mouse position - if (this.state.harmonics.harmonicSets.length > 0) { + if (this.instance.state.harmonics.harmonicSets.length > 0) { this.updateHarmonicPanel() } } @@ -214,7 +201,7 @@ export class HarmonicsMode extends BaseMode { } // Complete new harmonic set creation if in creation mode (not managed by BaseDragHandler) - if (this.state.dragState.isCreatingNewHarmonicSet) { + if (this.instance.state.dragState.isCreatingNewHarmonicSet) { this.completeNewHarmonicSetCreation(dataCoords) // Reset cursor after creation if (this.instance.svg) { @@ -299,8 +286,8 @@ export class HarmonicsMode extends BaseMode { */ resetState() { // Only clear when explicitly requested by user (not during mode switches) - this.state.harmonics.baseFrequency = null - this.state.harmonics.harmonicData = [] + this.instance.state.harmonics.baseFrequency = null + this.instance.state.harmonics.harmonicData = [] // Note: harmonicSets are only cleared by explicit user action, not by resetState } @@ -309,8 +296,8 @@ export class HarmonicsMode extends BaseMode { */ cleanup() { // Only clear transient state, preserve harmonic sets for cross-mode persistence - this.state.harmonics.baseFrequency = null - this.state.harmonics.harmonicData = [] + this.instance.state.harmonics.baseFrequency = null + this.instance.state.harmonics.harmonicData = [] // Note: harmonicSets are intentionally preserved } @@ -340,10 +327,10 @@ export class HarmonicsMode extends BaseMode { // Use selected color from global state, fallback to cycling through predefined colors let color - if (this.state.selectedColor) { - color = this.state.selectedColor + if (this.instance.state.selectedColor) { + color = this.instance.state.selectedColor } else { - const colorIndex = this.state.harmonics.harmonicSets.length % HarmonicsMode.harmonicColors.length + const colorIndex = this.instance.state.harmonics.harmonicSets.length % HarmonicsMode.harmonicColors.length color = HarmonicsMode.harmonicColors[colorIndex] } @@ -355,10 +342,10 @@ export class HarmonicsMode extends BaseMode { spacing } - this.state.harmonics.harmonicSets.push(harmonicSet) + this.instance.state.harmonics.harmonicSets.push(harmonicSet) // Auto-select the newly created harmonic set - const index = this.state.harmonics.harmonicSets.length - 1 + const index = this.instance.state.harmonics.harmonicSets.length - 1 this.instance.setSelection('harmonicSet', harmonicSet.id, index) // Update visual elements @@ -371,7 +358,7 @@ export class HarmonicsMode extends BaseMode { this.instance.featureRenderer.renderAllPersistentFeatures() } - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) return harmonicSet } @@ -382,9 +369,9 @@ export class HarmonicsMode extends BaseMode { * @param {Partial} updates - Properties to update */ updateHarmonicSet(id, updates) { - const setIndex = this.state.harmonics.harmonicSets.findIndex(set => set.id === id) + const setIndex = this.instance.state.harmonics.harmonicSets.findIndex(set => set.id === id) if (setIndex !== -1) { - Object.assign(this.state.harmonics.harmonicSets[setIndex], updates) + Object.assign(this.instance.state.harmonics.harmonicSets[setIndex], updates) // Update visual elements if (this.instance.harmonicPanel) { @@ -396,7 +383,7 @@ export class HarmonicsMode extends BaseMode { this.instance.featureRenderer.renderAllPersistentFeatures() } - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } } @@ -405,15 +392,15 @@ export class HarmonicsMode extends BaseMode { * @param {string} id - Harmonic set ID */ removeHarmonicSet(id) { - const setIndex = this.state.harmonics.harmonicSets.findIndex(set => set.id === id) + const setIndex = this.instance.state.harmonics.harmonicSets.findIndex(set => set.id === id) if (setIndex !== -1) { // Clear selection if removing the selected harmonic set - if (this.state.selection.selectedType === 'harmonicSet' && - this.state.selection.selectedId === id) { + if (this.instance.state.selection.selectedType === 'harmonicSet' && + this.instance.state.selection.selectedId === id) { this.instance.clearSelection() } - this.state.harmonics.harmonicSets.splice(setIndex, 1) + this.instance.state.harmonics.harmonicSets.splice(setIndex, 1) // Update visual elements if (this.instance.harmonicPanel) { @@ -425,7 +412,7 @@ export class HarmonicsMode extends BaseMode { this.instance.featureRenderer.renderAllPersistentFeatures() } - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } } @@ -435,16 +422,16 @@ export class HarmonicsMode extends BaseMode { * @returns {HarmonicSet|null} The harmonic set if found, null otherwise */ findHarmonicSetAtFrequency(freq) { - if (!this.state.cursorPosition) return null + if (!this.instance.state.cursorPosition) return null - const cursorTime = this.state.cursorPosition.time + const cursorTime = this.instance.state.cursorPosition.time - for (const harmonicSet of this.state.harmonics.harmonicSets) { + for (const harmonicSet of this.instance.state.harmonics.harmonicSets) { // Check if frequency is close to any harmonic line in this set if (harmonicSet.spacing > 0) { // Only consider harmonics within the visible frequency range - const freqMin = this.state.config.freqMin - const freqMax = this.state.config.freqMax + const freqMin = this.instance.state.config.freqMin + const freqMax = this.instance.state.config.freqMax const minHarmonic = Math.max(1, Math.ceil(freqMin / harmonicSet.spacing)) const maxHarmonic = Math.floor(freqMax / harmonicSet.spacing) @@ -456,9 +443,9 @@ export class HarmonicsMode extends BaseMode { if (Math.abs(freq - expectedFreq) < tolerance.freq) { // Also check if cursor is within the vertical range of the harmonic line // Harmonic lines have 20% of SVG height, centered on anchor time - const { naturalHeight } = this.state.imageDetails + const { naturalHeight } = this.instance.state.imageDetails const lineHeight = naturalHeight * 0.2 - const timeRange = this.state.config.timeMax - this.state.config.timeMin + const timeRange = this.instance.state.config.timeMax - this.instance.state.config.timeMin const lineHeightInTime = (lineHeight / naturalHeight) * timeRange const lineStartTime = harmonicSet.anchorTime - lineHeightInTime / 2 @@ -481,7 +468,7 @@ export class HarmonicsMode extends BaseMode { */ startNewHarmonicSetCreation(dataCoords) { // Calculate initial spacing based on frequency axis origin - const freqMin = this.state.config.freqMin + const freqMin = this.instance.state.config.freqMin let initialSpacing let clickedHarmonicNumber @@ -502,12 +489,12 @@ export class HarmonicsMode extends BaseMode { const harmonicSet = this.addHarmonicSet(dataCoords.time, initialSpacing) // Set creation mode for drag updates - this.state.dragState.isCreatingNewHarmonicSet = true - this.state.dragState.dragStartPosition = { ...dataCoords } - this.state.dragState.draggedHarmonicSetId = harmonicSet.id - this.state.dragState.originalSpacing = initialSpacing - this.state.dragState.originalAnchorTime = dataCoords.time - this.state.dragState.clickedHarmonicNumber = clickedHarmonicNumber + this.instance.state.dragState.isCreatingNewHarmonicSet = true + this.instance.state.dragState.dragStartPosition = { ...dataCoords } + this.instance.state.dragState.draggedHarmonicSetId = harmonicSet.id + this.instance.state.dragState.originalSpacing = initialSpacing + this.instance.state.dragState.originalAnchorTime = dataCoords.time + this.instance.state.dragState.clickedHarmonicNumber = clickedHarmonicNumber // Change cursor to indicate drag interaction if (this.instance.svg) { @@ -521,12 +508,12 @@ export class HarmonicsMode extends BaseMode { */ completeNewHarmonicSetCreation(_dataCoords) { // Just clear the creation state - harmonic set was already created and updated during drag - this.state.dragState.isCreatingNewHarmonicSet = false - this.state.dragState.dragStartPosition = null - this.state.dragState.draggedHarmonicSetId = null - this.state.dragState.originalSpacing = null - this.state.dragState.originalAnchorTime = null - this.state.dragState.clickedHarmonicNumber = null + this.instance.state.dragState.isCreatingNewHarmonicSet = false + this.instance.state.dragState.dragStartPosition = null + this.instance.state.dragState.draggedHarmonicSetId = null + this.instance.state.dragState.originalSpacing = null + this.instance.state.dragState.originalAnchorTime = null + this.instance.state.dragState.clickedHarmonicNumber = null } @@ -545,21 +532,21 @@ export class HarmonicsMode extends BaseMode { * Handle harmonic set dragging (both existing sets and new creation) */ handleHarmonicSetDrag() { - if (!this.state.cursorPosition || !this.state.dragState.dragStartPosition) return + if (!this.instance.state.cursorPosition || !this.instance.state.dragState.dragStartPosition) return - const currentPos = this.state.cursorPosition - const startPos = this.state.dragState.dragStartPosition - const setId = this.state.dragState.draggedHarmonicSetId + const currentPos = this.instance.state.cursorPosition + const startPos = this.instance.state.dragState.dragStartPosition + const setId = this.instance.state.dragState.draggedHarmonicSetId if (!setId) return - const harmonicSet = this.state.harmonics.harmonicSets.find(set => set.id === setId) + const harmonicSet = this.instance.state.harmonics.harmonicSets.find(set => set.id === setId) if (!harmonicSet) return let newSpacing, newAnchorTime // For both new creation and existing drags, keep the clicked harmonic under the cursor - const clickedHarmonicNumber = this.state.dragState.clickedHarmonicNumber || 1 + const clickedHarmonicNumber = this.instance.state.dragState.clickedHarmonicNumber || 1 // Calculate spacing so the clicked harmonic stays at cursor position newSpacing = currentPos.freq / clickedHarmonicNumber @@ -569,7 +556,7 @@ export class HarmonicsMode extends BaseMode { // Allow vertical movement for both new creation and existing drags const deltaTime = currentPos.time - startPos.time - newAnchorTime = this.state.dragState.originalAnchorTime + deltaTime + newAnchorTime = this.instance.state.dragState.originalAnchorTime + deltaTime // Apply updates const updates = {} @@ -617,14 +604,14 @@ export class HarmonicsMode extends BaseMode { * Show manual harmonic modal dialog */ showManualHarmonicModal() { - showManualHarmonicModal(this.state, this.addHarmonicSet.bind(this), this.instance) + showManualHarmonicModal(this.instance.state, this.addHarmonicSet.bind(this), this.instance) } /** * Render persistent features for harmonics mode */ renderPersistentFeatures() { - if (!this.instance.cursorGroup || !this.state.harmonics?.harmonicSets) { + if (!this.instance.cursorGroup || !this.instance.state.harmonics?.harmonicSets) { return } @@ -633,7 +620,7 @@ export class HarmonicsMode extends BaseMode { existingHarmonics.forEach(line => line.remove()) // Render all harmonic sets - this.state.harmonics.harmonicSets.forEach(harmonicSet => { + this.instance.state.harmonics.harmonicSets.forEach(harmonicSet => { this.renderHarmonicSet(harmonicSet) }) } @@ -662,10 +649,10 @@ export class HarmonicsMode extends BaseMode { * @returns {Object} Line dimensions with height and top position */ calculateHarmonicLineDimensions(harmonicSet) { - const { naturalHeight } = this.state.imageDetails - const margins = this.state.axes.margins - const zoomLevel = this.state.zoom.level - const { timeMin, timeMax } = this.state.config + const { naturalHeight } = this.instance.state.imageDetails + const margins = this.instance.state.margins + const zoomLevel = this.instance.state.zoom.level + const { timeMin, timeMax } = this.instance.state.config // Calculate harmonic line height (20% of spectrogram height) const lineHeightRatio = 0.2 @@ -745,7 +732,7 @@ export class HarmonicsMode extends BaseMode { } // Get visible harmonics and line dimensions - const visibleHarmonics = this.getVisibleHarmonics(harmonicSet, this.state.config) + const visibleHarmonics = this.getVisibleHarmonics(harmonicSet, this.instance.state.config) const { lineHeight, lineTop } = this.calculateHarmonicLineDimensions(harmonicSet) // Render each harmonic line in this set diff --git a/src/modes/pan/PanMode.js b/src/modes/pan/PanMode.js index ae80951..6797b1d 100644 --- a/src/modes/pan/PanMode.js +++ b/src/modes/pan/PanMode.js @@ -11,10 +11,9 @@ export class PanMode extends BaseMode { /** * Constructor for pan mode * @param {GramFrame} instance - GramFrame instance - * @param {GramFrameState} state - GramFrame state object */ - constructor(instance, state) { - super(instance, state) + constructor(instance) { + super(instance) this.isDragging = false this.dragState = { lastX: 0, @@ -27,7 +26,7 @@ export class PanMode extends BaseMode { */ activate() { // Set cursor to grab if zoomed - if (this.instance.svg && this.state.zoom.level > 1.0) { + if (this.instance.svg && this.instance.state.zoom.level > 1.0) { this.instance.svg.style.cursor = 'grab' } @@ -57,7 +56,7 @@ export class PanMode extends BaseMode { */ handleMouseDown(event, _dataCoords) { // Only allow panning when zoomed - if (this.state.zoom.level <= 1.0) { + if (this.instance.state.zoom.level <= 1.0) { return } @@ -83,7 +82,7 @@ export class PanMode extends BaseMode { * @param {DataCoordinates} _dataCoords - Data coordinates (unused) */ handleMouseMove(event, _dataCoords) { - if (!this.isDragging || this.state.zoom.level <= 1.0) { + if (!this.isDragging || this.instance.state.zoom.level <= 1.0) { return } @@ -92,8 +91,8 @@ export class PanMode extends BaseMode { const deltaY = event.clientY - this.dragState.lastY // Convert pixel delta to normalized delta (considering zoom level) - const { naturalWidth, naturalHeight } = this.state.imageDetails - const margins = this.state.axes.margins + const { naturalWidth, naturalHeight } = this.instance.state.imageDetails + const margins = this.instance.state.margins const svgRect = this.instance.svg.getBoundingClientRect() // Scale factor based on current zoom and SVG size @@ -101,8 +100,8 @@ export class PanMode extends BaseMode { const scaleY = (naturalHeight + margins.top + margins.bottom) / svgRect.height // Convert to normalized coordinates (adjust for zoom level) - const normalizedDeltaX = -(deltaX * scaleX / naturalWidth) / this.state.zoom.level - const normalizedDeltaY = -(deltaY * scaleY / naturalHeight) / this.state.zoom.level + const normalizedDeltaX = -(deltaX * scaleX / naturalWidth) / this.instance.state.zoom.level + const normalizedDeltaY = -(deltaY * scaleY / naturalHeight) / this.instance.state.zoom.level // Apply pan this.panImage(normalizedDeltaX, normalizedDeltaY) @@ -126,7 +125,7 @@ export class PanMode extends BaseMode { this.isDragging = false // Restore cursor to grab (pan mode still active) - if (this.instance.svg && this.state.zoom.level > 1.0) { + if (this.instance.svg && this.instance.state.zoom.level > 1.0) { this.instance.svg.style.cursor = 'grab' } } @@ -140,7 +139,7 @@ export class PanMode extends BaseMode { this.isDragging = false // Restore cursor - if (this.instance.svg && this.state.zoom.level > 1.0) { + if (this.instance.svg && this.instance.state.zoom.level > 1.0) { this.instance.svg.style.cursor = 'grab' } } @@ -152,16 +151,16 @@ export class PanMode extends BaseMode { * @param {number} deltaY - Change in Y position (normalized -1 to 1) */ panImage(deltaX, deltaY) { - if (this.state.zoom.level <= 1.0) { + if (this.instance.state.zoom.level <= 1.0) { return // No panning when not zoomed } // Calculate new center point, constrained to valid range - const newCenterX = Math.max(0, Math.min(1, this.state.zoom.centerX + deltaX)) - const newCenterY = Math.max(0, Math.min(1, this.state.zoom.centerY + deltaY)) + const newCenterX = Math.max(0, Math.min(1, this.instance.state.zoom.centerX + deltaX)) + const newCenterY = Math.max(0, Math.min(1, this.instance.state.zoom.centerY + deltaY)) // Update zoom with new center point - this.setZoom(this.state.zoom.level, newCenterX, newCenterY) + this.setZoom(this.instance.state.zoom.level, newCenterX, newCenterY) } /** @@ -172,9 +171,9 @@ export class PanMode extends BaseMode { */ setZoom(level, centerX, centerY) { // Update state - this.state.zoom.level = level - this.state.zoom.centerX = centerX - this.state.zoom.centerY = centerY + this.instance.state.zoom.level = level + this.instance.state.zoom.centerX = centerX + this.instance.state.zoom.centerY = centerY // Apply zoom transform if (this.instance.svg) { @@ -184,7 +183,7 @@ export class PanMode extends BaseMode { // Note: zoom button states will be updated by the main zoom change handler // Notify listeners - notifyStateListeners(this.state, this.instance.stateListeners) + notifyStateListeners(this.instance.state, this.instance.stateListeners) } /** @@ -217,7 +216,7 @@ export class PanMode extends BaseMode { * @returns {boolean} True if enabled, false if disabled */ isEnabled() { - return this.state.zoom.level > 1.0 + return this.instance.state.zoom.level > 1.0 } /** @@ -230,13 +229,13 @@ export class PanMode extends BaseMode { label: '−', title: 'Zoom Out', action: () => this.instance._zoomOut(), - isEnabled: () => this.state.zoom.level > 1.0 + isEnabled: () => this.instance.state.zoom.level > 1.0 }, { label: '+', title: 'Zoom In', action: () => this.instance._zoomIn(), - isEnabled: () => this.state.zoom.level < 10.0 + isEnabled: () => this.instance.state.zoom.level < 10.0 } ] } diff --git a/src/rendering/cursors.js b/src/rendering/cursors.js index eee9999..f734ef3 100644 --- a/src/rendering/cursors.js +++ b/src/rendering/cursors.js @@ -38,7 +38,7 @@ export function updateCursorIndicators(instance) { * @param {DataCoordinates} endPoint - Current drag end point */ export function drawDopplerPreview(instance, startPoint, endPoint) { - const margins = instance.state.axes.margins + const margins = instance.state.margins const { naturalWidth, naturalHeight } = instance.state.imageDetails const { timeMin, timeMax, freqMin, freqMax } = instance.state.config diff --git a/src/types.js b/src/types.js index 2e22dcc..0a7ad5a 100644 --- a/src/types.js +++ b/src/types.js @@ -134,11 +134,6 @@ * @property {number} top - Top margin */ -/** - * Axes configuration - * @typedef {Object} AxesConfig - * @property {AxesMargins} margins - Margin configuration for axes - */ /** * Zoom state configuration @@ -149,6 +144,15 @@ * @property {boolean} panMode - Whether pan mode is active */ +/** + * Viewport configuration for coordinate transformations + * @typedef {Object} ViewportConfig + * @property {AxesMargins} margins - SVG margins configuration + * @property {ImageDetails} imageDetails - Image dimensions + * @property {Config} config - Time/frequency range configuration + * @property {ZoomState} zoom - Current zoom state + */ + /** * Selection state for keyboard fine control * @typedef {Object} SelectionState @@ -177,7 +181,7 @@ * @property {ImageDetails} imageDetails - Image source and dimensions * @property {Config} config - Time and frequency configuration * @property {DisplayDimensions} displayDimensions - Current display dimensions - * @property {AxesConfig} axes - Axes configuration + * @property {AxesMargins} margins - Axes margin configuration * @property {ZoomState} zoom - Zoom state configuration */ diff --git a/tests/focus-simple.spec.js b/tests/focus-simple.spec.js index 0575a9f..4aba993 100644 --- a/tests/focus-simple.spec.js +++ b/tests/focus-simple.spec.js @@ -13,14 +13,14 @@ test.describe('Simple Focus Test', () => { const gramFrame1 = page.locator('.gram-frame-container').first() const gramFrame2 = page.locator('.gram-frame-container').nth(1) - // Initially, the first instance should be focused (auto-focus) + // Initially, no instance should be focused until user interaction await page.waitForTimeout(500) // Let focus system initialize const initialFocus1 = await gramFrame1.evaluate(el => el.classList.contains('gram-frame-focused')) const initialFocus2 = await gramFrame2.evaluate(el => el.classList.contains('gram-frame-focused')) - // First should be focused initially - expect(initialFocus1).toBe(true) + // Neither should be focused initially + expect(initialFocus1).toBe(false) expect(initialFocus2).toBe(false) // Click on the second GramFrame to switch focus diff --git a/tests/keyboard-focus-simple.spec.js.disabled b/tests/keyboard-focus-simple.spec.js.disabled index 8abfe6b..8d0665b 100644 --- a/tests/keyboard-focus-simple.spec.js.disabled +++ b/tests/keyboard-focus-simple.spec.js.disabled @@ -1,29 +1,32 @@ import { test, expect } from '@playwright/test' +/** + * Ensures the container is in Analysis mode, then creates a marker reliably. + * Falls back across a few selectors because debug-multiple may differ. + */ async function ensureMarkerExistsIn(container, page) { - // Click mode switcher if present - const analysisBtn = container.locator('button:has-text("Analysis"), [aria-label="Analysis"]') + // 1) Switch to Analysis mode if a mode switcher exists + const analysisBtn = container.locator('button:has-text("Analysis"), [data-mode="analysis"], [aria-label="Analysis"]') if (await analysisBtn.count()) { await analysisBtn.first().click() + // small settle await page.waitForTimeout(50) } - // Click center of hitlayer/SVG - const target = container.locator('.gram-frame-hitlayer, .gram-frame-svg') - await expect(target).toBeVisible({ timeout: 5000 }) - const box = await target.boundingBox() - if (!box) throw new Error('No bounding box for click target') - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { force: true }) - - // Wait for marker to appear in panel - const row = container.locator('[data-panel="markers"] tbody tr, .markers table tbody tr') - await expect(row.first()).toBeVisible({ timeout: 5000 }) - - // Try to find visual marker - const marker = container.locator( - '.gram-frame-marker, [data-test="marker-cross"], .analysis-marker, .cross-cursor-marker' - ).first() - return (await marker.count()) ? marker : row.first() + // 2) Click center of the SVG to create a marker + const svg = container.locator('.gram-frame-svg, svg.gram-frame-svg, .gram-frame .gram-frame-svg') + await expect(svg).toBeVisible({ timeout: 5000 }) + const box = await svg.boundingBox() + if (!box) throw new Error('SVG has no bounding box') + + await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5) + await page.waitForTimeout(80) + + // 3) Wait for a marker to appear + const marker = container.locator('.gram-frame-marker, [data-test="marker-cross"], .analysis-marker').first() + await expect(marker).toBeVisible({ timeout: 3000 }) + + return marker } test.describe('Keyboard Focus Behavior', () => { diff --git a/tests/keyboard-focus.spec.js.disabled b/tests/keyboard-focus.spec.js.disabled index d3fe38f..2131534 100644 --- a/tests/keyboard-focus.spec.js.disabled +++ b/tests/keyboard-focus.spec.js.disabled @@ -1,31 +1,29 @@ import { test, expect } from '@playwright/test' async function ensureMarkerExistsIn(container, page) { - const analysisBtn = container.locator('button:has-text("Analysis"), [aria-label="Analysis"]') + const analysisBtn = container.locator('button:has-text("Analysis"), [data-mode="analysis"], [aria-label="Analysis"]') if (await analysisBtn.count()) { await analysisBtn.first().click() await page.waitForTimeout(50) } - const target = container.locator('.gram-frame-hitlayer, .gram-frame-svg') - await expect(target).toBeVisible({ timeout: 5000 }) - const box = await target.boundingBox() - if (!box) throw new Error('No bounding box for click target') - await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { force: true }) + const svg = container.locator('.gram-frame-svg, svg.gram-frame-svg, .gram-frame .gram-frame-svg') + await expect(svg).toBeVisible({ timeout: 5000 }) + const box = await svg.boundingBox() + if (!box) throw new Error('SVG has no bounding box') - const row = container.locator('[data-panel="markers"] tbody tr, .markers table tbody tr') - await expect(row.first()).toBeVisible({ timeout: 5000 }) + await page.mouse.click(box.x + box.width * 0.5, box.y + box.height * 0.5) + await page.waitForTimeout(80) - const marker = container.locator( - '.gram-frame-marker, [data-test="marker-cross"], .analysis-marker, .cross-cursor-marker' - ).first() - return (await marker.count()) ? marker : row.first() + const marker = container.locator('.gram-frame-marker, [data-test="marker-cross"], .analysis-marker').first() + await expect(marker).toBeVisible({ timeout: 3000 }) + return marker } test.describe('Keyboard Focus with Multiple GramFrames', () => { test.beforeEach(async ({ page }) => { await page.goto('http://localhost:5173/debug-multiple.html') - await page.waitForSelector('.gram-frame-container', { timeout: 10000 }) + await page.waitForSelector('.gram-frame-container', { timeout: 15000 }) const containers = await page.locator('.gram-frame-container').count() expect(containers).toBe(2) })