mirror of
https://github.com/wavelog/wavelog.git
synced 2026-03-22 10:24:14 +00:00
The frequency change event is already triggered conditionally when fromWaterfall=false, so the unconditional trigger on the first line was redundant and could cause the change handler to execute twice.
6910 lines
284 KiB
JavaScript
6910 lines
284 KiB
JavaScript
// @ts-nocheck
|
|
/**
|
|
* @fileoverview DX WATERFALL for WaveLog
|
|
* @version 0.9.3 // also change line 38
|
|
* @author Wavelog Team
|
|
*
|
|
* @description
|
|
* Real-time DX spot visualization with interactive frequency navigation,
|
|
* CAT control integration, and smart hunting features for amateur radio.
|
|
*
|
|
* @requires jQuery
|
|
* @requires base_url (global from Wavelog)
|
|
* @requires setFrequency (global function from Wavelog)
|
|
* @requires setMode (global function from Wavelog)
|
|
* @requires frequencyToBand (global function from Wavelog)
|
|
|
|
* @features
|
|
* - Canvas-based visualization
|
|
* - ES6+ syntax (const/let recommended, var used for compatibility)
|
|
* - Passive event listeners for scroll performance
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
|
|
// ========================================
|
|
// CONSTANTS AND CONFIGURATION
|
|
// ========================================
|
|
|
|
var DX_WATERFALL_CONSTANTS = {
|
|
// Version
|
|
VERSION: '0.9.3', // DX Waterfall version (keep in sync with @version in file header)
|
|
|
|
// Debug and logging
|
|
DEBUG_MODE: false, // Set to true for verbose logging, false for production
|
|
|
|
// Timing and debouncing
|
|
DEBOUNCE: {
|
|
SPOT_COLLECTION_MS: 1000, // Minimum time between spot collections
|
|
FREQUENCY_CACHE_REFRESH_MS: 200, // Throttle for frequency cache refresh
|
|
ZOOM_CHANGE_MS: 100, // Prevent rapid zoom changes
|
|
DX_SPOTS_FETCH_INTERVAL_MS: 60000, // DX spots auto-refresh interval (60 seconds)
|
|
FETCH_REQUEST_MS: 500, // Minimum time between debounced fetch requests
|
|
MODE_FILTER_CHANGE_MS: 500, // Wait time after mode filter toggle
|
|
SET_FREQUENCY_MS: 500, // Debounce for setFrequency calls
|
|
PROGRAMMATIC_MODE_RESET_MS: 100, // Reset programmatic mode change flag
|
|
FREQUENCY_COMMIT_SHORT_MS: 50, // Very short delay for CAT command completion
|
|
FREQUENCY_COMMIT_RETRY_MS: 100, // Retry delay for frequency commit
|
|
ICON_FEEDBACK_MS: 200, // Visual feedback duration for icon clicks
|
|
ZOOM_ICON_FEEDBACK_MS: 150, // Visual feedback duration for zoom icons
|
|
MODE_CHANGE_SETTLE_MS: 200, // Delay for radio mode change to settle
|
|
FORM_POPULATE_DELAY_MS: 50, // Delay before populating QSO form
|
|
ZOOM_MENU_UPDATE_DELAY_MS: 150 // Delay for zoom menu update after navigation
|
|
},
|
|
|
|
// CAT and radio control
|
|
// Note: Some values are initialized and will be recalculated based on catPollInterval
|
|
CAT: {
|
|
POLL_INTERVAL_MS: 3000, // Default CAT polling interval (can be overridden by config)
|
|
TUNING_FLAG_FALLBACK_MS: 4500, // Fallback timeout for tuning flags (1.5x poll interval)
|
|
FREQUENCY_WAIT_TIMEOUT_MS: 6000, // Initial load wait time for CAT frequency (2x poll interval)
|
|
|
|
// WebSocket timing (low latency - no overlay blink)
|
|
WEBSOCKET_CONFIRM_TIMEOUT_MS: 2000, // WebSocket: Fast confirmation timeout (increased for rapid clicking)
|
|
WEBSOCKET_FALLBACK_TIMEOUT_MS: 500, // WebSocket: Fast fallback timeout (vs 1.5x poll interval)
|
|
WEBSOCKET_COMMIT_DELAY_MS: 10, // WebSocket: Minimal commit delay (vs 50ms polling)
|
|
|
|
// Polling timing (standard latency)
|
|
POLLING_CONFIRM_TIMEOUT_MS: 10000, // Polling: Extended confirmation timeout (3+ poll cycles for rapid clicking)
|
|
POLLING_COMMIT_DELAY_MS: 50, // Polling: Standard commit delay (from DEBOUNCE.FREQUENCY_COMMIT_SHORT_MS)
|
|
|
|
// Auto-populate timing
|
|
TUNING_STOPPED_DELAY_MS: 1000 // Delay after user stops tuning to auto-populate spot (1 second)
|
|
},
|
|
|
|
// Visual timing
|
|
VISUAL: {
|
|
STATIC_NOISE_REFRESH_MS: 100 // Static noise animation frame rate (100ms = 10fps, twice as fast as before)
|
|
},
|
|
|
|
// Cookie configuration
|
|
COOKIE: {
|
|
NAME_FONT_SIZE: 'dxwaterfall_fontsize', // Cookie name for font size
|
|
NAME_MODE_FILTERS: 'dxwaterfall_modefilters', // Cookie name for mode filters
|
|
EXPIRY_DAYS: 365 // Cookie expiration in days
|
|
},
|
|
|
|
// Canvas dimensions and spacing
|
|
CANVAS: {
|
|
MIN_TEXT_AREA_WIDTH: 100, // Minimum width to display text labels
|
|
RULER_HEIGHT: 25, // Height of the frequency ruler at bottom
|
|
TOP_MARGIN: 10, // Top margin for spot labels
|
|
BOTTOM_MARGIN: 10, // Bottom margin above ruler
|
|
SPOT_PADDING: 2, // Padding around spot labels
|
|
SPOT_TICKBOX_SIZE: 4, // Size of status tickbox in pixels
|
|
LOGO_OFFSET_Y: 100, // Logo vertical offset from center
|
|
TEXT_OFFSET_Y: 40 // Text vertical offset from center
|
|
},
|
|
|
|
// AJAX configuration
|
|
AJAX: {
|
|
TIMEOUT_MS: 30000 // AJAX request timeout (30 seconds)
|
|
},
|
|
|
|
// Thresholds and tolerances
|
|
THRESHOLDS: {
|
|
FREQUENCY_COMPARISON: 0.1, // Frequency comparison tolerance in kHz
|
|
FT8_FREQUENCY_TOLERANCE: 5, // FT8 frequency detection tolerance in kHz
|
|
MAJOR_TICK_TOLERANCE: 0.05, // Floating point precision for major tick detection
|
|
SPOT_FREQUENCY_MATCH: 0.01, // Frequency match tolerance for spot navigation (kHz)
|
|
CAT_FREQUENCY_HZ: 50, // CAT frequency confirmation tolerance (50 Hz for radio tuning variations)
|
|
FREQUENCY_MATCH_KHZ: 0.1, // General frequency matching tolerance (kHz)
|
|
CENTER_SPOT_TOLERANCE_KHZ: 0.1 // Tolerance for center spot frequency matching (kHz)
|
|
},
|
|
|
|
// Zoom levels configuration
|
|
ZOOM: {
|
|
DEFAULT_LEVEL: 3, // Default zoom level
|
|
MAX_LEVEL: 5, // Maximum zoom level
|
|
MIN_LEVEL: 0, // Minimum zoom level
|
|
// Pixels per kHz for each zoom level
|
|
PIXELS_PER_KHZ: {
|
|
0: 2, // ±50 kHz view (widest - new level)
|
|
1: 4, // ±25 kHz view
|
|
2: 8, // ±12.5 kHz view
|
|
3: 20, // ±5 kHz view (default)
|
|
4: 32, // ±3.125 kHz view
|
|
5: 50 // ±2 kHz view (most zoomed)
|
|
}
|
|
},
|
|
|
|
// Colors (using CSS-compatible color strings)
|
|
COLORS: {
|
|
// Background and base
|
|
CANVAS_BORDER: '#000000',
|
|
BLACK: '#000000',
|
|
WHITE: '#FFFFFF',
|
|
OVERLAY_BACKGROUND: 'rgba(0, 0, 0, 0.7)',
|
|
OVERLAY_DARK_GREY: 'rgba(64, 64, 64, 0.7)',
|
|
TOOLTIP_BACKGROUND: 'rgba(0, 0, 0, 0.9)',
|
|
|
|
// Frequency ruler
|
|
RULER_BACKGROUND: '#000000bb',
|
|
RULER_LINE: '#888888',
|
|
RULER_TEXT: '#888888',
|
|
INVALID_FREQUENCY_OVERLAY: 'rgba(128, 128, 128, 0.5)',
|
|
|
|
// Center marker and bandwidth
|
|
CENTER_MARKER: '#FF0000',
|
|
CENTER_MARKER_RX: '#00FF00', // Green for RX in split operation
|
|
CENTER_MARKER_TX: '#FF0000', // Red for TX in split operation
|
|
RED: '#FF0000',
|
|
GREEN: '#00FF00',
|
|
BANDWIDTH_INDICATOR: 'rgba(255, 255, 0, 0.3)',
|
|
|
|
// Messages and text
|
|
MESSAGE_TEXT_WHITE: '#FFFFFF',
|
|
WATERFALL_LINK: '#888888',
|
|
GREY: '#888888',
|
|
|
|
// Static noise RGB components
|
|
STATIC_NOISE_RGB: {R: 34, G: 34, B: 34}, // Base RGB values for noise generation
|
|
|
|
// DX Spots by mode
|
|
SPOT_PHONE: '#00FF00',
|
|
SPOT_CW: '#FFA500',
|
|
SPOT_DIGI: '#0096FF',
|
|
SPOT_OTHER: 'rgba(160, 32, 240, ', // Purple - incomplete for opacity
|
|
|
|
// Spot status colors
|
|
GREEN: '#00FF00', // Confirmed
|
|
ORANGE: '#FFA500', // Worked
|
|
|
|
// Spot mode color bases (for rgba with variable opacity)
|
|
PHONE_RGB: 'rgba(0, 255, 0, ', // Green
|
|
CW_RGB: 'rgba(255, 165, 0, ', // Orange
|
|
DIGI_RGB: 'rgba(0, 150, 255, ', // Blue
|
|
OTHER_RGB: 'rgba(160, 32, 240, ', // Purple
|
|
|
|
// Band limits
|
|
OUT_OF_BAND: 'rgba(255, 0, 0, 0.2)',
|
|
OUT_OF_BAND_BORDER_LIGHT: 'rgba(255, 0, 0, 0.3)',
|
|
OUT_OF_BAND_BORDER_DARK: 'rgba(255, 0, 0, 0.6)'
|
|
},
|
|
|
|
// Font configurations
|
|
FONTS: {
|
|
FAMILY: '"Consolas", "Courier New", monospace',
|
|
RULER: '11px "Consolas", "Courier New", monospace',
|
|
CENTER_MARKER: '12px "Consolas", "Courier New", monospace',
|
|
SPOT_LABELS: 'bold 13px "Consolas", "Courier New", monospace', // Increased from 12px to 13px (5% larger)
|
|
SPOT_INFO: '11px "Consolas", "Courier New", monospace',
|
|
WAITING_MESSAGE: '14px "Consolas", "Courier New", monospace',
|
|
TITLE_LARGE: 'bold 24px "Consolas", "Courier New", monospace',
|
|
FREQUENCY_CHANGE: '14px "Consolas", "Courier New", monospace',
|
|
OUT_OF_BAND: 'bold 14px "Consolas", "Courier New", monospace',
|
|
SMALL_MONO: '12px "Consolas", "Courier New", monospace',
|
|
|
|
// Label size configurations (x-small, small, medium, large, x-large)
|
|
LABEL_SIZES: [11, 12, 13, 15, 17], // Regular spot label sizes
|
|
LABEL_HEIGHTS: [13, 14, 15, 17, 19] // Heights for overlap detection
|
|
},
|
|
|
|
// Logo configuration
|
|
LOGO_FILENAME: 'assets/logo/wavelog_logo_darkly_wide.png',
|
|
|
|
// ========================================
|
|
// STATE MACHINE STATES
|
|
// ========================================
|
|
STATES: {
|
|
// Lifecycle states
|
|
DISABLED: 'disabled', // DX Waterfall not initialized or fully destroyed
|
|
INITIALIZING: 'initializing', // Canvas setup, loading settings, event listeners
|
|
DEINITIALIZING: 'deinitializing', // Cleanup in progress, removing listeners, clearing timers
|
|
|
|
// Data fetching states
|
|
FETCHING_SPOTS: 'fetching_spots', // AJAX request in progress (includes filter changes)
|
|
|
|
// Frequency change states
|
|
TUNING: 'tuning', // Radio tuning to new frequency (CAT command sent)
|
|
|
|
// Normal operation states
|
|
READY: 'ready', // Normal operation - DX Waterfall displaying (even if 0 spots)
|
|
|
|
// Error states
|
|
ERROR: 'error' // Critical error - user must manually restart DX Waterfall
|
|
}
|
|
|
|
};
|
|
|
|
// ========================================
|
|
// STATE MACHINE
|
|
// ========================================
|
|
|
|
/**
|
|
* DX Waterfall State Machine
|
|
* Manages state transitions and ensures clean state handling
|
|
*/
|
|
var DXWaterfallStateMachine = {
|
|
currentState: DX_WATERFALL_CONSTANTS.STATES.DISABLED,
|
|
previousState: null,
|
|
stateData: {}, // Additional data for current state
|
|
stateTimer: null, // Timer for state timeouts
|
|
|
|
/**
|
|
* Transition to a new state
|
|
* @param {string} newState - New state from DX_WATERFALL_CONSTANTS.STATES
|
|
* @param {Object} data - Optional data associated with the state
|
|
*/
|
|
setState: function(newState, data) {
|
|
// Validate state
|
|
var validStates = Object.values(DX_WATERFALL_CONSTANTS.STATES);
|
|
if (validStates.indexOf(newState) === -1) {
|
|
DX_WATERFALL_UTILS.log.error('[State Machine] Invalid state: ' + newState);
|
|
return false;
|
|
}
|
|
|
|
// Skip if already in this state (unless data changed)
|
|
if (this.currentState === newState && !data) {
|
|
return false;
|
|
}
|
|
|
|
var oldState = this.currentState;
|
|
this.previousState = oldState;
|
|
this.currentState = newState;
|
|
this.stateData = data || {};
|
|
|
|
// Clear any existing state timer
|
|
if (this.stateTimer) {
|
|
clearTimeout(this.stateTimer);
|
|
this.stateTimer = null;
|
|
}
|
|
|
|
// Log state transition
|
|
DX_WATERFALL_UTILS.log.debug('[State Machine] ' + oldState + ' → ' + newState +
|
|
(data ? ' (' + JSON.stringify(data) + ')' : ''));
|
|
|
|
// Call state entry handler
|
|
this._onStateEnter(newState, oldState);
|
|
|
|
// Trigger refresh if waterfall is initialized
|
|
if (typeof dxWaterfall !== 'undefined' && dxWaterfall.canvas && dxWaterfall.ctx) {
|
|
dxWaterfall.refresh();
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Get current state
|
|
* @returns {string} Current state
|
|
*/
|
|
getState: function() {
|
|
return this.currentState;
|
|
},
|
|
|
|
/**
|
|
* Check if in a specific state
|
|
* @param {string} state - State to check
|
|
* @returns {boolean} True if in that state
|
|
*/
|
|
isState: function(state) {
|
|
return this.currentState === state;
|
|
},
|
|
|
|
/**
|
|
* Check if in any of the provided states
|
|
* @param {Array<string>} states - Array of states to check
|
|
* @returns {boolean} True if in any of those states
|
|
*/
|
|
isAnyState: function(states) {
|
|
return states.indexOf(this.currentState) !== -1;
|
|
},
|
|
|
|
/**
|
|
* Get state data
|
|
* @returns {Object} Current state data
|
|
*/
|
|
getStateData: function() {
|
|
return this.stateData;
|
|
},
|
|
|
|
/**
|
|
* Set a timeout for current state (auto-transition on timeout)
|
|
* @param {number} ms - Milliseconds until timeout
|
|
* @param {string} timeoutState - State to transition to on timeout
|
|
*/
|
|
setStateTimeout: function(ms, timeoutState) {
|
|
var self = this;
|
|
if (this.stateTimer) {
|
|
clearTimeout(this.stateTimer);
|
|
}
|
|
|
|
this.stateTimer = setTimeout(function() {
|
|
// TUNING → READY timeout is normal (fallback when radio doesn't respond quickly)
|
|
// Only log warnings for other timeout transitions that indicate problems
|
|
var isNormalTimeout = (self.currentState === 'tuning' && timeoutState === 'ready');
|
|
|
|
if (!isNormalTimeout) {
|
|
DX_WATERFALL_UTILS.log.warn('[State Machine] State timeout: ' + self.currentState + ' → ' + timeoutState);
|
|
}
|
|
|
|
self.setState(timeoutState);
|
|
}, ms);
|
|
},
|
|
|
|
/**
|
|
* Handle state entry
|
|
* @private
|
|
*/
|
|
_onStateEnter: function(newState, oldState) {
|
|
var STATES = DX_WATERFALL_CONSTANTS.STATES;
|
|
|
|
switch (newState) {
|
|
case STATES.INITIALIZING:
|
|
// Set timeout for initialization - increased to 15 seconds to account for 3-second initial delay
|
|
this.setStateTimeout(15000, STATES.ERROR);
|
|
break;
|
|
|
|
case STATES.FETCHING_SPOTS:
|
|
// Set timeout for fetch operation - network issues should trigger error
|
|
this.setStateTimeout(DX_WATERFALL_CONSTANTS.AJAX.TIMEOUT_MS, STATES.ERROR);
|
|
break;
|
|
|
|
case STATES.TUNING:
|
|
// Set timeout for tuning operation - fallback to READY if radio doesn't respond
|
|
var timings = getCATTimings();
|
|
this.setStateTimeout(timings.fallbackTimeout, STATES.READY);
|
|
break;
|
|
|
|
case STATES.READY:
|
|
// Normal operation - no timeout needed
|
|
break;
|
|
|
|
case STATES.ERROR:
|
|
// ERROR state has no auto-recovery
|
|
// User must manually turn off/on DX Waterfall to recover
|
|
DX_WATERFALL_UTILS.log.error('[State Machine] Entered ERROR state - manual recovery required');
|
|
break;
|
|
|
|
case STATES.DEINITIALIZING:
|
|
// Cleanup should be fast - timeout to force DISABLED if stuck
|
|
this.setStateTimeout(2000, STATES.DISABLED);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
// ========================================
|
|
// CAT TIMING INITIALIZATION
|
|
// ========================================
|
|
|
|
/**
|
|
* Initialize CAT timing constants based on configured poll interval
|
|
* Called automatically from footer.php after catPollInterval is set
|
|
* @param {number} pollInterval - CAT polling interval in milliseconds
|
|
*/
|
|
function initCATTimings(pollInterval) {
|
|
DX_WATERFALL_CONSTANTS.CAT.POLL_INTERVAL_MS = pollInterval;
|
|
DX_WATERFALL_CONSTANTS.CAT.TUNING_FLAG_FALLBACK_MS = pollInterval * 1.5;
|
|
DX_WATERFALL_CONSTANTS.CAT.FREQUENCY_WAIT_TIMEOUT_MS = pollInterval * 2;
|
|
}
|
|
|
|
/**
|
|
* Get CAT timing based on connection type (WebSocket vs Polling)
|
|
* WebSocket connections have much lower latency and can use shorter timeouts
|
|
* @returns {object} - Object with timeout values appropriate for current connection type
|
|
*/
|
|
function getCATTimings() {
|
|
var isWebSocket = typeof dxwaterfall_cat_state !== 'undefined' && dxwaterfall_cat_state === 'websocket';
|
|
|
|
if (isWebSocket) {
|
|
return {
|
|
confirmTimeout: DX_WATERFALL_CONSTANTS.CAT.WEBSOCKET_CONFIRM_TIMEOUT_MS,
|
|
fallbackTimeout: DX_WATERFALL_CONSTANTS.CAT.WEBSOCKET_FALLBACK_TIMEOUT_MS,
|
|
commitDelay: DX_WATERFALL_CONSTANTS.CAT.WEBSOCKET_COMMIT_DELAY_MS
|
|
};
|
|
} else {
|
|
return {
|
|
confirmTimeout: DX_WATERFALL_CONSTANTS.CAT.POLLING_CONFIRM_TIMEOUT_MS,
|
|
fallbackTimeout: DX_WATERFALL_CONSTANTS.CAT.TUNING_FLAG_FALLBACK_MS,
|
|
commitDelay: DX_WATERFALL_CONSTANTS.CAT.POLLING_COMMIT_DELAY_MS
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle CAT frequency update
|
|
* Handles frequency confirmation and cache invalidation
|
|
* @param {number} radioFrequency - Frequency from CAT in Hz
|
|
* @param {Function} updateCallback - Function to call to update UI
|
|
* @returns {boolean} - True if update was processed
|
|
*/
|
|
/**
|
|
* Handle CAT frequency update
|
|
* Manages state transitions based on frequency confirmation
|
|
* @param {number} radioFrequency - Frequency from CAT in Hz
|
|
* @param {Function} updateCallback - Function to call to update UI
|
|
* @returns {boolean} - True if update was processed
|
|
*/
|
|
function handleCATFrequencyUpdate(radioFrequency, updateCallback) {
|
|
var now = Date.now();
|
|
|
|
// Check if frequency actually changed BEFORE updating UI
|
|
var frequencyChanged = false;
|
|
var isInitialLoad = false;
|
|
|
|
if (typeof dxWaterfall !== 'undefined' && dxWaterfall.lastValidCommittedFreq !== null && dxWaterfall.lastValidCommittedUnit) {
|
|
// Compare incoming CAT frequency with last committed value
|
|
// CAT sends frequency in Hz, convert to kHz for comparison
|
|
var lastKhz = convertFrequency(
|
|
dxWaterfall.lastValidCommittedFreq,
|
|
dxWaterfall.lastValidCommittedUnit,
|
|
'kHz'
|
|
);
|
|
var incomingHz = parseFloat(radioFrequency);
|
|
var incomingKhz = incomingHz / 1000; // Convert Hz to kHz
|
|
var tolerance = 0.001; // 1 Hz
|
|
var diff = Math.abs(incomingKhz - lastKhz);
|
|
frequencyChanged = diff > tolerance;
|
|
} else if (typeof dxWaterfall !== 'undefined') {
|
|
// First time receiving CAT frequency - always consider it changed
|
|
isInitialLoad = true;
|
|
frequencyChanged = true;
|
|
}
|
|
|
|
// Check if we're waiting for a specific frequency to be confirmed BEFORE updating UI
|
|
var shouldSkipStaleUpdate = false;
|
|
|
|
// If we're waiting for radio to tune to a target frequency, check if this CAT update is stale
|
|
if (typeof dxWaterfall !== 'undefined' && dxWaterfall.targetFrequencyHz) {
|
|
// In split operation mode, use RX frequency for confirmation (waterfall is centered on RX)
|
|
// In simplex mode, use TX frequency (main frequency)
|
|
var incomingHz;
|
|
if (window.catState && window.catState.frequency_rx && window.catState.frequency_rx > 0) {
|
|
// Split mode - check RX frequency
|
|
incomingHz = parseFloat(window.catState.frequency_rx);
|
|
DX_WATERFALL_UTILS.log.debug('[CAT] Split mode - using RX frequency for confirmation');
|
|
} else {
|
|
// Simplex mode - check TX frequency (main frequency)
|
|
incomingHz = parseFloat(radioFrequency);
|
|
}
|
|
|
|
var targetHz = dxWaterfall.targetFrequencyHz;
|
|
var toleranceHz = DX_WATERFALL_CONSTANTS.THRESHOLDS.CAT_FREQUENCY_HZ; // 50 Hz tolerance
|
|
var diff = Math.abs(incomingHz - targetHz);
|
|
|
|
// Debug logging to see what we're comparing
|
|
DX_WATERFALL_UTILS.log.debug('[CAT] Frequency check - Target: ' + targetHz + ' Hz, Incoming: ' + incomingHz + ' Hz, Diff: ' + diff + ' Hz, Tolerance: ' + toleranceHz + ' Hz');
|
|
|
|
dxWaterfall.targetFrequencyConfirmAttempts = (dxWaterfall.targetFrequencyConfirmAttempts || 0) + 1;
|
|
|
|
if (diff <= toleranceHz) {
|
|
// ========================================
|
|
// FREQUENCY CONFIRMED - TRANSITION TO READY
|
|
// ========================================
|
|
DX_WATERFALL_UTILS.log.debug('[CAT] Frequency CONFIRMED - transitioning to READY');
|
|
|
|
// Cancel any pending frequency confirmation timeout
|
|
if (dxWaterfall.frequencyConfirmTimeoutId) {
|
|
clearTimeout(dxWaterfall.frequencyConfirmTimeoutId);
|
|
dxWaterfall.frequencyConfirmTimeoutId = null;
|
|
}
|
|
|
|
// Clear state machine timeout (prevents fallback timeout warning)
|
|
if (DXWaterfallStateMachine.stateTimer) {
|
|
clearTimeout(DXWaterfallStateMachine.stateTimer);
|
|
DXWaterfallStateMachine.stateTimer = null;
|
|
}
|
|
|
|
dxWaterfall.targetFrequencyConfirmAttempts = 0;
|
|
dxWaterfall.targetFrequencyHz = null;
|
|
|
|
// Wait 2 render frames before transitioning to READY
|
|
// This allows marker animation to complete behind the TUNING overlay
|
|
dxWaterfall.readyTransitionTimer = setTimeout(function() {
|
|
dxWaterfall.readyTransitionTimer = null;
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.READY);
|
|
}, DX_WATERFALL_CONSTANTS.VISUAL.STATIC_NOISE_REFRESH_MS * 2);
|
|
|
|
shouldSkipStaleUpdate = false; // Proceed normally - radio is at correct frequency
|
|
} else {
|
|
// Frequency doesn't match - this is a stale update from before radio finished tuning
|
|
DX_WATERFALL_UTILS.log.debug('[CAT] Frequency MISMATCH - attempt ' + dxWaterfall.targetFrequencyConfirmAttempts + ' of 3');
|
|
|
|
// If we've tried 3 times and still no match, give up and accept current frequency
|
|
if (dxWaterfall.targetFrequencyConfirmAttempts >= 3) {
|
|
DX_WATERFALL_UTILS.log.debug('[CAT] Giving up after 3 attempts, accepting current frequency');
|
|
dxWaterfall.targetFrequencyHz = null;
|
|
dxWaterfall.targetFrequencyConfirmAttempts = 0;
|
|
|
|
// Give up waiting, transition to READY
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.READY);
|
|
|
|
shouldSkipStaleUpdate = false; // Give up, accept current frequency
|
|
} else {
|
|
// Skip this stale update - waterfall already showing target frequency
|
|
shouldSkipStaleUpdate = true;
|
|
return true; // Exit early - don't process this stale CAT update
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update UI with new frequency
|
|
if (updateCallback) {
|
|
updateCallback();
|
|
}
|
|
|
|
// Only invalidate cache and commit if frequency actually changed
|
|
if (typeof dxWaterfall !== 'undefined' && (frequencyChanged || isInitialLoad)) {
|
|
// IMPORTANT: Commit BEFORE invalidating cache
|
|
if (dxWaterfall.commitFrequency) {
|
|
dxWaterfall.commitFrequency();
|
|
}
|
|
if (dxWaterfall.invalidateFrequencyCache) {
|
|
dxWaterfall.invalidateFrequencyCache();
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if CAT control is available
|
|
* @returns {boolean} - True if CAT is available (polling or websocket)
|
|
*/
|
|
function isCATAvailable() {
|
|
// Check if global CAT state variable exists and is defined
|
|
if (typeof dxwaterfall_cat_state === 'undefined' || dxwaterfall_cat_state === null) {
|
|
return false;
|
|
}
|
|
|
|
// Check if tuneRadioToFrequency function exists
|
|
if (typeof tuneRadioToFrequency !== 'function') {
|
|
return false;
|
|
}
|
|
|
|
// Valid states are 'polling' or 'websocket'
|
|
return (dxwaterfall_cat_state === "polling" || dxwaterfall_cat_state === "websocket");
|
|
}
|
|
|
|
// ========================================
|
|
// UTILITY FUNCTIONS
|
|
// ========================================
|
|
|
|
var DX_WATERFALL_UTILS = {
|
|
|
|
// Logging utilities with debug control
|
|
log: {
|
|
/**
|
|
* Debug log - only shown when DEBUG_MODE is true
|
|
* @param {string} message - Log message
|
|
*/
|
|
debug: function(message) {
|
|
if (DX_WATERFALL_CONSTANTS.DEBUG_MODE && console && console.log) {
|
|
console.log(message);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Warning log - always shown
|
|
* @param {string} message - Warning message
|
|
*/
|
|
warn: function(message) {
|
|
if (console && console.warn) {
|
|
console.warn(message);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Error log - always shown
|
|
* @param {string} message - Error message
|
|
* @param {Error} [error] - Optional error object
|
|
*/
|
|
error: function(message, error) {
|
|
if (console && console.error) {
|
|
if (error) {
|
|
console.error(message, error);
|
|
} else {
|
|
console.error(message);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// Frequency utilities
|
|
frequency: {
|
|
// Validate frequency value
|
|
isValid: function(value) {
|
|
var freq = parseFloat(value) || 0;
|
|
return freq > 0;
|
|
},
|
|
|
|
// Parse and validate frequency
|
|
parseAndValidate: function(value) {
|
|
var freq = parseFloat(value) || 0;
|
|
return { value: freq, valid: freq > 0 };
|
|
}
|
|
},
|
|
|
|
// Sorting utilities for common patterns
|
|
sorting: {
|
|
byFrequency: function(a, b) {
|
|
return a.frequency - b.frequency;
|
|
},
|
|
|
|
byAbsOffset: function(a, b) {
|
|
return a.absOffset - b.absOffset;
|
|
}
|
|
},
|
|
|
|
// Timing utilities
|
|
timing: {
|
|
/**
|
|
* Generic debounce function - delays execution until after wait time has elapsed
|
|
* @param {Function} func - Function to debounce
|
|
* @param {number} wait - Milliseconds to wait
|
|
* @param {Object} context - Context object that stores the timer
|
|
* @param {string} timerProperty - Property name on context object for timer storage
|
|
* @returns {Function} Debounced function
|
|
*/
|
|
debounce: function(func, wait, context, timerProperty) {
|
|
return function() {
|
|
var args = arguments;
|
|
var later = function() {
|
|
context[timerProperty] = null;
|
|
func.apply(context, args);
|
|
};
|
|
if (context[timerProperty]) {
|
|
clearTimeout(context[timerProperty]);
|
|
}
|
|
context[timerProperty] = setTimeout(later, wait);
|
|
};
|
|
}
|
|
},
|
|
|
|
// DOM selector utilities (cached for performance)
|
|
dom: {
|
|
waterfall: null,
|
|
|
|
init: function() {
|
|
this.waterfall = $('#dxWaterfall');
|
|
},
|
|
|
|
getWaterfall: function() {
|
|
return this.waterfall || $('#dxWaterfall');
|
|
}
|
|
},
|
|
|
|
// Field mapping utilities - allows pages to remap field IDs
|
|
fieldMapping: {
|
|
/**
|
|
* Get the actual field ID for a logical field name
|
|
* @param {string} fieldName - Logical field name (e.g., 'callsign', 'freq_calculated')
|
|
* @param {boolean} isOptional - Whether this is an optional field
|
|
* @returns {string} Actual DOM element ID
|
|
*/
|
|
getFieldId: function(fieldName, isOptional) {
|
|
isOptional = isOptional || false;
|
|
|
|
// Check if page has defined a field mapping
|
|
if (window.DX_WATERFALL_FIELD_MAP) {
|
|
var category = isOptional ? 'optional' : 'required';
|
|
if (window.DX_WATERFALL_FIELD_MAP[category] &&
|
|
window.DX_WATERFALL_FIELD_MAP[category][fieldName]) {
|
|
return window.DX_WATERFALL_FIELD_MAP[category][fieldName];
|
|
}
|
|
}
|
|
|
|
// Fallback to default field name (for backward compatibility)
|
|
return fieldName;
|
|
},
|
|
|
|
/**
|
|
* Get jQuery element for a field by logical name
|
|
* @param {string} fieldName - Logical field name
|
|
* @param {boolean} isOptional - Whether this is an optional field
|
|
* @returns {jQuery} jQuery element or empty jQuery object if not found
|
|
*/
|
|
getField: function(fieldName, isOptional) {
|
|
var fieldId = this.getFieldId(fieldName, isOptional);
|
|
return $('#' + fieldId);
|
|
},
|
|
|
|
/**
|
|
* Check if an optional field exists on the page
|
|
* @param {string} fieldName - Logical field name
|
|
* @returns {boolean} True if field exists in DOM
|
|
*/
|
|
hasOptionalField: function(fieldName) {
|
|
// Use custom checker if provided
|
|
if (window.DX_WATERFALL_HAS_FIELD && typeof window.DX_WATERFALL_HAS_FIELD === 'function') {
|
|
return window.DX_WATERFALL_HAS_FIELD(fieldName);
|
|
}
|
|
|
|
// Fallback to checking DOM
|
|
var fieldId = this.getFieldId(fieldName, true);
|
|
return document.getElementById(fieldId) !== null;
|
|
}
|
|
},
|
|
|
|
// Mode classification utilities (now use global functions from radiohelpers.js)
|
|
modes: {
|
|
// Get color for a classified mode with customizable alpha
|
|
getModeColor: function(classifiedMode, alpha) {
|
|
alpha = alpha !== undefined ? alpha : 0.6; // Default 60% opacity
|
|
return this.getModeColorBase(classifiedMode) + alpha + ')';
|
|
},
|
|
|
|
// Get base color string without alpha for gradient construction
|
|
getModeColorBase: function(classifiedMode) {
|
|
switch (classifiedMode) {
|
|
case 'phone':
|
|
return DX_WATERFALL_CONSTANTS.COLORS.PHONE_RGB;
|
|
case 'cw':
|
|
return DX_WATERFALL_CONSTANTS.COLORS.CW_RGB;
|
|
case 'digi':
|
|
return DX_WATERFALL_CONSTANTS.COLORS.DIGI_RGB;
|
|
default:
|
|
return DX_WATERFALL_CONSTANTS.COLORS.OTHER_RGB;
|
|
}
|
|
}
|
|
},
|
|
|
|
// Spot utilities for common spot object creation
|
|
spots: {
|
|
// Create standardized spot object from raw spot data
|
|
createSpotObject: function(spot, options) {
|
|
options = options || {};
|
|
var spotFreq = parseFloat(spot.frequency);
|
|
|
|
// Determine the correct mode to use
|
|
// Priority: program-specific mode from DXCC data > generic mode field
|
|
// Check for POTA, SOTA, WWFF, or IOTA specific modes
|
|
var spotMode = spot.mode || '';
|
|
|
|
if (spot.dxcc_spotted) {
|
|
// Check for program-specific modes in priority order
|
|
if (spot.dxcc_spotted.pota_mode) {
|
|
spotMode = spot.dxcc_spotted.pota_mode;
|
|
} else if (spot.dxcc_spotted.sota_mode) {
|
|
spotMode = spot.dxcc_spotted.sota_mode;
|
|
} else if (spot.dxcc_spotted.wwff_mode) {
|
|
spotMode = spot.dxcc_spotted.wwff_mode;
|
|
} else if (spot.dxcc_spotted.iota_mode) {
|
|
spotMode = spot.dxcc_spotted.iota_mode;
|
|
}
|
|
} else if (spot.dxcc_spotter) {
|
|
// Fallback to spotter's DXCC data
|
|
if (spot.dxcc_spotter.pota_mode) {
|
|
spotMode = spot.dxcc_spotter.pota_mode;
|
|
} else if (spot.dxcc_spotter.sota_mode) {
|
|
spotMode = spot.dxcc_spotter.sota_mode;
|
|
} else if (spot.dxcc_spotter.wwff_mode) {
|
|
spotMode = spot.dxcc_spotter.wwff_mode;
|
|
} else if (spot.dxcc_spotter.iota_mode) {
|
|
spotMode = spot.dxcc_spotter.iota_mode;
|
|
}
|
|
}
|
|
|
|
var spotObj = {
|
|
callsign: spot.spotted,
|
|
frequency: spotFreq,
|
|
mode: spotMode
|
|
};
|
|
|
|
// Add optional fields based on options
|
|
if (options.includeSpotter) {
|
|
spotObj.spotter = spot.spotter;
|
|
}
|
|
if (options.includeTimestamp) {
|
|
spotObj.when_pretty = spot.when_pretty || '';
|
|
}
|
|
if (options.includeMessage) {
|
|
spotObj.message = spot.message || '';
|
|
}
|
|
if (options.includeOffsets && options.middleFreq !== undefined) {
|
|
var freqOffset = spotFreq - options.middleFreq;
|
|
spotObj.freqOffset = freqOffset;
|
|
spotObj.absOffset = Math.abs(freqOffset);
|
|
}
|
|
if (options.includePosition && options.x !== undefined) {
|
|
spotObj.x = options.x;
|
|
}
|
|
if (options.includeWorkStatus) {
|
|
spotObj.dxcc_spotted = spot.dxcc_spotted || {};
|
|
spotObj.lotw_user = spot.lotw_user || false;
|
|
spotObj.worked_dxcc = spot.worked_dxcc || false;
|
|
spotObj.worked_continent = spot.worked_continent || false;
|
|
spotObj.worked_call = spot.worked_call || false;
|
|
spotObj.cnfmd_dxcc = spot.cnfmd_dxcc || false;
|
|
spotObj.cnfmd_continent = spot.cnfmd_continent || false;
|
|
spotObj.cnfmd_call = spot.cnfmd_call || false;
|
|
}
|
|
|
|
// Park references are provided by server in dxcc_spotted object
|
|
if (options.includeParkRefs !== false) {
|
|
spotObj.sotaRef = (spot.dxcc_spotted && spot.dxcc_spotted.sota_ref) || '';
|
|
spotObj.potaRef = (spot.dxcc_spotted && spot.dxcc_spotted.pota_ref) || '';
|
|
spotObj.iotaRef = (spot.dxcc_spotted && spot.dxcc_spotted.iota_ref) || '';
|
|
spotObj.wwffRef = (spot.dxcc_spotted && spot.dxcc_spotted.wwff_ref) || '';
|
|
} else {
|
|
spotObj.sotaRef = spotObj.potaRef = spotObj.iotaRef = spotObj.wwffRef = '';
|
|
}
|
|
|
|
return spotObj;
|
|
},
|
|
|
|
/**
|
|
* Filter and collect spots based on criteria with automatic deduplication
|
|
* Efficiently processes DX spots with mode filtering, custom filtering, and duplicate removal
|
|
*
|
|
* @param {Object} waterfallContext - The dxWaterfall object context
|
|
* @param {Function} [filterFunction] - Optional custom filter function(spot, spotFreq, context) => boolean
|
|
* @param {Object} [options] - Configuration options
|
|
* @param {Object} [options.spotOptions] - Options passed to createSpotObject
|
|
* @param {Function} [options.postProcess] - Optional post-processing function(spotObj, originalSpot) => spotObj
|
|
* @param {boolean} [options.deduplication=true] - Enable automatic duplicate detection (default: true)
|
|
* @returns {{spots: Array, stats: Object}} Object containing filtered spots array and statistics
|
|
* - spots: Array of processed spot objects
|
|
* - stats: {filtered, invalid, processed, duplicates} - Processing statistics
|
|
*/
|
|
filterSpots: function(waterfallContext, filterFunction, options) {
|
|
options = options || {};
|
|
|
|
if (!waterfallContext.dxSpots || waterfallContext.dxSpots.length === 0) {
|
|
return { spots: [], stats: { filtered: 0, invalid: 0, processed: 0, duplicates: 0 } };
|
|
}
|
|
|
|
// Use Map for efficient duplicate detection
|
|
var spotMap = options.deduplication !== false ? {} : null; // Enable deduplication by default
|
|
var spots = [];
|
|
var stats = {
|
|
filtered: 0,
|
|
invalid: 0,
|
|
processed: 0,
|
|
duplicates: 0
|
|
};
|
|
|
|
for (var i = 0; i < waterfallContext.dxSpots.length; i++) {
|
|
var spot = waterfallContext.dxSpots[i];
|
|
var spotFreq = parseFloat(spot.frequency);
|
|
|
|
// Validate basic spot data
|
|
if (!spotFreq || !spot.spotted || !spot.mode) {
|
|
stats.invalid++;
|
|
continue;
|
|
}
|
|
|
|
// Check for duplicates using frequency:callsign key
|
|
if (spotMap) {
|
|
var spotKey = spotFreq.toFixed(1) + ':' + spot.spotted;
|
|
if (spotMap[spotKey]) {
|
|
stats.duplicates++;
|
|
continue;
|
|
}
|
|
spotMap[spotKey] = true;
|
|
}
|
|
|
|
// Apply mode filter
|
|
if (!waterfallContext.spotMatchesModeFilter(spot)) {
|
|
stats.filtered++;
|
|
continue;
|
|
}
|
|
|
|
// Apply custom filter function if provided
|
|
if (filterFunction && !filterFunction(spot, spotFreq, waterfallContext)) {
|
|
stats.filtered++;
|
|
continue;
|
|
}
|
|
|
|
// Create spot object
|
|
var spotOptions = options.spotOptions || {};
|
|
var spotObj = this.createSpotObject(spot, spotOptions);
|
|
|
|
// Apply any post-processing
|
|
if (options.postProcess) {
|
|
spotObj = options.postProcess(spotObj, spot);
|
|
}
|
|
|
|
spots.push(spotObj);
|
|
stats.processed++;
|
|
}
|
|
|
|
return {
|
|
spots: spots,
|
|
stats: stats
|
|
};
|
|
}
|
|
},
|
|
|
|
// QSO form utilities
|
|
qsoForm: {
|
|
// Timer for pending population to allow cancellation
|
|
pendingPopulationTimer: null,
|
|
pendingLookupTimer: null,
|
|
|
|
/**
|
|
* Clear the QSO form by clicking the reset button
|
|
* Note: reset_fields() in qso.js handles all field clearing including park references
|
|
*/
|
|
clearForm: function() {
|
|
var $btnReset = $('#btn_reset');
|
|
if ($btnReset.length > 0) {
|
|
$btnReset.click();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Populate QSO form with spot data (callsign, mode, and park references)
|
|
* Assumes form has already been cleared if needed
|
|
* @param {Object} spotData - Spot data object
|
|
* @param {string} spotData.callsign - Callsign to populate
|
|
* @param {string} [spotData.mode] - Mode to set
|
|
* @param {string} [spotData.sotaRef] - SOTA reference
|
|
* @param {string} [spotData.potaRef] - POTA reference
|
|
* @param {string} [spotData.iotaRef] - IOTA reference
|
|
* @param {string} [spotData.wwffRef] - WWFF reference
|
|
* @param {boolean} [triggerLookup=true] - Whether to trigger callsign lookup
|
|
*/
|
|
populateFromSpot: function(spotData, triggerLookup) {
|
|
if (typeof triggerLookup === 'undefined') {
|
|
triggerLookup = true;
|
|
}
|
|
|
|
if (!spotData.callsign) return;
|
|
|
|
// Cancel any pending population timers from previous navigation
|
|
if (this.pendingLookupTimer) {
|
|
clearTimeout(this.pendingLookupTimer);
|
|
this.pendingLookupTimer = null;
|
|
}
|
|
|
|
// Set preventLookup flag BEFORE any form changes to prevent duplicate lookups
|
|
// This blocks the $("#callsign").blur() triggered by mode change handler
|
|
var wasPreventLookupSet = false;
|
|
if (triggerLookup && typeof preventLookup !== 'undefined') {
|
|
preventLookup = true;
|
|
wasPreventLookupSet = true;
|
|
}
|
|
|
|
// Populate the callsign input field
|
|
var callsignInput = $('#callsign');
|
|
var formattedCallsign = spotData.callsign.toUpperCase().replace(/0/g, 'Ø');
|
|
callsignInput.val(formattedCallsign);
|
|
|
|
// Set the mode if available - determine the actual radio mode
|
|
if (spotData.mode) {
|
|
// Use global determineRadioMode from radiohelpers.js
|
|
var frequencyHz = parseFloat(spotData.frequency) * 1000; // Convert kHz to Hz
|
|
var radioMode = determineRadioMode(spotData.mode, frequencyHz);
|
|
// Use skipTrigger=true to prevent change event race condition
|
|
setMode(radioMode, true);
|
|
}
|
|
|
|
// Store park ref data to re-apply after callsign lookup clears the form
|
|
// Don't populate them now - they'll just be cleared by resetDefaultQSOFields()
|
|
var parkRefs = {
|
|
sota: spotData.sotaRef || null,
|
|
pota: spotData.potaRef || null,
|
|
iota: spotData.iotaRef || null,
|
|
wwff: spotData.wwffRef || null
|
|
};
|
|
|
|
// Trigger callsign lookup immediately, then trigger park ref lookups
|
|
if (triggerLookup) {
|
|
var self = this;
|
|
|
|
// Set up one-time event listener for when callsign lookup completes
|
|
$(document).one('callsignLookupComplete', function() {
|
|
// Re-populate park references after callsign lookup has cleared them
|
|
if (parkRefs.sota) {
|
|
var $sotaSelect = $('#sota_ref');
|
|
if ($sotaSelect.length > 0 && $sotaSelect[0].selectize) {
|
|
var sotaSelectize = $sotaSelect[0].selectize;
|
|
sotaSelectize.addOption({name: parkRefs.sota});
|
|
sotaSelectize.setValue(parkRefs.sota, false);
|
|
$('#sota_ref').trigger('change');
|
|
}
|
|
}
|
|
|
|
if (parkRefs.pota) {
|
|
var $potaSelect = $('#pota_ref');
|
|
if ($potaSelect.length > 0 && $potaSelect[0].selectize) {
|
|
var potaSelectize = $potaSelect[0].selectize;
|
|
potaSelectize.addOption({name: parkRefs.pota});
|
|
potaSelectize.setValue(parkRefs.pota, false);
|
|
$('#pota_ref').trigger('change');
|
|
}
|
|
}
|
|
|
|
if (parkRefs.iota) {
|
|
var $iotaSelect = $('#iota_ref');
|
|
if ($iotaSelect.length > 0) {
|
|
var optionExists = $iotaSelect.find('option[value="' + parkRefs.iota + '"]').length > 0;
|
|
if (!optionExists) {
|
|
$iotaSelect.append($('<option>', {
|
|
value: parkRefs.iota,
|
|
text: parkRefs.iota
|
|
}));
|
|
}
|
|
$iotaSelect.val(parkRefs.iota);
|
|
$('#iota_ref').trigger('change');
|
|
}
|
|
}
|
|
|
|
if (parkRefs.wwff) {
|
|
var $wwffSelect = $('#wwff_ref');
|
|
if ($wwffSelect.length > 0 && $wwffSelect[0].selectize) {
|
|
var wwffSelectize = $wwffSelect[0].selectize;
|
|
wwffSelectize.addOption({name: parkRefs.wwff});
|
|
wwffSelectize.setValue(parkRefs.wwff, false);
|
|
$('#wwff_ref').trigger('change');
|
|
}
|
|
}
|
|
|
|
DX_WATERFALL_UTILS.navigation.navigating = false;
|
|
});
|
|
|
|
this.pendingLookupTimer = setTimeout(function() {
|
|
// Clear preventLookup flag just before triggering the lookup
|
|
if (wasPreventLookupSet) {
|
|
preventLookup = false;
|
|
}
|
|
|
|
// Trigger callsign lookup
|
|
callsignInput.trigger('focusout');
|
|
|
|
self.pendingLookupTimer = null;
|
|
}, 50);
|
|
} else {
|
|
// No lookup - clear navigation flag immediately
|
|
DX_WATERFALL_UTILS.navigation.navigating = false;
|
|
// Clear the preventLookup flag
|
|
if (wasPreventLookupSet) {
|
|
preventLookup = false;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// Navigation utilities for spot navigation
|
|
navigation: {
|
|
// Timer for pending navigation actions
|
|
pendingNavigationTimer: null,
|
|
// Flag to block interference during navigation
|
|
navigating: false,
|
|
|
|
// Common navigation logic shared by all spot navigation functions
|
|
navigateToSpot: function(waterfallContext, targetSpot, targetIndex, shouldPrefill) {
|
|
// Default to false - only prefill if explicitly requested
|
|
shouldPrefill = (typeof shouldPrefill !== 'undefined') ? shouldPrefill : false;
|
|
|
|
if (!targetSpot) {
|
|
return false;
|
|
}
|
|
|
|
// Set navigation flag to block refresh interference
|
|
this.navigating = true;
|
|
|
|
// Cancel any pending navigation timers
|
|
if (this.pendingNavigationTimer) {
|
|
clearTimeout(this.pendingNavigationTimer);
|
|
this.pendingNavigationTimer = null;
|
|
}
|
|
|
|
// Update the band spot index
|
|
waterfallContext.currentBandSpotIndex = targetIndex;
|
|
|
|
// Set frequency to the spot (like clicking behavior)
|
|
if (targetSpot.frequency) {
|
|
// Only clear form if we're going to prefill it
|
|
if (shouldPrefill) {
|
|
DX_WATERFALL_UTILS.qsoForm.clearForm();
|
|
}
|
|
|
|
// CRITICAL: Set mode FIRST before calling setFrequency
|
|
// setFrequency reads the mode from $('#mode').val(), so the mode must be set first
|
|
var frequencyHz = parseFloat(targetSpot.frequency) * 1000; // Convert kHz to Hz
|
|
var radioMode = determineRadioMode(targetSpot.mode, frequencyHz);
|
|
|
|
// Set CAT debounce lock early to block incoming CAT updates during navigation
|
|
if (typeof setFrequency.catDebounceLock !== 'undefined') {
|
|
setFrequency.catDebounceLock = 1;
|
|
}
|
|
|
|
setMode(radioMode, true); // skipTrigger = true to prevent change event
|
|
|
|
// Now set frequency - it will read the correct mode from the dropdown
|
|
setFrequency(targetSpot.frequency, true); // Pass true to indicate waterfall-initiated change
|
|
|
|
// Send frequency command again after short delay to correct any drift from mode change
|
|
// (radio control lib bug: mode change can cause slight frequency shift)
|
|
setTimeout(function() {
|
|
setFrequency(targetSpot.frequency, true);
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.MODE_CHANGE_SETTLE_MS);
|
|
|
|
// Manually set the frequency in the input field immediately
|
|
var formattedFreq = Math.round(targetSpot.frequency * 1000); // Convert to Hz
|
|
$('#frequency').val(formattedFreq);
|
|
|
|
// CRITICAL: Directly update the cache to the target frequency
|
|
// getCachedMiddleFreq() uses lastValidCommittedFreq which isn't updated by just setting the input value
|
|
// So we bypass the cache and set it directly to ensure getSpotInfo() uses the correct frequency
|
|
waterfallContext.cache.middleFreq = targetSpot.frequency; // Already in kHz
|
|
waterfallContext.lastValidCommittedFreq = formattedFreq;
|
|
waterfallContext.lastValidCommittedUnit = 'kHz';
|
|
|
|
var cachedFreq = waterfallContext.getCachedMiddleFreq();
|
|
|
|
// Now get spot info - this will use the new frequency we just set
|
|
var spotInfo = waterfallContext.getSpotInfo();
|
|
|
|
// Only populate form if explicitly requested
|
|
if (shouldPrefill && spotInfo) {
|
|
var self = this;
|
|
this.pendingNavigationTimer = setTimeout(function() {
|
|
DX_WATERFALL_UTILS.qsoForm.populateFromSpot(spotInfo, true);
|
|
self.pendingNavigationTimer = null;
|
|
// Clear navigation flag after population completes
|
|
self.navigating = false;
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.FORM_POPULATE_DELAY_MS);
|
|
} else {
|
|
// Clear navigation flag immediately if not populating
|
|
this.navigating = false;
|
|
}
|
|
|
|
// Commit the new frequency
|
|
setTimeout(function() {
|
|
waterfallContext.commitFrequency();
|
|
}, 50);
|
|
|
|
// Update zoom menu immediately to reflect navigation button states
|
|
waterfallContext.updateZoomMenu(true);
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
// Check if navigation is allowed (not during frequency changes)
|
|
canNavigate: function(waterfallContext) {
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
return currentState === DX_WATERFALL_CONSTANTS.STATES.READY && waterfallContext.allBandSpots.length > 0;
|
|
}
|
|
},
|
|
|
|
// Drawing utilities for common canvas operations
|
|
drawing: {
|
|
|
|
// Draw overlay message with logo
|
|
drawOverlayMessage: function(canvas, ctx, message, colorKey) {
|
|
if (!canvas) return;
|
|
|
|
// Draw semi-transparent overlay over current content
|
|
this.drawOverlay(ctx, canvas.width, canvas.height, 'OVERLAY_BACKGROUND');
|
|
|
|
// Calculate center position
|
|
var centerX = canvas.width / 2;
|
|
var centerY = canvas.height / 2;
|
|
|
|
// Draw pulsing Wavelog logo above the message
|
|
var logoY = centerY - DX_WATERFALL_CONSTANTS.CANVAS.LOGO_OFFSET_Y;
|
|
if (typeof dxWaterfall !== 'undefined' && dxWaterfall.drawWavelogLogo) {
|
|
// Calculate pulsing opacity (0.5 to 1.0 for smooth fade effect)
|
|
var pulseOpacity = 0.75 + 0.25 * Math.sin(Date.now() / 300);
|
|
dxWaterfall.drawWavelogLogo(centerX, logoY, pulseOpacity);
|
|
}
|
|
|
|
// Text position (moved down lower for more space)
|
|
var textY = centerY + DX_WATERFALL_CONSTANTS.CANVAS.TEXT_OFFSET_Y;
|
|
|
|
// Draw message text
|
|
this.drawCenteredText(ctx, message, centerX, textY, 'FREQUENCY_CHANGE', colorKey);
|
|
|
|
// Reset opacity
|
|
ctx.globalAlpha = 1.0;
|
|
},
|
|
|
|
drawCenteredText: function(ctx, text, x, y, fontKey, colorKey) {
|
|
ctx.font = DX_WATERFALL_CONSTANTS.FONTS[fontKey] || DX_WATERFALL_CONSTANTS.FONTS.SMALL_MONO;
|
|
ctx.fillStyle = DX_WATERFALL_CONSTANTS.COLORS[colorKey] || DX_WATERFALL_CONSTANTS.COLORS.MESSAGE_TEXT_WHITE;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, x, y);
|
|
},
|
|
|
|
drawOverlay: function(ctx, width, height, colorKey) {
|
|
ctx.fillStyle = DX_WATERFALL_CONSTANTS.COLORS[colorKey || 'OVERLAY_BACKGROUND'];
|
|
ctx.fillRect(0, 0, width, height);
|
|
}
|
|
},
|
|
};
|
|
|
|
// ========================================
|
|
// MAIN DX WATERFALL OBJECT
|
|
// ========================================
|
|
var dxWaterfall = {
|
|
|
|
// ========================================
|
|
// CORE CANVAS PROPERTIES
|
|
// ========================================
|
|
canvas: null,
|
|
ctx: null,
|
|
|
|
// ========================================
|
|
// DATA MANAGEMENT PROPERTIES
|
|
// ========================================
|
|
dxSpots: [],
|
|
initialFetchDone: false,
|
|
totalSpotsCount: 0,
|
|
|
|
// Timing
|
|
pageLoadTime: null,
|
|
operationStartTime: null,
|
|
|
|
// Refresh throttling
|
|
lastRefreshTime: 0,
|
|
refreshPending: false,
|
|
|
|
// ========================================
|
|
// USER INTERFACE STATE
|
|
// ========================================
|
|
userEditingFrequency: false,
|
|
|
|
// Cache for zoom menu HTML to prevent unnecessary DOM updates
|
|
lastZoomMenuHTML: null,
|
|
spotInfoDiv: null,
|
|
spotTooltipDiv: null,
|
|
lastSpotInfoKey: null,
|
|
currentContinent: 'NA',
|
|
currentMaxAge: 60,
|
|
|
|
// ========================================
|
|
// SPOT NAVIGATION STATE
|
|
// ========================================
|
|
lastUpdateTime: null,
|
|
lastFetchBand: null,
|
|
lastFetchContinent: null,
|
|
lastFetchAge: null,
|
|
relevantSpots: [],
|
|
currentSpotIndex: 0,
|
|
allBandSpots: [],
|
|
currentBandSpotIndex: 0,
|
|
|
|
// ========================================
|
|
// VISUAL CONFIGURATION
|
|
// ========================================
|
|
fonts: DX_WATERFALL_CONSTANTS.FONTS,
|
|
labelSizeLevel: 2, // 0=x-small, 1=small, 2=medium (default), 3=large, 4=x-large
|
|
labelSizeProcessing: false, // Mutex lock to prevent concurrent label size operations
|
|
|
|
// ========================================
|
|
// PERFORMANCE CACHING
|
|
// ========================================
|
|
cache: {
|
|
noise1: null,
|
|
noise2: null,
|
|
currentNoiseFrame: 0,
|
|
noiseWidth: 0,
|
|
noiseHeight: 0,
|
|
middleFreq: null,
|
|
lastQrgUnit: null,
|
|
lastValidCommittedFreq: null,
|
|
lastValidCommittedUnit: null,
|
|
visibleSpots: null,
|
|
visibleSpotsParams: null
|
|
},
|
|
|
|
// Misc flags
|
|
lastPopulatedSpot: null,
|
|
pendingSpotSelection: null,
|
|
|
|
// Band tracking - tracks which band we currently have spots for
|
|
currentSpotBand: null, // The band we last fetched spots for
|
|
|
|
// Display configuration
|
|
displayConfig: {
|
|
isSplit: false,
|
|
centerFrequency: null,
|
|
markers: [],
|
|
showBandwidthIndicator: true
|
|
},
|
|
|
|
// ========================================
|
|
// ZOOM AND NAVIGATION STATE
|
|
// ========================================
|
|
currentZoomLevel: DX_WATERFALL_CONSTANTS.ZOOM.DEFAULT_LEVEL,
|
|
maxZoomLevel: DX_WATERFALL_CONSTANTS.ZOOM.MAX_LEVEL,
|
|
zoomMenuDiv: null,
|
|
|
|
// ========================================
|
|
// SMART HUNTER FUNCTIONALITY
|
|
// ========================================
|
|
smartHunterSpots: [],
|
|
currentSmartHunterIndex: 0,
|
|
smartHunterActive: false,
|
|
|
|
// ========================================
|
|
// CONTINENT FILTERING
|
|
// ========================================
|
|
continents: CONTINENTS, // Use global CONTINENTS constant from radiohelpers.js
|
|
pendingContinent: null,
|
|
|
|
// CAT frequency tracking
|
|
targetFrequencyHz: null,
|
|
targetFrequencyConfirmAttempts: 0,
|
|
lastWaterfallFrequencyCommandTime: 0,
|
|
lastFrequencyRefreshTime: 0,
|
|
|
|
// Spot fetch debouncing
|
|
userInitiatedFetch: false,
|
|
lastSpotCollectionTime: 0,
|
|
spotCollectionThrottleMs: DX_WATERFALL_CONSTANTS.DEBOUNCE.SPOT_COLLECTION_MS,
|
|
fetchDebounceTimer: null,
|
|
fetchDebounceMs: DX_WATERFALL_CONSTANTS.DEBOUNCE.FETCH_REQUEST_MS,
|
|
|
|
// Mode filter management
|
|
modeFilters: {
|
|
phone: true,
|
|
cw: true,
|
|
digi: false
|
|
},
|
|
pendingModeFilters: null,
|
|
modeFilterChangeTimer: null,
|
|
|
|
ft8Frequencies: FT8_FREQUENCIES, // Use global FT8_FREQUENCIES constant from radiohelpers.js
|
|
|
|
// Error handling
|
|
errorShutdownTimer: null, // Timer for auto-shutdown after error state
|
|
readyTransitionTimer: null, // Timer for delayed TUNING → READY transition
|
|
|
|
// Band plan management
|
|
bandPlans: null, // Cached band plans from database
|
|
bandEdgesData: null, // Raw band edges data with mode information for mode indicators
|
|
currentRegion: null, // Current IARU region (1, 2, 3)
|
|
bandLimitsCache: null, // Cached band limits for current band+region
|
|
cachedBandForEdges: null, // The band for which band edges are currently cached
|
|
|
|
// ========================================
|
|
// INITIALIZATION AND SETUP FUNCTIONS
|
|
// ========================================
|
|
|
|
/**
|
|
* Check if waterfall is properly initialized
|
|
* @returns {boolean} - True if canvas and context are initialized
|
|
*/
|
|
isInitialized: function() {
|
|
return this.canvas !== null && this.ctx !== null;
|
|
},
|
|
|
|
// ========================================
|
|
// STATE-BASED RENDERING HELPERS
|
|
// ========================================
|
|
|
|
/**
|
|
* Render waterfall in DISABLED state
|
|
* @private
|
|
*/
|
|
_renderDisabled: function() {
|
|
// Canvas is not available - nothing to render
|
|
// This should not normally be called as refresh() checks for canvas existence
|
|
},
|
|
|
|
/**
|
|
* Render waterfall in INITIALIZING state
|
|
* @private
|
|
*/
|
|
_renderInitializing: function() {
|
|
// Display waiting message with black screen, logo, and "Please wait" message
|
|
// During the initial 3-second delay, this shows a loading screen
|
|
this.displayWaitingMessage(lang_dxwaterfall_please_wait);
|
|
this.updateZoomMenu();
|
|
},
|
|
|
|
/**
|
|
* Render waterfall in FETCHING_SPOTS state
|
|
* @private
|
|
*/
|
|
_renderFetchingSpots: function() {
|
|
// Show fetching message only for user-initiated fetches or band changes
|
|
// Background periodic refreshes should not show the waiting screen
|
|
if (this.userInitiatedFetch || !this.dxSpots || this.dxSpots.length === 0) {
|
|
this.displayWaitingMessage(lang_dxwaterfall_downloading_data);
|
|
}
|
|
|
|
// Update zoom menu to show loading state
|
|
this.updateZoomMenu();
|
|
},
|
|
|
|
/**
|
|
* Render waterfall in TUNING state
|
|
* @private
|
|
*/
|
|
_renderTuning: function() {
|
|
// Display waiting message with "Changing frequency" text
|
|
this.displayWaitingMessage(lang_dxwaterfall_changing_frequency);
|
|
this.updateZoomMenu();
|
|
},
|
|
|
|
/**
|
|
* Render waterfall in READY state (normal operation)
|
|
* @private
|
|
*/
|
|
_renderReady: function() {
|
|
// Update dimensions to match current CSS
|
|
this.updateDimensions();
|
|
|
|
// Collect all band spots for navigation
|
|
this.collectAllBandSpots();
|
|
this.collectSmartHunterSpots();
|
|
|
|
// Always update zoom menu in READY state
|
|
this.updateZoomMenu();
|
|
|
|
// Clear the entire canvas
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
// Draw static noise background
|
|
this.drawStaticNoise();
|
|
|
|
// Draw www.wavelog.org link (above noise, below all other elements)
|
|
this.drawWavelogLink();
|
|
|
|
// Draw band limit overlays (out-of-band areas with grey overlay)
|
|
this.drawBandLimits();
|
|
|
|
// Draw receiving bandwidth indicator (below red line, above static noise)
|
|
this.drawReceivingBandwidth();
|
|
|
|
// Draw DX spot bandwidth indicators
|
|
this.drawDxSpotBandwidths();
|
|
|
|
// Draw frequency ruler
|
|
this.drawFrequencyRuler();
|
|
|
|
// Draw red center marker
|
|
this.drawCenterMarker();
|
|
|
|
// Draw DX spots
|
|
this.drawDxSpots();
|
|
|
|
// Draw center callsign label (on top of everything)
|
|
this.drawCenterCallsignLabel();
|
|
|
|
// Update spot info in the div above canvas (prevents update on every frame)
|
|
this.updateSpotInfoDiv();
|
|
|
|
// Draw black border (left, right, bottom only - top border is on the div)
|
|
this.ctx.strokeStyle = '#000000';
|
|
this.ctx.lineWidth = 1;
|
|
// Draw left border
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(0, 0);
|
|
this.ctx.lineTo(0, this.canvas.height);
|
|
this.ctx.stroke();
|
|
// Draw right border
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(this.canvas.width, 0);
|
|
this.ctx.lineTo(this.canvas.width, this.canvas.height);
|
|
this.ctx.stroke();
|
|
},
|
|
|
|
/**
|
|
* Render waterfall in ERROR state
|
|
* Auto-shuts down after 5 seconds
|
|
* @private
|
|
*/
|
|
_renderError: function() {
|
|
// Get error message from state data if available
|
|
var stateData = DXWaterfallStateMachine.getStateData();
|
|
var errorMessage = stateData.message || 'Error occurred - DX Waterfall will shut down';
|
|
|
|
DX_WATERFALL_UTILS.drawing.drawOverlayMessage(
|
|
this.canvas,
|
|
this.ctx,
|
|
errorMessage,
|
|
'MESSAGE_TEXT_WHITE'
|
|
);
|
|
|
|
// Set up auto-shutdown timer if not already set
|
|
if (!this.errorShutdownTimer) {
|
|
var self = this;
|
|
this.errorShutdownTimer = setTimeout(function() {
|
|
self.errorShutdownTimer = null;
|
|
|
|
// Show error toast notification (10 seconds)
|
|
if (typeof showToast === 'function') {
|
|
var toastMessage = (typeof lang_dxwaterfall_error_shutdown !== 'undefined')
|
|
? lang_dxwaterfall_error_shutdown
|
|
: 'DX Waterfall has experienced an unexpected error and will be shut down. Please contact the Wavelog team for assistance.';
|
|
showToast('DX Waterfall Error', toastMessage, 'bg-danger text-white', 10000);
|
|
}
|
|
|
|
// Trigger power-off icon click to cleanly shut down waterfall
|
|
$('#dxWaterfallPowerOffIcon').trigger('click');
|
|
}, 5000); // 5 seconds
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Render waterfall in DEINITIALIZING state
|
|
* @private
|
|
*/
|
|
_renderDeinitializing: function() {
|
|
// Show cleanup message
|
|
DX_WATERFALL_UTILS.drawing.drawOverlayMessage(
|
|
this.canvas,
|
|
this.ctx,
|
|
'Shutting down...',
|
|
'MESSAGE_TEXT_WHITE'
|
|
);
|
|
},
|
|
|
|
// ========================================
|
|
// INITIALIZATION AND SETUP FUNCTIONS
|
|
// ========================================
|
|
|
|
/**
|
|
* Initialize the DX waterfall canvas and event handlers
|
|
* Sets up canvas context, dimensions, and starts initial data fetch
|
|
* @returns {void}
|
|
*/
|
|
init: function() {
|
|
// Check if already initialized to prevent duplicate initialization
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState !== DX_WATERFALL_CONSTANTS.STATES.DISABLED) {
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Already initialized, skipping');
|
|
return;
|
|
}
|
|
|
|
// Check if we have valid frequency data before initializing
|
|
var $freqInput = $('#frequency');
|
|
var currentFreq = parseFloat($freqInput.val()) || 0;
|
|
|
|
if (currentFreq === 0 || !DX_WATERFALL_UTILS.frequency.isValid(currentFreq)) {
|
|
// No valid frequency yet - wait for frequency data
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Waiting for valid frequency data...');
|
|
|
|
// Transition to INITIALIZING state to show waiting message
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.INITIALIZING);
|
|
|
|
// Initialize canvas to show waiting message
|
|
if (!this._initializeCanvas()) {
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.ERROR, {
|
|
message: 'Canvas element not found'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Show waiting message while checking for valid frequency
|
|
this.displayWaitingMessage(lang_dxwaterfall_please_wait);
|
|
|
|
// Set up retry mechanism to check for valid frequency
|
|
var self = this;
|
|
var frequencyCheckTimer = null;
|
|
var checkFrequency = function(attemptsLeft) {
|
|
// Check if already completed (state changed from INITIALIZING)
|
|
var state = DXWaterfallStateMachine.getState();
|
|
if (state === DX_WATERFALL_CONSTANTS.STATES.READY || state === DX_WATERFALL_CONSTANTS.STATES.ERROR) {
|
|
// Already initialized or error occurred - stop checking
|
|
if (frequencyCheckTimer) {
|
|
clearTimeout(frequencyCheckTimer);
|
|
frequencyCheckTimer = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
var freq = parseFloat($freqInput.val()) || 0;
|
|
if (freq > 0 && DX_WATERFALL_UTILS.frequency.isValid(freq)) {
|
|
// Valid frequency found - proceed with initialization
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Valid frequency detected: ' + freq + ' Hz');
|
|
if (frequencyCheckTimer) {
|
|
clearTimeout(frequencyCheckTimer);
|
|
frequencyCheckTimer = null;
|
|
}
|
|
self._completeInitialization();
|
|
} else if (attemptsLeft > 0) {
|
|
// Retry after delay
|
|
frequencyCheckTimer = setTimeout(function() {
|
|
checkFrequency(attemptsLeft - 1);
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.FREQUENCY_COMMIT_RETRY_MS);
|
|
} else {
|
|
// Give up after max attempts - show error
|
|
if (frequencyCheckTimer) {
|
|
clearTimeout(frequencyCheckTimer);
|
|
frequencyCheckTimer = null;
|
|
}
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.ERROR, {
|
|
message: 'No valid frequency data available'
|
|
});
|
|
}
|
|
};
|
|
|
|
// Start checking for valid frequency (20 attempts = 2 seconds)
|
|
checkFrequency(20);
|
|
return;
|
|
}
|
|
|
|
// Valid frequency available - proceed with initialization immediately
|
|
this._completeInitialization();
|
|
},
|
|
|
|
/**
|
|
* Complete waterfall initialization after valid frequency is confirmed
|
|
* @private
|
|
*/
|
|
_completeInitialization: function() {
|
|
// Check if already initialized to prevent duplicate initialization
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.READY) {
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Already in READY state, skipping re-initialization');
|
|
return;
|
|
}
|
|
|
|
// Ensure we're in INITIALIZING state
|
|
if (currentState !== DX_WATERFALL_CONSTANTS.STATES.INITIALIZING) {
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.INITIALIZING);
|
|
}
|
|
|
|
// Always log initialization (user-facing message)
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Initializing...');
|
|
|
|
// Initialize canvas and context (may already be initialized from waiting state)
|
|
if (!this.canvas || !this.ctx) {
|
|
if (!this._initializeCanvas()) {
|
|
// Failed to initialize - transition to ERROR state
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.ERROR, {
|
|
message: 'Canvas element not found'
|
|
});
|
|
return; // Canvas not found, abort initialization
|
|
}
|
|
}
|
|
|
|
// Set up event listeners
|
|
this._setupEventListeners();
|
|
|
|
// Load saved settings and initialize state
|
|
this._loadSettings();
|
|
|
|
// Set up initial frequency commit
|
|
this._setupInitialFrequencyCommit();
|
|
|
|
// Force initial spot fetch to transition from INITIALIZING to FETCHING_SPOTS
|
|
// This ensures we don't get stuck in INITIALIZING state
|
|
var self = this;
|
|
setTimeout(function() {
|
|
// Only fetch if still in INITIALIZING state (not already fetching)
|
|
if (DXWaterfallStateMachine.getState() === DX_WATERFALL_CONSTANTS.STATES.INITIALIZING) {
|
|
self.fetchDxSpots({userInitiated: false});
|
|
}
|
|
}, 100);
|
|
|
|
// Trigger initial refresh to display INITIALIZING state
|
|
this.refresh();
|
|
|
|
// Always log successful initialization (user-facing message)
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] v' + DX_WATERFALL_CONSTANTS.VERSION + ' loaded successfully');
|
|
},
|
|
|
|
/**
|
|
* Continue initialization after 3-second delay (called from turnOnWaterfall)
|
|
* Checks for valid frequency and proceeds with setup
|
|
* @private
|
|
*/
|
|
_continueInitialization: function() {
|
|
// Check if we have valid frequency data before proceeding
|
|
var $freqInput = $('#frequency');
|
|
var currentFreq = parseFloat($freqInput.val()) || 0;
|
|
|
|
// If no frequency but we're in offline mode and have a valid band, use typical band frequency
|
|
if ((currentFreq === 0 || !DX_WATERFALL_UTILS.frequency.isValid(currentFreq)) &&
|
|
typeof isCATAvailable === 'function' && !isCATAvailable()) {
|
|
var currentBand = this.$bandSelect ? this.$bandSelect.val() : null;
|
|
if (currentBand && currentBand.toLowerCase() !== 'select') {
|
|
var bandFreq = getTypicalBandFrequency(currentBand);
|
|
if (bandFreq > 0) {
|
|
// Use typical band frequency for initialization
|
|
currentFreq = bandFreq;
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Offline mode - using typical frequency for ' + currentBand + ': ' + currentFreq + ' kHz');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (currentFreq === 0 || !DX_WATERFALL_UTILS.frequency.isValid(currentFreq)) {
|
|
// No valid frequency yet - set up retry mechanism
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Waiting for valid frequency data...');
|
|
|
|
var self = this;
|
|
var frequencyCheckTimer = null;
|
|
var checkFrequency = function(attemptsLeft) {
|
|
// Check if already completed (state changed from INITIALIZING)
|
|
var state = DXWaterfallStateMachine.getState();
|
|
if (state === DX_WATERFALL_CONSTANTS.STATES.READY ||
|
|
state === DX_WATERFALL_CONSTANTS.STATES.FETCHING_SPOTS ||
|
|
state === DX_WATERFALL_CONSTANTS.STATES.ERROR) {
|
|
// Already initialized or error occurred - stop checking
|
|
if (frequencyCheckTimer) {
|
|
clearTimeout(frequencyCheckTimer);
|
|
frequencyCheckTimer = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
var freq = parseFloat($freqInput.val()) || 0;
|
|
if (freq > 0 && DX_WATERFALL_UTILS.frequency.isValid(freq)) {
|
|
// Valid frequency found - proceed with initialization
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Valid frequency detected: ' + freq + ' Hz');
|
|
if (frequencyCheckTimer) {
|
|
clearTimeout(frequencyCheckTimer);
|
|
frequencyCheckTimer = null;
|
|
}
|
|
self._completeInitialization();
|
|
} else if (attemptsLeft > 0) {
|
|
// Retry after delay
|
|
frequencyCheckTimer = setTimeout(function() {
|
|
checkFrequency(attemptsLeft - 1);
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.FREQUENCY_COMMIT_RETRY_MS);
|
|
} else {
|
|
// Give up after max attempts - show error
|
|
if (frequencyCheckTimer) {
|
|
clearTimeout(frequencyCheckTimer);
|
|
frequencyCheckTimer = null;
|
|
}
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.ERROR, {
|
|
message: 'No valid frequency data available'
|
|
});
|
|
}
|
|
};
|
|
|
|
// Start checking for valid frequency (20 attempts = 2 seconds)
|
|
checkFrequency(20);
|
|
return;
|
|
}
|
|
|
|
// Valid frequency available - proceed with initialization immediately
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Valid frequency detected: ' + currentFreq + ' Hz');
|
|
this._completeInitialization();
|
|
},
|
|
|
|
/**
|
|
* Initialize canvas element and context
|
|
* @private
|
|
* @returns {boolean} - True if successful, false if canvas not found
|
|
*/
|
|
_initializeCanvas: function() {
|
|
this.canvas = document.getElementById('dxWaterfall');
|
|
|
|
// Check if canvas element exists
|
|
if (!this.canvas) {
|
|
return false;
|
|
}
|
|
|
|
this.ctx = this.canvas.getContext('2d');
|
|
var $waterfall = DX_WATERFALL_UTILS.dom.getWaterfall();
|
|
this.canvas.width = $waterfall.width();
|
|
this.canvas.height = $waterfall.height();
|
|
|
|
// Get reference to spot info div and menu div
|
|
this.spotInfoDiv = document.getElementById('dxWaterfallSpotContent');
|
|
this.zoomMenuDiv = document.getElementById('dxWaterfallMenu');
|
|
|
|
// Cache frequently accessed DOM elements for performance
|
|
this.$freqCalculated = $('#freq_calculated');
|
|
this.$qrgUnit = $('#qrg_unit');
|
|
this.$bandSelect = $('#band');
|
|
this.$modeSelect = $('#mode');
|
|
|
|
// Set page load time for waiting state management
|
|
this.pageLoadTime = Date.now();
|
|
this.operationStartTime = Date.now();
|
|
|
|
// Set initial div content to maintain layout
|
|
if (this.spotInfoDiv) {
|
|
this.spotInfoDiv.innerHTML = ' ';
|
|
}
|
|
if (this.zoomMenuDiv) {
|
|
this.zoomMenuDiv.innerHTML = ' ';
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
/**
|
|
* Set up all event listeners for canvas and form inputs
|
|
* @private
|
|
*/
|
|
_setupEventListeners: function() {
|
|
var self = this;
|
|
|
|
// Ensure canvas exists before setting up event listeners
|
|
if (!this.canvas) {
|
|
DX_WATERFALL_UTILS.log.warn('[DX Waterfall] Cannot setup event listeners - canvas not initialized');
|
|
return;
|
|
}
|
|
|
|
// Remove any existing event listeners first to prevent duplicates
|
|
if (this._wheelHandler) {
|
|
this.canvas.removeEventListener('wheel', this._wheelHandler);
|
|
}
|
|
if (this._mousemoveHandler) {
|
|
this.canvas.removeEventListener('mousemove', this._mousemoveHandler);
|
|
}
|
|
|
|
// Store event handler references for proper cleanup
|
|
this._wheelHandler = function(e) {
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.TUNING) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var delta = e.deltaY;
|
|
if (delta < 0) {
|
|
self.zoomIn();
|
|
} else if (delta > 0) {
|
|
self.zoomOut();
|
|
}
|
|
};
|
|
|
|
this._mousemoveHandler = function(e) {
|
|
self.handleSpotLabelHover(e);
|
|
};
|
|
|
|
// Add canvas event listeners
|
|
this.canvas.addEventListener('wheel', this._wheelHandler, { passive: false });
|
|
this.canvas.addEventListener('mousemove', this._mousemoveHandler);
|
|
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Event listeners attached to canvas');
|
|
|
|
// Set up frequency input event listeners
|
|
this.$freqCalculated.on('focus', function() {
|
|
self.userEditingFrequency = true;
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
|
|
// If user is editing frequency while in TUNING state and no target set, transition to READY
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.TUNING && !self.targetFrequencyHz) {
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FOCUS: User editing frequency, transitioning to READY');
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.READY);
|
|
self.updateZoomMenu();
|
|
}
|
|
if (self.lastValidCommittedFreq === null) {
|
|
var currentFreq = parseFloat($(this).val()) || 0;
|
|
if (currentFreq > 0) {
|
|
self.commitFrequency();
|
|
}
|
|
}
|
|
});
|
|
|
|
this.$freqCalculated.on('blur', function() {
|
|
self.userEditingFrequency = false;
|
|
self.commitFrequency();
|
|
});
|
|
|
|
this.$freqCalculated.on('input', function() {
|
|
self.userEditingFrequency = true;
|
|
});
|
|
|
|
this.$freqCalculated.on('keydown', function(e) {
|
|
if (e.which === 13) {
|
|
e.preventDefault();
|
|
self.userEditingFrequency = false;
|
|
self.commitFrequency();
|
|
$(this).blur();
|
|
return false;
|
|
}
|
|
});
|
|
|
|
// Set up band dropdown change handler for offline mode
|
|
// When user changes band in offline mode, update frequency and fetch spots
|
|
this.$bandSelect.on('change', function() {
|
|
// Only handle in offline mode
|
|
if (typeof isCATAvailable === 'function' && !isCATAvailable()) {
|
|
var newBand = $(this).val();
|
|
|
|
// Get a typical frequency for the selected band
|
|
var bandFreq = getTypicalBandFrequency(newBand);
|
|
|
|
if (bandFreq > 0) {
|
|
// Update frequency field
|
|
self.$freqCalculated.val(bandFreq);
|
|
self.$qrgUnit.text('kHz');
|
|
|
|
// Update virtual CAT state
|
|
var freqHz = bandFreq * 1000; // Convert kHz to Hz
|
|
if (typeof window.catState === 'undefined' || window.catState === null) {
|
|
window.catState = {};
|
|
}
|
|
window.catState.frequency = freqHz;
|
|
window.catState.lastUpdate = Date.now();
|
|
|
|
// Update mode from form if available
|
|
if (self.$modeSelect && self.$modeSelect.val()) {
|
|
window.catState.mode = self.$modeSelect.val();
|
|
}
|
|
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Offline mode - band change to ' + newBand + ': virtual CAT updated with freq=' + freqHz + ' Hz');
|
|
|
|
// Commit the frequency change
|
|
self.commitFrequency();
|
|
|
|
// Fetch spots for the new band (state machine will handle FETCHING_SPOTS state)
|
|
self.fetchDxSpots(true, true); // User-initiated fetch
|
|
|
|
// Invalidate band-related caches
|
|
self.bandLimitsCache = null;
|
|
self.cachedBandForEdges = newBand;
|
|
self.currentSpotBand = newBand;
|
|
|
|
// Force refresh to show "Waiting for DX Cluster data" message
|
|
if (self.canvas && self.ctx) {
|
|
self.refresh();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Set up mode dropdown change handler
|
|
// When mode changes (CAT or manual), update display and refresh waterfall
|
|
this.$modeSelect.on('change', function() {
|
|
var newMode = $(this).val();
|
|
|
|
// Update virtual CAT state (for both online and offline modes)
|
|
if (typeof window.catState === 'undefined' || window.catState === null) {
|
|
window.catState = {};
|
|
}
|
|
|
|
// In offline mode, also preserve frequency
|
|
if (typeof isCATAvailable === 'function' && !isCATAvailable()) {
|
|
// Preserve existing frequency if available
|
|
if (!window.catState.frequency && self.$freqCalculated.val()) {
|
|
var freqVal = parseFloat(self.$freqCalculated.val());
|
|
var unit = self.$qrgUnit.text() || 'kHz';
|
|
var freqKhz = convertFrequency(freqVal, unit, 'kHz');
|
|
window.catState.frequency = freqKhz * 1000; // Convert to Hz
|
|
}
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Offline mode - mode change to ' + newMode + ': virtual CAT updated');
|
|
} else {
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] CAT mode change detected: ' + newMode);
|
|
}
|
|
|
|
window.catState.mode = newMode;
|
|
window.catState.lastUpdate = Date.now();
|
|
|
|
// Force refresh to update bandwidth indicator with new mode
|
|
if (self.canvas && self.ctx) {
|
|
self.refresh();
|
|
}
|
|
|
|
// Update relevant spots collection (mode affects spot filtering)
|
|
if (self.collectAllBandSpots) {
|
|
self.collectAllBandSpots(true);
|
|
}
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Load saved settings from cookies and initialize state
|
|
* @private
|
|
*/
|
|
_loadSettings: function() {
|
|
this.loadSettingsFromCookies();
|
|
|
|
// Initialize band cache for edge calculations
|
|
this.cachedBandForEdges = this.getCurrentBand();
|
|
},
|
|
|
|
/**
|
|
* Set up initial frequency commit with retry logic
|
|
* @private
|
|
*/
|
|
_setupInitialFrequencyCommit: function() {
|
|
var self = this;
|
|
var attemptCommit = function(attemptsLeft) {
|
|
var freq = parseFloat(self.$freqCalculated.val()) || 0;
|
|
if (freq > 0) {
|
|
self.commitFrequency();
|
|
} else if (attemptsLeft > 0) {
|
|
setTimeout(function() {
|
|
attemptCommit(attemptsLeft - 1);
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.FREQUENCY_COMMIT_RETRY_MS * (6 - attemptsLeft));
|
|
}
|
|
};
|
|
attemptCommit(5);
|
|
},
|
|
|
|
/**
|
|
* Set up CAT frequency wait timeout
|
|
* @private
|
|
*/
|
|
// Check if current frequency input differs from last committed value
|
|
// Returns true if frequency has changed, false if same
|
|
hasFrequencyChanged: function() {
|
|
// Safety check: return false if waterfall is not initialized
|
|
if (!this.$freqCalculated || !this.$qrgUnit) {
|
|
return false;
|
|
}
|
|
|
|
var currentInput = this.$freqCalculated.val();
|
|
var currentUnit = this.$qrgUnit.text() || 'kHz';
|
|
|
|
// If we don't have a last committed value, consider it changed
|
|
if (this.lastValidCommittedFreq === null) {
|
|
return true;
|
|
}
|
|
|
|
// Convert both frequencies to kHz for comparison (normalize units)
|
|
var currentKhz = convertFrequency(currentInput, currentUnit, 'kHz');
|
|
var lastKhz = convertFrequency(this.lastValidCommittedFreq, this.lastValidCommittedUnit, 'kHz');
|
|
|
|
// Compare frequencies with 1 Hz tolerance (0.001 kHz) to account for floating point errors
|
|
var tolerance = 0.001; // 1 Hz
|
|
return Math.abs(currentKhz - lastKhz) > tolerance;
|
|
},
|
|
|
|
// Commit the current frequency value (called on blur or Enter key)
|
|
// This prevents the waterfall from shifting while the user is typing
|
|
commitFrequency: function() {
|
|
// This function is primarily for the fallback case when CAT is not available
|
|
// When CAT is active, the waterfall reads from window.catState.frequency
|
|
|
|
// Safety check: return early if waterfall is not initialized (destroyed or not yet ready)
|
|
if (!this.$freqCalculated || !this.$qrgUnit) {
|
|
return;
|
|
}
|
|
|
|
var currentInput = this.$freqCalculated.val();
|
|
var currentUnit = this.$qrgUnit.text() || 'kHz';
|
|
|
|
// If this is a valid frequency, save it as the last valid committed frequency
|
|
// (used as fallback when CAT not available)
|
|
var freqValue = parseFloat(currentInput) || 0;
|
|
if (freqValue > 0) {
|
|
this.lastValidCommittedFreq = currentInput;
|
|
this.lastValidCommittedUnit = currentUnit;
|
|
|
|
// Store the committed frequency in kHz for comparison checks
|
|
var currentFreqKhz = convertFrequency(freqValue, currentUnit, 'kHz');
|
|
this.committedFrequencyKHz = currentFreqKhz;
|
|
|
|
// In offline mode, populate catState with form values to act as "virtual CAT"
|
|
if (typeof isCATAvailable === 'function' && !isCATAvailable()) {
|
|
var freqHz = currentFreqKhz * 1000; // Convert kHz to Hz
|
|
|
|
// Initialize catState if it doesn't exist
|
|
if (typeof window.catState === 'undefined' || window.catState === null) {
|
|
window.catState = {};
|
|
}
|
|
|
|
// Update frequency in catState
|
|
window.catState.frequency = freqHz;
|
|
window.catState.lastUpdate = Date.now();
|
|
|
|
// Update mode from form
|
|
if (this.$modeSelect && this.$modeSelect.val()) {
|
|
window.catState.mode = this.$modeSelect.val();
|
|
}
|
|
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Offline mode - virtual CAT updated: freq=' + freqHz + ' Hz, mode=' + window.catState.mode);
|
|
|
|
// Update relevant spots for the new frequency
|
|
if (this.collectAllBandSpots) {
|
|
this.collectAllBandSpots(true);
|
|
}
|
|
}
|
|
|
|
// Manual frequency change triggers initial fetch if not done yet
|
|
if (!this.initialFetchDone) {
|
|
this.refresh();
|
|
}
|
|
}
|
|
|
|
// Force a refresh to update the display (mainly for non-CAT usage)
|
|
if (this.canvas && this.ctx) {
|
|
this.refresh();
|
|
}
|
|
|
|
// Update zoom menu to reflect new arrow states based on frequency position
|
|
if (this.zoomMenuDiv) {
|
|
this.updateZoomMenu();
|
|
}
|
|
},
|
|
|
|
// Get cached middle frequency to avoid repeated DOM access and parsing
|
|
// Always returns frequency in kHz for internal calculations
|
|
getCachedMiddleFreq: function() {
|
|
// PRIORITY 1: Use CAT state if available (radio controls waterfall)
|
|
// The waterfall should display what the radio is tuned to, not what's in the form
|
|
if (window.catState && window.catState.frequency && window.catState.frequency > 0) {
|
|
var freqHz = window.catState.frequency;
|
|
var freqKhz = freqHz / 1000;
|
|
|
|
// Cache the CAT frequency
|
|
this.cache.middleFreq = freqKhz;
|
|
|
|
// Update split operation state and get display configuration
|
|
this.updateSplitOperationState();
|
|
|
|
return this.displayConfig.centerFrequency;
|
|
}
|
|
|
|
// FALLBACK: Use committed frequency values (only updated on blur/Enter) to prevent shifting while typing
|
|
// This is used when CAT is not available (no radio connected)
|
|
// Strategy:
|
|
// 1. If we have a valid committed frequency from this session, always use last VALID commit
|
|
// 2. Otherwise use real-time values (initial load before any commits)
|
|
|
|
var hasValidCommit = this.lastValidCommittedFreq !== null;
|
|
|
|
var currentInput, currentUnit;
|
|
|
|
if (hasValidCommit) {
|
|
// After first valid commit, always use the LAST VALID committed values
|
|
// This keeps the waterfall stable even when user deletes and starts typing
|
|
currentInput = this.lastValidCommittedFreq;
|
|
currentUnit = this.lastValidCommittedUnit || 'kHz';
|
|
} else {
|
|
// Before first valid commit (initial load), use real-time values
|
|
currentInput = this.$freqCalculated.val();
|
|
currentUnit = this.$qrgUnit.text() || 'kHz';
|
|
}
|
|
|
|
// Invalidate cache if input OR unit changes
|
|
if (this.lastValidCommittedFreq !== currentInput || this.lastQrgUnit !== currentUnit) {
|
|
this.lastValidCommittedFreq = currentInput;
|
|
this.lastQrgUnit = currentUnit;
|
|
|
|
// Convert to kHz using utility function
|
|
this.cache.middleFreq = convertFrequency(currentInput, currentUnit, 'kHz');
|
|
}
|
|
|
|
// Update split operation state and get display configuration
|
|
this.updateSplitOperationState();
|
|
|
|
return this.displayConfig.centerFrequency;
|
|
},
|
|
|
|
// Update split operation state and configure display parameters
|
|
updateSplitOperationState: function() {
|
|
// Prefer CAT state for frequency_rx (radio controls split operation)
|
|
var frequencyRxValue = null;
|
|
|
|
if (window.catState && window.catState.frequency_rx) {
|
|
frequencyRxValue = window.catState.frequency_rx;
|
|
} else if (DX_WATERFALL_UTILS.fieldMapping.hasOptionalField('frequency_rx')) {
|
|
// Fallback to form field if CAT not available
|
|
var $frequencyRx = DX_WATERFALL_UTILS.fieldMapping.getField('frequency_rx', true);
|
|
frequencyRxValue = $frequencyRx.val();
|
|
}
|
|
|
|
if (frequencyRxValue && frequencyRxValue != '' && parseFloat(frequencyRxValue) > 0) {
|
|
// SPLIT OPERATION MODE
|
|
var rxFreq = parseFloat(frequencyRxValue) / 1000; // Convert Hz to kHz
|
|
var txFreq = this.cache.middleFreq; // TX is from main frequency field
|
|
|
|
this.displayConfig = {
|
|
isSplit: true,
|
|
centerFrequency: rxFreq, // Waterfall centered on RX
|
|
markers: [
|
|
{
|
|
frequency: rxFreq,
|
|
color: DX_WATERFALL_CONSTANTS.COLORS.CENTER_MARKER_RX,
|
|
label: 'RX'
|
|
},
|
|
{
|
|
frequency: txFreq,
|
|
color: DX_WATERFALL_CONSTANTS.COLORS.CENTER_MARKER_TX,
|
|
label: 'TX'
|
|
}
|
|
],
|
|
showBandwidthIndicator: false
|
|
};
|
|
} else {
|
|
// SIMPLEX OPERATION MODE
|
|
this.displayConfig = {
|
|
isSplit: false,
|
|
centerFrequency: this.cache.middleFreq,
|
|
markers: [
|
|
{
|
|
frequency: this.cache.middleFreq,
|
|
color: DX_WATERFALL_CONSTANTS.COLORS.CENTER_MARKER,
|
|
label: 'CENTER'
|
|
}
|
|
],
|
|
showBandwidthIndicator: true
|
|
};
|
|
}
|
|
},
|
|
|
|
// Force invalidate frequency cache - called when CAT updates frequency
|
|
/**
|
|
* Invalidate frequency cache and handle CAT frequency updates
|
|
* Manages state transitions for frequency changes
|
|
* @param {number} frequencyKHz - New frequency in kHz
|
|
* @param {boolean} isImmediateUpdate - True if spot click (immediate update)
|
|
*/
|
|
invalidateFrequencyCache: function(frequencyKHz, isImmediateUpdate) {
|
|
// Safety check: Don't run if waterfall is not initialized
|
|
if (!this.canvas) {
|
|
return;
|
|
}
|
|
|
|
// Don't invalidate cache if user is actively editing frequency
|
|
if (this.userEditingFrequency) {
|
|
return;
|
|
}
|
|
|
|
// If this is an immediate update from clicking a spot, update frequency NOW
|
|
if (isImmediateUpdate && frequencyKHz) {
|
|
this.cache.middleFreq = frequencyKHz;
|
|
|
|
// ========================================
|
|
// TRANSITION TO TUNING STATE (only if not already tuning)
|
|
// ========================================
|
|
if (isCATAvailable() && DXWaterfallStateMachine.getState() !== DX_WATERFALL_CONSTANTS.STATES.TUNING) {
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.TUNING, {
|
|
targetFrequency: frequencyKHz,
|
|
reason: 'spot_click'
|
|
});
|
|
}
|
|
|
|
return; // CAT will confirm later
|
|
}
|
|
|
|
// Check if we're waiting for a specific target frequency
|
|
if (this.targetFrequencyHz) {
|
|
// Waiting for target frequency - skip normal processing, CAT will confirm later
|
|
return; // Exit early
|
|
}
|
|
|
|
// ========================================
|
|
// TRANSITION TO READY STATE
|
|
// ========================================
|
|
// Frequency is now confirmed by CAT system
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.TUNING) {
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.READY);
|
|
}
|
|
|
|
// Force immediate cache refresh and visual update
|
|
this.lastFrequencyRefreshTime = 0;
|
|
|
|
// Trigger refresh
|
|
if (this.canvas && this.ctx) {
|
|
this.refresh();
|
|
}
|
|
},
|
|
|
|
// Periodically refresh frequency cache to ensure display stays current
|
|
refreshFrequencyCache: function() {
|
|
// Safety check: Don't run if waterfall is not initialized
|
|
if (!this.$freqCalculated || !this.$qrgUnit) {
|
|
return;
|
|
}
|
|
|
|
// Don't interfere during waterfall-initiated frequency changes or when user is editing
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.TUNING || this.userEditingFrequency) {
|
|
return;
|
|
}
|
|
|
|
// Throttle to prevent excessive calls (max once per 200ms)
|
|
var currentTime = Date.now();
|
|
if (currentTime - this.lastFrequencyRefreshTime < DX_WATERFALL_CONSTANTS.DEBOUNCE.FREQUENCY_CACHE_REFRESH_MS) {
|
|
return;
|
|
}
|
|
this.lastFrequencyRefreshTime = currentTime;
|
|
|
|
// Get current DOM frequency
|
|
var currentInput = this.$freqCalculated.val();
|
|
if (!currentInput || currentInput === '') {
|
|
return;
|
|
}
|
|
|
|
var freqValue = parseFloat(currentInput) || 0;
|
|
if (freqValue <= 0) {
|
|
return;
|
|
}
|
|
|
|
var currentUnit = this.$qrgUnit.text() || 'kHz';
|
|
|
|
// Convert to kHz using utility function
|
|
var currentFreqFromDOM = convertFrequency(freqValue, currentUnit, 'kHz');
|
|
|
|
// If cache is outdated, refresh it (but only if not during waterfall operations)
|
|
if (!this.cache.middleFreq || Math.abs(currentFreqFromDOM - this.cache.middleFreq) > 0.1) {
|
|
// Clear all frequency-related cache to ensure fresh read
|
|
this.cache.middleFreq = null;
|
|
this.lastQrgUnit = null;
|
|
this.lastMarkerFreq = undefined;
|
|
|
|
// Directly set the new frequency from DOM calculation
|
|
this.cache.middleFreq = currentFreqFromDOM;
|
|
|
|
// Also update committed frequency values to prevent getCachedMiddleFreq() conflicts
|
|
// This ensures that getCachedMiddleFreq() will use the updated frequency instead of old committed values
|
|
this.lastValidCommittedFreq = currentInput;
|
|
this.lastValidCommittedUnit = currentUnit;
|
|
|
|
}
|
|
},
|
|
|
|
// Check if there's a relevant spot at current frequency and populate the QSO form
|
|
checkAndPopulateSpotAtFrequency: function() {
|
|
// Get spot info at current frequency
|
|
var spotInfo = this.getSpotInfo();
|
|
|
|
if (spotInfo && spotInfo.callsign) {
|
|
// Create a unique identifier for this spot
|
|
var spotId = spotInfo.callsign + '_' + spotInfo.frequency + '_' + (spotInfo.mode || '');
|
|
|
|
// Only populate if this is a different spot than the last one we populated
|
|
if (this.lastPopulatedSpot !== spotId) {
|
|
this.lastPopulatedSpot = spotId;
|
|
|
|
// Clear the form first
|
|
DX_WATERFALL_UTILS.qsoForm.clearForm();
|
|
|
|
// Populate form with spot data after a short delay
|
|
setTimeout(function() {
|
|
if (typeof DX_WATERFALL_UTILS !== 'undefined' &&
|
|
typeof DX_WATERFALL_UTILS.qsoForm !== 'undefined' &&
|
|
typeof DX_WATERFALL_UTILS.qsoForm.populateFromSpot === 'function') {
|
|
DX_WATERFALL_UTILS.qsoForm.populateFromSpot(spotInfo, true);
|
|
}
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.FORM_POPULATE_DELAY_MS);
|
|
}
|
|
} else {
|
|
// No spot at current frequency, clear the last populated spot tracker
|
|
this.lastPopulatedSpot = null;
|
|
}
|
|
},
|
|
|
|
// Get the current label font based on labelSizeLevel
|
|
getCurrentLabelFont: function() {
|
|
var size = DX_WATERFALL_CONSTANTS.FONTS.LABEL_SIZES[this.labelSizeLevel] || 13;
|
|
return 'bold ' + size + 'px ' + DX_WATERFALL_CONSTANTS.FONTS.FAMILY;
|
|
},
|
|
|
|
// Get the current CENTER label font (1px larger than regular labels)
|
|
getCurrentCenterLabelFont: function() {
|
|
var size = (DX_WATERFALL_CONSTANTS.FONTS.LABEL_SIZES[this.labelSizeLevel] || 13) + 1;
|
|
return 'bold ' + size + 'px ' + DX_WATERFALL_CONSTANTS.FONTS.FAMILY;
|
|
},
|
|
|
|
// Get the current label height in pixels based on labelSizeLevel
|
|
getCurrentLabelHeight: function() {
|
|
return DX_WATERFALL_CONSTANTS.FONTS.LABEL_HEIGHTS[this.labelSizeLevel] || 15;
|
|
},
|
|
|
|
// ========================================
|
|
// COOKIE MANAGEMENT
|
|
// ========================================
|
|
|
|
/**
|
|
* Save font size to cookie
|
|
*/
|
|
saveFontSizeToCookie: function() {
|
|
DX_WATERFALL_UTILS.log.debug('[Cookie] Saving font size to cookie: ' + this.labelSizeLevel);
|
|
setCookie(
|
|
DX_WATERFALL_CONSTANTS.COOKIE.NAME_FONT_SIZE,
|
|
this.labelSizeLevel.toString(),
|
|
DX_WATERFALL_CONSTANTS.COOKIE.EXPIRY_DAYS
|
|
);
|
|
DX_WATERFALL_UTILS.log.debug('[Cookie] Font size saved');
|
|
},
|
|
|
|
/**
|
|
* Load font size from cookie
|
|
* @returns {number|null} Font size level (0-4) or null if not found
|
|
*/
|
|
loadFontSizeFromCookie: function() {
|
|
var cookieValue = getCookie(DX_WATERFALL_CONSTANTS.COOKIE.NAME_FONT_SIZE);
|
|
DX_WATERFALL_UTILS.log.debug('[Cookie] Loading font size from cookie, raw value: ' + cookieValue);
|
|
if (cookieValue !== null) {
|
|
var level = parseInt(cookieValue, 10);
|
|
if (!isNaN(level) && level >= 0 && level <= 4) {
|
|
DX_WATERFALL_UTILS.log.debug('[Cookie] Valid font size loaded: ' + level);
|
|
return level;
|
|
}
|
|
}
|
|
DX_WATERFALL_UTILS.log.debug('[Cookie] No valid font size in cookie, using default');
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Save mode filters to cookie
|
|
*/
|
|
saveModeFiltersToCookie: function() {
|
|
setCookie(
|
|
DX_WATERFALL_CONSTANTS.COOKIE.NAME_MODE_FILTERS,
|
|
JSON.stringify(this.modeFilters),
|
|
DX_WATERFALL_CONSTANTS.COOKIE.EXPIRY_DAYS
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Load mode filters from cookie
|
|
* @returns {Object|null} Mode filters object or null if not found
|
|
*/
|
|
loadModeFiltersFromCookie: function() {
|
|
var cookieValue = getCookie(DX_WATERFALL_CONSTANTS.COOKIE.NAME_MODE_FILTERS);
|
|
if (cookieValue) {
|
|
try {
|
|
var filters = JSON.parse(cookieValue);
|
|
// Validate that it has the expected properties
|
|
if (typeof filters.phone === 'boolean' &&
|
|
typeof filters.cw === 'boolean' &&
|
|
typeof filters.digi === 'boolean') {
|
|
return filters;
|
|
}
|
|
} catch (e) {
|
|
// Silently ignore invalid cookie data
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Load saved settings from cookies on initialization
|
|
*/
|
|
loadSettingsFromCookies: function() {
|
|
DX_WATERFALL_UTILS.log.debug('[Settings] Loading settings from cookies...');
|
|
|
|
// Load font size
|
|
var savedFontSize = this.loadFontSizeFromCookie();
|
|
if (savedFontSize !== null) {
|
|
this.labelSizeLevel = savedFontSize;
|
|
DX_WATERFALL_UTILS.log.debug('[Settings] Font size level set to: ' + this.labelSizeLevel);
|
|
} else {
|
|
DX_WATERFALL_UTILS.log.debug('[Settings] Using default font size level: ' + this.labelSizeLevel);
|
|
}
|
|
|
|
// Load mode filters
|
|
var savedModeFilters = this.loadModeFiltersFromCookie();
|
|
if (savedModeFilters) {
|
|
this.modeFilters.phone = savedModeFilters.phone;
|
|
this.modeFilters.cw = savedModeFilters.cw;
|
|
this.modeFilters.digi = savedModeFilters.digi;
|
|
DX_WATERFALL_UTILS.log.debug('[Settings] Mode filters loaded: ' + JSON.stringify(this.modeFilters));
|
|
} else {
|
|
DX_WATERFALL_UTILS.log.debug('[Settings] Using default mode filters');
|
|
}
|
|
},
|
|
|
|
// ========================================
|
|
// SPOT LABEL TOOLTIP HANDLER
|
|
// ========================================
|
|
|
|
/**
|
|
* Handle mousemove over canvas to show spot label tooltips
|
|
* Efficient implementation - only creates tooltip when needed
|
|
*/
|
|
handleSpotLabelHover: function(e) {
|
|
// Don't show tooltips while not in READY state or if no spots
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState !== DX_WATERFALL_CONSTANTS.STATES.READY || !this.dxSpots || this.dxSpots.length === 0) {
|
|
this.hideSpotTooltip();
|
|
return;
|
|
}
|
|
|
|
var rect = this.canvas.getBoundingClientRect();
|
|
var mouseX = e.clientX - rect.left;
|
|
var mouseY = e.clientY - rect.top;
|
|
|
|
// Find if mouse is over any spot label
|
|
var hoveredSpot = this.findSpotAtPosition(mouseX, mouseY);
|
|
|
|
if (hoveredSpot) {
|
|
this.canvas.style.cursor = 'pointer'; // Change cursor to pointer when over spot
|
|
this.showSpotTooltip(hoveredSpot, e.clientX, e.clientY);
|
|
} else {
|
|
this.canvas.style.cursor = 'default'; // Reset cursor when not over spot
|
|
this.hideSpotTooltip();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Find spot at mouse position (checks both left and right spots + center spot)
|
|
*/
|
|
findSpotAtPosition: function(x, y) {
|
|
var labelHeight = this.getCurrentLabelHeight();
|
|
var tolerance = 2; // Pixels tolerance for easier hovering
|
|
|
|
// Check center spot(s) first
|
|
var centerSpot = this.getSpotInfo();
|
|
if (centerSpot && this.relevantSpots && this.relevantSpots.length > 0) {
|
|
var centerX = this.canvas.width / 2;
|
|
var waterfallHeight = this.canvas.height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var centerY = waterfallHeight / 2;
|
|
|
|
// Check if we have multiple spots near the center frequency (same logic as drawCenterCallsignLabel)
|
|
var centerFreq = this.getCachedMiddleFreq(); // Use the actual tuned frequency
|
|
var spotsAtSameFreq = [];
|
|
var frequencyTolerance = 0.1; // Same as in drawCenterCallsignLabel
|
|
|
|
for (var i = 0; i < this.relevantSpots.length; i++) {
|
|
var spot = this.relevantSpots[i];
|
|
var spotFreq = parseFloat(spot.frequency);
|
|
if (Math.abs(spotFreq - centerFreq) <= frequencyTolerance) {
|
|
spotsAtSameFreq.push(spot);
|
|
}
|
|
}
|
|
|
|
this.ctx.font = this.getCurrentCenterLabelFont();
|
|
var padding = Math.ceil(DX_WATERFALL_CONSTANTS.CANVAS.SPOT_PADDING * 1.1);
|
|
var centerLabelHeight = labelHeight + 1;
|
|
var spacing = 4;
|
|
|
|
if (spotsAtSameFreq.length > 1) {
|
|
// Check each stacked label
|
|
var totalHeight = (spotsAtSameFreq.length * centerLabelHeight) + ((spotsAtSameFreq.length - 1) * spacing);
|
|
var startY = centerY - (totalHeight / 2);
|
|
|
|
for (var j = 0; j < spotsAtSameFreq.length; j++) {
|
|
var stackedSpot = spotsAtSameFreq[j];
|
|
var textWidth = this.ctx.measureText(stackedSpot.callsign).width;
|
|
var centerLabelWidth = textWidth + (padding * 2);
|
|
|
|
var rectY = startY + (j * (centerLabelHeight + spacing));
|
|
var centerLeft = centerX - centerLabelWidth / 2;
|
|
var centerRight = centerX + centerLabelWidth / 2;
|
|
var centerTop = rectY;
|
|
var centerBottom = rectY + centerLabelHeight;
|
|
|
|
if (x >= centerLeft - tolerance && x <= centerRight + tolerance &&
|
|
y >= centerTop - tolerance && y <= centerBottom + tolerance) {
|
|
return stackedSpot;
|
|
}
|
|
}
|
|
} else {
|
|
// Single center spot
|
|
var textWidth = this.ctx.measureText(centerSpot.callsign).width;
|
|
var centerLabelWidth = textWidth + (padding * 2);
|
|
var centerLabelHeight = labelHeight + 1;
|
|
|
|
var centerLeft = centerX - centerLabelWidth / 2;
|
|
var centerRight = centerX + centerLabelWidth / 2;
|
|
var centerTop = centerY - centerLabelHeight / 2;
|
|
var centerBottom = centerY + centerLabelHeight / 2;
|
|
|
|
if (x >= centerLeft - tolerance && x <= centerRight + tolerance &&
|
|
y >= centerTop - tolerance && y <= centerBottom + tolerance) {
|
|
return centerSpot;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check all visible spots (left and right)
|
|
for (var i = 0; i < this.dxSpots.length; i++) {
|
|
var spot = this.dxSpots[i];
|
|
if (spot.x !== undefined && spot.y !== undefined && spot.labelWidth !== undefined) {
|
|
// spot.x is the CENTER of the label
|
|
// spot.y is the CENTER of the text (y + 1 from drawing)
|
|
// spot.labelWidth is the full width of the label
|
|
|
|
var spotLeft = spot.x - spot.labelWidth / 2;
|
|
var spotRight = spot.x + spot.labelWidth / 2;
|
|
var spotTop = spot.y - labelHeight / 2;
|
|
var spotBottom = spot.y + labelHeight / 2;
|
|
|
|
if (x >= spotLeft - tolerance && x <= spotRight + tolerance &&
|
|
y >= spotTop - tolerance && y <= spotBottom + tolerance) {
|
|
// Return a properly formatted spot object (like getSpotInfo does)
|
|
return DX_WATERFALL_UTILS.spots.createSpotObject(spot, {
|
|
includeSpotter: true,
|
|
includeTimestamp: true,
|
|
includeMessage: true,
|
|
includeWorkStatus: true
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
/**
|
|
* Show tooltip for a spot
|
|
*/
|
|
showSpotTooltip: function(spot, clientX, clientY) {
|
|
// Create tooltip div if it doesn't exist
|
|
if (!this.spotTooltipDiv) {
|
|
this.spotTooltipDiv = document.createElement('div');
|
|
this.spotTooltipDiv.id = 'dxWaterfallTooltip';
|
|
this.spotTooltipDiv.style.position = 'fixed';
|
|
this.spotTooltipDiv.style.backgroundColor = DX_WATERFALL_CONSTANTS.COLORS.TOOLTIP_BACKGROUND;
|
|
this.spotTooltipDiv.style.color = DX_WATERFALL_CONSTANTS.COLORS.WHITE;
|
|
this.spotTooltipDiv.style.padding = '5px 10px';
|
|
this.spotTooltipDiv.style.borderRadius = '4px';
|
|
this.spotTooltipDiv.style.fontSize = '11px';
|
|
this.spotTooltipDiv.style.fontFamily = '"Consolas", "Courier New", monospace';
|
|
this.spotTooltipDiv.style.pointerEvents = 'none';
|
|
this.spotTooltipDiv.style.zIndex = '10000';
|
|
this.spotTooltipDiv.style.whiteSpace = 'nowrap';
|
|
document.body.appendChild(this.spotTooltipDiv);
|
|
}
|
|
|
|
// Build tooltip content with all information
|
|
var tooltipParts = [];
|
|
|
|
// Spotter name (already cleaned during data load)
|
|
if (spot.spotter) {
|
|
tooltipParts.push(lang_dxwaterfall_spotted_by + ' ' + spot.spotter);
|
|
}
|
|
|
|
// Time from when_pretty field (format: "DD/MM/YY HH:MM")
|
|
if (spot.when_pretty) {
|
|
var parts = spot.when_pretty.split(' ');
|
|
if (parts.length === 2) {
|
|
// Extract just the time part (HH:MM) and add Z for UTC
|
|
tooltipParts.push('@' + parts[1] + 'Z');
|
|
}
|
|
}
|
|
|
|
// Add worked status indicators
|
|
// Check both transformed properties (newContinent, newDxcc, newCallsign)
|
|
// and raw properties (worked_continent, worked_dxcc, worked_call)
|
|
var statusParts = [];
|
|
|
|
// New continent: check newContinent or worked_continent === false
|
|
if (spot.newContinent || spot.worked_continent === false) {
|
|
statusParts.push(lang_dxwaterfall_new_continent);
|
|
}
|
|
|
|
// New DXCC: check newDxcc or worked_dxcc === false
|
|
if (spot.newDxcc || spot.worked_dxcc === false) {
|
|
statusParts.push(lang_dxwaterfall_new_dxcc);
|
|
}
|
|
|
|
// New callsign: check newCallsign or worked_call === false
|
|
if (spot.newCallsign || spot.worked_call === false) {
|
|
statusParts.push(lang_dxwaterfall_new_callsign);
|
|
}
|
|
|
|
if (statusParts.length > 0) {
|
|
tooltipParts.push('(' + statusParts.join(') (') + ')');
|
|
}
|
|
|
|
this.spotTooltipDiv.textContent = tooltipParts.join(' ');
|
|
|
|
// Get canvas boundaries to keep tooltip inside
|
|
var canvasRect = this.canvas.getBoundingClientRect();
|
|
|
|
// Calculate tooltip dimensions (need to show it briefly to measure)
|
|
this.spotTooltipDiv.style.display = 'block';
|
|
var tooltipWidth = this.spotTooltipDiv.offsetWidth;
|
|
var tooltipHeight = this.spotTooltipDiv.offsetHeight;
|
|
|
|
// Default position (offset from cursor)
|
|
var tooltipLeft = clientX + 15;
|
|
var tooltipTop = clientY + 10;
|
|
|
|
// Keep tooltip inside canvas horizontally
|
|
if (tooltipLeft + tooltipWidth > canvasRect.right) {
|
|
tooltipLeft = clientX - tooltipWidth - 15; // Show on left side of cursor
|
|
}
|
|
if (tooltipLeft < canvasRect.left) {
|
|
tooltipLeft = canvasRect.left + 5; // Clamp to left edge
|
|
}
|
|
|
|
// Keep tooltip inside canvas vertically
|
|
if (tooltipTop + tooltipHeight > canvasRect.bottom) {
|
|
tooltipTop = clientY - tooltipHeight - 10; // Show above cursor
|
|
}
|
|
if (tooltipTop < canvasRect.top) {
|
|
tooltipTop = canvasRect.top + 5; // Clamp to top edge
|
|
}
|
|
|
|
this.spotTooltipDiv.style.left = tooltipLeft + 'px';
|
|
this.spotTooltipDiv.style.top = tooltipTop + 'px';
|
|
},
|
|
|
|
/**
|
|
* Hide tooltip
|
|
*/
|
|
hideSpotTooltip: function() {
|
|
if (this.spotTooltipDiv) {
|
|
this.spotTooltipDiv.style.display = 'none';
|
|
}
|
|
},
|
|
|
|
// Get cached pixels per kHz to avoid repeated mode checking
|
|
getCachedPixelsPerKHz: function() {
|
|
var currentMode = this.getCurrentMode();
|
|
if (this.lastModeForCache !== currentMode) {
|
|
this.lastModeForCache = currentMode;
|
|
this.cachedPixelsPerKHz = this.getPixelsPerKHz();
|
|
}
|
|
return this.cachedPixelsPerKHz;
|
|
},
|
|
|
|
/**
|
|
* Get visible spots with caching to avoid re-filtering on every render
|
|
* Cache is invalidated when spots, frequency, canvas size, or mode filter changes
|
|
* @returns {{left: Array, right: Array}} Object with left and right spot arrays
|
|
*/
|
|
getVisibleSpots: function() {
|
|
var centerX = this.canvas.width / 2;
|
|
var middleFreq = this.getCachedMiddleFreq();
|
|
var pixelsPerKHz = this.getCachedPixelsPerKHz();
|
|
var currentMode = this.getCurrentMode();
|
|
|
|
// Create cache key based on parameters that affect visible spots
|
|
var cacheKey = {
|
|
spotsLength: this.dxSpots ? this.dxSpots.length : 0,
|
|
middleFreq: middleFreq,
|
|
canvasWidth: this.canvas.width,
|
|
mode: currentMode,
|
|
labelSizeLevel: this.labelSizeLevel,
|
|
phoneFilter: this.modeFilters.phone,
|
|
cwFilter: this.modeFilters.cw,
|
|
digiFilter: this.modeFilters.digi
|
|
};
|
|
|
|
// Check if cache is valid
|
|
if (this.cache.visibleSpots && this.cache.visibleSpotsParams) {
|
|
var params = this.cache.visibleSpotsParams;
|
|
if (params.spotsLength === cacheKey.spotsLength &&
|
|
params.middleFreq === cacheKey.middleFreq &&
|
|
params.canvasWidth === cacheKey.canvasWidth &&
|
|
params.mode === cacheKey.mode &&
|
|
params.labelSizeLevel === cacheKey.labelSizeLevel &&
|
|
params.phoneFilter === cacheKey.phoneFilter &&
|
|
params.cwFilter === cacheKey.cwFilter &&
|
|
params.digiFilter === cacheKey.digiFilter) {
|
|
return this.cache.visibleSpots;
|
|
}
|
|
}
|
|
|
|
// Cache miss - rebuild visible spots
|
|
|
|
var leftSpots = [];
|
|
var rightSpots = [];
|
|
var centerFrequency = middleFreq;
|
|
var centerFrequencyTolerance = DX_WATERFALL_CONSTANTS.THRESHOLDS.CENTER_SPOT_TOLERANCE_KHZ;
|
|
var filteredCount = 0;
|
|
var outOfBoundsCount = 0;
|
|
var centerSkipCount = 0;
|
|
|
|
for (var i = 0; i < this.dxSpots.length; i++) {
|
|
var spot = this.dxSpots[i];
|
|
var spotFreq = parseFloat(spot.frequency);
|
|
|
|
if (spotFreq && spot.spotted && spot.mode) {
|
|
// Apply mode filter
|
|
if (!this.spotMatchesModeFilter(spot)) {
|
|
filteredCount++;
|
|
continue;
|
|
}
|
|
|
|
var freqOffset = spotFreq - middleFreq;
|
|
var x = centerX + (freqOffset * pixelsPerKHz);
|
|
|
|
// Only include if within canvas bounds
|
|
if (x >= 0 && x <= this.canvas.width) {
|
|
// Skip spots at center frequency (within tolerance)
|
|
if (centerFrequency && Math.abs(spotFreq - centerFrequency) <= centerFrequencyTolerance) {
|
|
centerSkipCount++;
|
|
continue;
|
|
}
|
|
|
|
var spotData = DX_WATERFALL_UTILS.spots.createSpotObject(spot, {
|
|
includePosition: true,
|
|
x: x,
|
|
includeOffsets: true,
|
|
middleFreq: middleFreq,
|
|
includeWorkStatus: true
|
|
});
|
|
|
|
// Store reference to original spot
|
|
spotData.originalSpot = spot;
|
|
|
|
if (freqOffset < 0) {
|
|
leftSpots.push(spotData);
|
|
} else if (freqOffset > 0) {
|
|
rightSpots.push(spotData);
|
|
}
|
|
} else {
|
|
outOfBoundsCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pre-calculate label widths for all spots (cached with visible spots)
|
|
if (this.ctx) {
|
|
var currentLabelFont = this.getCurrentLabelFont();
|
|
this.ctx.font = currentLabelFont;
|
|
var padding = DX_WATERFALL_CONSTANTS.CANVAS.SPOT_PADDING;
|
|
|
|
for (var j = 0; j < leftSpots.length; j++) {
|
|
var textWidth = this.ctx.measureText(leftSpots[j].callsign).width;
|
|
leftSpots[j].labelWidth = textWidth + (padding * 2);
|
|
}
|
|
|
|
for (var k = 0; k < rightSpots.length; k++) {
|
|
var textWidthRight = this.ctx.measureText(rightSpots[k].callsign).width;
|
|
rightSpots[k].labelWidth = textWidthRight + (padding * 2);
|
|
}
|
|
}
|
|
|
|
// Cache the result
|
|
var result = { left: leftSpots, right: rightSpots };
|
|
this.cache.visibleSpots = result;
|
|
this.cache.visibleSpotsParams = cacheKey;
|
|
|
|
return result;
|
|
},
|
|
|
|
// Get cached bandwidth parameters to avoid repeated calculations
|
|
getCachedBandwidthParams: function(mode, frequency) {
|
|
// Use floor of frequency to reduce cache misses for small frequency changes
|
|
var freqKey = Math.floor(frequency);
|
|
|
|
// Check if cache is valid (same mode and frequency bucket)
|
|
if (this.cachedBandwidthParams &&
|
|
this.cachedBandwidthParams.mode === mode &&
|
|
this.cachedBandwidthParams.freqKey === freqKey) {
|
|
return this.cachedBandwidthParams.params;
|
|
}
|
|
|
|
// Cache miss - calculate and store
|
|
this.cachedBandwidthParams = {
|
|
mode: mode,
|
|
freqKey: freqKey,
|
|
params: this.getBandwidthParams(mode, frequency)
|
|
};
|
|
return this.cachedBandwidthParams.params;
|
|
},
|
|
|
|
// Load band plans from database
|
|
loadBandPlans: function() {
|
|
var self = this;
|
|
|
|
if (this.bandPlans !== null) {
|
|
return;
|
|
}
|
|
|
|
this.bandPlans = 'loading';
|
|
|
|
var baseUrl = (typeof base_url !== 'undefined') ? base_url : '';
|
|
if (!baseUrl) {
|
|
this.bandPlans = {};
|
|
return;
|
|
}
|
|
|
|
// Determine region from current continent
|
|
var region = continentToRegion(this.currentContinent);
|
|
|
|
$.ajax({
|
|
url: baseUrl + 'index.php/band/get_user_bandedges?region=' + region,
|
|
type: 'GET',
|
|
dataType: 'json',
|
|
cache: true, // Cache the band plans
|
|
success: function(data) {
|
|
// Transform the database format to the expected band plans format
|
|
// Database returns: [{frequencyfrom: 14000000, frequencyto: 14070000, mode: "CW"}, ...]
|
|
// Need to group by band and create structure for getBandLimits
|
|
self.bandPlans = self.transformBandEdgesToBandPlans(data, region);
|
|
// Invalidate cache to trigger redraw with band limits
|
|
self.bandLimitsCache = null;
|
|
},
|
|
error: function(xhr, status, error) {
|
|
self.bandPlans = {}; // Set to empty object to prevent repeated attempts
|
|
}
|
|
});
|
|
},
|
|
|
|
// Transform band edges from database into band plans structure
|
|
transformBandEdgesToBandPlans: function(bandEdges, region) {
|
|
if (!bandEdges || bandEdges.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
var bandPlans = {};
|
|
var regionKey = 'region' + region;
|
|
bandPlans[regionKey] = {};
|
|
|
|
// Also store raw band edges data grouped by band for mode indicators
|
|
if (!this.bandEdgesData) {
|
|
this.bandEdgesData = {};
|
|
}
|
|
this.bandEdgesData[regionKey] = {};
|
|
|
|
// Group by band - find min/max frequencies for each band
|
|
var bandRanges = {};
|
|
|
|
for (var i = 0; i < bandEdges.length; i++) {
|
|
var edge = bandEdges[i];
|
|
var freqFrom = parseInt(edge.frequencyfrom);
|
|
var freqTo = parseInt(edge.frequencyto);
|
|
|
|
// Determine band from frequency (use center frequency)
|
|
var centerFreq = (freqFrom + freqTo) / 2;
|
|
var band = frequencyToBand(centerFreq); // Use global function from radiohelpers.js
|
|
|
|
if (band) {
|
|
// Store band ranges for limits
|
|
if (!bandRanges[band]) {
|
|
bandRanges[band] = {
|
|
start_hz: freqFrom,
|
|
end_hz: freqTo
|
|
};
|
|
} else {
|
|
// Expand range if this edge extends beyond current range
|
|
if (freqFrom < bandRanges[band].start_hz) {
|
|
bandRanges[band].start_hz = freqFrom;
|
|
}
|
|
if (freqTo > bandRanges[band].end_hz) {
|
|
bandRanges[band].end_hz = freqTo;
|
|
}
|
|
}
|
|
|
|
// Store raw band edges for mode indicators
|
|
if (!this.bandEdgesData[regionKey][band]) {
|
|
this.bandEdgesData[regionKey][band] = [];
|
|
}
|
|
this.bandEdgesData[regionKey][band].push({
|
|
frequencyfrom: freqFrom,
|
|
frequencyto: freqTo,
|
|
mode: edge.mode
|
|
});
|
|
}
|
|
}
|
|
|
|
// Convert to expected format
|
|
bandPlans[regionKey] = bandRanges;
|
|
return bandPlans;
|
|
},
|
|
|
|
// Get band limits for current band and region
|
|
getBandLimits: function() {
|
|
// Determine which band to use for limits
|
|
// Use currentSpotBand if available (the band we fetched spots for)
|
|
// Otherwise use frequency's band with 20kHz margin for tolerance
|
|
var bandToUse;
|
|
if (this.currentSpotBand && this.currentSpotBand !== 'All') {
|
|
bandToUse = this.currentSpotBand;
|
|
} else {
|
|
var middleFreq = this.getCachedMiddleFreq();
|
|
// Use 20kHz margin for band detection (extends band edges)
|
|
bandToUse = frequencyToBandKhz(middleFreq, 20);
|
|
if (bandToUse === 'All') {
|
|
return null; // Out of band and no spots loaded
|
|
}
|
|
}
|
|
|
|
var currentRegion = continentToRegion(this.currentContinent);
|
|
var regionKey = 'region' + currentRegion;
|
|
|
|
// Check if we need to update cache
|
|
if (this.bandLimitsCache &&
|
|
this.bandLimitsCache.band === bandToUse &&
|
|
this.bandLimitsCache.region === currentRegion) {
|
|
return this.bandLimitsCache.limits;
|
|
}
|
|
|
|
// Load band plans if not loaded yet
|
|
if (this.bandPlans === null) {
|
|
this.loadBandPlans();
|
|
return null; // Will be available on next refresh
|
|
}
|
|
|
|
// Check if still loading
|
|
if (this.bandPlans === 'loading') {
|
|
return null;
|
|
}
|
|
|
|
// Get limits from band plans
|
|
var limits = null;
|
|
if (this.bandPlans && this.bandPlans[regionKey]) {
|
|
if (this.bandPlans[regionKey][bandToUse]) {
|
|
var bandData = this.bandPlans[regionKey][bandToUse];
|
|
limits = {
|
|
start_khz: bandData.start_hz / 1000, // Convert Hz to kHz
|
|
end_khz: bandData.end_hz / 1000 // Convert Hz to kHz
|
|
};
|
|
}
|
|
}
|
|
|
|
// Cache the result
|
|
this.bandLimitsCache = {
|
|
band: bandToUse,
|
|
region: currentRegion,
|
|
limits: limits
|
|
};
|
|
|
|
return limits;
|
|
},
|
|
|
|
// ========================================
|
|
// CANVAS DRAWING AND RENDERING FUNCTIONS
|
|
// ========================================
|
|
|
|
// Draw band mode indicators (colored lines below ruler showing CW/DIGI/PHONE segments)
|
|
drawBandModeIndicators: function() {
|
|
// Use the band we have spots for, not the form selector
|
|
// This prevents drawing wrong band mode indicators when form is changed manually
|
|
var currentBand = this.currentSpotBand || this.getCurrentBand();
|
|
var currentRegion = continentToRegion(this.currentContinent);
|
|
var regionKey = 'region' + currentRegion;
|
|
|
|
// Check if we have band plans loaded
|
|
if (!this.bandPlans || this.bandPlans === 'loading' || !this.bandPlans[regionKey]) {
|
|
return;
|
|
}
|
|
|
|
// Get band edges from the raw data (we need mode information)
|
|
// We need to access the original band edges data with mode info
|
|
if (!this.bandEdgesData || !this.bandEdgesData[regionKey]) {
|
|
return;
|
|
}
|
|
|
|
var centerX = this.canvas.width / 2;
|
|
var middleFreq = this.getCachedMiddleFreq(); // In kHz
|
|
var pixelsPerKHz = this.getCachedPixelsPerKHz();
|
|
var rulerY = this.canvas.height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
|
|
// Determine which band to draw
|
|
// Use currentSpotBand if available (the band we fetched spots for)
|
|
// Otherwise use frequency's band with 20kHz margin for tolerance
|
|
var bandToDraw;
|
|
if (this.currentSpotBand && this.currentSpotBand !== 'All') {
|
|
bandToDraw = this.currentSpotBand;
|
|
} else {
|
|
// Use 20kHz margin for band detection (extends band edges)
|
|
bandToDraw = frequencyToBandKhz(middleFreq, 20);
|
|
if (bandToDraw === 'All') {
|
|
return; // Out of band and no spots loaded, don't draw
|
|
}
|
|
}
|
|
|
|
// Get band edges for the band to draw
|
|
var bandEdges = this.bandEdgesData[regionKey][bandToDraw];
|
|
if (!bandEdges || bandEdges.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Draw mode indicators as 2px lines below the ruler
|
|
this.ctx.lineWidth = 2;
|
|
var indicatorY = rulerY + 2; // 2px below the ruler line
|
|
|
|
for (var i = 0; i < bandEdges.length; i++) {
|
|
var edge = bandEdges[i];
|
|
var freqFromKhz = edge.frequencyfrom / 1000; // Convert Hz to kHz
|
|
var freqToKhz = edge.frequencyto / 1000;
|
|
var mode = edge.mode.toLowerCase();
|
|
|
|
// Calculate pixel positions
|
|
var startX = this.freqToPixel(freqFromKhz, centerX, middleFreq, pixelsPerKHz);
|
|
var endX = this.freqToPixel(freqToKhz, centerX, middleFreq, pixelsPerKHz);
|
|
|
|
// Clip to canvas bounds
|
|
startX = Math.max(0, Math.min(startX, this.canvas.width));
|
|
endX = Math.max(0, Math.min(endX, this.canvas.width));
|
|
|
|
// Only draw if visible on canvas
|
|
if (endX > 0 && startX < this.canvas.width && endX > startX) {
|
|
// Determine color based on mode
|
|
var color;
|
|
if (mode === 'cw') {
|
|
color = DX_WATERFALL_CONSTANTS.COLORS.SPOT_CW;
|
|
} else if (mode === 'digi' || mode === 'data') {
|
|
color = DX_WATERFALL_CONSTANTS.COLORS.SPOT_DIGI;
|
|
} else if (mode === 'phone' || mode === 'ssb' || mode === 'lsb' || mode === 'usb') {
|
|
color = DX_WATERFALL_CONSTANTS.COLORS.SPOT_PHONE;
|
|
} else {
|
|
// Unknown mode, skip
|
|
continue;
|
|
}
|
|
|
|
// Draw the mode indicator line
|
|
this.ctx.strokeStyle = color;
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(startX, indicatorY);
|
|
this.ctx.lineTo(endX, indicatorY);
|
|
this.ctx.stroke();
|
|
}
|
|
}
|
|
},
|
|
|
|
// Draw band limit overlays (out-of-band areas)
|
|
drawBandLimits: function() {
|
|
var bandLimits = this.getBandLimits();
|
|
|
|
// If no band limits available, don't draw anything
|
|
if (!bandLimits) {
|
|
return;
|
|
}
|
|
|
|
var centerX = this.canvas.width / 2;
|
|
var middleFreq = this.getCachedMiddleFreq(); // In kHz
|
|
var pixelsPerKHz = this.getCachedPixelsPerKHz();
|
|
|
|
var bandStart = bandLimits.start_khz;
|
|
var bandEnd = bandLimits.end_khz;
|
|
|
|
// Calculate pixel positions for band edges
|
|
var startX = this.freqToPixel(bandStart, centerX, middleFreq, pixelsPerKHz);
|
|
var endX = this.freqToPixel(bandEnd, centerX, middleFreq, pixelsPerKHz);
|
|
|
|
// Cache canvas dimensions for performance
|
|
var canvasWidth = this.canvas.width;
|
|
var canvasHeight = this.canvas.height;
|
|
|
|
// Draw left out-of-band area (below band start)
|
|
if (startX > 0) {
|
|
this.drawOutOfBandArea(0, 0, Math.min(startX, canvasWidth), canvasHeight, startX / 2, 'right');
|
|
}
|
|
|
|
// Draw right out-of-band area (above band end)
|
|
if (endX < canvasWidth) {
|
|
var rightStartX = Math.max(0, endX);
|
|
var rightWidth = canvasWidth - rightStartX;
|
|
this.drawOutOfBandArea(rightStartX, 0, rightWidth, canvasHeight, rightStartX + (rightWidth / 2), 'left');
|
|
}
|
|
},
|
|
|
|
// Helper function to draw out-of-band areas with text
|
|
drawOutOfBandArea: function(x, y, width, height, textCenterX, borderSide) {
|
|
// Clip to the area to keep stripes inside
|
|
this.ctx.save();
|
|
this.ctx.beginPath();
|
|
this.ctx.rect(x, y, width, height);
|
|
this.ctx.clip();
|
|
|
|
// Draw dark grey background
|
|
this.ctx.fillStyle = DX_WATERFALL_CONSTANTS.COLORS.OVERLAY_DARK_GREY;
|
|
this.ctx.fillRect(x, y, width, height);
|
|
|
|
// Draw red diagonal stripes pattern (clipped to area)
|
|
this.ctx.strokeStyle = DX_WATERFALL_CONSTANTS.COLORS.OUT_OF_BAND_BORDER_LIGHT;
|
|
this.ctx.lineWidth = 2;
|
|
var stripeSpacing = 15; // Distance between stripes
|
|
|
|
// Calculate stripe positions to cover the area
|
|
var maxDistance = Math.sqrt(width * width + height * height);
|
|
for (var i = -maxDistance; i < maxDistance; i += stripeSpacing) {
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(x + i, y);
|
|
this.ctx.lineTo(x + i + height, y + height);
|
|
this.ctx.stroke();
|
|
}
|
|
|
|
this.ctx.restore();
|
|
|
|
// Draw red border only on the side facing the valid band
|
|
this.ctx.strokeStyle = DX_WATERFALL_CONSTANTS.COLORS.OUT_OF_BAND_BORDER_DARK;
|
|
this.ctx.lineWidth = 3;
|
|
this.ctx.beginPath();
|
|
if (borderSide === 'right') {
|
|
// Border on the right edge (for left out-of-band area)
|
|
this.ctx.moveTo(x + width, y);
|
|
this.ctx.lineTo(x + width, y + height);
|
|
} else if (borderSide === 'left') {
|
|
// Border on the left edge (for right out-of-band area)
|
|
this.ctx.moveTo(x, y);
|
|
this.ctx.lineTo(x, y + height);
|
|
}
|
|
this.ctx.stroke();
|
|
|
|
// Add "OUT OF BANDPLAN" text if there's enough space
|
|
if (width > DX_WATERFALL_CONSTANTS.CANVAS.MIN_TEXT_AREA_WIDTH) {
|
|
// Calculate vertical center of waterfall area (excluding ruler at bottom)
|
|
var waterfallHeight = height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var textCenterY = y + (waterfallHeight / 2);
|
|
this.setCanvasTextStyle(this.ctx, DX_WATERFALL_CONSTANTS.FONTS.OUT_OF_BAND, DX_WATERFALL_CONSTANTS.COLORS.MESSAGE_TEXT_WHITE, 'center', 'middle');
|
|
this.ctx.fillText(lang_dxwaterfall_out_of_bandplan, textCenterX, textCenterY);
|
|
}
|
|
},
|
|
|
|
// Helper function to draw invalid frequency area (< 0 Hz)
|
|
drawInvalidArea: function(x, y, width, height, textCenterX) {
|
|
this.ctx.fillStyle = DX_WATERFALL_CONSTANTS.COLORS.OVERLAY_DARK_GREY;
|
|
this.ctx.fillRect(x, y, width, height);
|
|
|
|
// Add "INVALID" warning text if there's enough space
|
|
if (width > DX_WATERFALL_CONSTANTS.CANVAS.MIN_TEXT_AREA_WIDTH) {
|
|
// Calculate vertical center of waterfall area (excluding ruler at bottom)
|
|
var waterfallHeight = height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var textCenterY = y + (waterfallHeight / 2);
|
|
this.setCanvasTextStyle(this.ctx, 'bold 14px "Consolas", "Courier New", monospace', DX_WATERFALL_CONSTANTS.COLORS.MESSAGE_TEXT_WHITE, 'center', 'middle');
|
|
this.ctx.fillText(lang_dxwaterfall_invalid, textCenterX, textCenterY);
|
|
}
|
|
},
|
|
|
|
// Helper function to set canvas text styling
|
|
setCanvasTextStyle: function(ctx, font, color, align, baseline) {
|
|
ctx.font = font || DX_WATERFALL_CONSTANTS.FONTS.SMALL_MONO;
|
|
ctx.fillStyle = color || DX_WATERFALL_CONSTANTS.COLORS.MESSAGE_TEXT_WHITE;
|
|
ctx.textAlign = align || 'center';
|
|
ctx.textBaseline = baseline || 'middle';
|
|
},
|
|
|
|
// Helper function to convert frequency to pixel position
|
|
freqToPixel: function(frequency, centerX, middleFreq, pixelsPerKHz) {
|
|
return centerX + ((frequency - middleFreq) * pixelsPerKHz);
|
|
},
|
|
|
|
// ========================================
|
|
// DATA FETCHING AND AJAX FUNCTIONS
|
|
// ========================================
|
|
|
|
/**
|
|
* Fetch DX spots from the server with debouncing
|
|
* Retrieves spots based on current band, mode, and continent settings
|
|
* @param {boolean} immediate - If true, skip debouncing and fetch immediately
|
|
* @param {boolean} userInitiated - If true, this is a user-initiated fetch (show loading indicator)
|
|
* @returns {void}
|
|
*/
|
|
/**
|
|
* Fetch DX spots from server
|
|
* Transitions to FETCHING_SPOTS state during AJAX request
|
|
* @param {boolean} immediate - If true, fetch immediately. If false, debounce the request
|
|
* @param {boolean} userInitiated - True if user clicked refresh button
|
|
*/
|
|
fetchDxSpots: function(immediate, userInitiated) {
|
|
var self = this;
|
|
|
|
// Clear any existing debounce timer
|
|
if (this.fetchDebounceTimer) {
|
|
clearTimeout(this.fetchDebounceTimer);
|
|
this.fetchDebounceTimer = null;
|
|
}
|
|
|
|
// If not immediate, debounce the request
|
|
if (!immediate) {
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FETCH SPOTS: Debouncing for ' + this.fetchDebounceMs + 'ms');
|
|
this.fetchDebounceTimer = setTimeout(function() {
|
|
self.fetchDebounceTimer = null;
|
|
self.fetchDxSpots(true, userInitiated); // Pass userInitiated through
|
|
}, this.fetchDebounceMs);
|
|
return;
|
|
}
|
|
|
|
// Set userInitiatedFetch flag
|
|
this.userInitiatedFetch = userInitiated === true;
|
|
|
|
// Calculate band from current frequency with 20kHz margin for tolerance
|
|
var currentFreqKhz = this.getCachedMiddleFreq();
|
|
var band = null;
|
|
|
|
if (currentFreqKhz > 0) {
|
|
band = frequencyToBandKhz(currentFreqKhz, 20); // 20kHz margin
|
|
}
|
|
|
|
// If band is 'All' (out of band), don't fetch spots
|
|
if (!band || band === 'All' || band === '' || band.toLowerCase() === 'select') {
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FETCH SPOTS: Out of band, skipping spot fetch');
|
|
// Stay in current state or transition to ready if we were fetching
|
|
if (DXWaterfallStateMachine.getState() === DX_WATERFALL_CONSTANTS.STATES.FETCHING_SPOTS) {
|
|
this.stateMachine_setState(DX_WATERFALL_CONSTANTS.STATES.READY);
|
|
}
|
|
return;
|
|
}
|
|
|
|
var mode = "All"; // Fetch all modes
|
|
var age = 60; // minutes
|
|
var de = this.currentContinent; // Use current continent (may have been cycled)
|
|
|
|
// Check if dxwaterfall_maxage is defined
|
|
if (typeof dxwaterfall_maxage !== "undefined" && dxwaterfall_maxage != null) {
|
|
age = dxwaterfall_maxage;
|
|
}
|
|
|
|
// Store current settings
|
|
this.currentMaxAge = age;
|
|
|
|
// Check if we're already fetching for a DIFFERENT band - abort and start new fetch
|
|
// Otherwise block concurrent requests for the same band
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.FETCHING_SPOTS) {
|
|
// If fetching for a different band, abort and continue with new fetch
|
|
if (this.lastFetchBand !== band) {
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FETCH SPOTS: Band changed during fetch (' + this.lastFetchBand + ' → ' + band + '), aborting current request');
|
|
if (this.pendingFetchRequest) {
|
|
this.pendingFetchRequest.abort();
|
|
this.pendingFetchRequest = null;
|
|
}
|
|
// Continue with new fetch below
|
|
} else {
|
|
// Same band - skip to avoid duplicate requests
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FETCH SPOTS: Already fetching same band, skipping request');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check if we recently fetched the same data (band, continent, age)
|
|
// Skip if we fetched the same parameters within the fetch interval
|
|
if (this.lastFetchBand === band &&
|
|
this.lastFetchContinent === de &&
|
|
this.lastFetchAge === age &&
|
|
this.lastUpdateTime) {
|
|
var timeSinceLastFetch = Date.now() - this.lastUpdateTime.getTime();
|
|
if (timeSinceLastFetch < DX_WATERFALL_CONSTANTS.DEBOUNCE.DX_SPOTS_FETCH_INTERVAL_MS) {
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FETCH SPOTS: Recently fetched same data, skipping');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check if base_url is defined, if not use a default or skip
|
|
var baseUrl = (typeof base_url !== 'undefined') ? base_url : '';
|
|
if (!baseUrl) {
|
|
DX_WATERFALL_UTILS.log.error('[DX Waterfall] FETCH SPOTS: base_url not defined');
|
|
return;
|
|
}
|
|
|
|
var ajaxUrl = baseUrl + 'index.php/dxcluster/spots/' + band + '/' + age + '/' + de + '/' + mode;
|
|
|
|
// ========================================
|
|
// TRANSITION TO FETCHING_SPOTS STATE
|
|
// ========================================
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.FETCHING_SPOTS, {
|
|
band: band,
|
|
continent: de,
|
|
age: age,
|
|
userInitiated: userInitiated
|
|
});
|
|
|
|
// Force immediate refresh to show waiting message (if user-initiated)
|
|
if (userInitiated && this.canvas && this.ctx) {
|
|
this.refresh();
|
|
}
|
|
|
|
// Note: State machine handles timeout via setStateTimeout in _onStateEnter
|
|
|
|
// Abort any pending fetch request
|
|
if (this.pendingFetchRequest) {
|
|
this.pendingFetchRequest.abort();
|
|
this.pendingFetchRequest = null;
|
|
}
|
|
|
|
// Store the AJAX request so we can abort it if needed
|
|
this.pendingFetchRequest = $.ajax({
|
|
url: ajaxUrl,
|
|
type: 'GET',
|
|
dataType: 'json',
|
|
timeout: DX_WATERFALL_CONSTANTS.AJAX.TIMEOUT_MS,
|
|
cache: false,
|
|
success: function(data) {
|
|
// Clear the pending request reference
|
|
self.pendingFetchRequest = null;
|
|
|
|
// Check if band has changed since this fetch was initiated
|
|
// Compare against currentSpotBand (what we're displaying) not form selector
|
|
var currentDisplayBand = self.currentSpotBand || band;
|
|
if (band !== currentDisplayBand) {
|
|
// Band changed - this data is stale, fetch for current band
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FETCH SUCCESS: Band changed during fetch, refetching');
|
|
self.userInitiatedFetch = false; // Clear user-initiated flag for stale data
|
|
|
|
// Trigger immediate fetch for the correct (current) band
|
|
self.fetchDxSpots(true);
|
|
return;
|
|
}
|
|
|
|
if (data && !data.error) {
|
|
// Clean up spotter callsigns (remove -# suffix)
|
|
// Park references are already provided by server in dxcc_spotted object
|
|
for (var i = 0; i < data.length; i++) {
|
|
if (data[i].spotter) {
|
|
data[i].spotter = data[i].spotter.replace(/-#$/, '');
|
|
}
|
|
}
|
|
|
|
self.dxSpots = data;
|
|
self.totalSpotsCount = data.length;
|
|
self.lastUpdateTime = new Date(); // Record update time
|
|
|
|
// Track fetch parameters to prevent duplicate fetches
|
|
self.lastFetchBand = band;
|
|
self.lastFetchContinent = de;
|
|
self.lastFetchAge = age;
|
|
|
|
// Track which band we currently have spots for
|
|
self.currentSpotBand = band;
|
|
|
|
// Invalidate caches when spots are updated
|
|
self.cache.visibleSpots = null;
|
|
self.cache.visibleSpotsParams = null;
|
|
self.relevantSpots = [];
|
|
|
|
self.collectAllBandSpots(true); // Update band spot collection for navigation (force after data fetch)
|
|
self.collectSmartHunterSpots(); // Update smart hunter spots collection
|
|
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FETCH SUCCESS: Received ' + data.length + ' spots for ' + band);
|
|
} else {
|
|
// No spots or error in response (e.g., {"error": "not found"})
|
|
self.dxSpots = [];
|
|
self.totalSpotsCount = 0;
|
|
self.lastUpdateTime = new Date(); // Record update time even on error
|
|
|
|
// Track fetch parameters to prevent duplicate fetches
|
|
self.lastFetchBand = band;
|
|
self.lastFetchContinent = de;
|
|
self.lastFetchAge = age;
|
|
|
|
// Invalidate caches when spots are cleared
|
|
self.cache.visibleSpots = null;
|
|
self.cache.visibleSpotsParams = null;
|
|
self.relevantSpots = [];
|
|
|
|
self.allBandSpots = []; // Clear band spots
|
|
self.currentBandSpotIndex = 0;
|
|
self.smartHunterSpots = []; // Clear smart hunter spots
|
|
self.currentSmartHunterIndex = 0;
|
|
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FETCH SUCCESS: No spots for ' + band);
|
|
}
|
|
|
|
// Clear user-initiated flag
|
|
self.userInitiatedFetch = false;
|
|
|
|
// Clear operation timer to prevent stale timer display
|
|
self.operationStartTime = null;
|
|
|
|
// ========================================
|
|
// TRANSITION BACK TO READY STATE
|
|
// ========================================
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.READY);
|
|
|
|
// Force menu update after data fetch
|
|
self.updateZoomMenu(true);
|
|
|
|
// Trigger refresh to display new data
|
|
self.refresh();
|
|
},
|
|
error: function(xhr, status, error) {
|
|
// Clear the pending request reference
|
|
self.pendingFetchRequest = null;
|
|
|
|
// Check if this was an intentional abort (e.g., during waterfall disable)
|
|
if (status === 'abort') {
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] FETCH ABORTED: Request was intentionally cancelled');
|
|
// Don't transition to ERROR state - this is expected
|
|
return;
|
|
}
|
|
|
|
// AJAX request failed
|
|
DX_WATERFALL_UTILS.log.error('[DX Waterfall] FETCH ERROR: status=' + status + ', error=' + error + ', readyState=' + xhr.readyState);
|
|
|
|
// Clear user-initiated flag
|
|
self.userInitiatedFetch = false;
|
|
|
|
// Clear data
|
|
self.dxSpots = [];
|
|
self.totalSpotsCount = 0;
|
|
|
|
// Invalidate caches on error
|
|
self.cache.visibleSpots = null;
|
|
self.cache.visibleSpotsParams = null;
|
|
self.relevantSpots = [];
|
|
|
|
self.allBandSpots = []; // Clear band spots
|
|
self.currentBandSpotIndex = 0;
|
|
self.smartHunterSpots = []; // Clear smart hunter spots
|
|
self.currentSmartHunterIndex = 0;
|
|
|
|
// ========================================
|
|
// TRANSITION TO ERROR STATE
|
|
// ========================================
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.ERROR, {
|
|
message: 'Failed to fetch spots: ' + error
|
|
});
|
|
|
|
// Populate menu even after error (so user can still interact)
|
|
self.updateZoomMenu();
|
|
}
|
|
});
|
|
},
|
|
|
|
// Get current band from form or default to 20m
|
|
getCurrentBand: function() {
|
|
// Safety check: return default if not initialized
|
|
if (!this.$bandSelect) {
|
|
return '20m';
|
|
}
|
|
// Try to get band from form - adjust selector based on your HTML structure
|
|
var band = this.$bandSelect.val() || '20m';
|
|
return band;
|
|
},
|
|
|
|
// Get current mode from form or default to All
|
|
getCurrentMode: function() {
|
|
// Prefer CAT state if available (radio controls mode)
|
|
if (window.catState && window.catState.mode) {
|
|
return window.catState.mode;
|
|
}
|
|
|
|
// Fallback to form field if CAT not available
|
|
// Safety check: return default if not initialized
|
|
if (!this.$modeSelect) {
|
|
return 'All';
|
|
}
|
|
var mode = this.$modeSelect.val() || 'All';
|
|
return mode;
|
|
},
|
|
|
|
// Quick dimension update to prevent stretching - no redraw
|
|
updateDimensions: function() {
|
|
if (this.canvas) {
|
|
var currentWidth = this.canvas.offsetWidth;
|
|
var currentHeight = this.canvas.offsetHeight;
|
|
|
|
if (this.canvas.width !== currentWidth || this.canvas.height !== currentHeight) {
|
|
this.canvas.width = currentWidth;
|
|
this.canvas.height = currentHeight;
|
|
// Reset noise cache when dimensions change
|
|
this.cache.noise1 = null;
|
|
this.cache.noise2 = null;
|
|
}
|
|
}
|
|
},
|
|
|
|
// Generate and cache static noise patterns for animation
|
|
generateCachedNoise: function() {
|
|
var width = this.canvas.width;
|
|
var height = this.canvas.height;
|
|
|
|
// Only regenerate if canvas dimensions changed or first time
|
|
if (this.cache.noiseWidth !== width || this.cache.noiseHeight !== height || !this.cache.noise1) {
|
|
this.cache.noiseWidth = width;
|
|
this.cache.noiseHeight = height;
|
|
|
|
// Generate first noise pattern
|
|
var imageData1 = this.ctx.createImageData(width, height);
|
|
var data1 = imageData1.data;
|
|
|
|
for (var i = 0; i < data1.length; i += 4) {
|
|
// Start with dark background using constants
|
|
var baseR = DX_WATERFALL_CONSTANTS.COLORS.STATIC_NOISE_RGB.R;
|
|
var baseG = DX_WATERFALL_CONSTANTS.COLORS.STATIC_NOISE_RGB.G;
|
|
var baseB = DX_WATERFALL_CONSTANTS.COLORS.STATIC_NOISE_RGB.B;
|
|
|
|
// Generate random noise values
|
|
var noise = Math.random() * 80; // 0-80 intensity
|
|
|
|
// Add subtle blueish noise tint to the dark background
|
|
data1[i] = baseR + (noise * 0.3); // Red channel
|
|
data1[i + 1] = baseG + (noise * 0.5); // Green channel
|
|
data1[i + 2] = baseB + (noise * 0.4); // Blue channel
|
|
data1[i + 3] = 255; // Fully opaque
|
|
}
|
|
this.cache.noise1 = imageData1;
|
|
|
|
// Generate second noise pattern
|
|
var imageData2 = this.ctx.createImageData(width, height);
|
|
var data2 = imageData2.data;
|
|
|
|
for (var i = 0; i < data2.length; i += 4) {
|
|
// Start with dark background using constants
|
|
var baseR = DX_WATERFALL_CONSTANTS.COLORS.STATIC_NOISE_RGB.R;
|
|
var baseG = DX_WATERFALL_CONSTANTS.COLORS.STATIC_NOISE_RGB.G;
|
|
var baseB = DX_WATERFALL_CONSTANTS.COLORS.STATIC_NOISE_RGB.B;
|
|
|
|
// Generate random noise values
|
|
var noise = Math.random() * 80; // 0-80 intensity
|
|
|
|
// Add subtle blueish noise tint to the dark background
|
|
data2[i] = baseR + (noise * 0.3); // Red channel
|
|
data2[i + 1] = baseG + (noise * 0.5); // Green channel
|
|
data2[i + 2] = baseB + (noise * 0.4); // Blue channel
|
|
data2[i + 3] = 255; // Fully opaque
|
|
}
|
|
this.cache.noise2 = imageData2;
|
|
}
|
|
},
|
|
|
|
// Draw static noise background (cached and animated)
|
|
drawStaticNoise: function() {
|
|
try {
|
|
// Generate cached noise only if needed (dimensions changed or first time)
|
|
this.generateCachedNoise();
|
|
|
|
// Alternate between noise patterns for animation effect
|
|
var noiseToUse = (this.cache.currentNoiseFrame === 0) ? this.cache.noise1 : this.cache.noise2;
|
|
this.ctx.putImageData(noiseToUse, 0, 0);
|
|
|
|
// Switch to next frame for next refresh
|
|
this.cache.currentNoiseFrame = 1 - this.cache.currentNoiseFrame; // Toggle between 0 and 1
|
|
} catch (e) {
|
|
DX_WATERFALL_UTILS.log.error('[DX Waterfall] Error drawing static noise:', e);
|
|
// Fall back to simple black background
|
|
this.ctx.fillStyle = DX_WATERFALL_CONSTANTS.COLORS.BLACK;
|
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
}
|
|
},
|
|
|
|
// Draw Wavelog logo centered above message
|
|
drawWavelogLogo: function(centerX, logoY, opacity) {
|
|
var self = this;
|
|
|
|
// opacity: 0.0 to 1.0 (optional, defaults to 1.0 for full opacity)
|
|
if (typeof opacity === 'undefined') {
|
|
opacity = 1.0;
|
|
}
|
|
|
|
// Ensure canvas context exists before proceeding
|
|
if (!this.ctx || !this.canvas) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Create image object if it doesn't exist or reuse existing one
|
|
if (!this.wavelogLogoImage) {
|
|
this.wavelogLogoImage = new Image();
|
|
this.wavelogLogoImage.onload = function() {
|
|
self.wavelogLogoImage.loaded = true;
|
|
};
|
|
this.wavelogLogoImage.onerror = function() {
|
|
DX_WATERFALL_UTILS.log.error('Failed to load Wavelog logo');
|
|
};
|
|
// Get base URL from global variable or construct it
|
|
var baseUrl = (typeof base_url !== 'undefined') ? base_url : '';
|
|
this.wavelogLogoImage.src = baseUrl + DX_WATERFALL_CONSTANTS.LOGO_FILENAME;
|
|
}
|
|
|
|
// Draw logo if it's loaded, ensuring it stays within canvas bounds
|
|
if (this.wavelogLogoImage.loaded) {
|
|
var logoWidth = 140;
|
|
var logoHeight = this.wavelogLogoImage.height * (logoWidth / this.wavelogLogoImage.width);
|
|
var logoX = centerX - (logoWidth / 2);
|
|
|
|
// Clamp logo position to stay within canvas bounds
|
|
logoX = Math.max(0, Math.min(logoX, this.canvas.width - logoWidth));
|
|
logoY = Math.max(0, Math.min(logoY, this.canvas.height - logoHeight));
|
|
|
|
// Save canvas state to ensure logo doesn't affect other drawings
|
|
this.ctx.save();
|
|
this.ctx.globalAlpha = opacity;
|
|
this.ctx.drawImage(this.wavelogLogoImage, logoX, logoY, logoWidth, logoHeight);
|
|
this.ctx.restore();
|
|
}
|
|
} catch (e) {
|
|
DX_WATERFALL_UTILS.log.error('[DX Waterfall] Error drawing logo:', e);
|
|
// Silently fail - logo is non-critical
|
|
}
|
|
}, // Display waiting message with black overlay and spinner
|
|
displayWaitingMessage: function(customMessage) {
|
|
if (!this.canvas) {
|
|
return;
|
|
}
|
|
|
|
// Don't clear zoom menu here - let updateZoomMenu() handle it
|
|
// This prevents brief empty states when displayWaitingMessage() is called
|
|
// followed immediately by updateZoomMenu()
|
|
|
|
// Update canvas dimensions to match current CSS dimensions
|
|
this.updateDimensions();
|
|
|
|
// Draw semi-transparent black overlay over current content
|
|
DX_WATERFALL_UTILS.drawing.drawOverlay(this.ctx, this.canvas.width, this.canvas.height, 'OVERLAY_BACKGROUND');
|
|
|
|
// Calculate center position
|
|
var centerX = this.canvas.width / 2;
|
|
var centerY = this.canvas.height / 2;
|
|
|
|
// Draw pulsing Wavelog logo above the message
|
|
var logoY = centerY - DX_WATERFALL_CONSTANTS.CANVAS.LOGO_OFFSET_Y;
|
|
// Calculate pulsing opacity (0.5 to 1.0 for smooth fade effect)
|
|
var pulseOpacity = 0.75 + 0.25 * Math.sin(Date.now() / 300);
|
|
this.drawWavelogLogo(centerX, logoY, pulseOpacity);
|
|
|
|
// Text position (moved down lower for more space)
|
|
var textY = centerY + DX_WATERFALL_CONSTANTS.CANVAS.TEXT_OFFSET_Y;
|
|
|
|
// Use custom message if provided, otherwise default to downloading data
|
|
var message = customMessage || lang_dxwaterfall_downloading_data;
|
|
|
|
// Draw waiting message
|
|
DX_WATERFALL_UTILS.drawing.drawCenteredText(this.ctx, message, centerX, textY, 'WAITING_MESSAGE', 'MESSAGE_TEXT_WHITE');
|
|
|
|
// Reset opacity
|
|
this.ctx.globalAlpha = 1.0;
|
|
},
|
|
|
|
// Get pixels per kHz based on current mode and zoom level
|
|
getPixelsPerKHz: function() {
|
|
// Calculate pixels per kHz based on zoom level with better scaling
|
|
// Level-specific scaling for better usability
|
|
var pixelsPerKHz;
|
|
switch(this.currentZoomLevel) {
|
|
case 0:
|
|
pixelsPerKHz = 2; // ±50 kHz range (widest)
|
|
break;
|
|
case 1:
|
|
pixelsPerKHz = 4; // ±25 kHz range
|
|
break;
|
|
case 2:
|
|
pixelsPerKHz = 8; // ±12.5 kHz range
|
|
break;
|
|
case 3:
|
|
pixelsPerKHz = 20; // ±5 kHz range (default, more zoomed)
|
|
break;
|
|
case 4:
|
|
pixelsPerKHz = 32; // ±3.125 kHz range
|
|
break;
|
|
case 5:
|
|
pixelsPerKHz = 50; // ±2 kHz range (max zoom)
|
|
break;
|
|
default:
|
|
pixelsPerKHz = 20; // Default to level 3
|
|
}
|
|
|
|
return pixelsPerKHz;
|
|
},
|
|
|
|
// Draw frequency ruler at bottom
|
|
drawFrequencyRuler: function() {
|
|
var rulerY = this.canvas.height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var centerX = this.canvas.width / 2;
|
|
var middleFreq = this.getCachedMiddleFreq(); // Use cached frequency
|
|
var currentMode = this.getCurrentMode().toLowerCase();
|
|
|
|
// Draw background bar for ruler
|
|
this.ctx.fillStyle = DX_WATERFALL_CONSTANTS.COLORS.RULER_BACKGROUND;
|
|
this.ctx.fillRect(0, rulerY, this.canvas.width, DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT);
|
|
|
|
// Use consistent scale for all modes
|
|
var pixelsPerKHz = this.getCachedPixelsPerKHz(); // Use cached scaling
|
|
var tickInterval = 1; // 1 kHz intervals for all modes
|
|
var majorTickInterval = 5; // 5 kHz major ticks for all modes
|
|
|
|
// Draw main ruler line
|
|
this.ctx.strokeStyle = DX_WATERFALL_CONSTANTS.COLORS.RULER_LINE;
|
|
this.ctx.lineWidth = 2;
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(0, rulerY);
|
|
this.ctx.lineTo(this.canvas.width, rulerY);
|
|
this.ctx.stroke();
|
|
|
|
// Draw band mode indicators (colored lines showing CW/DIGI/PHONE segments)
|
|
this.drawBandModeIndicators();
|
|
|
|
// Calculate frequency range based on canvas width
|
|
var halfWidthKHz = (this.canvas.width / 2) / pixelsPerKHz;
|
|
var startFreq = middleFreq - halfWidthKHz;
|
|
var endFreq = middleFreq + halfWidthKHz;
|
|
|
|
// Draw ticks and labels
|
|
this.ctx.lineWidth = 1;
|
|
this.ctx.fillStyle = DX_WATERFALL_CONSTANTS.COLORS.RULER_TEXT;
|
|
this.ctx.font = DX_WATERFALL_CONSTANTS.FONTS.RULER;
|
|
this.ctx.textAlign = 'center';
|
|
this.ctx.textBaseline = 'top';
|
|
|
|
// Calculate tick positions
|
|
var startTick = Math.floor(startFreq / tickInterval) * tickInterval;
|
|
var endTick = Math.ceil(endFreq / tickInterval) * tickInterval;
|
|
|
|
// For very wide zoom levels, track which major tick we're on to skip labels
|
|
var majorTickCounter = 0;
|
|
|
|
for (var freq = startTick; freq <= endTick; freq += tickInterval) {
|
|
// Round to avoid floating point precision issues
|
|
freq = Math.round(freq * 10) / 10;
|
|
|
|
// Skip frequencies <= 0 (not physically valid)
|
|
if (freq <= 0) {
|
|
continue;
|
|
}
|
|
|
|
var x = centerX + (freq - middleFreq) * pixelsPerKHz;
|
|
|
|
if (x >= 0 && x <= this.canvas.width) {
|
|
var isMajorTick = (Math.abs(freq % majorTickInterval) < DX_WATERFALL_CONSTANTS.THRESHOLDS.MAJOR_TICK_TOLERANCE);
|
|
var tickHeight = isMajorTick ? 10 : 5;
|
|
|
|
// Draw tick (only below the ruler line)
|
|
this.ctx.strokeStyle = DX_WATERFALL_CONSTANTS.COLORS.RULER_LINE;
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(x, rulerY);
|
|
this.ctx.lineTo(x, rulerY + tickHeight);
|
|
this.ctx.stroke();
|
|
|
|
// Draw frequency label for major ticks
|
|
if (isMajorTick) {
|
|
// For very wide zoom levels, skip labels to reduce clutter
|
|
var shouldShowLabel = true;
|
|
if (this.currentZoomLevel === 0) {
|
|
// Zoom level 0: show every 4th label (widest view)
|
|
shouldShowLabel = (majorTickCounter % 4 === 0);
|
|
majorTickCounter++;
|
|
} else if (this.currentZoomLevel === 1) {
|
|
// Zoom level 1: show every 2nd label
|
|
shouldShowLabel = (majorTickCounter % 2 === 0);
|
|
majorTickCounter++;
|
|
}
|
|
|
|
if (shouldShowLabel) {
|
|
var labelText = freq.toString();
|
|
this.ctx.font = DX_WATERFALL_CONSTANTS.FONTS.RULER;
|
|
this.ctx.fillText(labelText, x, rulerY + 14);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Draw grey overlay on the left side for frequencies <= 0 (invalid range)
|
|
if (startFreq <= 0) {
|
|
var zeroFreqX = this.freqToPixel(0, centerX, middleFreq, pixelsPerKHz);
|
|
if (zeroFreqX > 0) {
|
|
this.drawInvalidArea(0, 0, zeroFreqX, this.canvas.height, zeroFreqX / 2);
|
|
}
|
|
}
|
|
},
|
|
|
|
// Draw receiving bandwidth indicator
|
|
drawReceivingBandwidth: function() {
|
|
// Skip bandwidth indicator if disabled in display config
|
|
if (!this.displayConfig.showBandwidthIndicator) {
|
|
return;
|
|
}
|
|
|
|
var centerX = this.canvas.width / 2;
|
|
var rulerY = this.canvas.height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var middleFreq = this.getCachedMiddleFreq(); // Use cached frequency
|
|
var pixelsPerKHz = this.getCachedPixelsPerKHz(); // Use cached scaling
|
|
var currentMode = this.getCurrentMode().toLowerCase();
|
|
|
|
// Use the same bandwidth logic as DX spots for consistency (with caching)
|
|
var bandwidthParams = this.getCachedBandwidthParams(currentMode, middleFreq);
|
|
var bandwidthKHz = bandwidthParams.bandwidth;
|
|
var offsetKHz = bandwidthParams.offset;
|
|
|
|
// Only draw bandwidth indicator for phone and CW modes (not for digital modes)
|
|
var modeCategory = getModeCategory(currentMode) || 'other';
|
|
if (modeCategory !== 'phone' && modeCategory !== 'cw') {
|
|
return; // No bandwidth indicator for digital modes
|
|
}
|
|
|
|
// Calculate pixel positions
|
|
var bandwidthPixels = bandwidthKHz * pixelsPerKHz;
|
|
var offsetPixels = offsetKHz * pixelsPerKHz;
|
|
var startX = centerX + offsetPixels - (bandwidthPixels / 2);
|
|
var endX = startX + bandwidthPixels;
|
|
|
|
// Ensure we stay within canvas bounds
|
|
startX = Math.max(0, startX);
|
|
endX = Math.min(this.canvas.width, endX);
|
|
|
|
if (startX < endX) {
|
|
// Draw semi-transparent rectangle from red line to ruler
|
|
this.ctx.fillStyle = DX_WATERFALL_CONSTANTS.COLORS.BANDWIDTH_INDICATOR;
|
|
this.ctx.fillRect(startX, 0, endX - startX, rulerY);
|
|
}
|
|
},
|
|
|
|
// Draw center line marker(s) - uses displayConfig mapping
|
|
drawCenterMarker: function() {
|
|
var centerX = this.canvas.width / 2;
|
|
var rulerY = this.canvas.height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var pixelsPerKhz = this.getPixelsPerKHz();
|
|
var centerFreq = this.displayConfig.centerFrequency;
|
|
|
|
// Draw all configured markers
|
|
for (var i = 0; i < this.displayConfig.markers.length; i++) {
|
|
var marker = this.displayConfig.markers[i];
|
|
|
|
// Calculate marker position relative to center frequency
|
|
var offset = (marker.frequency - centerFreq) * pixelsPerKhz;
|
|
var markerX = centerX + offset;
|
|
|
|
// Check if marker is within canvas bounds
|
|
if (markerX >= 0 && markerX <= this.canvas.width) {
|
|
// Draw marker line
|
|
this.ctx.strokeStyle = marker.color;
|
|
this.ctx.lineWidth = 2;
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(markerX, 0);
|
|
this.ctx.lineTo(markerX, rulerY + 5);
|
|
this.ctx.stroke();
|
|
} else if (this.displayConfig.isSplit && marker.label === 'TX') {
|
|
// In split mode, if TX marker is off-screen, draw arrow indicator on ruler
|
|
var arrowY = rulerY + 10; // Position on ruler
|
|
var arrowSize = 6;
|
|
|
|
this.ctx.fillStyle = marker.color;
|
|
this.ctx.beginPath();
|
|
|
|
if (markerX < 0) {
|
|
// TX is to the left (below current view) - draw left-pointing arrow on left side
|
|
var arrowX = 10;
|
|
this.ctx.moveTo(arrowX + arrowSize, arrowY - arrowSize);
|
|
this.ctx.lineTo(arrowX, arrowY);
|
|
this.ctx.lineTo(arrowX + arrowSize, arrowY + arrowSize);
|
|
} else {
|
|
// TX is to the right (above current view) - draw right-pointing arrow on right side
|
|
var arrowX = this.canvas.width - 10;
|
|
this.ctx.moveTo(arrowX - arrowSize, arrowY - arrowSize);
|
|
this.ctx.lineTo(arrowX, arrowY);
|
|
this.ctx.lineTo(arrowX - arrowSize, arrowY + arrowSize);
|
|
}
|
|
|
|
this.ctx.closePath();
|
|
this.ctx.fill();
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Get bandwidth parameters for a given mode and frequency (WATERFALL VISUALIZATION SPECIFIC)
|
|
* Returns the signal bandwidth and frequency offset for proper signal visualization
|
|
* Uses getSignalBandwidth() from radiohelpers.js for bandwidth, adds offset for sideband drawing
|
|
*
|
|
* @param {string} mode - The transmission mode (e.g., 'LSB', 'USB', 'FT8', 'CW')
|
|
* @param {number} frequency - Frequency in kHz
|
|
* @returns {{bandwidth: number, offset: number}} Object with bandwidth (in kHz) and offset (in kHz)
|
|
* - bandwidth: Width of the signal in kHz
|
|
* - offset: Frequency offset from carrier (negative for LSB, positive for USB, 0 for centered)
|
|
*/
|
|
getBandwidthParams: function(mode, frequency) {
|
|
var freq = parseFloat(frequency) || 0;
|
|
|
|
// Get bandwidth from global function (handles all modes consistently)
|
|
var bandwidth = getSignalBandwidth(mode);
|
|
|
|
// Phone modes with sideband behavior need offset calculation
|
|
// Use isPhoneMode() to check if mode is phone/voice (more robust than string comparison)
|
|
if (isPhoneMode(mode)) {
|
|
var modeUpper = mode.toUpperCase();
|
|
|
|
// AM and FM span both sides of carrier (like CW) - centered with no offset
|
|
if (modeUpper === 'AM' || modeUpper === 'FM' || modeUpper === 'SAM' ||
|
|
modeUpper === 'DSB' || modeUpper === 'A3E') {
|
|
return { bandwidth: bandwidth, offset: 0 };
|
|
}
|
|
|
|
// If mode explicitly specifies LSB or USB, use that
|
|
if (modeUpper === 'LSB') {
|
|
return { bandwidth: bandwidth, offset: -bandwidth / 2 };
|
|
} else if (modeUpper === 'USB') {
|
|
return { bandwidth: bandwidth, offset: bandwidth / 2 };
|
|
}
|
|
|
|
// For generic phone/SSB mode, determine based on frequency
|
|
// This handles cases where the mode is just "Phone" or "SSB" without explicit sideband
|
|
var ssbMode = determineSSBMode(freq);
|
|
if (ssbMode === 'LSB') {
|
|
return { bandwidth: bandwidth, offset: -bandwidth / 2 };
|
|
} else { // USB
|
|
return { bandwidth: bandwidth, offset: bandwidth / 2 };
|
|
}
|
|
}
|
|
|
|
// All other modes (CW, digital, etc.) are centered (offset = 0)
|
|
return { bandwidth: bandwidth, offset: 0 };
|
|
},
|
|
|
|
// Draw bandwidth indicators for DX spots
|
|
drawDxSpotBandwidths: function() {
|
|
if (!this.dxSpots || this.dxSpots.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Cache frequently accessed properties
|
|
var canvasWidth = this.canvas.width;
|
|
var canvasHeight = this.canvas.height;
|
|
var centerX = canvasWidth / 2;
|
|
var rulerY = canvasHeight - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var rulerBottom = rulerY + DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT; // Precalculate for loop
|
|
var middleFreq = this.getCachedMiddleFreq(); // Use cached frequency
|
|
var pixelsPerKHz = this.getCachedPixelsPerKHz(); // Use cached scaling
|
|
|
|
for (var i = 0, spotsLen = this.dxSpots.length; i < spotsLen; i++) {
|
|
var spot = this.dxSpots[i];
|
|
var spotFreq = parseFloat(spot.frequency);
|
|
|
|
if (spotFreq && spot.spotted && spot.mode) {
|
|
// Apply mode filter
|
|
var matchesFilter = this.spotMatchesModeFilter(spot);
|
|
if (!matchesFilter) {
|
|
continue;
|
|
}
|
|
|
|
// Get detailed submode information for consistent classification
|
|
var submodeInfo = classifyMode(spot);
|
|
var classifiedMode = submodeInfo.category;
|
|
|
|
// Determine mode for bandwidth calculation using utility functions
|
|
var modeForBandwidth = spot.mode.toLowerCase();
|
|
|
|
// Use submode if available, otherwise use category
|
|
if (submodeInfo.submode) {
|
|
modeForBandwidth = submodeInfo.submode.toLowerCase();
|
|
} else {
|
|
var utilityCategory = getModeCategory(spot.mode) || 'other';
|
|
if (utilityCategory === 'cw') {
|
|
modeForBandwidth = 'cw';
|
|
} else if (utilityCategory === 'phone' && modeForBandwidth !== 'lsb' && modeForBandwidth !== 'usb') {
|
|
// If classified as phone but mode isn't specific, use 'phone' to trigger freq-based LSB/USB
|
|
modeForBandwidth = 'phone';
|
|
} else if (utilityCategory === 'digi') {
|
|
modeForBandwidth = 'digi'; // Generic digital
|
|
}
|
|
}
|
|
|
|
var bandwidthParams = this.getCachedBandwidthParams(modeForBandwidth, spotFreq);
|
|
var bandwidthKHz = bandwidthParams.bandwidth;
|
|
var offsetKHz = bandwidthParams.offset;
|
|
|
|
// Calculate pixel positions
|
|
var spotCenterFreq = spotFreq + offsetKHz; // Center frequency of bandwidth
|
|
var freqOffset = spotCenterFreq - middleFreq;
|
|
var bandwidthPixels = bandwidthKHz * pixelsPerKHz;
|
|
var centerX_spot = centerX + (freqOffset * pixelsPerKHz);
|
|
|
|
var startX = centerX_spot - (bandwidthPixels / 2);
|
|
var endX = startX + bandwidthPixels;
|
|
|
|
// Calculate exact frequency line position
|
|
var exactFreqX = centerX + ((spotFreq - middleFreq) * pixelsPerKHz);
|
|
|
|
// Draw if any part is within canvas bounds
|
|
if (endX > 0 && startX < canvasWidth) {
|
|
// Clip to canvas bounds
|
|
startX = Math.max(0, startX);
|
|
endX = Math.min(canvasWidth, endX);
|
|
|
|
// Draw gradient sideband based on classified mode and bandwidth mode
|
|
this.drawSidebandGradient(startX, endX, rulerBottom, spotFreq, modeForBandwidth, exactFreqX, classifiedMode);
|
|
}
|
|
|
|
// Draw exact frequency line with color based on mode
|
|
if (exactFreqX >= 0 && exactFreqX <= canvasWidth) {
|
|
var lineColor = DX_WATERFALL_UTILS.modes.getModeColor(classifiedMode, 0.6);
|
|
|
|
this.ctx.strokeStyle = lineColor;
|
|
this.ctx.lineWidth = 1;
|
|
this.ctx.beginPath();
|
|
this.ctx.moveTo(exactFreqX, 0);
|
|
this.ctx.lineTo(exactFreqX, rulerBottom);
|
|
this.ctx.stroke();
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
// Draw static gradient sideband for DX spots
|
|
drawSidebandGradient: function(startX, endX, height, spotFreq, mode, exactFreqX, classifiedMode) {
|
|
// Determine base color based on classified mode (passed from caller)
|
|
var baseColor = DX_WATERFALL_UTILS.modes.getModeColorBase(classifiedMode);
|
|
|
|
// Determine sideband type
|
|
var modeStr = mode.toLowerCase();
|
|
var sidebandType = 'centered'; // Default to centered
|
|
|
|
// For phone/ssb modes, determine actual sideband based on frequency
|
|
if (modeStr === 'phone' || modeStr === 'ssb') {
|
|
var freq = parseFloat(spotFreq);
|
|
sidebandType = determineSSBMode(freq).toLowerCase();
|
|
} else if (modeStr === 'lsb' || modeStr === 'usb') {
|
|
sidebandType = modeStr;
|
|
}
|
|
// AM and FM stay as 'centered' (default)
|
|
// CW, digital modes also stay as 'centered' (default)
|
|
|
|
// Create gradient based on sideband type
|
|
var gradient;
|
|
if (sidebandType === 'lsb') {
|
|
// LSB: Most visible at spot frequency, fade to the left (lower frequencies)
|
|
gradient = this.ctx.createLinearGradient(exactFreqX, 0, startX, 0);
|
|
gradient.addColorStop(0, baseColor + '0.6)'); // Strong at spot frequency
|
|
gradient.addColorStop(1, baseColor + '0.1)'); // Fade at left edge
|
|
} else if (sidebandType === 'usb') {
|
|
// USB: Most visible at spot frequency, fade to the right (higher frequencies)
|
|
gradient = this.ctx.createLinearGradient(exactFreqX, 0, endX, 0);
|
|
gradient.addColorStop(0, baseColor + '0.6)'); // Strong at spot frequency
|
|
gradient.addColorStop(1, baseColor + '0.1)'); // Fade at right edge
|
|
} else {
|
|
// CW, digital modes (FT8, FT4, RTTY, digi), and other centered modes
|
|
// Create horizontal linear gradient from center outward
|
|
gradient = this.ctx.createLinearGradient(startX, 0, endX, 0);
|
|
gradient.addColorStop(0, baseColor + '0.1)'); // Fade at left edge
|
|
gradient.addColorStop(0.5, baseColor + '0.6)'); // Strong at center
|
|
gradient.addColorStop(1, baseColor + '0.1)'); // Fade at right edge
|
|
}
|
|
|
|
// Apply gradient and draw
|
|
this.ctx.fillStyle = gradient;
|
|
this.ctx.fillRect(startX, 0, endX - startX, height);
|
|
},
|
|
|
|
/**
|
|
* Get colors for spot label based on worked/confirmed status
|
|
* @param {Object} spot - Spot object with status flags
|
|
* @returns {Object} {bgColor, borderColor, tickboxColor}
|
|
*/
|
|
getSpotColors: function(spot) {
|
|
return {
|
|
bgColor: spot.cnfmd_dxcc ? DX_WATERFALL_CONSTANTS.COLORS.GREEN : (spot.worked_dxcc ? DX_WATERFALL_CONSTANTS.COLORS.ORANGE : DX_WATERFALL_CONSTANTS.COLORS.RED),
|
|
borderColor: spot.cnfmd_continent ? DX_WATERFALL_CONSTANTS.COLORS.GREEN : (spot.worked_continent ? DX_WATERFALL_CONSTANTS.COLORS.ORANGE : DX_WATERFALL_CONSTANTS.COLORS.RED),
|
|
tickboxColor: spot.cnfmd_call ? DX_WATERFALL_CONSTANTS.COLORS.GREEN : (spot.worked_call ? DX_WATERFALL_CONSTANTS.COLORS.ORANGE : DX_WATERFALL_CONSTANTS.COLORS.RED)
|
|
};
|
|
},
|
|
|
|
// Draw DX spots if available
|
|
drawDxSpots: function() {
|
|
if (!this.dxSpots || this.dxSpots.length === 0) {
|
|
return; // No spots to draw
|
|
}
|
|
|
|
// Clear position data from ALL spots before drawing
|
|
// This ensures filtered-out spots don't remain hoverable/clickable
|
|
for (var i = 0; i < this.dxSpots.length; i++) {
|
|
delete this.dxSpots[i].x;
|
|
delete this.dxSpots[i].y;
|
|
delete this.dxSpots[i].labelWidth;
|
|
}
|
|
|
|
var centerX = this.canvas.width / 2;
|
|
var rulerY = this.canvas.height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var middleFreq = this.getCachedMiddleFreq(); // Use cached frequency
|
|
var pixelsPerKHz = this.getCachedPixelsPerKHz(); // Use cached scaling
|
|
|
|
// Get visible spots using cached method - this avoids re-filtering on every render
|
|
var visibleSpots = this.getVisibleSpots();
|
|
var leftSpots = visibleSpots.left;
|
|
var rightSpots = visibleSpots.right;
|
|
|
|
// Smart label culling based on zoom level to prevent overcrowding
|
|
// At lower zoom levels (zoomed out), limit the number of labels shown
|
|
// Increased limits to show more spots at all zoom levels
|
|
var maxLabelsPerSide = Math.max(20, Math.floor(40 + (this.currentZoomLevel * 20)));
|
|
|
|
// If we have too many spots, keep only the closest ones to center
|
|
if (leftSpots.length > maxLabelsPerSide) {
|
|
// Sort by absolute frequency offset (closest first)
|
|
leftSpots.sort(function(a, b) {
|
|
return Math.abs(a.freqOffset) - Math.abs(b.freqOffset);
|
|
});
|
|
leftSpots = leftSpots.slice(0, maxLabelsPerSide);
|
|
}
|
|
|
|
if (rightSpots.length > maxLabelsPerSide) {
|
|
rightSpots.sort(function(a, b) {
|
|
return Math.abs(a.freqOffset) - Math.abs(b.freqOffset);
|
|
});
|
|
rightSpots = rightSpots.slice(0, maxLabelsPerSide);
|
|
}
|
|
|
|
// Calculate available vertical space - from top margin to above ruler line
|
|
var topMargin = DX_WATERFALL_CONSTANTS.CANVAS.TOP_MARGIN;
|
|
var bottomMargin = DX_WATERFALL_CONSTANTS.CANVAS.BOTTOM_MARGIN;
|
|
var topY = topMargin;
|
|
var bottomY = rulerY - bottomMargin;
|
|
var availableHeight = bottomY - topY;
|
|
|
|
// Check if center label is shown to avoid that area
|
|
var centerFrequency = middleFreq;
|
|
var centerSpotShown = centerFrequency !== null;
|
|
var centerY = (this.canvas.height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT) / 2;
|
|
var baseLabelHeight = this.getCurrentLabelHeight(); // Regular label height
|
|
var centerLabelHeight = baseLabelHeight + 1; // Center is 1px taller
|
|
var centerExclusionHeight = Math.ceil(centerLabelHeight * 1.2) + 20; // Center label height + 20px margin
|
|
var centerExclusionTop = centerY - (centerExclusionHeight / 2);
|
|
var centerExclusionBottom = centerY + (centerExclusionHeight / 2);
|
|
|
|
// Capture references for use in nested function
|
|
var self = this;
|
|
var fonts = this.fonts;
|
|
|
|
// Get current label font based on size level
|
|
var currentLabelFont = this.getCurrentLabelFont();
|
|
|
|
// Label height constants for overlap detection - adjust based on label size
|
|
var labelHeight = this.getCurrentLabelHeight();
|
|
var minSpacing = 3; // Minimum spacing between labels in pixels
|
|
|
|
// Function to distribute spots vertically with anti-overlap algorithm
|
|
var drawSpotsSide = function(spots, ctx) {
|
|
if (spots.length === 0) return; // Note: Label widths are already pre-calculated in getVisibleSpots() and cached
|
|
// Set font for drawing (not for measuring)
|
|
ctx.font = currentLabelFont;
|
|
|
|
// Sort spots by absolute frequency offset (closest to center first)
|
|
// This helps prioritize important spots and improves distribution
|
|
spots.sort(function(a, b) {
|
|
return Math.abs(a.absOffset) - Math.abs(b.absOffset);
|
|
});
|
|
|
|
// Function to draw a single spot
|
|
var drawSpot = function(spot, y) {
|
|
// Get colors using utility function
|
|
var colors = self.getSpotColors(spot);
|
|
var bgColor = colors.bgColor;
|
|
var borderColor = colors.borderColor;
|
|
var tickboxColor = colors.tickboxColor;
|
|
|
|
// Calculate dimensions based on label size level
|
|
var rectHeight = labelHeight;
|
|
var rectX = spot.x - (spot.labelWidth / 2);
|
|
var rectY = y - Math.floor(rectHeight / 2) - 1; // Center vertically
|
|
var rectWidth = spot.labelWidth;
|
|
|
|
// Draw background rectangle
|
|
ctx.fillStyle = bgColor;
|
|
ctx.fillRect(rectX, rectY, rectWidth, rectHeight);
|
|
|
|
// Draw border around the rectangle
|
|
ctx.strokeStyle = borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(rectX, rectY, rectWidth, rectHeight);
|
|
|
|
// Draw small tickbox at top-right corner
|
|
var tickboxSize = DX_WATERFALL_CONSTANTS.CANVAS.SPOT_TICKBOX_SIZE;
|
|
ctx.fillStyle = tickboxColor;
|
|
ctx.fillRect(rectX + rectWidth - tickboxSize, rectY, tickboxSize, tickboxSize);
|
|
|
|
// Draw the callsign text in black
|
|
ctx.fillStyle = '#000000';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(spot.callsign, spot.x, y + 1);
|
|
|
|
// Store coordinates and dimensions in original spot for tooltip hover detection
|
|
if (spot.originalSpot) {
|
|
spot.originalSpot.x = spot.x;
|
|
spot.originalSpot.y = y + 1;
|
|
spot.originalSpot.labelWidth = spot.labelWidth;
|
|
}
|
|
|
|
// Draw underline if LoTW user
|
|
if (spot.lotw_user) {
|
|
var textWidth = spot.labelWidth - (padding * 2);
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(spot.x - (textWidth / 2), y + 3);
|
|
ctx.lineTo(spot.x + (textWidth / 2), y + 3);
|
|
ctx.stroke();
|
|
}
|
|
};
|
|
|
|
// Check if a position would overlap with center label, other spots, or horizontally with nearby spots
|
|
var checkOverlap = function(spot, y, occupiedPositions) {
|
|
var spotLeft = spot.x - (spot.labelWidth / 2);
|
|
var spotRight = spot.x + (spot.labelWidth / 2);
|
|
var spotTop = y - (labelHeight / 2);
|
|
var spotBottom = y + (labelHeight / 2);
|
|
|
|
// Check center label overlap
|
|
if (centerSpotShown) {
|
|
if (!(spotBottom < centerExclusionTop || spotTop > centerExclusionBottom)) {
|
|
return true; // Overlaps center vertically
|
|
}
|
|
}
|
|
|
|
// Check overlap with other spots (both vertical and horizontal)
|
|
for (var i = 0; i < occupiedPositions.length; i++) {
|
|
var other = occupiedPositions[i];
|
|
var otherLeft = other.x - (other.labelWidth / 2);
|
|
var otherRight = other.x + (other.labelWidth / 2);
|
|
var otherTop = other.y - (labelHeight / 2);
|
|
var otherBottom = other.y + (labelHeight / 2);
|
|
|
|
// Calculate horizontal distance between label edges (not centers)
|
|
var horizontalGap;
|
|
if (spot.x < other.x) {
|
|
// Spot is to the left
|
|
horizontalGap = otherLeft - spotRight;
|
|
} else {
|
|
// Spot is to the right
|
|
horizontalGap = spotLeft - otherRight;
|
|
}
|
|
|
|
// Check if rectangles overlap (both horizontally AND vertically)
|
|
var horizontalOverlap = !(spotRight < otherLeft - minSpacing || spotLeft > otherRight + minSpacing);
|
|
var verticalOverlap = !(spotBottom < otherTop - minSpacing || spotTop > otherBottom + minSpacing);
|
|
|
|
// If there's sufficient horizontal gap (accounting for actual label widths),
|
|
// allow labels to share vertical space. At lower zoom levels (more zoomed out),
|
|
// be more aggressive about sharing vertical space since spots are spread wider.
|
|
// Zoom level 0 = max zoom out, 5 = max zoom in
|
|
var gapMultiplier = 0.5 - (self.currentZoomLevel * 0.05); // 0.5 at zoom 0, 0.25 at zoom 5
|
|
var minClearGap = Math.max(spot.labelWidth, other.labelWidth) * gapMultiplier;
|
|
if (horizontalGap > minClearGap) {
|
|
continue; // Enough horizontal separation, can share Y position
|
|
}
|
|
|
|
if (horizontalOverlap && verticalOverlap) {
|
|
return true; // Overlaps both ways
|
|
}
|
|
}
|
|
|
|
return false; // No overlap
|
|
};
|
|
|
|
// Find best vertical position for a spot
|
|
var findBestPosition = function(spot, occupiedPositions) {
|
|
// Create candidate positions - distribute across available space
|
|
var candidates = [];
|
|
var numCandidates = Math.max(20, spots.length * 3); // More candidates for better distribution
|
|
|
|
// Generate candidate positions
|
|
if (centerSpotShown) {
|
|
// Split candidates between top and bottom sections
|
|
var topSectionHeight = centerExclusionTop - topY;
|
|
var bottomSectionHeight = bottomY - centerExclusionBottom;
|
|
var halfCandidates = Math.floor(numCandidates / 2);
|
|
|
|
// Top section candidates
|
|
for (var i = 0; i < halfCandidates; i++) {
|
|
candidates.push(topY + (topSectionHeight * i / (halfCandidates - 1 || 1)));
|
|
}
|
|
|
|
// Bottom section candidates
|
|
for (var j = 0; j < (numCandidates - halfCandidates); j++) {
|
|
candidates.push(centerExclusionBottom + (bottomSectionHeight * j / ((numCandidates - halfCandidates - 1) || 1)));
|
|
}
|
|
} else {
|
|
// Full height candidates
|
|
for (var k = 0; k < numCandidates; k++) {
|
|
candidates.push(topY + (availableHeight * k / (numCandidates - 1 || 1)));
|
|
}
|
|
}
|
|
|
|
// Find first non-overlapping candidate
|
|
for (var m = 0; m < candidates.length; m++) {
|
|
if (!checkOverlap(spot, candidates[m], occupiedPositions)) {
|
|
return candidates[m];
|
|
}
|
|
}
|
|
|
|
// If no good position found, return middle position (fallback)
|
|
return topY + (availableHeight / 2);
|
|
};
|
|
|
|
// Track occupied positions with full rectangle info
|
|
var occupiedPositions = [];
|
|
|
|
// Position and draw each spot
|
|
for (var i = 0; i < spots.length; i++) {
|
|
var spot = spots[i];
|
|
var bestY = findBestPosition(spot, occupiedPositions);
|
|
occupiedPositions.push({
|
|
x: spot.x,
|
|
y: bestY,
|
|
labelWidth: spot.labelWidth
|
|
});
|
|
drawSpot(spot, bestY);
|
|
}
|
|
};
|
|
|
|
// Draw left side spots
|
|
drawSpotsSide(leftSpots, this.ctx);
|
|
|
|
// Draw right side spots
|
|
drawSpotsSide(rightSpots, this.ctx);
|
|
},
|
|
|
|
// Draw vertical "www.wavelog.org" link on the right side
|
|
drawWavelogLink: function() {
|
|
var ctx = this.ctx;
|
|
var text = 'www.wavelog.org';
|
|
|
|
// Set font and measure text (using monospace font consistent with canvas)
|
|
ctx.font = DX_WATERFALL_CONSTANTS.FONTS.SMALL_MONO;
|
|
var textWidth = ctx.measureText(text).width;
|
|
|
|
// Position: right side, vertically centered in waterfall area (excluding ruler), 2px inside from border
|
|
var x = this.canvas.width - 8; // 8px from right edge (2px border + 6px spacing)
|
|
var waterfallHeight = this.canvas.height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var y = waterfallHeight / 2; // Vertically centered in waterfall area
|
|
|
|
// Save context state
|
|
ctx.save();
|
|
|
|
// Move to position and rotate 270 degrees (90 degrees counter-clockwise, reads bottom to top)
|
|
ctx.translate(x, y);
|
|
ctx.rotate(-Math.PI / 2); // -90 degrees in radians (270 degrees clockwise)
|
|
|
|
// Draw text in slightly brighter gray (above noise, below all other elements)
|
|
ctx.fillStyle = '#888888'; // Brighter gray
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, 0, 0);
|
|
|
|
// Restore context state
|
|
ctx.restore();
|
|
},
|
|
|
|
// Draw center callsign label when standing at a relevant spot frequency
|
|
drawCenterCallsignLabel: function() {
|
|
// Get the current spot info (populates this.relevantSpots)
|
|
var spotInfo = this.getSpotInfo();
|
|
|
|
if (!spotInfo || !this.relevantSpots || this.relevantSpots.length === 0) {
|
|
return; // No spot at current frequency
|
|
}
|
|
|
|
var ctx = this.ctx;
|
|
var centerX = this.canvas.width / 2;
|
|
var waterfallHeight = this.canvas.height - DX_WATERFALL_CONSTANTS.CANVAS.RULER_HEIGHT;
|
|
var centerY = waterfallHeight / 2;
|
|
|
|
// Check if we have multiple spots near the CENTER/TUNED frequency
|
|
var centerFreq = this.getCachedMiddleFreq(); // Use the actual tuned frequency
|
|
var spotsAtSameFreq = [];
|
|
var exactMatchTolerance = 0.01; // 10 Hz - very tight tolerance for exact match
|
|
|
|
// First pass: check if there are any spots at our EXACT frequency (within 10 Hz)
|
|
for (var i = 0; i < this.relevantSpots.length; i++) {
|
|
var spot = this.relevantSpots[i];
|
|
var spotFreq = parseFloat(spot.frequency);
|
|
if (Math.abs(spotFreq - centerFreq) <= exactMatchTolerance) {
|
|
spotsAtSameFreq.push({
|
|
spot: spot,
|
|
index: i
|
|
});
|
|
}
|
|
}
|
|
|
|
// If no exact matches, fall back to broader tolerance for nearby spots
|
|
if (spotsAtSameFreq.length === 0) {
|
|
var frequencyTolerance = DX_WATERFALL_CONSTANTS.THRESHOLDS.CENTER_SPOT_TOLERANCE_KHZ;
|
|
for (var i = 0; i < this.relevantSpots.length; i++) {
|
|
var spot = this.relevantSpots[i];
|
|
var spotFreq = parseFloat(spot.frequency);
|
|
if (Math.abs(spotFreq - centerFreq) <= frequencyTolerance) {
|
|
spotsAtSameFreq.push({
|
|
spot: spot,
|
|
index: i
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we have multiple spots at the same frequency, stack them
|
|
if (spotsAtSameFreq.length > 1) {
|
|
// Calculate dimensions for labels
|
|
var baseLabelHeight = this.getCurrentLabelHeight();
|
|
var centerLabelHeight = baseLabelHeight + 1;
|
|
var padding = Math.ceil(DX_WATERFALL_CONSTANTS.CANVAS.SPOT_PADDING * 1.1);
|
|
var spacing = 4; // Spacing between stacked labels
|
|
|
|
// Use center label font
|
|
ctx.font = this.getCurrentCenterLabelFont();
|
|
|
|
// Calculate total height needed for all labels
|
|
var totalHeight = (spotsAtSameFreq.length * centerLabelHeight) + ((spotsAtSameFreq.length - 1) * spacing);
|
|
var startY = centerY - (totalHeight / 2);
|
|
|
|
// Draw each spot at the same frequency
|
|
for (var j = 0; j < spotsAtSameFreq.length; j++) {
|
|
var spotData = spotsAtSameFreq[j];
|
|
var spot = spotData.spot;
|
|
var callsign = spot.callsign;
|
|
var isSelected = (spotData.index === this.currentSpotIndex);
|
|
|
|
// Get colors using same logic as spots
|
|
var colors = this.getSpotColors(spot);
|
|
|
|
// Measure text
|
|
var textWidth = ctx.measureText(callsign).width;
|
|
|
|
// Calculate position for this label
|
|
var rectHeight = centerLabelHeight;
|
|
var rectWidth = textWidth + (padding * 2);
|
|
var rectX = centerX - (textWidth / 2) - padding;
|
|
var rectY = startY + (j * (centerLabelHeight + spacing));
|
|
var labelCenterY = rectY + (rectHeight / 2);
|
|
|
|
// Draw background rectangle using DXCC status color
|
|
ctx.fillStyle = colors.bgColor;
|
|
ctx.fillRect(rectX, rectY, rectWidth, rectHeight);
|
|
|
|
// Draw border using continent status color
|
|
ctx.strokeStyle = colors.borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(rectX, rectY, rectWidth, rectHeight);
|
|
|
|
// Draw additional black border for selected spot
|
|
if (isSelected) {
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(rectX - 1, rectY - 1, rectWidth + 2, rectHeight + 2);
|
|
}
|
|
|
|
// Draw small tickbox at top-right corner using callsign status color
|
|
var tickboxSize = DX_WATERFALL_CONSTANTS.CANVAS.SPOT_TICKBOX_SIZE;
|
|
ctx.fillStyle = colors.tickboxColor;
|
|
ctx.fillRect(rectX + rectWidth - tickboxSize, rectY, tickboxSize, tickboxSize);
|
|
|
|
// Draw the callsign text in black (same as spots)
|
|
ctx.fillStyle = '#000000';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(callsign, centerX, labelCenterY);
|
|
|
|
// Draw underline if LoTW user (same as spots)
|
|
if (spot.lotw_user) {
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(centerX - (textWidth / 2), labelCenterY + 3);
|
|
ctx.lineTo(centerX + (textWidth / 2), labelCenterY + 3);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
} else {
|
|
// Original logic: Draw single selected spot label
|
|
var callsign = spotInfo.callsign;
|
|
|
|
// Get colors using same logic as spots
|
|
var colors = this.getSpotColors(spotInfo);
|
|
|
|
// Use center label font (1px larger than regular spots, scales with labelSizeLevel)
|
|
ctx.font = this.getCurrentCenterLabelFont();
|
|
var textWidth = ctx.measureText(callsign).width;
|
|
|
|
// Calculate background rectangle dimensions based on current label size
|
|
var baseLabelHeight = this.getCurrentLabelHeight(); // Same as regular labels
|
|
var centerLabelHeight = baseLabelHeight + 1; // Center is 1px taller
|
|
var padding = Math.ceil(DX_WATERFALL_CONSTANTS.CANVAS.SPOT_PADDING * 1.1); // Slightly more padding for center
|
|
var rectHeight = centerLabelHeight;
|
|
var rectWidth = textWidth + (padding * 2);
|
|
var rectX = centerX - (textWidth / 2) - padding;
|
|
var rectY = centerY - (rectHeight / 2);
|
|
|
|
// Draw background rectangle using DXCC status color
|
|
ctx.fillStyle = colors.bgColor;
|
|
ctx.fillRect(rectX, rectY, rectWidth, rectHeight);
|
|
|
|
// Draw border using continent status color
|
|
ctx.strokeStyle = colors.borderColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(rectX, rectY, rectWidth, rectHeight);
|
|
|
|
// Draw small tickbox at top-right corner using callsign status color
|
|
var tickboxSize = DX_WATERFALL_CONSTANTS.CANVAS.SPOT_TICKBOX_SIZE;
|
|
ctx.fillStyle = colors.tickboxColor;
|
|
ctx.fillRect(rectX + rectWidth - tickboxSize, rectY, tickboxSize, tickboxSize);
|
|
|
|
// Draw the callsign text in black (same as spots)
|
|
ctx.fillStyle = '#000000';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(callsign, centerX, centerY);
|
|
|
|
// Draw underline if LoTW user (same as spots)
|
|
if (spotInfo.lotw_user) {
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(centerX - (textWidth / 2), centerY + 3);
|
|
ctx.lineTo(centerX + (textWidth / 2), centerY + 3);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Main refresh function - updates and redraws the entire DX waterfall canvas
|
|
* Handles state management, data fetching, and rendering pipeline
|
|
*
|
|
* This function:
|
|
* - Checks for parameter changes and fetches new spots if needed
|
|
* - Manages waiting states and loading messages
|
|
* - Handles CAT radio control operations
|
|
* - Draws all canvas elements (noise, spots, ruler, markers, etc.)
|
|
* - Updates the spot information display
|
|
*
|
|
* Called periodically by external refresh timer or on user interaction
|
|
* Uses early returns to handle various states efficiently
|
|
*
|
|
* @returns {void}
|
|
*/
|
|
refresh: function() {
|
|
// Throttle rapid refresh calls to prevent excessive rendering
|
|
// This prevents excessive rendering when multiple events fire simultaneously
|
|
var currentTime = Date.now();
|
|
var minRefreshInterval = 50; // Minimum 50ms between actual refreshes (~20 FPS max)
|
|
|
|
// If we already have a pending refresh, skip this call
|
|
if (this.refreshPending) {
|
|
return;
|
|
}
|
|
|
|
// If not enough time has passed since last refresh, schedule for next frame
|
|
var timeSinceLastRefresh = currentTime - this.lastRefreshTime;
|
|
if (timeSinceLastRefresh < minRefreshInterval) {
|
|
this.refreshPending = true;
|
|
var self = this;
|
|
var remainingTime = minRefreshInterval - timeSinceLastRefresh;
|
|
setTimeout(function() {
|
|
self.refreshPending = false;
|
|
self._performRefresh();
|
|
}, remainingTime);
|
|
return;
|
|
}
|
|
|
|
// Execute refresh immediately
|
|
this._performRefresh();
|
|
},
|
|
|
|
/**
|
|
* Internal refresh implementation (called by throttled refresh())
|
|
* Uses state machine for clear, maintainable rendering logic
|
|
*
|
|
* MAIN RENDERING LOOP - State Machine Based
|
|
* Each state has its own render method for clarity and maintainability
|
|
*
|
|
* @private
|
|
*/
|
|
_performRefresh: function() {
|
|
// Update last refresh time
|
|
this.lastRefreshTime = Date.now();
|
|
|
|
// Ensure canvas is initialized
|
|
if (!this.canvas) {
|
|
this.init();
|
|
if (!this.canvas) {
|
|
return; // Canvas not available, abort
|
|
}
|
|
}
|
|
|
|
// Check if canvas is visible in DOM
|
|
if (!this.canvas.offsetParent && this.canvas.style.display !== 'none') {
|
|
return; // Canvas not visible or removed from DOM
|
|
}
|
|
|
|
// ========================================
|
|
// AUTO-FETCH LOGIC (Band Change Detection)
|
|
// ========================================
|
|
// Check if band has changed via CAT - only when in READY state
|
|
// This triggers automatic spot fetching when radio changes bands
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
var STATES = DX_WATERFALL_CONSTANTS.STATES;
|
|
|
|
if (currentState === STATES.READY) {
|
|
var currentFreqKhz = this.getCachedMiddleFreq();
|
|
var calculatedBand = null;
|
|
|
|
if (currentFreqKhz > 0) {
|
|
calculatedBand = frequencyToBandKhz(currentFreqKhz);
|
|
}
|
|
|
|
// Check if we need to fetch spots for a different band
|
|
if (calculatedBand && calculatedBand !== '' && calculatedBand.toLowerCase() !== 'select') {
|
|
if (!this.currentSpotBand || calculatedBand !== this.currentSpotBand) {
|
|
// Band has changed! Fetch new spots
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Band changed: ' + this.currentSpotBand + ' → ' + calculatedBand);
|
|
|
|
// Update band immediately to prevent infinite loop
|
|
this.currentSpotBand = calculatedBand;
|
|
|
|
// Invalidate band-related caches
|
|
this.bandLimitsCache = null;
|
|
this.cachedBandForEdges = calculatedBand;
|
|
|
|
// Trigger spot fetch - mark as user-initiated to show loading message
|
|
// Band changes (via CAT or manual) are significant events that warrant visual feedback
|
|
this.fetchDxSpots(true, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// STATE-BASED RENDERING
|
|
// ========================================
|
|
// Route to appropriate render method based on current state
|
|
// Each state has clear, isolated rendering logic
|
|
|
|
switch (currentState) {
|
|
case STATES.DISABLED:
|
|
// Waterfall not initialized - nothing to render
|
|
this._renderDisabled();
|
|
break;
|
|
|
|
case STATES.INITIALIZING:
|
|
// Canvas setup in progress - show loading
|
|
this._renderInitializing();
|
|
break;
|
|
|
|
case STATES.FETCHING_SPOTS:
|
|
// AJAX request in progress - show loading
|
|
this._renderFetchingSpots();
|
|
break;
|
|
|
|
case STATES.TUNING:
|
|
// Radio is tuning - show tuning message
|
|
this._renderTuning();
|
|
break;
|
|
|
|
case STATES.READY:
|
|
// Normal operation - render full waterfall
|
|
this._renderReady();
|
|
break;
|
|
|
|
case STATES.ERROR:
|
|
// Error occurred - show error message
|
|
this._renderError();
|
|
break;
|
|
|
|
case STATES.DEINITIALIZING:
|
|
// Cleanup in progress - show shutdown message
|
|
this._renderDeinitializing();
|
|
break;
|
|
|
|
default:
|
|
// Unknown state - log error and show error state
|
|
DX_WATERFALL_UTILS.log.error('[DX Waterfall] Unknown state: ' + currentState);
|
|
DXWaterfallStateMachine.setState(STATES.ERROR, {
|
|
message: 'Unknown state: ' + currentState
|
|
});
|
|
this._renderError();
|
|
break;
|
|
}
|
|
},
|
|
|
|
// Get the most relevant spot in our sideband
|
|
getSpotInfo: function() {
|
|
if (!this.dxSpots || this.dxSpots.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
var middleFreq = this.getCachedMiddleFreq(); // Use cached frequency
|
|
var currentMode = this.getCurrentMode().toLowerCase();
|
|
|
|
var relevantSpots = [];
|
|
var exactMatchSpots = []; // Spots at our exact frequency
|
|
var exactMatchTolerance = 0.01; // 10 Hz - very tight tolerance for exact match
|
|
|
|
// Get current bandwidth parameters for signal width detection (with caching)
|
|
var bandwidthParams = this.getCachedBandwidthParams(currentMode, middleFreq);
|
|
var signalBandwidth = bandwidthParams.bandwidth; // kHz
|
|
|
|
// Determine detection range based on mode
|
|
var detectionRange = 0;
|
|
|
|
if (currentMode === 'lsb' || currentMode === 'usb' || currentMode === 'phone') {
|
|
// SSB modes: Use symmetric ±1 kHz detection range
|
|
// This allows spots within ±1 kHz to be detected regardless of sideband
|
|
detectionRange = 1.0; // ±1 kHz symmetric range for SSB
|
|
} else if (currentMode === 'cw') {
|
|
detectionRange = SIGNAL_BANDWIDTHS.CW;
|
|
} else {
|
|
// Other modes (digital, etc.) - centered with half bandwidth
|
|
detectionRange = signalBandwidth * 0.5; // 50% of bandwidth for other modes
|
|
} // Find spots within our signal bandwidth on the correct sideband
|
|
for (var i = 0; i < this.dxSpots.length; i++) {
|
|
var spot = this.dxSpots[i];
|
|
var spotFreq = parseFloat(spot.frequency);
|
|
|
|
if (spotFreq && spot.spotted && spot.spotter) {
|
|
var freqOffset = spotFreq - middleFreq;
|
|
var absOffset = Math.abs(freqOffset);
|
|
|
|
// All modes now use centered/symmetric detection
|
|
// For SSB/CW/Digital: spots within ±detectionRange are considered relevant
|
|
var isInRange = absOffset <= detectionRange;
|
|
|
|
if (isInRange) {
|
|
// Apply mode filter
|
|
if (!this.spotMatchesModeFilter(spot)) {
|
|
continue;
|
|
}
|
|
|
|
var spotObj = DX_WATERFALL_UTILS.spots.createSpotObject(spot, {
|
|
includeSpotter: true,
|
|
includeTimestamp: true,
|
|
includeMessage: true,
|
|
includeOffsets: true,
|
|
middleFreq: middleFreq,
|
|
includeWorkStatus: true
|
|
});
|
|
|
|
relevantSpots.push(spotObj);
|
|
|
|
// Check if this is an exact match (within 10 Hz)
|
|
if (absOffset <= exactMatchTolerance) {
|
|
exactMatchSpots.push(spotObj);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (relevantSpots.length === 0) {
|
|
this.relevantSpots = [];
|
|
this.currentSpotIndex = 0;
|
|
return null; // No relevant spots in our sideband
|
|
}
|
|
|
|
// Sort by absolute frequency offset (closest to our frequency first)
|
|
relevantSpots.sort(DX_WATERFALL_UTILS.sorting.byAbsOffset);
|
|
exactMatchSpots.sort(DX_WATERFALL_UTILS.sorting.byAbsOffset);
|
|
|
|
// IMPORTANT: If we have exact matches, use ONLY those for relevantSpots
|
|
// This ensures cycling only goes through spots at our exact frequency
|
|
if (exactMatchSpots.length > 0) {
|
|
this.relevantSpots = exactMatchSpots;
|
|
} else {
|
|
this.relevantSpots = relevantSpots;
|
|
}
|
|
|
|
// Ensure current index is valid
|
|
if (this.currentSpotIndex >= this.relevantSpots.length) {
|
|
this.currentSpotIndex = 0;
|
|
}
|
|
|
|
// If there's a pending spot selection (from clicking a stacked spot), restore the correct index
|
|
if (this.pendingSpotSelection) {
|
|
var foundIndex = -1;
|
|
for (var i = 0; i < this.relevantSpots.length; i++) {
|
|
if (this.relevantSpots[i].callsign === this.pendingSpotSelection.callsign &&
|
|
Math.abs(this.relevantSpots[i].frequency - this.pendingSpotSelection.frequency) < 0.01) {
|
|
foundIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
if (foundIndex >= 0) {
|
|
this.currentSpotIndex = foundIndex;
|
|
}
|
|
// Clear the pending selection after restoring
|
|
this.pendingSpotSelection = null;
|
|
}
|
|
|
|
// Return the currently selected spot
|
|
var selectedSpot = this.relevantSpots[this.currentSpotIndex];
|
|
return selectedSpot;
|
|
},
|
|
|
|
// Format spot date/time from "DD/MM/YY HH:MM" to "YY-MM-DD HH:MM UTC"
|
|
formatSpotDateTime: function(whenPretty) {
|
|
if (!whenPretty) return '';
|
|
|
|
// Input format: "18/10/25 14:30" (DD/MM/YY HH:MM)
|
|
// Output format: "25-10-18 14:30 UTC" (YY-MM-DD HH:MM UTC)
|
|
var parts = whenPretty.split(' ');
|
|
if (parts.length !== 2) return whenPretty + ' UTC'; // Fallback if format is unexpected
|
|
|
|
var datePart = parts[0]; // "18/10/25"
|
|
var timePart = parts[1]; // "14:30"
|
|
|
|
var dateComponents = datePart.split('/');
|
|
if (dateComponents.length !== 3) return whenPretty + ' UTC'; // Fallback
|
|
|
|
var day = dateComponents[0];
|
|
var month = dateComponents[1];
|
|
var year = dateComponents[2];
|
|
|
|
// Reformat to YY-MM-DD HH:MM UTC
|
|
return year + '-' + month + '-' + day + ' ' + timePart + ' UTC';
|
|
},
|
|
|
|
// Update spot information in the dxWaterfallSpot div
|
|
updateSpotInfoDiv: function() {
|
|
if (!this.spotInfoDiv) {
|
|
return;
|
|
}
|
|
|
|
// Block updates during navigation to prevent interference
|
|
if (DX_WATERFALL_UTILS.navigation.navigating) {
|
|
return;
|
|
}
|
|
|
|
// Don't show spot info unless in READY state
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState !== DX_WATERFALL_CONSTANTS.STATES.READY) {
|
|
if (this.spotInfoDiv.innerHTML !== ' ') {
|
|
this.spotInfoDiv.innerHTML = ' ';
|
|
this.lastSpotInfoKey = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
var spotInfo = this.getSpotInfo();
|
|
|
|
// Create a unique key for the current spot state to detect changes
|
|
var currentKey;
|
|
if (!spotInfo) {
|
|
currentKey = 'no-spot';
|
|
} else {
|
|
// Include spot details and index in the key to detect any meaningful change
|
|
currentKey = spotInfo.callsign + '|' + spotInfo.frequency + '|' +
|
|
this.currentSpotIndex + '|' + this.relevantSpots.length;
|
|
}
|
|
|
|
// Only update if the spot has actually changed
|
|
if (this.lastSpotInfoKey === currentKey) {
|
|
return; // No change, skip re-rendering
|
|
}
|
|
|
|
// Store the new key
|
|
this.lastSpotInfoKey = currentKey;
|
|
|
|
var infoText;
|
|
if (!spotInfo) {
|
|
// No active spot in bandwidth - clear the div (don't show cluster statistics here)
|
|
infoText = ' ';
|
|
} else {
|
|
// Active spot in bandwidth - show spot details
|
|
|
|
// Get detailed submode information using centralized function
|
|
var submodeInfo = classifyMode(spotInfo);
|
|
var modeLabel = submodeInfo.submode || spotInfo.mode || 'Unknown';
|
|
// Use detailed submode for mode field (e.g., "FT8" instead of "digi")
|
|
var modeForField = submodeInfo.submode || spotInfo.mode || '';
|
|
|
|
// Prepare text with flag, continent, entity, DXCC, and LoTW indicator
|
|
var dxccInfo = spotInfo.dxcc_spotted || {};
|
|
var flag = dxccInfo.flag || '';
|
|
var continent = dxccInfo.cont || '';
|
|
var entity = dxccInfo.entity || '';
|
|
var dxccId = dxccInfo.dxcc_id || '';
|
|
var lotwIndicator = spotInfo.lotw_user ? ' <span style="color: #FFFF00;">L</span>' : '';
|
|
|
|
var prefixText = '';
|
|
if (flag || continent || entity || dxccId) {
|
|
// Wrap flag in span with default font for emoji support, rest uses monospace
|
|
var flagPart = flag ? '<span class="flag-emoji">' + flag + '</span> ' : '';
|
|
|
|
// Add tune icon to set frequency (use detailed submode)
|
|
// Store callsign to ensure correct spot is used when populating form
|
|
var tuneIcon = '<i class="fas fa-headset tune-icon" title="' + lang_dxwaterfall_tune_to_spot + '" data-frequency="' + spotInfo.frequency + '" data-mode="' + modeForField + '" data-callsign="' + spotInfo.callsign + '"></i> ';
|
|
|
|
// Add cycle icon if there are multiple spots
|
|
var cycleIcon = '';
|
|
var spotCounter = '';
|
|
if (this.relevantSpots.length > 1) {
|
|
cycleIcon = '<i class="fas fa-exchange-alt cycle-spot-icon" title="' + lang_dxwaterfall_cycle_nearby_spots + '"></i> ';
|
|
spotCounter = '[' + (this.currentSpotIndex + 1) + '/' + this.relevantSpots.length + '] ';
|
|
}
|
|
|
|
// Build prefix with tune and cycle icons, then entity info
|
|
prefixText = tuneIcon + cycleIcon + spotCounter + flagPart + entity + ' ';
|
|
}
|
|
|
|
// Format the time only (HH:MM) with Z suffix for UTC
|
|
var timeMatch = spotInfo.when_pretty.match(/(\d{2}:\d{2})/);
|
|
var timeStr = timeMatch ? timeMatch[1] : '??:??';
|
|
|
|
// Build mode/submode string using category from classifyMode
|
|
// Category will be: 'digi', 'phone', or 'cw'
|
|
var categoryStr = submodeInfo.category || '';
|
|
var submodeStr = submodeInfo.submode || '';
|
|
var modeDisplay = '';
|
|
|
|
// Format: [Category-Submode] if both exist and differ, else just [Submode] or [Category]
|
|
if (categoryStr && submodeStr && categoryStr !== submodeStr.toLowerCase()) {
|
|
modeDisplay = '[' + categoryStr + '-' + submodeStr + ']';
|
|
} else if (submodeStr) {
|
|
modeDisplay = '[' + submodeStr + ']';
|
|
} else if (categoryStr) {
|
|
modeDisplay = '[' + categoryStr + ']';
|
|
}
|
|
|
|
infoText = prefixText + modeDisplay + lotwIndicator + ' ' + spotInfo.callsign + ' de ' + spotInfo.spotter + ' @' + timeStr + 'Z ';
|
|
|
|
// Add medal icons at the end if new (unconfirmed)
|
|
// Order: Continent (Gold), DXCC (Silver), Callsign (Bronze)
|
|
var awards = '';
|
|
if (spotInfo.worked_continent === false) {
|
|
// New Continent (not worked before) - Gold medal
|
|
awards += ' <i class="fas fa-medal new-continent-icon" title="' + lang_dxwaterfall_new_continent + '"></i>';
|
|
}
|
|
if (spotInfo.worked_dxcc === false) {
|
|
// New DXCC (not worked before) - Silver medal
|
|
awards += ' <i class="fas fa-medal new-dxcc-icon" title="' + lang_dxwaterfall_new_dxcc + '"></i>';
|
|
}
|
|
if (spotInfo.worked_call === false) {
|
|
// New Callsign (not worked before) - Bronze medal
|
|
awards += ' <i class="fas fa-medal new-callsign-icon" title="' + lang_dxwaterfall_new_callsign + '"></i>';
|
|
}
|
|
|
|
infoText += awards + ' ' + lang_dxwaterfall_comment + spotInfo.message;
|
|
}
|
|
|
|
// Update the div only when content actually changed
|
|
this.spotInfoDiv.innerHTML = infoText;
|
|
},
|
|
|
|
// Update zoom menu display
|
|
// @param {boolean} forceUpdate - If true, bypass state check
|
|
updateZoomMenu: function(forceUpdate) {
|
|
if (!this.zoomMenuDiv) {
|
|
return;
|
|
}
|
|
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
var STATES = DX_WATERFALL_CONSTANTS.STATES;
|
|
|
|
// Don't show menu during TUNING state (frequency changes invisible to user)
|
|
// UNLESS forceUpdate is true (e.g., after data fetch completes)
|
|
if (!forceUpdate && currentState === STATES.TUNING) {
|
|
// Don't update menu during frequency changes - keep showing last state
|
|
return;
|
|
}
|
|
|
|
// ========================================
|
|
// INITIALIZING STATE - Show loading
|
|
// ========================================
|
|
if (currentState === STATES.INITIALIZING) {
|
|
var loadingHTML = '<div style="display: flex; align-items: center; flex: 1;"><i class="fas fa-hourglass-half" style="margin-right: 5px; animation: blink 1s infinite;"></i><span style="margin-right: 10px;">' + lang_dxwaterfall_please_wait + '</span></div>';
|
|
if (this.lastZoomMenuHTML !== loadingHTML) {
|
|
this.zoomMenuDiv.innerHTML = loadingHTML;
|
|
this.lastZoomMenuHTML = loadingHTML;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ========================================
|
|
// FETCHING_SPOTS STATE - Show loading with timer
|
|
// ========================================
|
|
if (currentState === STATES.FETCHING_SPOTS) {
|
|
// If we have no spots yet (initial fetch), show simple loading message
|
|
if (!this.dxSpots || this.dxSpots.length === 0) {
|
|
var fetchingHTML;
|
|
if (this.operationStartTime) {
|
|
var elapsed = ((Date.now() - this.operationStartTime) / 1000).toFixed(1);
|
|
var displayText = (elapsed < 1.0) ? lang_dxwaterfall_please_wait : elapsed + 's';
|
|
fetchingHTML = '<div style="display: flex; align-items: center; flex: 1;"><i class="fas fa-hourglass-half" style="margin-right: 5px; animation: blink 1s infinite;"></i><span style="margin-right: 10px;">' + displayText + '</span></div>';
|
|
} else {
|
|
fetchingHTML = '<div style="display: flex; align-items: center; flex: 1;"><i class="fas fa-hourglass-half" style="margin-right: 5px; animation: blink 1s infinite;"></i><span style="margin-right: 10px;"> </span></div>';
|
|
}
|
|
if (this.lastZoomMenuHTML !== fetchingHTML) {
|
|
this.zoomMenuDiv.innerHTML = fetchingHTML;
|
|
this.lastZoomMenuHTML = fetchingHTML;
|
|
}
|
|
return;
|
|
}
|
|
// If we have spots already, fall through to show full menu with loading indicator
|
|
}
|
|
|
|
// ========================================
|
|
// READY STATE (or FETCHING with existing data) - Show full menu
|
|
// ========================================
|
|
var currentMode = this.getCurrentMode().toLowerCase();
|
|
|
|
// Build zoom controls HTML - start with status indicator and band spot navigation
|
|
var zoomHTML = '<div style="display: flex; align-items: center; flex: 1;">';
|
|
|
|
// Add loading indicator if fetching spots (refreshing existing data)
|
|
var showLoadingIndicator = (currentState === STATES.FETCHING_SPOTS) && this.userInitiatedFetch;
|
|
|
|
if (showLoadingIndicator) {
|
|
if (this.operationStartTime) {
|
|
var elapsed = ((Date.now() - this.operationStartTime) / 1000).toFixed(1);
|
|
var hasData = this.dxSpots && this.dxSpots.length > 0;
|
|
var displayText = (!hasData && elapsed < 1.0) ? lang_dxwaterfall_please_wait : elapsed + 's';
|
|
zoomHTML += '<i class="fas fa-hourglass-half" style="margin-right: 5px; animation: blink 1s infinite;"></i><span style="margin-right: 10px;">' + displayText + '</span>';
|
|
} else {
|
|
zoomHTML += '<i class="fas fa-hourglass-half" style="margin-right: 5px; animation: blink 1s infinite;"></i><span style="margin-right: 10px;"> </span>';
|
|
}
|
|
}
|
|
|
|
// Add band spot navigation controls - always show them
|
|
if (this.allBandSpots.length > 0) {
|
|
var currentFreq = this.getCachedMiddleFreq();
|
|
|
|
// Check if there's any spot with lower frequency (for prev/left)
|
|
var hasPrevSpot = false;
|
|
for (var i = 0; i < this.allBandSpots.length; i++) {
|
|
if (this.allBandSpots[i].frequency < currentFreq) {
|
|
hasPrevSpot = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check if there's any spot with higher frequency (for next/right)
|
|
var hasNextSpot = false;
|
|
for (var i = 0; i < this.allBandSpots.length; i++) {
|
|
if (this.allBandSpots[i].frequency > currentFreq) {
|
|
hasNextSpot = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Previous spot button
|
|
if (hasPrevSpot) {
|
|
zoomHTML += '<i class="fas fa-chevron-left prev-spot-icon" title="' + lang_dxwaterfall_previous_spot + '"></i> ';
|
|
} else {
|
|
zoomHTML += '<i class="fas fa-chevron-left prev-spot-icon disabled" title="' + lang_dxwaterfall_no_spots_lower + '" style="opacity: 0.3; cursor: not-allowed;"></i> ';
|
|
}
|
|
|
|
// Next spot button
|
|
if (hasNextSpot) {
|
|
zoomHTML += '<i class="fas fa-chevron-right next-spot-icon" title="' + lang_dxwaterfall_next_spot + '"></i>';
|
|
} else {
|
|
zoomHTML += '<i class="fas fa-chevron-right next-spot-icon disabled" title="' + lang_dxwaterfall_no_spots_higher + '" style="opacity: 0.3; cursor: not-allowed;"></i>';
|
|
}
|
|
} else {
|
|
// No spots - show both as disabled
|
|
zoomHTML += '<i class="fas fa-chevron-left prev-spot-icon disabled" title="' + lang_dxwaterfall_no_spots_available + '" style="opacity: 0.3; cursor: not-allowed;"></i> ';
|
|
zoomHTML += '<i class="fas fa-chevron-right next-spot-icon disabled" title="' + lang_dxwaterfall_no_spots_available + '" style="opacity: 0.3; cursor: not-allowed;"></i>';
|
|
}
|
|
|
|
// Add separator
|
|
zoomHTML += '<span style="margin: 0 10px; opacity: 0.5;">|</span>';
|
|
|
|
// Check if there are any unworked continent/DXCC spots on the band
|
|
var hasSmartSpots = this.smartHunterSpots.length > 0;
|
|
|
|
if (hasSmartSpots) {
|
|
zoomHTML += '<i class="fas fa-crosshairs smart-hunter-icon" title="' + lang_dxwaterfall_cycle_unworked + '"></i>';
|
|
zoomHTML += '<span class="smart-hunter-text" title="' + lang_dxwaterfall_cycle_unworked + '">' + lang_dxwaterfall_dx_hunter + '</span>';
|
|
} else {
|
|
zoomHTML += '<i class="fas fa-crosshairs smart-hunter-icon disabled" title="' + lang_dxwaterfall_no_unworked + '" style="opacity: 0.3; cursor: not-allowed;"></i>';
|
|
zoomHTML += '<span class="smart-hunter-text disabled" title="' + lang_dxwaterfall_no_unworked + '" style="opacity: 0.3; cursor: not-allowed;">' + lang_dxwaterfall_dx_hunter + '</span>';
|
|
}
|
|
|
|
// Add separator
|
|
zoomHTML += '<span style="margin: 0 10px; opacity: 0.5;">|</span>';
|
|
|
|
// Add continent cycling controls
|
|
var isFetchingSpots = (currentState === DX_WATERFALL_CONSTANTS.STATES.FETCHING_SPOTS);
|
|
if (isFetchingSpots) {
|
|
// Fetching data - show as disabled
|
|
zoomHTML += '<i class="fas fa-globe-americas continent-cycle-icon disabled" title="' + lang_dxwaterfall_downloading_data + '" style="opacity: 0.3; cursor: not-allowed;"></i>';
|
|
zoomHTML += '<span class="continent-cycle-text disabled" title="' + lang_dxwaterfall_downloading_data + '" style="opacity: 0.3; cursor: not-allowed;">de ' + this.currentContinent + '</span>';
|
|
} else if (this.pendingContinent) {
|
|
// Pending change - show with blinking effect
|
|
zoomHTML += '<i class="fas fa-globe-americas continent-cycle-icon" title="' + lang_dxwaterfall_click_to_cycle + '" style="animation: blink 0.5s linear infinite;"></i>';
|
|
zoomHTML += '<span class="continent-cycle-text" title="' + lang_dxwaterfall_click_to_cycle + '" style="animation: blink 0.5s linear infinite;">de ' + this.pendingContinent + '</span>';
|
|
} else {
|
|
// Normal state
|
|
zoomHTML += '<i class="fas fa-globe-americas continent-cycle-icon" title="' + lang_dxwaterfall_change_continent + '"></i>';
|
|
zoomHTML += '<span class="continent-cycle-text" title="' + lang_dxwaterfall_change_continent + '">de ' + this.currentContinent + '</span>';
|
|
}
|
|
|
|
|
|
// Add separator before mode filters
|
|
zoomHTML += '<span style="margin: 0 10px; opacity: 0.5;">|</span>';
|
|
|
|
// Add mode filter controls
|
|
var activeFilters = this.pendingModeFilters || this.modeFilters;
|
|
var blinkStyle = this.pendingModeFilters ? 'animation: blink 0.5s linear infinite;' : '';
|
|
|
|
zoomHTML += '<i class="fas fa-filter mode-filter-icon" title="' + lang_dxwaterfall_filter_by_mode + '"></i>';
|
|
zoomHTML += '<span style="margin-left: 5px; margin-right: 3px; font-size: 13px;">' + lang_dxwaterfall_modes_label + '</span>';
|
|
|
|
// CW filter - Orange
|
|
var cwClass = activeFilters.cw ? 'mode-filter-cw active' : 'mode-filter-cw';
|
|
var cwStyle = activeFilters.cw ? 'color: #FFA500; font-weight: bold;' : 'color: #888888;';
|
|
if (this.pendingModeFilters) cwStyle += ' ' + blinkStyle;
|
|
cwStyle += ' cursor: pointer;';
|
|
zoomHTML += '<span class="' + cwClass + '" title="' + lang_dxwaterfall_toggle_cw + '" style="' + cwStyle + ' margin: 0 3px; font-size: 13px; transition: color 0.2s;">' + lang_dxwaterfall_cw + '</span>';
|
|
|
|
// Digi filter - Blue
|
|
var digiClass = activeFilters.digi ? 'mode-filter-digi active' : 'mode-filter-digi';
|
|
var digiStyle = activeFilters.digi ? 'color: #0096FF; font-weight: bold;' : 'color: #888888;';
|
|
if (this.pendingModeFilters) digiStyle += ' ' + blinkStyle;
|
|
digiStyle += ' cursor: pointer;';
|
|
zoomHTML += '<span class="' + digiClass + '" title="' + lang_dxwaterfall_toggle_digi + '" style="' + digiStyle + ' margin: 0 3px; font-size: 13px; transition: color 0.2s;">' + lang_dxwaterfall_digi + '</span>';
|
|
|
|
// Phone filter - Green
|
|
var phoneClass = activeFilters.phone ? 'mode-filter-phone active' : 'mode-filter-phone';
|
|
var phoneStyle = activeFilters.phone ? 'color: #00FF00; font-weight: bold;' : 'color: #888888;';
|
|
if (this.pendingModeFilters) phoneStyle += ' ' + blinkStyle;
|
|
phoneStyle += ' cursor: pointer;';
|
|
zoomHTML += '<span class="' + phoneClass + '" title="' + lang_dxwaterfall_toggle_phone + '" style="' + phoneStyle + ' margin: 0 3px; font-size: 13px; transition: color 0.2s;">' + lang_dxwaterfall_phone + '</span>';
|
|
|
|
zoomHTML += '</div>';
|
|
|
|
// Center section: spot count information
|
|
// Format: "31/43 20m NA spots @22:16LT" or "No spots" when empty
|
|
zoomHTML += '<div style="flex: 1; display: flex; justify-content: center; align-items: center;">';
|
|
|
|
// Check if we're out of band (using 20kHz margin)
|
|
var currentFreqKhz = this.getCachedMiddleFreq();
|
|
var detectedBand = frequencyToBandKhz(currentFreqKhz, 20);
|
|
var isOutOfBand = (detectedBand === 'All');
|
|
|
|
if (isOutOfBand && (!this.currentSpotBand || this.currentSpotBand === 'All')) {
|
|
// Out of band with no spots loaded - show "Out of band" message
|
|
zoomHTML += '<span style="font-size: 13px; color: #FF6B6B; font-weight: bold;">';
|
|
zoomHTML += '<i class="fas fa-exclamation-triangle" style="margin-right: 5px;"></i>';
|
|
zoomHTML += (typeof lang_dxwaterfall_out_of_band !== 'undefined') ? lang_dxwaterfall_out_of_band : 'Out of band';
|
|
zoomHTML += '</span>';
|
|
} else if (this.lastUpdateTime) {
|
|
var hours = String(this.lastUpdateTime.getHours()).padStart(2, '0');
|
|
var minutes = String(this.lastUpdateTime.getMinutes()).padStart(2, '0');
|
|
var updateTimeStr = hours + ':' + minutes;
|
|
// Display the band we have spots for, not the form selector
|
|
var currentBand = this.currentSpotBand || this.getCurrentBand();
|
|
|
|
zoomHTML += '<span style="font-size: 13px; color: #888888;">';
|
|
|
|
if (this.dxSpots && this.dxSpots.length > 0) {
|
|
// Count displayed spots
|
|
var displayedSpotsCount = 0;
|
|
for (var i = 0; i < this.dxSpots.length; i++) {
|
|
if (this.spotMatchesModeFilter(this.dxSpots[i])) {
|
|
displayedSpotsCount++;
|
|
}
|
|
}
|
|
zoomHTML += displayedSpotsCount + '/' + this.totalSpotsCount + ' ' + currentBand + ' ' + this.currentContinent + ' ' + lang_dxwaterfall_spots + ' @' + updateTimeStr + 'LT';
|
|
} else {
|
|
// No spots available - still show band and continent info
|
|
zoomHTML += '0 ' + currentBand + ' ' + this.currentContinent + ' ' + lang_dxwaterfall_spots + ' @' + updateTimeStr + 'LT';
|
|
}
|
|
|
|
zoomHTML += '</span>';
|
|
}
|
|
zoomHTML += '</div>';
|
|
|
|
// Right side: label size and zoom controls
|
|
zoomHTML += '<div style="display: flex; align-items: center; white-space: nowrap;">';
|
|
|
|
// Label size cycle icon with tooltip showing current size and next size
|
|
var labelSizeNames = [
|
|
lang_dxwaterfall_label_size_xsmall,
|
|
lang_dxwaterfall_label_size_small,
|
|
lang_dxwaterfall_label_size_medium,
|
|
lang_dxwaterfall_label_size_large,
|
|
lang_dxwaterfall_label_size_xlarge
|
|
];
|
|
var currentSizeText = labelSizeNames[this.labelSizeLevel];
|
|
var nextSizeIndex = (this.labelSizeLevel + 1) % 5;
|
|
var nextSizeText = labelSizeNames[nextSizeIndex];
|
|
zoomHTML += '<i class="fas fa-font label-size-icon" title="' + lang_dxwaterfall_label_size_cycle + ': ' + currentSizeText + ' → ' + nextSizeText + '"></i>';
|
|
|
|
// Separator
|
|
zoomHTML += '<span style="color: #666666; margin: 0 8px;">|</span>';
|
|
|
|
// Zoom out button (disabled if at minimum level)
|
|
if (this.currentZoomLevel > DX_WATERFALL_CONSTANTS.ZOOM.MIN_LEVEL) {
|
|
zoomHTML += '<i class="fas fa-search-minus zoom-out-icon" title="' + lang_dxwaterfall_zoom_out + '"></i> ';
|
|
} else {
|
|
zoomHTML += '<i class="fas fa-search-minus zoom-out-icon disabled" title="' + lang_dxwaterfall_zoom_out + '" style="opacity: 0.3; cursor: not-allowed;"></i> ';
|
|
}
|
|
|
|
// Reset zoom button (disabled if already at default level)
|
|
if (this.currentZoomLevel !== DX_WATERFALL_CONSTANTS.ZOOM.DEFAULT_LEVEL) {
|
|
zoomHTML += '<i class="fas fa-undo zoom-reset-icon" title="' + lang_dxwaterfall_reset_zoom + '" style="margin: 0 5px;"></i> ';
|
|
} else {
|
|
zoomHTML += '<i class="fas fa-undo zoom-reset-icon disabled" title="' + lang_dxwaterfall_reset_zoom + '" style="margin: 0 5px; opacity: 0.3; cursor: not-allowed;"></i> ';
|
|
}
|
|
|
|
// Zoom in button (disabled if at max level)
|
|
if (this.currentZoomLevel < this.maxZoomLevel) {
|
|
zoomHTML += '<i class="fas fa-search-plus zoom-in-icon" title="' + lang_dxwaterfall_zoom_in + '"></i>';
|
|
} else {
|
|
zoomHTML += '<i class="fas fa-search-plus zoom-in-icon disabled" title="' + lang_dxwaterfall_zoom_in + '" style="opacity: 0.3; cursor: not-allowed;"></i>';
|
|
}
|
|
|
|
zoomHTML += '</div>';
|
|
|
|
// Only update DOM if HTML actually changed (prevents destroying button event handlers)
|
|
if (this.lastZoomMenuHTML !== zoomHTML) {
|
|
this.zoomMenuDiv.innerHTML = zoomHTML;
|
|
this.lastZoomMenuHTML = zoomHTML;
|
|
}
|
|
},
|
|
|
|
// Zoom in (increase zoom level)
|
|
zoomIn: function() {
|
|
// Prevent rapid-fire zoom changes
|
|
if (this.zoomChanging) {
|
|
return;
|
|
}
|
|
|
|
if (this.currentZoomLevel < this.maxZoomLevel) {
|
|
this.zoomChanging = true;
|
|
this.currentZoomLevel++;
|
|
this.cachedPixelsPerKHz = null; // Invalidate cache
|
|
this.lastModeForCache = null; // Force recalculation
|
|
this.cache.visibleSpots = null; // Invalidate visible spots cache
|
|
this.cache.visibleSpotsParams = null;
|
|
|
|
// Update zoom menu and reset flag after a delay
|
|
var self = this;
|
|
setTimeout(function() {
|
|
self.updateZoomMenu();
|
|
self.zoomChanging = false;
|
|
}, 100);
|
|
}
|
|
},
|
|
|
|
// Zoom out (decrease zoom level)
|
|
zoomOut: function() {
|
|
// Prevent rapid-fire zoom changes
|
|
if (this.zoomChanging) {
|
|
return;
|
|
}
|
|
|
|
if (this.currentZoomLevel > DX_WATERFALL_CONSTANTS.ZOOM.MIN_LEVEL) {
|
|
this.zoomChanging = true;
|
|
this.currentZoomLevel--;
|
|
this.cachedPixelsPerKHz = null; // Invalidate cache
|
|
this.lastModeForCache = null; // Force recalculation
|
|
this.cache.visibleSpots = null; // Invalidate visible spots cache
|
|
this.cache.visibleSpotsParams = null;
|
|
|
|
// Update zoom menu and reset flag after a delay
|
|
var self = this;
|
|
setTimeout(function() {
|
|
self.updateZoomMenu();
|
|
self.zoomChanging = false;
|
|
}, 100);
|
|
}
|
|
},
|
|
|
|
// Reset zoom to default level (3)
|
|
resetZoom: function() {
|
|
// Prevent rapid-fire zoom changes
|
|
if (this.zoomChanging) {
|
|
return;
|
|
}
|
|
|
|
if (this.currentZoomLevel !== DX_WATERFALL_CONSTANTS.ZOOM.DEFAULT_LEVEL) {
|
|
this.zoomChanging = true;
|
|
this.currentZoomLevel = DX_WATERFALL_CONSTANTS.ZOOM.DEFAULT_LEVEL;
|
|
this.cachedPixelsPerKHz = null; // Invalidate cache
|
|
this.lastModeForCache = null; // Force recalculation
|
|
this.cache.visibleSpots = null; // Invalidate visible spots cache
|
|
this.cache.visibleSpotsParams = null;
|
|
|
|
// Update zoom menu and reset flag after a delay
|
|
var self = this;
|
|
setTimeout(function() {
|
|
self.updateZoomMenu();
|
|
self.zoomChanging = false;
|
|
}, 100);
|
|
}
|
|
},
|
|
|
|
// ========================================
|
|
// SPOT NAVIGATION AND FREQUENCY FUNCTIONS
|
|
// ========================================
|
|
|
|
/**
|
|
* Find nearest spot in a given direction (prev/next)
|
|
* @param {string} direction - 'prev' (lower frequency) or 'next' (higher frequency)
|
|
* @returns {object|null} Object with {spot, index} or null if not found
|
|
*/
|
|
findNearestSpot: function(direction) {
|
|
if (!DX_WATERFALL_UTILS.navigation.canNavigate(this)) {
|
|
return null;
|
|
}
|
|
|
|
var currentFreq = this.getCachedMiddleFreq();
|
|
var targetSpot = null;
|
|
var targetIndex = -1;
|
|
|
|
if (direction === 'prev') {
|
|
// Find nearest spot to the left (lower frequency)
|
|
// Iterate backwards since allBandSpots is sorted ascending
|
|
for (var i = this.allBandSpots.length - 1; i >= 0; i--) {
|
|
if (this.allBandSpots[i].frequency < currentFreq) {
|
|
targetSpot = this.allBandSpots[i];
|
|
targetIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
} else if (direction === 'next') {
|
|
// Find nearest spot to the right (higher frequency)
|
|
// Iterate forward since allBandSpots is sorted ascending
|
|
for (var i = 0; i < this.allBandSpots.length; i++) {
|
|
if (this.allBandSpots[i].frequency > currentFreq) {
|
|
targetSpot = this.allBandSpots[i];
|
|
targetIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return targetSpot ? {spot: targetSpot, index: targetIndex} : null;
|
|
},
|
|
|
|
// Navigate to previous spot (nearest spot to the left of current frequency)
|
|
prevSpot: function() {
|
|
var result = this.findNearestSpot('prev');
|
|
if (result) {
|
|
DX_WATERFALL_UTILS.navigation.navigateToSpot(this, result.spot, result.index, false); // Don't prefill
|
|
}
|
|
},
|
|
|
|
// Navigate to next spot (nearest spot to the right of current frequency)
|
|
nextSpot: function() {
|
|
var result = this.findNearestSpot('next');
|
|
if (result) {
|
|
DX_WATERFALL_UTILS.navigation.navigateToSpot(this, result.spot, result.index, false); // Don't prefill
|
|
}
|
|
},
|
|
|
|
// Sync relevant spot index when navigating to a specific callsign/frequency
|
|
syncRelevantSpotIndex: function(spot) {
|
|
|
|
var found = false;
|
|
// Find this spot in the relevantSpots array
|
|
for (var i = 0; i < this.relevantSpots.length; i++) {
|
|
if (this.relevantSpots[i].callsign === spot.callsign &&
|
|
Math.abs(this.relevantSpots[i].frequency - spot.frequency) < 0.01) {
|
|
// Found the spot - set the index
|
|
this.currentSpotIndex = i;
|
|
found = true;
|
|
// Force update of spot info display
|
|
this.updateSpotInfoDiv();
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
|
|
// Collect all valid spots on the band for navigation
|
|
collectAllBandSpots: function(forceUpdate) {
|
|
// Throttle spot collection to prevent excessive calls
|
|
var currentTime = Date.now();
|
|
if (!forceUpdate && (currentTime - this.lastSpotCollectionTime) < this.spotCollectionThrottleMs) {
|
|
return;
|
|
}
|
|
this.lastSpotCollectionTime = currentTime;
|
|
|
|
var currentFreq = this.getCachedMiddleFreq();
|
|
// Use the band we have spots for, not the form selector
|
|
var currentBand = this.currentSpotBand || this.getCurrentBand();
|
|
var currentMode = this.getCurrentMode();
|
|
|
|
// Filter spots for current band
|
|
var result = DX_WATERFALL_UTILS.spots.filterSpots(this, function(spot, spotFreq, context) {
|
|
// Validate that spot belongs to current band (prevent cross-band contamination)
|
|
var spotBand = frequencyToBandKhz(spotFreq);
|
|
return spotBand === currentBand;
|
|
}, {
|
|
postProcess: function(spotObj, originalSpot) {
|
|
// Add reference to the original spot object for precise matching later
|
|
spotObj._originalSpot = originalSpot;
|
|
return spotObj;
|
|
}
|
|
});
|
|
|
|
var spots = result.spots;
|
|
|
|
// Sort by frequency (ascending)
|
|
spots.sort(DX_WATERFALL_UTILS.sorting.byFrequency);
|
|
|
|
this.allBandSpots = spots;
|
|
|
|
// If no spots after filtering, reset index and return
|
|
if (spots.length === 0) {
|
|
this.currentBandSpotIndex = 0;
|
|
return;
|
|
}
|
|
|
|
// Find current spot index based on current frequency
|
|
var currentFreq = this.getCachedMiddleFreq();
|
|
|
|
var closestIndex = 0;
|
|
var minDiff = Math.abs(spots[0].frequency - currentFreq);
|
|
|
|
for (var i = 1; i < spots.length; i++) {
|
|
var diff = Math.abs(spots[i].frequency - currentFreq);
|
|
if (diff < minDiff) {
|
|
minDiff = diff;
|
|
closestIndex = i;
|
|
}
|
|
}
|
|
|
|
this.currentBandSpotIndex = closestIndex;
|
|
},
|
|
|
|
// Collect spots with unworked continents or DXCC entities
|
|
collectSmartHunterSpots: function() {
|
|
// Filter spots for unworked continents or DXCC entities
|
|
var result = DX_WATERFALL_UTILS.spots.filterSpots(this, function(spot, spotFreq, context) {
|
|
// Check if continent or DXCC is not worked (false = not worked)
|
|
var isNewContinent = (spot.worked_continent === false);
|
|
var isNewDxcc = (spot.worked_dxcc === false);
|
|
return isNewContinent || isNewDxcc;
|
|
}, {
|
|
postProcess: function(spotObj, originalSpot) {
|
|
// Add specific fields needed for smart hunter logic
|
|
spotObj.worked_continent = originalSpot.worked_continent;
|
|
spotObj.worked_dxcc = originalSpot.worked_dxcc;
|
|
// Add reference to the original spot object for precise matching later
|
|
spotObj._originalSpot = originalSpot;
|
|
return spotObj;
|
|
}
|
|
});
|
|
|
|
var spots = result.spots;
|
|
|
|
// Sort by frequency (ascending)
|
|
spots.sort(function(a, b) {
|
|
return a.frequency - b.frequency;
|
|
});
|
|
|
|
this.smartHunterSpots = spots;
|
|
|
|
// If smart hunter is active, find current spot index
|
|
if (this.smartHunterActive && spots.length > 0) {
|
|
var currentFreq = this.getCachedMiddleFreq();
|
|
var closestIndex = 0;
|
|
var minDiff = Math.abs(spots[0].frequency - currentFreq);
|
|
|
|
for (var i = 1; i < spots.length; i++) {
|
|
var diff = Math.abs(spots[i].frequency - currentFreq);
|
|
if (diff < minDiff) {
|
|
minDiff = diff;
|
|
closestIndex = i;
|
|
}
|
|
}
|
|
|
|
this.currentSmartHunterIndex = closestIndex;
|
|
} else {
|
|
this.currentSmartHunterIndex = 0;
|
|
}
|
|
},
|
|
|
|
// Cycle to next smart hunter spot (nearest unworked spot to the right of current frequency)
|
|
nextSmartHunterSpot: function() {
|
|
if (this.spotNavigating) {
|
|
return; // Already navigating, prevent double trigger
|
|
}
|
|
|
|
if (this.smartHunterSpots.length === 0) {
|
|
return; // No smart spots available
|
|
}
|
|
|
|
// Set smart hunter active flag
|
|
this.smartHunterActive = true;
|
|
|
|
// Get current frequency
|
|
var currentFreq = this.getCachedMiddleFreq();
|
|
|
|
var targetSpot = null;
|
|
var targetIndex = -1;
|
|
|
|
// Find the nearest unworked spot to the right (higher frequency) of current position
|
|
// Since smartHunterSpots is sorted by frequency ascending, iterate forward
|
|
for (var i = 0; i < this.smartHunterSpots.length; i++) {
|
|
if (this.smartHunterSpots[i].frequency > currentFreq) {
|
|
targetSpot = this.smartHunterSpots[i];
|
|
targetIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If no spot found to the right, wrap to the lowest frequency unworked spot
|
|
if (!targetSpot && this.smartHunterSpots.length > 0) {
|
|
targetSpot = this.smartHunterSpots[0];
|
|
targetIndex = 0;
|
|
}
|
|
|
|
if (targetSpot) {
|
|
// Update smart hunter index
|
|
this.currentSmartHunterIndex = targetIndex;
|
|
|
|
// Get the complete spot data from the stored reference
|
|
var completeSpot = targetSpot._originalSpot || targetSpot;
|
|
|
|
// Use centralized navigation logic with prefill enabled
|
|
DX_WATERFALL_UTILS.navigation.navigateToSpot(this, completeSpot, targetIndex, true);
|
|
}
|
|
},
|
|
|
|
// Jump to first spot in band
|
|
firstSpot: function() {
|
|
// Don't handle navigation when in TUNING state
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.TUNING) {
|
|
return; // Block navigation during frequency changes
|
|
}
|
|
|
|
var spot = this.allBandSpots[0];
|
|
if (spot) {
|
|
DX_WATERFALL_UTILS.navigation.navigateToSpot(this, spot, 0, false); // Don't prefill
|
|
}
|
|
},
|
|
|
|
// Jump to last spot in band
|
|
lastSpot: function() {
|
|
// Check if navigation is allowed
|
|
if (!DX_WATERFALL_UTILS.navigation.canNavigate(this)) {
|
|
return;
|
|
}
|
|
|
|
var lastIndex = this.allBandSpots.length - 1;
|
|
var spot = this.allBandSpots[lastIndex];
|
|
if (spot) {
|
|
DX_WATERFALL_UTILS.navigation.navigateToSpot(this, spot, lastIndex, false); // Don't prefill
|
|
}
|
|
},
|
|
|
|
// Cycle to next continent (debounced - waits 1.5s after last click)
|
|
cycleContinent: function() {
|
|
var self = this;
|
|
|
|
// Find current continent index (use pendingContinent if set, otherwise currentContinent)
|
|
var baseContinent = this.pendingContinent || this.currentContinent;
|
|
var currentIndex = this.continents.indexOf(baseContinent);
|
|
|
|
// Move to next continent (with wrap around)
|
|
var nextIndex = (currentIndex + 1) % this.continents.length;
|
|
var nextContinent = this.continents[nextIndex];
|
|
|
|
// Store pending continent
|
|
this.pendingContinent = nextContinent;
|
|
|
|
// Update menu immediately to show the pending continent
|
|
this.updateZoomMenu();
|
|
|
|
// Clear existing timer if there is one
|
|
if (this.continentChangeTimer) {
|
|
clearTimeout(this.continentChangeTimer);
|
|
}
|
|
|
|
// Set new timer - actual change happens after 1.5 seconds of no clicks
|
|
this.continentChangeTimer = setTimeout(function() {
|
|
// Only proceed if we have a pending continent
|
|
if (!self.pendingContinent) {
|
|
return;
|
|
}
|
|
|
|
// Apply the continent change
|
|
self.currentContinent = self.pendingContinent;
|
|
self.pendingContinent = null;
|
|
|
|
// Invalidate band limits cache (region may have changed)
|
|
self.bandLimitsCache = null;
|
|
|
|
// Reset band plans to force reload for new region
|
|
self.bandPlans = null;
|
|
self.bandEdgesData = null;
|
|
|
|
// Load band plans for new region (based on new continent)
|
|
self.loadBandPlans();
|
|
|
|
// Set spot info to nbsp to maintain layout height
|
|
if (self.spotInfoDiv) {
|
|
self.spotInfoDiv.innerHTML = ' ';
|
|
}
|
|
|
|
// Clear current spots
|
|
self.dxSpots = [];
|
|
self.allBandSpots = [];
|
|
self.smartHunterSpots = [];
|
|
self.relevantSpots = [];
|
|
self.currentBandSpotIndex = 0;
|
|
self.currentSmartHunterIndex = 0;
|
|
self.currentSpotIndex = 0;
|
|
self.lastSpotInfoKey = null; // Reset spot info key
|
|
|
|
// Invalidate visible spots cache
|
|
self.cache.visibleSpots = null;
|
|
self.cache.visibleSpotsParams = null;
|
|
|
|
// Update zoom menu to show new continent
|
|
self.updateZoomMenu();
|
|
|
|
// Fetch new spots with the new continent (state machine handles FETCHING_SPOTS state)
|
|
self.fetchDxSpots(true, true); // User changed continent - mark as user-initiated
|
|
|
|
}, 1500); // Wait 1.5 seconds after last click before fetching
|
|
},
|
|
|
|
// Check if a spot should be shown based on active mode filters
|
|
spotMatchesModeFilter: function(spot) {
|
|
// Use comprehensive mode classification utility directly
|
|
var classification = classifyMode(spot);
|
|
var spotMode = classification.category;
|
|
|
|
// Use pending filters if they exist, otherwise use current filters
|
|
var filters = this.pendingModeFilters || this.modeFilters;
|
|
|
|
// If mode is unknown/unclassified, default to phone (treat as SSB)
|
|
if (!spotMode || (spotMode !== 'phone' && spotMode !== 'cw' && spotMode !== 'digi')) {
|
|
spotMode = 'phone';
|
|
}
|
|
|
|
// For digi mode spots: if digi filter is OFF, also hide spots on FT8 frequencies
|
|
// This prevents clutter from FT8 spots when user doesn't want to see digi modes
|
|
// But if digi filter is ON, show all digi spots including FT8 frequencies
|
|
if (spotMode === 'digi') {
|
|
var spotFreq = parseFloat(spot.frequency);
|
|
var isOnFT8Freq = isFT8Frequency(spotFreq, 'kHz');
|
|
|
|
// If digi filter is OFF and spot is on FT8 frequency, hide it
|
|
if (!filters.digi && isOnFT8Freq) {
|
|
return false;
|
|
}
|
|
|
|
// If digi filter is ON, show all digi spots (including FT8)
|
|
// If digi filter is OFF and NOT on FT8, also hide (filter is off)
|
|
return filters.digi;
|
|
}
|
|
|
|
// For phone, cw: check the corresponding filter
|
|
return filters[spotMode] === true;
|
|
},
|
|
|
|
// Toggle a mode filter (debounced - waits 0.5s after last click, no re-fetch needed)
|
|
toggleModeFilter: function(modeType) {
|
|
var self = this;
|
|
|
|
// Prevent rapid double-clicks from causing issues
|
|
var now = Date.now();
|
|
if (this.lastFilterToggleTime && (now - this.lastFilterToggleTime) < 50) {
|
|
DX_WATERFALL_UTILS.log.debug('[Filter Toggle] Ignoring rapid double-click');
|
|
return;
|
|
}
|
|
this.lastFilterToggleTime = now;
|
|
|
|
// Create pending filters if they don't exist (clone current filters)
|
|
if (!this.pendingModeFilters) {
|
|
this.pendingModeFilters = {
|
|
phone: this.modeFilters.phone,
|
|
cw: this.modeFilters.cw,
|
|
digi: this.modeFilters.digi
|
|
};
|
|
}
|
|
|
|
// Toggle the specified filter
|
|
this.pendingModeFilters[modeType] = !this.pendingModeFilters[modeType];
|
|
|
|
// Apply filter changes immediately for instant visual feedback
|
|
this.modeFilters.phone = this.pendingModeFilters.phone;
|
|
this.modeFilters.cw = this.pendingModeFilters.cw;
|
|
this.modeFilters.digi = this.pendingModeFilters.digi;
|
|
|
|
// Invalidate visible spots cache immediately for instant update
|
|
this.cache.visibleSpots = null;
|
|
this.cache.visibleSpotsParams = null;
|
|
|
|
// Trigger immediate refresh to show filter changes
|
|
// This ensures the display updates instantly without waiting for the next interval
|
|
if (this.canvas && this.ctx) {
|
|
this.refresh();
|
|
}
|
|
|
|
// Don't update menu here - it will be updated by the timeout handler
|
|
// Updating here causes the button to be recreated which can trigger duplicate events
|
|
|
|
// Clear existing timer if there is one
|
|
if (this.modeFilterChangeTimer) {
|
|
clearTimeout(this.modeFilterChangeTimer);
|
|
}
|
|
|
|
// Set new timer - for saving and collection updates after 0.5 seconds of no clicks
|
|
this.modeFilterChangeTimer = setTimeout(function() {
|
|
// Only proceed if we have pending filters
|
|
if (!self.pendingModeFilters) {
|
|
return;
|
|
}
|
|
|
|
// Clear pending filters
|
|
self.pendingModeFilters = null;
|
|
|
|
// Save to cookie
|
|
self.saveModeFiltersToCookie();
|
|
|
|
// No need to fetch new spots - we already have all modes from cluster
|
|
// Just re-collect spots with the new filters applied
|
|
self.collectAllBandSpots(true); // Update band spot collection (force after filter change)
|
|
self.collectSmartHunterSpots(); // Update smart hunter spots
|
|
|
|
// Update zoom menu to show final state
|
|
self.updateZoomMenu();
|
|
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.MODE_FILTER_CHANGE_MS);
|
|
},
|
|
|
|
/**
|
|
* Cleanup method to unbind event handlers and free resources
|
|
* Call this method before removing the waterfall from the DOM to prevent memory leaks
|
|
*/
|
|
/**
|
|
* Destroy the waterfall and clean up all resources
|
|
* Transitions through DEINITIALIZING to DISABLED state
|
|
*/
|
|
destroy: function() {
|
|
// Transition to DEINITIALIZING state
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.DEINITIALIZING);
|
|
|
|
// Clear error shutdown timer if active
|
|
if (this.errorShutdownTimer) {
|
|
clearTimeout(this.errorShutdownTimer);
|
|
this.errorShutdownTimer = null;
|
|
}
|
|
|
|
// Clear ready transition timer if active
|
|
if (this.readyTransitionTimer) {
|
|
clearTimeout(this.readyTransitionTimer);
|
|
this.readyTransitionTimer = null;
|
|
}
|
|
|
|
// Clear all state machine timers
|
|
if (DXWaterfallStateMachine.stateTimer) {
|
|
clearTimeout(DXWaterfallStateMachine.stateTimer);
|
|
DXWaterfallStateMachine.stateTimer = null;
|
|
}
|
|
|
|
// Clear all application timers to prevent memory leaks
|
|
if (this.fetchDebounceTimer) {
|
|
clearTimeout(this.fetchDebounceTimer);
|
|
this.fetchDebounceTimer = null;
|
|
}
|
|
if (this.modeFilterChangeTimer) {
|
|
clearTimeout(this.modeFilterChangeTimer);
|
|
this.modeFilterChangeTimer = null;
|
|
}
|
|
|
|
// Abort any pending AJAX requests
|
|
if (this.pendingFetchRequest) {
|
|
this.pendingFetchRequest.abort();
|
|
this.pendingFetchRequest = null;
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Aborted pending fetch request');
|
|
}
|
|
|
|
// Clear QSO form utility timers
|
|
if (DX_WATERFALL_UTILS.qsoForm.pendingPopulationTimer) {
|
|
clearTimeout(DX_WATERFALL_UTILS.qsoForm.pendingPopulationTimer);
|
|
DX_WATERFALL_UTILS.qsoForm.pendingPopulationTimer = null;
|
|
}
|
|
if (DX_WATERFALL_UTILS.qsoForm.pendingLookupTimer) {
|
|
clearTimeout(DX_WATERFALL_UTILS.qsoForm.pendingLookupTimer);
|
|
DX_WATERFALL_UTILS.qsoForm.pendingLookupTimer = null;
|
|
}
|
|
|
|
// Clear navigation utility timers
|
|
if (DX_WATERFALL_UTILS.navigation.pendingNavigationTimer) {
|
|
clearTimeout(DX_WATERFALL_UTILS.navigation.pendingNavigationTimer);
|
|
DX_WATERFALL_UTILS.navigation.pendingNavigationTimer = null;
|
|
}
|
|
|
|
// Remove event listeners added in init() to prevent memory leaks
|
|
if (this.canvas) {
|
|
if (this._wheelHandler) {
|
|
this.canvas.removeEventListener('wheel', this._wheelHandler);
|
|
this._wheelHandler = null;
|
|
}
|
|
if (this._mousemoveHandler) {
|
|
this.canvas.removeEventListener('mousemove', this._mousemoveHandler);
|
|
this._mousemoveHandler = null;
|
|
}
|
|
}
|
|
|
|
// Unbind jQuery event handlers that were added in init()
|
|
// Note: Event handlers registered outside dxWaterfall object (like menu clicks)
|
|
// should NOT be unbound here as they are global and persistent
|
|
if (this.$freqCalculated) {
|
|
this.$freqCalculated.off('focus blur input keydown');
|
|
}
|
|
if (this.canvas) {
|
|
// Unbind jQuery events on canvas (click is handled globally, not here)
|
|
$(this.canvas).off('wheel');
|
|
}
|
|
|
|
// Clear canvas
|
|
if (this.ctx && this.canvas) {
|
|
try {
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
} catch (e) {
|
|
DX_WATERFALL_UTILS.log.warn('[DX Waterfall] Error clearing canvas:', e);
|
|
}
|
|
}
|
|
|
|
// Clear cached data
|
|
this.dxSpots = [];
|
|
this.relevantSpots = [];
|
|
this.allBandSpots = [];
|
|
this.smartHunterSpots = [];
|
|
|
|
// Clear cache references
|
|
this.cache.noise1 = null;
|
|
this.cache.noise2 = null;
|
|
this.cache.middleFreq = null;
|
|
this.cache.lastQrgUnit = null;
|
|
this.cache.lastValidCommittedFreq = null;
|
|
this.cache.lastValidCommittedUnit = null;
|
|
this.cache.visibleSpots = null;
|
|
this.cache.visibleSpotsParams = null;
|
|
|
|
// Clear frequency tracking properties (used in getCachedMiddleFreq)
|
|
this.lastQrgUnit = null;
|
|
this.lastModeForCache = null;
|
|
this.lastValidCommittedFreq = null;
|
|
this.lastValidCommittedUnit = null;
|
|
|
|
// Clear cached pixels per kHz
|
|
this.cachedPixelsPerKHz = null;
|
|
|
|
// Reset indices
|
|
this.currentSpotIndex = 0;
|
|
this.currentBandSpotIndex = 0;
|
|
this.currentSmartHunterIndex = 0;
|
|
|
|
// Reset zoom level to default
|
|
this.currentZoomLevel = DX_WATERFALL_CONSTANTS.ZOOM.DEFAULT_LEVEL;
|
|
|
|
// Clear pending states
|
|
this.pendingContinent = null;
|
|
this.pendingModeFilters = null;
|
|
this.pendingSpotSelection = null;
|
|
|
|
// Reset spot info key
|
|
this.lastSpotInfoKey = null;
|
|
|
|
// Clear band tracking
|
|
this.lastFetchBand = null;
|
|
this.lastFetchContinent = null;
|
|
this.lastFetchAge = null;
|
|
this.currentSpotBand = null; // Reset the band we have spots for
|
|
|
|
// Reset timestamps
|
|
this.lastUpdateTime = null;
|
|
this.lastWaterfallFrequencyCommandTime = 0;
|
|
this.lastFrequencyRefreshTime = 0;
|
|
this.lastSpotCollectionTime = 0;
|
|
|
|
// Clear canvas and context references to force reinitialization
|
|
this.canvas = null;
|
|
this.ctx = null;
|
|
|
|
// Clear DOM element references
|
|
this.spotInfoDiv = null;
|
|
this.zoomMenuDiv = null;
|
|
this.$freqCalculated = null;
|
|
this.$qrgUnit = null;
|
|
this.$bandSelect = null;
|
|
this.$modeSelect = null;
|
|
|
|
// Transition to DISABLED state
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.DISABLED);
|
|
|
|
// Always log cleanup completion (user-facing message)
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Unloaded successfully');
|
|
}
|
|
};
|
|
|
|
// Helper function to safely set the mode with fallback
|
|
// @param mode - Mode to set
|
|
// @param skipTrigger - If true, don't trigger change event (prevents side effects)
|
|
function setMode(mode, skipTrigger) {
|
|
if (!mode) {
|
|
return false;
|
|
}
|
|
|
|
// Default skipTrigger to false
|
|
if (typeof skipTrigger === 'undefined') {
|
|
skipTrigger = false;
|
|
}
|
|
|
|
var modeSelect = $('#mode');
|
|
var modeUpper = mode.toUpperCase();
|
|
|
|
// Map common mode variations to standard ADIF modes
|
|
// Prefer LSB/USB over generic SSB when possible
|
|
var modeMapping = {
|
|
'PHONE-LSB': 'LSB',
|
|
'PHONE-USB': 'USB',
|
|
'SSB-LSB': 'LSB',
|
|
'SSB-USB': 'USB',
|
|
'DIGI': 'RTTY', // Fallback for generic digital
|
|
'DATA': 'RTTY' // Fallback for generic data
|
|
};
|
|
|
|
// Check if we need to map the mode
|
|
if (modeMapping[modeUpper]) {
|
|
modeUpper = modeMapping[modeUpper];
|
|
}
|
|
// For generic PHONE/SSB, try to determine LSB/USB based on frequency
|
|
else if (modeUpper === 'PHONE' || modeUpper === 'SSB') {
|
|
var currentFreq = dxWaterfall.getCachedMiddleFreq(); // Get frequency in kHz
|
|
var ssbMode = determineSSBMode(currentFreq);
|
|
if (ssbMode === 'LSB') {
|
|
// Check if LSB exists in options
|
|
if (modeSelect.find('option[value="LSB"]').length > 0) {
|
|
modeUpper = 'LSB';
|
|
} else {
|
|
modeUpper = 'SSB'; // Fallback to SSB
|
|
}
|
|
} else { // USB
|
|
// Check if USB exists in options
|
|
if (modeSelect.find('option[value="USB"]').length > 0) {
|
|
modeUpper = 'USB';
|
|
} else {
|
|
modeUpper = 'SSB'; // Fallback to SSB
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if the mode exists in the select options
|
|
var modeExists = modeSelect.find('option[value="' + modeUpper + '"]').length > 0;
|
|
|
|
if (modeExists) {
|
|
modeSelect.val(modeUpper);
|
|
|
|
// Only trigger change if skipTrigger is false
|
|
if (!skipTrigger) {
|
|
modeSelect.trigger('change');
|
|
}
|
|
|
|
return true;
|
|
} else {
|
|
// Mode doesn't exist, select the first available option as fallback
|
|
var firstOption = modeSelect.find('option:first').val();
|
|
if (firstOption) {
|
|
modeSelect.val(firstOption);
|
|
|
|
// Only trigger change if skipTrigger is false
|
|
if (!skipTrigger) {
|
|
modeSelect.trigger('change');
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Helper function to handle frequency changes via CAT or manual input
|
|
// Global function so it can be accessed from dxWaterfall methods
|
|
// @param frequencyInKHz - Target frequency in kHz
|
|
// @param fromWaterfall - True if this change was initiated by waterfall (clicking spot/tune icon), false for external calls
|
|
function setFrequency(frequencyInKHz, fromWaterfall) {
|
|
|
|
// PROTECTION: If user is manually updating frequency from form, don't tune radio
|
|
// This prevents form changes from controlling the radio (radio should control form)
|
|
if (typeof window.user_updating_frequency !== 'undefined' && window.user_updating_frequency) {
|
|
DX_WATERFALL_UTILS.log.info('[setFrequency] Skipping radio tune - user manually updating form');
|
|
return;
|
|
}
|
|
|
|
// Input validation
|
|
if (!frequencyInKHz || typeof frequencyInKHz !== 'number') {
|
|
DX_WATERFALL_UTILS.log.warn('[setFrequency] Invalid frequency parameter:', frequencyInKHz);
|
|
return;
|
|
}
|
|
|
|
// Validate frequency range (30 kHz to 3 GHz - reasonable amateur radio range)
|
|
if (frequencyInKHz < 0.03 || frequencyInKHz > 3000000) {
|
|
DX_WATERFALL_UTILS.log.warn('[setFrequency] Frequency out of valid range:', frequencyInKHz, 'kHz');
|
|
return;
|
|
}
|
|
|
|
// Default fromWaterfall to false (external call) unless explicitly set to true
|
|
if (typeof fromWaterfall === 'undefined') {
|
|
fromWaterfall = false;
|
|
}
|
|
|
|
// Hide spot tooltip when changing frequency
|
|
if (typeof dxWaterfall !== 'undefined' && dxWaterfall.hideSpotTooltip) {
|
|
dxWaterfall.hideSpotTooltip();
|
|
}
|
|
|
|
// Check if already changing frequency (TUNING state) and block rapid commands
|
|
var currentState = (typeof DXWaterfallStateMachine !== 'undefined') ? DXWaterfallStateMachine.getState() : null;
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.TUNING) {
|
|
return;
|
|
}
|
|
|
|
// Add simple debounce to prevent rapid-fire calls
|
|
// But allow waterfall-initiated calls (spot clicks) to bypass debounce
|
|
var now = Date.now();
|
|
if (typeof setFrequency.lastCall === 'undefined') {
|
|
setFrequency.lastCall = 0;
|
|
}
|
|
if (!fromWaterfall && now - setFrequency.lastCall < 500) { // Only debounce external calls
|
|
return;
|
|
}
|
|
setFrequency.lastCall = now;
|
|
|
|
var formattedFreq = Math.round(frequencyInKHz * 1000); // Convert to Hz and round to integer
|
|
var modeVal = $('#mode').val();
|
|
|
|
// Check if CAT is enabled and configured
|
|
if (isCATAvailable()) {
|
|
// Set the radio frequency via CAT command
|
|
// Use formattedFreq (in Hz) for consistency - rounded to integer for radio compatibility
|
|
|
|
// Map UI mode to CAT mode parameter using determineRadioMode logic
|
|
// CAT expects specific modes like: CW, RTTY, PSK, USB, LSB, AM, FM
|
|
var catMode = 'usb'; // Default fallback
|
|
|
|
if (modeVal) {
|
|
var modeUpper = modeVal.toUpperCase();
|
|
|
|
// Use the same logic as determineRadioMode for consistency
|
|
// CW modes
|
|
if (modeUpper === 'CW' || modeUpper === 'A1A') {
|
|
catMode = 'cw';
|
|
}
|
|
// Digital modes that should be sent as RTTY
|
|
else if (modeUpper === 'RTTY') {
|
|
catMode = 'rtty';
|
|
}
|
|
// PSK modes - transceiver expects 'psk'
|
|
else if (modeUpper.indexOf('PSK') !== -1) {
|
|
catMode = 'psk';
|
|
}
|
|
// AM mode
|
|
else if (modeUpper === 'AM') {
|
|
catMode = 'am';
|
|
}
|
|
// FM mode
|
|
else if (modeUpper === 'FM') {
|
|
catMode = 'fm';
|
|
}
|
|
// USB/LSB - pass through directly (already in correct format from determineRadioMode)
|
|
else if (modeUpper === 'USB') {
|
|
catMode = 'usb';
|
|
}
|
|
else if (modeUpper === 'LSB') {
|
|
catMode = 'lsb';
|
|
}
|
|
// Any other mode - default to frequency-based USB/LSB
|
|
else {
|
|
var ssbMode = determineSSBMode(frequencyInKHz);
|
|
catMode = ssbMode.toLowerCase();
|
|
}
|
|
}
|
|
|
|
// Use the new unified tuneRadioToFrequency function with callbacks
|
|
if (typeof tuneRadioToFrequency === 'function') {
|
|
// Set frequency changing flag and show visual feedback
|
|
if (typeof dxWaterfall !== 'undefined') {
|
|
// Check if we're already at the target frequency (within 1Hz tolerance)
|
|
var currentFreqHz = Math.round(dxWaterfall.committedFrequencyKHz * 1000);
|
|
var diff = Math.abs(currentFreqHz - formattedFreq);
|
|
if (diff <= 1) {
|
|
// Just update the waterfall display, don't transition states
|
|
dxWaterfall.invalidateFrequencyCache(formattedFreq / 1000, true);
|
|
return; // Skip the entire CAT process
|
|
}
|
|
|
|
// Cancel any previous frequency confirmation timeout
|
|
if (dxWaterfall.frequencyConfirmTimeoutId) {
|
|
clearTimeout(dxWaterfall.frequencyConfirmTimeoutId);
|
|
dxWaterfall.frequencyConfirmTimeoutId = null;
|
|
}
|
|
|
|
// Set target frequency FIRST before transition
|
|
dxWaterfall.targetFrequencyHz = formattedFreq;
|
|
dxWaterfall.targetFrequencyConfirmAttempts = 0; // Reset confirmation counter
|
|
dxWaterfall.operationStartTime = Date.now(); // Reset operation timer for display
|
|
dxWaterfall.lastWaterfallFrequencyCommandTime = Date.now(); // Track waterfall command time
|
|
|
|
// Transition to TUNING state
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.TUNING, {
|
|
targetFrequency: formattedFreq,
|
|
reason: fromWaterfall ? 'spot_click' : 'external'
|
|
});
|
|
|
|
// IMMEDIATELY update waterfall to show new frequency (don't wait for CAT)
|
|
// This prevents the visual "jump" when the old frequency comes back from CAT
|
|
dxWaterfall.invalidateFrequencyCache(formattedFreq / 1000, true); // true = immediate update
|
|
}
|
|
|
|
// Define success callback
|
|
var onSuccess = function(data, textStatus, jqXHR) {
|
|
// Optionally log response if it contains useful info
|
|
if (data && data.trim() !== '') {
|
|
// Response received
|
|
}
|
|
|
|
// Get timing based on connection type (WebSocket vs Polling)
|
|
var timings = getCATTimings();
|
|
|
|
// Note: State transitions are now handled by handleCATFrequencyUpdate()
|
|
// when radio confirms the frequency change
|
|
|
|
// Set a timeout to handle if radio doesn't confirm - store timeout ID so we can cancel it
|
|
dxWaterfall.frequencyConfirmTimeoutId = setTimeout(function() {
|
|
// Clear the timeout ID
|
|
dxWaterfall.frequencyConfirmTimeoutId = null;
|
|
|
|
// Check if we're still waiting for frequency confirmation
|
|
if (typeof dxWaterfall !== 'undefined' && dxWaterfall.targetFrequencyHz) {
|
|
// Radio didn't confirm frequency within timeout - transition to ERROR
|
|
var targetKHz = dxWaterfall.targetFrequencyHz / 1000;
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.ERROR, {
|
|
message: 'Radio did not confirm frequency ' + targetKHz.toFixed(3) + ' kHz within timeout'
|
|
});
|
|
|
|
// Clear target frequency
|
|
dxWaterfall.targetFrequencyHz = null;
|
|
dxWaterfall.targetFrequencyConfirmAttempts = 0;
|
|
dxWaterfall.spotNavigating = false;
|
|
}
|
|
}, timings.confirmTimeout); // WebSocket: 300ms, Polling: 3000ms
|
|
};
|
|
|
|
// Define error callback
|
|
var onError = function(jqXHR, textStatus, errorThrown) {
|
|
// CAT command failed - transition to ERROR state
|
|
var errorMsg = 'CAT command failed';
|
|
if (textStatus) {
|
|
errorMsg += ' (' + textStatus + ')';
|
|
}
|
|
if (errorThrown) {
|
|
errorMsg += ': ' + errorThrown;
|
|
}
|
|
|
|
if (typeof dxWaterfall !== 'undefined') {
|
|
// Clear target frequency tracking
|
|
dxWaterfall.targetFrequencyHz = null;
|
|
dxWaterfall.targetFrequencyConfirmAttempts = 0;
|
|
dxWaterfall.spotNavigating = false;
|
|
|
|
// Transition to ERROR state
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.ERROR, {
|
|
message: errorMsg
|
|
});
|
|
}
|
|
|
|
// Log detailed error for debugging
|
|
if (textStatus !== 'timeout' && jqXHR && jqXHR.status !== 0) {
|
|
if (jqXHR.responseText) {
|
|
DX_WATERFALL_UTILS.log.warn('DX Waterfall: CAT command error details:', jqXHR.responseText);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Call unified tuning function with callbacks
|
|
// Pass skipWaterfall=true to prevent infinite loop (don't call setFrequency again)
|
|
tuneRadioToFrequency(null, formattedFreq, catMode, onSuccess, onError, true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Update frequency field with value in Hz
|
|
$('#frequency').val(frequencyInKHz * 1000);
|
|
|
|
// Trigger change event to update calculated fields and unit display
|
|
// Skip trigger when called from waterfall to prevent recursive updates
|
|
if (!fromWaterfall) {
|
|
$('#frequency').trigger('change');
|
|
}
|
|
|
|
// Clear navigation flags immediately since no CAT operation is happening
|
|
if (typeof dxWaterfall !== 'undefined') {
|
|
dxWaterfall.spotNavigating = false; // Clear navigation flag immediately
|
|
// Don't call invalidateFrequencyCache - it's for CAT confirmation
|
|
// When CAT is disabled, waterfall frequency is managed independently
|
|
}
|
|
}
|
|
|
|
// Wait for jQuery to be available before initializing
|
|
(function waitForJQuery() {
|
|
if (typeof jQuery !== 'undefined') {
|
|
// jQuery is loaded, proceed with initialization
|
|
$(document).ready(function() {
|
|
// Initialize DOM cache
|
|
DX_WATERFALL_UTILS.dom.init();
|
|
|
|
// Function to try initializing the canvas with retries
|
|
function tryInitCanvas() {
|
|
if (document.getElementById('dxWaterfall')) {
|
|
// Canvas found, but DON'T auto-initialize
|
|
// Wait for user to click the power button
|
|
|
|
// Set up DX spots fetching at regular intervals (only when initialized)
|
|
setInterval(function() {
|
|
if (dxWaterfall.canvas) { // Only fetch if waterfall has been initialized
|
|
dxWaterfall.fetchDxSpots(true, false); // Background fetch - NOT user-initiated
|
|
}
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.DX_SPOTS_FETCH_INTERVAL_MS);
|
|
|
|
} else {
|
|
// Canvas not found, try again in 100ms
|
|
setTimeout(tryInitCanvas, 100);
|
|
}
|
|
}
|
|
|
|
// Start trying to initialize
|
|
tryInitCanvas();
|
|
|
|
// Handle window resize to prevent canvas stretching
|
|
$(window).on('resize', function() {
|
|
// Immediately update canvas dimensions to prevent stretching
|
|
dxWaterfall.updateDimensions();
|
|
});
|
|
|
|
// Handle click on the cycle icon in dxWaterfallSpotContent div to cycle through spots
|
|
$('#dxWaterfallSpotContent').on('click', '.cycle-spot-icon', function(e) {
|
|
e.stopPropagation(); // Prevent event bubbling
|
|
|
|
// Prevent rapid clicking - check if navigation is in progress
|
|
if (dxWaterfall.spotNavigating) {
|
|
return;
|
|
}
|
|
|
|
// Cycle to next spot
|
|
if (dxWaterfall.relevantSpots.length > 1) {
|
|
dxWaterfall.spotNavigating = true;
|
|
|
|
dxWaterfall.currentSpotIndex = (dxWaterfall.currentSpotIndex + 1) % dxWaterfall.relevantSpots.length;
|
|
|
|
// Update spot info display
|
|
dxWaterfall.updateSpotInfoDiv();
|
|
|
|
// Clear QSO form first
|
|
DX_WATERFALL_UTILS.qsoForm.clearForm();
|
|
|
|
// Populate form with the new spot data after delay
|
|
setTimeout(function() {
|
|
var spotInfo = dxWaterfall.getSpotInfo();
|
|
if (spotInfo) {
|
|
DX_WATERFALL_UTILS.qsoForm.populateFromSpot(spotInfo, true);
|
|
}
|
|
|
|
// Re-enable navigation after operation completes
|
|
setTimeout(function() {
|
|
dxWaterfall.spotNavigating = false;
|
|
}, 100);
|
|
}, 100);
|
|
|
|
// Visual feedback - briefly change icon color (with transition for smooth effect)
|
|
var icon = $(this);
|
|
icon.css({'color': '#FFFF00', 'transition': 'color 0.2s'});
|
|
setTimeout(function() {
|
|
icon.css('color', '');
|
|
}, 200);
|
|
}
|
|
});
|
|
|
|
// Handle click on the tune icon in dxWaterfallSpotContent div to set frequency
|
|
$('#dxWaterfallSpotContent').on('click', '.tune-icon', function(e) {
|
|
e.stopPropagation(); // Prevent event bubbling
|
|
|
|
var frequency = parseFloat($(this).data('frequency'));
|
|
var mode = $(this).data('mode');
|
|
var callsign = $(this).data('callsign');
|
|
|
|
if (frequency) {
|
|
// Set the mode if available - use skipTrigger=true to prevent change events
|
|
// This prevents the form from being cleared by event handlers
|
|
if (mode) {
|
|
setMode(mode, true); // Skip triggering change event
|
|
}
|
|
|
|
// Use helper function to set frequency
|
|
// fromWaterfall=true prevents frequency change event from being triggered
|
|
setFrequency(frequency, true);
|
|
|
|
// In offline mode, update virtual CAT state
|
|
if (typeof isCATAvailable === 'function' && !isCATAvailable()) {
|
|
var freqHz = Math.round(frequency * 1000); // Convert kHz to Hz
|
|
if (typeof window.catState === 'undefined' || window.catState === null) {
|
|
window.catState = {};
|
|
}
|
|
window.catState.frequency = freqHz;
|
|
window.catState.mode = mode;
|
|
window.catState.lastUpdate = Date.now();
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Offline mode - tune icon updated virtual CAT: freq=' + freqHz + ' Hz, mode=' + mode);
|
|
|
|
// Update relevant spots for the new frequency
|
|
if (typeof dxWaterfall !== 'undefined' && dxWaterfall && typeof dxWaterfall.collectAllBandSpots === 'function') {
|
|
dxWaterfall.collectAllBandSpots(true);
|
|
}
|
|
}
|
|
|
|
// Populate the QSO form with spot data
|
|
// Find the specific spot by callsign to ensure we use the displayed spot
|
|
var spotInfo = null;
|
|
if (callsign && dxWaterfall.relevantSpots && dxWaterfall.relevantSpots.length > 0) {
|
|
// Find the spot with matching callsign from relevant spots
|
|
for (var i = 0; i < dxWaterfall.relevantSpots.length; i++) {
|
|
if (dxWaterfall.relevantSpots[i].callsign === callsign) {
|
|
spotInfo = dxWaterfall.relevantSpots[i];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to getSpotInfo if callsign lookup failed
|
|
if (!spotInfo) {
|
|
spotInfo = dxWaterfall.getSpotInfo();
|
|
}
|
|
|
|
if (spotInfo) {
|
|
// Clear form first
|
|
DX_WATERFALL_UTILS.qsoForm.clearForm();
|
|
|
|
// Populate from spot with a brief delay (same as other prefill paths)
|
|
setTimeout(function() {
|
|
DX_WATERFALL_UTILS.qsoForm.populateFromSpot(spotInfo, true);
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.FORM_POPULATE_DELAY_MS);
|
|
}
|
|
|
|
// Visual feedback - briefly change icon color (with transition for smooth effect)
|
|
var icon = $(this);
|
|
icon.css({'color': '#FFFF00', 'transition': 'color 0.2s'});
|
|
setTimeout(function() {
|
|
icon.css('color', '');
|
|
}, 200);
|
|
}
|
|
});
|
|
|
|
// Handle click on zoom in button
|
|
$('#dxWaterfallMenu').on('click', '.zoom-in-icon:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.zoomIn();
|
|
});
|
|
|
|
// Handle click on zoom out button
|
|
$('#dxWaterfallMenu').on('click', '.zoom-out-icon:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.zoomOut();
|
|
});
|
|
|
|
// Handle click on zoom reset button
|
|
$('#dxWaterfallMenu').on('click', '.zoom-reset-icon:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.resetZoom();
|
|
});
|
|
|
|
// Handle click on label size cycle button (with processing lock to prevent double-click)
|
|
$('#dxWaterfallMenu').on('click', '.label-size-icon', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
// Lock check: if already processing, ignore this click
|
|
if (dxWaterfall.labelSizeProcessing) {
|
|
DX_WATERFALL_UTILS.log.debug('[Label Size] Click ignored (already processing)');
|
|
return;
|
|
}
|
|
|
|
// Set lock immediately
|
|
dxWaterfall.labelSizeProcessing = true;
|
|
|
|
var oldLevel = dxWaterfall.labelSizeLevel;
|
|
DX_WATERFALL_UTILS.log.debug('[Label Size] Click ACCEPTED, current level: ' + oldLevel);
|
|
|
|
// Cycle through 5 label sizes: 0 -> 1 -> 2 -> 3 -> 4 -> 0
|
|
dxWaterfall.labelSizeLevel = (dxWaterfall.labelSizeLevel + 1) % 5;
|
|
|
|
DX_WATERFALL_UTILS.log.debug('[Label Size] New level after cycle: ' + dxWaterfall.labelSizeLevel);
|
|
|
|
// Save to cookie
|
|
dxWaterfall.saveFontSizeToCookie();
|
|
DX_WATERFALL_UTILS.log.debug('[Label Size] Saved to cookie');
|
|
|
|
// Visual feedback - briefly change icon color BEFORE updating menu
|
|
var icon = $(this);
|
|
icon.css({'color': '#FFFF00', 'transition': 'color 0.2s'});
|
|
|
|
// Wait for visual feedback, then update menu and refresh
|
|
setTimeout(function() {
|
|
DX_WATERFALL_UTILS.log.debug('[Label Size] Updating menu and refreshing...');
|
|
|
|
// Update the menu to show new size in tooltip (this replaces the icon)
|
|
dxWaterfall.updateZoomMenu();
|
|
|
|
// Refresh the display to show new label sizes
|
|
dxWaterfall.refresh();
|
|
|
|
// Release lock after refresh completes (add small delay for safety)
|
|
setTimeout(function() {
|
|
dxWaterfall.labelSizeProcessing = false;
|
|
DX_WATERFALL_UTILS.log.debug('[Label Size] Processing lock released');
|
|
}, 100);
|
|
}, DX_WATERFALL_CONSTANTS.DEBOUNCE.ZOOM_ICON_FEEDBACK_MS);
|
|
}); // Handle click on previous band spot button
|
|
$('#dxWaterfallMenu').on('click', '.prev-spot-icon:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.prevSpot();
|
|
});
|
|
|
|
// Handle click on next band spot button
|
|
$('#dxWaterfallMenu').on('click', '.next-spot-icon:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.nextSpot();
|
|
});
|
|
|
|
// Handle click on Smart DX Hunter icon
|
|
$('#dxWaterfallMenu').on('click', '.smart-hunter-icon:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.nextSmartHunterSpot();
|
|
});
|
|
|
|
// Handle click on Smart DX Hunter text
|
|
$('#dxWaterfallMenu').on('click', '.smart-hunter-text:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.nextSmartHunterSpot();
|
|
});
|
|
|
|
// Handle click on continent cycle icon
|
|
$('#dxWaterfallMenu').on('click', '.continent-cycle-icon:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.cycleContinent();
|
|
});
|
|
|
|
// Handle click on continent cycle text
|
|
$('#dxWaterfallMenu').on('click', '.continent-cycle-text:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.cycleContinent();
|
|
});
|
|
|
|
// Handle click on mode filter - Phone
|
|
$('#dxWaterfallMenu').on('click', '.mode-filter-phone:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.toggleModeFilter('phone');
|
|
});
|
|
|
|
// Handle click on mode filter - CW
|
|
$('#dxWaterfallMenu').on('click', '.mode-filter-cw:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.toggleModeFilter('cw');
|
|
});
|
|
|
|
// Handle click on mode filter - Digi
|
|
$('#dxWaterfallMenu').on('click', '.mode-filter-digi:not(.disabled)', function(e) {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
|
|
dxWaterfall.toggleModeFilter('digi');
|
|
});
|
|
|
|
// Handle canvas click events for frequency detection
|
|
DX_WATERFALL_UTILS.dom.getWaterfall().on('click', function(e) {
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.TUNING) {
|
|
return;
|
|
}
|
|
|
|
// Only allow clicks in READY state
|
|
if (currentState !== DX_WATERFALL_CONSTANTS.STATES.READY) {
|
|
return;
|
|
}
|
|
|
|
// Get click coordinates relative to canvas
|
|
var canvas = this;
|
|
var rect = canvas.getBoundingClientRect();
|
|
var x = e.clientX - rect.left;
|
|
var y = e.clientY - rect.top;
|
|
|
|
// Check if user clicked on a spot label
|
|
var clickedSpot = dxWaterfall.findSpotAtPosition(x, y);
|
|
if (clickedSpot && clickedSpot.frequency) {
|
|
|
|
// Preserve spot selection across frequency changes
|
|
dxWaterfall.pendingSpotSelection = {
|
|
callsign: clickedSpot.callsign,
|
|
frequency: clickedSpot.frequency
|
|
};
|
|
|
|
// Update currentSpotIndex when clicking stacked center spots
|
|
if (dxWaterfall.relevantSpots && dxWaterfall.relevantSpots.length > 0) {
|
|
for (var i = 0; i < dxWaterfall.relevantSpots.length; i++) {
|
|
if (dxWaterfall.relevantSpots[i].callsign === clickedSpot.callsign &&
|
|
Math.abs(dxWaterfall.relevantSpots[i].frequency - clickedSpot.frequency) < 0.01) {
|
|
dxWaterfall.currentSpotIndex = i;
|
|
// Update spot info display to show the selected spot with visual border
|
|
dxWaterfall.updateSpotInfoDiv();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Set navigation flag to block refresh interference during spot click
|
|
DX_WATERFALL_UTILS.navigation.navigating = true;
|
|
|
|
// CRITICAL: Set mode FIRST (without triggering change event), THEN set frequency
|
|
// This ensures setFrequency() reads the correct mode from the dropdown
|
|
var frequencyHz = parseFloat(clickedSpot.frequency) * 1000; // Convert kHz to Hz
|
|
var radioMode = determineRadioMode(clickedSpot.mode, frequencyHz);
|
|
setMode(radioMode, true); // skipTrigger = true to prevent change event
|
|
|
|
// Now set frequency - it will read the correct mode from the dropdown
|
|
setFrequency(clickedSpot.frequency, true);
|
|
|
|
// In offline mode, update virtual CAT state with spot frequency and mode
|
|
if (typeof isCATAvailable === 'function' && !isCATAvailable()) {
|
|
var spotFreqHz = Math.round(clickedSpot.frequency * 1000); // Convert kHz to Hz
|
|
if (typeof window.catState === 'undefined' || window.catState === null) {
|
|
window.catState = {};
|
|
}
|
|
window.catState.frequency = spotFreqHz;
|
|
window.catState.mode = radioMode;
|
|
window.catState.lastUpdate = Date.now();
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Offline mode - spot click updated virtual CAT: freq=' + spotFreqHz + ' Hz, mode=' + radioMode);
|
|
}
|
|
|
|
// Send frequency command again after short delay to correct any drift from mode change
|
|
// (radio control lib bug: mode change can cause slight frequency shift)
|
|
setTimeout(function() {
|
|
setFrequency(clickedSpot.frequency, true);
|
|
}, 200); // 200ms delay to let mode change settle
|
|
|
|
// Update band spot collection and zoom menu after navigation
|
|
// This ensures the next/prev spot buttons reflect the new position
|
|
setTimeout(function() {
|
|
dxWaterfall.collectAllBandSpots(true); // Force update after spot click
|
|
dxWaterfall.updateZoomMenu(true); // Force update with forceUpdate=true
|
|
}, 300); // After frequency has settled
|
|
|
|
// Populate QSO form - flag will be cleared when population completes
|
|
DX_WATERFALL_UTILS.qsoForm.populateFromSpot(clickedSpot, true);
|
|
|
|
return; // Don't calculate frequency from position
|
|
}
|
|
|
|
// DEBOUNCE: Prevent rapid-fire clicks (multiple events from single physical click)
|
|
var now = Date.now();
|
|
if (typeof DX_WATERFALL_UTILS.dom.getWaterfall().data('lastClickTime') === 'undefined') {
|
|
DX_WATERFALL_UTILS.dom.getWaterfall().data('lastClickTime', 0);
|
|
}
|
|
var lastClickTime = DX_WATERFALL_UTILS.dom.getWaterfall().data('lastClickTime');
|
|
if (now - lastClickTime < 300) { // Ignore clicks within 300ms of previous click
|
|
return;
|
|
}
|
|
DX_WATERFALL_UTILS.dom.getWaterfall().data('lastClickTime', now);
|
|
|
|
// No spot label clicked - calculate frequency at clicked position
|
|
var centerX = canvas.width / 2;
|
|
var middleFreq = dxWaterfall.getCachedMiddleFreq(); // Use cached frequency
|
|
var pixelsPerKHz = dxWaterfall.getPixelsPerKHz(); // Mode-aware scaling
|
|
|
|
// Calculate frequency offset from center
|
|
var pixelOffset = x - centerX;
|
|
var freqOffset = pixelOffset / pixelsPerKHz;
|
|
var clickedFreq = middleFreq + freqOffset;
|
|
|
|
// Round to 3 decimal places (1 Hz precision in kHz)
|
|
clickedFreq = Math.round(clickedFreq * 1000) / 1000;
|
|
|
|
// Prevent setting frequency <= 0 (not physically valid)
|
|
if (clickedFreq <= 0) {
|
|
return; // Ignore clicks in invalid frequency range
|
|
}
|
|
|
|
// Set the frequency to where user clicked
|
|
setFrequency(clickedFreq, true);
|
|
|
|
// Update cache directly AND sync tracking variables to prevent recalculation
|
|
var formattedFreq = Math.round(clickedFreq * 1000); // Convert to Hz
|
|
dxWaterfall.cache.middleFreq = clickedFreq;
|
|
dxWaterfall.lastValidCommittedFreq = clickedFreq; // Store in kHz
|
|
dxWaterfall.lastValidCommittedUnit = 'kHz';
|
|
dxWaterfall.lastQrgUnit = 'kHz';
|
|
|
|
// In offline mode, update catState with clicked frequency (virtual CAT)
|
|
if (typeof isCATAvailable === 'function' && !isCATAvailable()) {
|
|
if (typeof window.catState === 'undefined' || window.catState === null) {
|
|
window.catState = {};
|
|
}
|
|
window.catState.frequency = formattedFreq; // Hz
|
|
window.catState.lastUpdate = Date.now();
|
|
DX_WATERFALL_UTILS.log.debug('[DX Waterfall] Offline mode - waterfall click updated virtual CAT: freq=' + formattedFreq + ' Hz');
|
|
}
|
|
|
|
// Update band spot collection and zoom menu to reflect new position
|
|
// This ensures next/prev spot buttons and position counter are updated
|
|
setTimeout(function() {
|
|
dxWaterfall.collectAllBandSpots(true); // Force update after frequency click
|
|
dxWaterfall.updateZoomMenu(true); // Force update with forceUpdate=true
|
|
}, 100); // Brief delay to let frequency settle
|
|
|
|
// Note: No need to call commitFrequency() here since we already set
|
|
// lastValidCommittedFreq directly above
|
|
});
|
|
|
|
// Handle keyboard shortcuts
|
|
$(document).on('keydown', function(e) {
|
|
// Block keyboard shortcuts when in TUNING state
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.TUNING) {
|
|
return; // Don't handle keys during frequency changes
|
|
}
|
|
|
|
// Use Cmd on Mac, Ctrl on Windows/Linux
|
|
var modKey = PlatformDetection.isModifierKey(e);
|
|
|
|
// Ctrl/Cmd+Left: Previous spot
|
|
if (modKey && !e.shiftKey && e.key === 'ArrowLeft') {
|
|
e.preventDefault();
|
|
dxWaterfall.prevSpot();
|
|
}
|
|
// Ctrl/Cmd+Right: Next spot
|
|
else if (modKey && !e.shiftKey && e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
dxWaterfall.nextSpot();
|
|
}
|
|
// Ctrl/Cmd+Up: Jump to last spot in band
|
|
else if (modKey && !e.shiftKey && e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
dxWaterfall.lastSpot();
|
|
}
|
|
// Ctrl/Cmd+Down: Jump to first spot in band
|
|
else if (modKey && !e.shiftKey && e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
dxWaterfall.firstSpot();
|
|
}
|
|
// Ctrl/Cmd++: Zoom in
|
|
else if (modKey && !e.shiftKey && (e.key === '+' || e.key === '=')) {
|
|
e.preventDefault();
|
|
dxWaterfall.zoomIn();
|
|
}
|
|
// Ctrl/Cmd+-: Zoom out
|
|
else if (modKey && !e.shiftKey && (e.key === '-' || e.key === '_')) {
|
|
e.preventDefault();
|
|
dxWaterfall.zoomOut();
|
|
}
|
|
// Ctrl/Cmd+Space: Cycle through nearby spots
|
|
else if (modKey && !e.shiftKey && e.key === ' ') {
|
|
e.preventDefault();
|
|
// Trigger the existing cycle icon click
|
|
var cycleIcon = $('#dxWaterfallSpotContent .cycle-spot-icon');
|
|
if (cycleIcon.length > 0) {
|
|
cycleIcon.trigger('click');
|
|
}
|
|
}
|
|
// Ctrl/Cmd+Shift+Space: Tune to current spot frequency
|
|
else if (modKey && e.shiftKey && e.key === ' ') {
|
|
e.preventDefault();
|
|
// Find the tune icon in the spot info div and trigger it
|
|
var tuneIcon = $('#dxWaterfallSpotContent .tune-icon');
|
|
if (tuneIcon.length > 0) {
|
|
tuneIcon.trigger('click');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle DX Waterfall power on/off
|
|
var waterfallActive = false;
|
|
var waterfallRefreshInterval = null; // Store interval ID for cleanup
|
|
|
|
// Initialize UI text and tooltip from language variables
|
|
$('#dxWaterfallMessage').text(lang_dxwaterfall_turn_on);
|
|
$('#dxWaterfallPowerOnIcon').attr('title', lang_dxwaterfall_turn_on);
|
|
$('#dxWaterfallPowerOffIcon').attr('title', lang_dxwaterfall_turn_off);
|
|
|
|
// Function to turn on DX Waterfall (shared by icon and message click)
|
|
var turnOnWaterfall = function(e) {
|
|
// DEBUG: Log what triggered power-on
|
|
DX_WATERFALL_UTILS.log.debug('[Power Control] Power-ON triggered', {
|
|
currentState: DXWaterfallStateMachine.getState(),
|
|
waterfallActive: waterfallActive,
|
|
eventType: e ? e.type : 'unknown',
|
|
eventTarget: e ? e.target : 'unknown'
|
|
});
|
|
|
|
if (waterfallActive) {
|
|
DX_WATERFALL_UTILS.log.debug('[Power Control] Already active, ignoring');
|
|
return; // Already active, prevent double initialization
|
|
}
|
|
|
|
waterfallActive = true;
|
|
|
|
// Update UI - hide header, show content area, show power-off icon, update container styling
|
|
$('#dxWaterfallSpot').addClass('active');
|
|
$('#dxWaterfallSpotHeader').addClass('hidden');
|
|
$('#dxWaterfallSpotContent').addClass('active');
|
|
$('#dxWaterfallPowerOffIcon').addClass('active');
|
|
$('#dxWaterfallHelpIconOff').addClass('active');
|
|
|
|
// Show waterfall and menu
|
|
$('#dxWaterfallCanvasContainer').show();
|
|
$('#dxWaterfallMenuContainer').show();
|
|
|
|
// Initialize waterfall - destroy first if already exists to ensure clean state
|
|
if (typeof dxWaterfall !== 'undefined') {
|
|
// Check current state - only destroy if in a state that needs cleanup
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
|
|
// Don't destroy if:
|
|
// - Already disabled (nothing to destroy)
|
|
// - In INITIALIZING state (let it finish initializing)
|
|
var shouldDestroy = currentState !== DX_WATERFALL_CONSTANTS.STATES.DISABLED &&
|
|
currentState !== DX_WATERFALL_CONSTANTS.STATES.INITIALIZING &&
|
|
dxWaterfall.canvas;
|
|
|
|
if (shouldDestroy) {
|
|
dxWaterfall.destroy();
|
|
}
|
|
|
|
// Only proceed if currently disabled
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.DISABLED) {
|
|
// Transition to INITIALIZING state immediately
|
|
DXWaterfallStateMachine.setState(DX_WATERFALL_CONSTANTS.STATES.INITIALIZING);
|
|
|
|
// Initialize canvas immediately so we can show waiting message
|
|
dxWaterfall._initializeCanvas();
|
|
|
|
// Show waiting message using the helper function (cleaner and consistent)
|
|
dxWaterfall.displayWaitingMessage(lang_dxwaterfall_please_wait);
|
|
|
|
// Set up periodic refresh interval
|
|
waterfallRefreshInterval = setInterval(function() {
|
|
if (dxWaterfall.canvas) {
|
|
var currentState = DXWaterfallStateMachine.getState();
|
|
if (currentState === DX_WATERFALL_CONSTANTS.STATES.TUNING) {
|
|
// Fast refresh during CAT operations for spinner animation
|
|
dxWaterfall.refresh();
|
|
} else {
|
|
// Normal refresh when idle
|
|
dxWaterfall.refresh();
|
|
}
|
|
}
|
|
}, DX_WATERFALL_CONSTANTS.VISUAL.STATIC_NOISE_REFRESH_MS);
|
|
|
|
// Add 3 second delay before actually initializing and fetching data
|
|
// This allows page to fully load and stabilize
|
|
setTimeout(function() {
|
|
// Only proceed if still in INITIALIZING state (not manually changed)
|
|
if (DXWaterfallStateMachine.getState() === DX_WATERFALL_CONSTANTS.STATES.INITIALIZING) {
|
|
// Continue initialization - check for valid frequency and start data fetch
|
|
dxWaterfall._continueInitialization();
|
|
}
|
|
}, 3000); // 3 second delay before data fetch
|
|
}
|
|
}
|
|
};
|
|
|
|
// Click anywhere on the header div to turn on DX Waterfall
|
|
// Use .off().on() to prevent double-binding if script loads multiple times
|
|
$('#dxWaterfallSpotHeader').off('click').on('click', turnOnWaterfall);
|
|
|
|
// Click on power-off icon to turn off DX Waterfall
|
|
$('#dxWaterfallPowerOffIcon').off('click').on('click', function(e) {
|
|
e.stopPropagation(); // Prevent triggering parent click
|
|
|
|
// DEBUG: Log what triggered power-off
|
|
DX_WATERFALL_UTILS.log.debug('[Power Control] Power-OFF triggered', {
|
|
currentState: DXWaterfallStateMachine.getState(),
|
|
waterfallActive: waterfallActive,
|
|
eventType: e ? e.type : 'unknown',
|
|
eventTarget: e ? e.target : 'unknown'
|
|
});
|
|
|
|
if (!waterfallActive) {
|
|
DX_WATERFALL_UTILS.log.debug('[Power Control] Already inactive, ignoring');
|
|
return; // Already inactive
|
|
}
|
|
|
|
waterfallActive = false;
|
|
|
|
// Stop the refresh interval (managed outside dxWaterfall object)
|
|
if (waterfallRefreshInterval) {
|
|
clearInterval(waterfallRefreshInterval);
|
|
waterfallRefreshInterval = null;
|
|
}
|
|
|
|
// Destroy the waterfall component (handles cleanup of memory, timers, event handlers, and DOM refs)
|
|
if (typeof dxWaterfall !== 'undefined' && dxWaterfall.canvas) {
|
|
dxWaterfall.destroy();
|
|
}
|
|
|
|
// Update UI - show header, hide content area, hide power-off icon, update container styling
|
|
$('#dxWaterfallSpot').removeClass('active');
|
|
$('#dxWaterfallSpotHeader').removeClass('hidden');
|
|
$('#dxWaterfallSpotContent').removeClass('active');
|
|
$('#dxWaterfallPowerOffIcon').removeClass('active');
|
|
$('#dxWaterfallHelpIconOff').removeClass('active');
|
|
|
|
// Hide waterfall and menu
|
|
$('#dxWaterfallCanvasContainer').hide();
|
|
$('#dxWaterfallMenuContainer').hide();
|
|
});
|
|
}); // End of $(document).ready()
|
|
} else {
|
|
// jQuery not loaded yet, try again in 50ms
|
|
setTimeout(waitForJQuery, 50);
|
|
}
|
|
})(); // End of waitForJQuery IIFE
|
|
|
|
|