diff --git a/application/controllers/Band.php b/application/controllers/Band.php
index fcf836d93..b8d95093a 100644
--- a/application/controllers/Band.php
+++ b/application/controllers/Band.php
@@ -46,6 +46,18 @@ class Band extends CI_Controller {
$this->load->view('interface_assets/footer', $footerData);
}
+ // API endpoint to get band edges for the logged-in user
+ public function get_user_bandedges()
+ {
+ $this->load->model('bands');
+
+ $data = $this->bands->get_all_bandedges_for_user();
+
+ header('Content-Type: application/json');
+ echo json_encode($data);
+ return;
+ }
+
public function create()
{
$this->load->model('bands');
diff --git a/application/controllers/Qso.php b/application/controllers/Qso.php
index a5277d0e6..dc401d80d 100644
--- a/application/controllers/Qso.php
+++ b/application/controllers/Qso.php
@@ -88,6 +88,14 @@ class QSO extends CI_Controller {
$data['user_dok_to_qso_tab'] = 0;
}
+ // Get status of DX Waterfall enable option
+ $qkey_opt=$this->user_options_model->get_options('dxwaterfall',array('option_name'=>'enable','option_key'=>'boolean'))->result();
+ if (count($qkey_opt)>0) {
+ $data['user_dxwaterfall_enable'] = $qkey_opt[0]->option_value;
+ } else {
+ $data['user_dxwaterfall_enable'] = 0;
+ }
+
$data['qso_count'] = $this->session->userdata('qso_page_last_qso_count');
$this->load->library('form_validation');
diff --git a/application/controllers/User.php b/application/controllers/User.php
index aed72819e..aca9ead7a 100644
--- a/application/controllers/User.php
+++ b/application/controllers/User.php
@@ -208,6 +208,7 @@ class User extends CI_Controller {
$data['user_dashboard_map'] = $this->input->post('user_dashboard_map') ?? 'Y';
$data['user_dashboard_banner'] = $this->input->post('user_dashboard_banner') ?? 'Y';
$data['user_dashboard_solar'] = $this->input->post('user_dashboard_solar') ?? 'Y';
+ $data['user_dxwaterfall_enable'] = $this->input->post('user_dxwaterfall_enable') ?? 'N';
$data['user_stylesheet'] = $this->input->post('user_stylesheet');
$data['user_qth_lookup'] = $this->input->post('user_qth_lookup');
$data['user_sota_lookup'] = $this->input->post('user_sota_lookup');
@@ -303,6 +304,7 @@ class User extends CI_Controller {
$this->input->post('user_dashboard_banner') ?? 'Y',
$this->input->post('user_dashboard_solar') ?? 'Y',
$this->input->post('clubstation') == '1' ? true : false,
+ $this->input->post('user_dxwaterfall_enable') ?? 'N',
$this->input->post('global_oqrs_text') ?? '',
$this->input->post('oqrs_grouped_search') ?? 'off',
$this->input->post('oqrs_grouped_search_show_station_name') ?? 'off',
@@ -366,6 +368,7 @@ class User extends CI_Controller {
$data['oqrs_grouped_search_show_station_name'] = $this->input->post('oqrs_grouped_search_show_station_name') ?? 'off';
$data['oqrs_auto_matching'] = $this->input->post('oqrs_auto_matching') ?? 'on';
$data['oqrs_direct_auto_matching'] = $this->input->post('oqrs_direct_auto_matching') ?? 'on';
+ $data['user_dxwaterfall_enable'] = $this->input->post('user_dxwaterfall_enable') ?? 'N';
$this->load->view('user/edit', $data);
$this->load->view('interface_assets/footer', $footerData);
}
@@ -733,6 +736,16 @@ class User extends CI_Controller {
}
}
+ // DX Waterfall enable option
+ if($this->input->post('user_dxwaterfall_enable')) {
+ $data['user_dxwaterfall_enable'] = $this->input->post('user_dxwaterfall_enable', false);
+ } else {
+ $dkey_opt=$this->user_options_model->get_options('dxwaterfall',array('option_name'=>'enable','option_key'=>'boolean'), $this->uri->segment(3))->result();
+ if (count($dkey_opt)>0) {
+ $data['user_dxwaterfall_enable'] = $dkey_opt[0]->option_value;
+ }
+ }
+
if($this->input->post('user_hamsat_workable_only')) {
$data['user_hamsat_workable_only'] = $this->input->post('user_hamsat_workable_only', false);
} else {
diff --git a/application/models/User_model.php b/application/models/User_model.php
index a189f1546..22782cbcc 100644
--- a/application/models/User_model.php
+++ b/application/models/User_model.php
@@ -224,7 +224,7 @@ class User_Model extends CI_Model {
$user_wwff_to_qso_tab, $user_pota_to_qso_tab, $user_sig_to_qso_tab, $user_dok_to_qso_tab,
$user_lotw_name, $user_lotw_password, $user_eqsl_name, $user_eqsl_password, $user_clublog_name, $user_clublog_password,
$user_winkey, $on_air_widget_enabled, $on_air_widget_display_last_seen, $on_air_widget_show_only_most_recent_radio,
- $qso_widget_display_qso_time, $dashboard_banner,$dashboard_solar, $clubstation = 0) {
+ $qso_widget_display_qso_time, $dashboard_banner,$dashboard_solar, $clubstation = 0,$user_dxwaterfall_enable) {
// Check that the user isn't already used
if(!$this->exists($username)) {
$data = array(
@@ -313,7 +313,7 @@ class User_Model extends CI_Model {
$this->db->query("insert into user_options (user_id, option_type, option_name, option_key, option_value) values (" . $insert_id . ", 'widget','on_air','display_last_seen','".(xss_clean($on_air_widget_display_last_seen ?? 'false'))."');");
$this->db->query("insert into user_options (user_id, option_type, option_name, option_key, option_value) values (" . $insert_id . ", 'widget','on_air','display_only_most_recent_radio','".(xss_clean($on_air_widget_show_only_most_recent_radio ?? 'true'))."');");
$this->db->query("insert into user_options (user_id, option_type, option_name, option_key, option_value) values (" . $insert_id . ", 'widget','qso','display_qso_time','".(xss_clean($qso_widget_display_qso_time ?? 'false'))."');");
-
+ $this->db->query("insert into user_options (user_id, option_type, option_name, option_key, option_value) values (" . $insert_id . ", 'dxwaterfall','enable','boolean','".xss_clean($user_dxwaterfall_enable ?? 'N')."');");
return OK;
} else {
return EUSERNAMEEXISTS;
@@ -387,11 +387,13 @@ class User_Model extends CI_Model {
$this->db->query("replace into user_options (user_id, option_type, option_name, option_key, option_value) values (" . $fields['id'] . ", 'dashboard','show_map','boolean','".xss_clean($fields['user_dashboard_map'] ?? 'Y')."');");
$this->db->query("replace into user_options (user_id, option_type, option_name, option_key, option_value) values (" . $fields['id'] . ", 'dashboard','show_dashboard_banner','boolean','".xss_clean($fields['user_dashboard_banner'] ?? 'Y')."');");
$this->db->query("replace into user_options (user_id, option_type, option_name, option_key, option_value) values (" . $fields['id'] . ", 'dashboard','show_dashboard_solar','boolean','".xss_clean($fields['user_dashboard_solar'] ?? 'N')."');");
+ $this->db->query("replace into user_options (user_id, option_type, option_name, option_key, option_value) values (" . $fields['id'] . ", 'dxwaterfall','enable','boolean','".xss_clean($fields['user_dxwaterfall_enable'] ?? 'N')."');");
$this->session->set_userdata('dashboard_last_qso_count', $dashboard_last_qso_count);
$this->session->set_userdata('qso_page_last_qso_count', $qso_page_last_qso_count);
$this->session->set_userdata('user_dashboard_map',xss_clean($fields['user_dashboard_map'] ?? 'Y'));
$this->session->set_userdata('user_dashboard_banner',xss_clean($fields['user_dashboard_banner'] ?? 'Y'));
$this->session->set_userdata('user_dashboard_solar',xss_clean($fields['user_dashboard_solar'] ?? 'N'));
+ $this->session->set_userdata('user_dxwaterfall_enable',xss_clean($fields['user_dxwaterfall_enable'] ?? 'N'));
// Check to see if the user is allowed to change user levels
if($this->session->userdata('user_type') == 99) {
@@ -551,6 +553,7 @@ class User_Model extends CI_Model {
'user_dashboard_map' => ((($this->session->userdata('user_dashboard_map') ?? 'Y') == 'Y') ? $this->user_options_model->get_options('dashboard', array('option_name' => 'show_map', 'option_key' => 'boolean'))->row()->option_value ?? 'Y' : $this->session->userdata('user_dashboard_map')),
'user_dashboard_banner' => ((($this->session->userdata('user_dashboard_banner') ?? 'Y') == 'Y') ? $this->user_options_model->get_options('dashboard', array('option_name' => 'show_dashboard_banner', 'option_key' => 'boolean'))->row()->option_value ?? 'Y' : $this->session->userdata('user_dashboard_banner')),
'user_dashboard_solar' => ((($this->session->userdata('user_dashboard_solar') ?? 'N') == 'Y') ? $this->session->userdata('user_dashboard_solar') : $this->user_options_model->get_options('dashboard', array('option_name' => 'show_dashboard_solar', 'option_key' => 'boolean'))->row()->option_value ?? 'N'),
+ 'user_dxwaterfall_enable' => ((($this->session->userdata('user_dxwaterfall_enable') ?? 'N') == 'Y') ? $this->session->userdata('user_dxwaterfall_enable') : $this->user_options_model->get_options('dxwaterfall', array('option_name' => 'enable', 'option_key' => 'boolean'))->row()->option_value ?? 'N'),
'user_date_format' => $u->row()->user_date_format,
'user_stylesheet' => $u->row()->user_stylesheet,
'user_qth_lookup' => isset($u->row()->user_qth_lookup) ? $u->row()->user_qth_lookup : 0,
diff --git a/application/views/interface_assets/footer.php b/application/views/interface_assets/footer.php
index d0ac6b6ae..9c5352943 100644
--- a/application/views/interface_assets/footer.php
+++ b/application/views/interface_assets/footer.php
@@ -69,6 +69,47 @@
var lang_notes_duplication_disabled_short = "= __("Duplication Disabled"); ?>";
var lang_notes_not_found = "= __("No notes were found"); ?>";
+ /*
+ DX Waterfall Language
+ */
+ var lang_dxwaterfall_tune_to_spot = "= __("Tune to spot frequency [Ctrl+Shift+Space]"); ?>";
+ var lang_dxwaterfall_cycle_through = "= __("Cycle through"); ?>";
+ var lang_dxwaterfall_spots_currently_showing = "= __("spots (currently showing"); ?>";
+ var lang_dxwaterfall_log_qso_with = "= __("Log QSO with"); ?>";
+ var lang_dxwaterfall_new_continent = "= __("New Continent"); ?>";
+ var lang_dxwaterfall_new_dxcc = "= __("New DXCC"); ?>";
+ var lang_dxwaterfall_new_callsign = "= __("New Callsign"); ?>";
+ var lang_dxwaterfall_previous_spot = "= __("Previous spot [Ctrl+Left] | First spot [Ctrl+Down]"); ?>";
+ var lang_dxwaterfall_no_spots_lower = "= __("No spots at lower frequency"); ?>";
+ var lang_dxwaterfall_next_spot = "= __("Next spot [Ctrl+Right] | Last spot [Ctrl+Up]"); ?>";
+ var lang_dxwaterfall_no_spots_higher = "= __("No spots at higher frequency"); ?>";
+ var lang_dxwaterfall_no_spots_available = "= __("No spots available"); ?>";
+ var lang_dxwaterfall_cycle_unworked = "= __("Cycle through unworked continents/DXCC"); ?>";
+ var lang_dxwaterfall_dx_hunter = "= __("DX Hunter"); ?>";
+ var lang_dxwaterfall_no_unworked = "= __("No unworked continents/DXCC on this band"); ?>";
+ var lang_dxwaterfall_fetching_spots = "= __("Fetching spots..."); ?>";
+ var lang_dxwaterfall_click_to_cycle = "= __("Click to cycle or wait 1.5s to apply"); ?>";
+ var lang_dxwaterfall_change_continent = "= __("Change spotter continent"); ?>";
+ var lang_dxwaterfall_filter_by_mode = "= __("Filter by mode"); ?>";
+ var lang_dxwaterfall_toggle_phone = "= __("Toggle Phone mode filter"); ?>";
+ var lang_dxwaterfall_phone = "= __("Phone"); ?>";
+ var lang_dxwaterfall_toggle_cw = "= __("Toggle CW mode filter"); ?>";
+ var lang_dxwaterfall_cw = "= __("CW"); ?>";
+ var lang_dxwaterfall_toggle_digi = "= __("Toggle Digital mode filter"); ?>";
+ var lang_dxwaterfall_digi = "= __("Digi"); ?>";
+ var lang_dxwaterfall_zoom_out = "= __("Zoom out [Ctrl+-]"); ?>";
+ var lang_dxwaterfall_reset_zoom = "= __("Reset zoom to default (3)"); ?>";
+ var lang_dxwaterfall_zoom_in = "= __("Zoom in [Ctrl++]"); ?>";
+ var lang_dxwaterfall_waiting_data = "= __("Waiting for DX Cluster data..."); ?>";
+ var lang_dxwaterfall_comment = "= __("Comment: "); ?>";
+ var lang_dxwaterfall_modes_label = "= __("modes:"); ?>";
+ var lang_dxwaterfall_out_of_bandplan = "= __("OUT OF BANDPLAN"); ?>";
+ var lang_dxwaterfall_changing_frequency = "= __("Changing radio frequency..."); ?>";
+ var lang_dxwaterfall_spots_fetched = "= __("spot(s) de"); ?>";
+ var lang_dxwaterfall_fetched_for_band = "= __("fetched from DXCluster for band"); ?>";
+ var lang_dxwaterfall_displaying = "= __(", displaying"); ?>";
+ var lang_dxwaterfall_invalid = "= __("INVALID"); ?>";
+
@@ -1096,6 +1137,9 @@ $($('#callsign')).on('keypress',function(e) {
uri->segment(1) == "qso") { ?>
+session->userdata('user_dxwaterfall_enable') == 'Y') { ?>
+
+
session->userdata('isWinkeyEnabled')) { ?>
@@ -1366,6 +1410,9 @@ mymap.on('mousemove', onQsoMapMove);
+
+session->userdata('user_dxwaterfall_enable') == 'Y' && $manual_mode == 0) { ?>
+
+
+
+
@@ -818,7 +964,7 @@ switch ($date_format) {
optionslib->get_option('disable_refresh_past_contacts');
if($result === null) { ?>
-
+
diff --git a/application/views/user/edit.php b/application/views/user/edit.php
index e9a2ad415..b5a7e6a7d 100644
--- a/application/views/user/edit.php
+++ b/application/views/user/edit.php
@@ -430,6 +430,16 @@
} ?>
+
+
+
+
+
+ = __("Show an interactive DX Cluster 'Waterfall' on the QSO logging page."); ?>
+
diff --git a/assets/js/dxwaterfall.js b/assets/js/dxwaterfall.js
new file mode 100644
index 000000000..ef287bddf
--- /dev/null
+++ b/assets/js/dxwaterfall.js
@@ -0,0 +1,4558 @@
+// ========================================
+// DX WATERFALL for WaveLog
+// Check at https://www.wavelog.org
+// ========================================
+
+// ========================================
+// CONSTANTS AND CONFIGURATION
+// ========================================
+
+var DX_WATERFALL_CONSTANTS = {
+ // 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
+ },
+
+ // CAT and radio control
+ CAT: {
+ TUNING_FLAG_FALLBACK_MS: 2000, // Fallback timeout for tuning flags (2 seconds)
+ FREQUENCY_WAIT_TIMEOUT_MS: 3000 // Initial load wait time for CAT frequency
+ },
+
+ // Visual timing
+ VISUAL: {
+ STATIC_NOISE_REFRESH_MS: 100 // Static noise animation frame rate
+ },
+
+ // 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
+ BAND_CHANGE_THRESHOLD: 1000, // kHz outside band before recalculation
+ MAJOR_TICK_TOLERANCE: 0.05, // Floating point precision for major tick detection
+ SPOT_FREQUENCY_MATCH: 0.01 // Frequency match tolerance for spot navigation (kHz)
+ },
+
+ // Zoom levels configuration
+ ZOOM: {
+ DEFAULT_LEVEL: 3, // Default zoom level
+ MAX_LEVEL: 5, // Maximum zoom level
+ MIN_LEVEL: 1, // Minimum zoom level
+ // Pixels per kHz for each zoom level
+ PIXELS_PER_KHZ: {
+ 1: 4, // ±25 kHz view (widest)
+ 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',
+ OVERLAY_BACKGROUND: 'rgba(0, 0, 0, 0.7)',
+ OVERLAY_DARK_GREY: 'rgba(64, 64, 64, 0.7)',
+
+ // 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',
+ BANDWIDTH_INDICATOR: 'rgba(255, 255, 0, 0.3)',
+
+ // Messages and text
+ MESSAGE_TEXT_WHITE: '#FFFFFF',
+ MESSAGE_TEXT_YELLOW: '#FFFF00',
+ WATERFALL_LINK: '#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',
+
+ // Band limits
+ OUT_OF_BAND: 'rgba(255, 0, 0, 0.2)'
+ },
+
+ // Font configurations
+ FONTS: {
+ RULER: '11px "Consolas", "Courier New", monospace',
+ CENTER_MARKER: '12px "Consolas", "Courier New", monospace',
+ SPOT_LABELS: 'bold 12px "Consolas", "Courier New", monospace',
+ SPOT_INFO: '11px "Consolas", "Courier New", monospace',
+ WAITING_MESSAGE: '16px "Consolas", "Courier New", monospace',
+ TITLE_LARGE: 'bold 24px "Consolas", "Courier New", monospace',
+ FREQUENCY_CHANGE: 'bold 18px "Consolas", "Courier New", monospace',
+ OUT_OF_BAND: 'bold 14px "Consolas", "Courier New", monospace',
+ SMALL_MONO: '12px "Consolas", "Courier New", monospace'
+ },
+
+ // Available continents for cycling
+ CONTINENTS: ['AF', 'AN', 'AS', 'EU', 'NA', 'OC', 'SA'],
+
+ // Logo configuration
+ LOGO_FILENAME: 'assets/logo/wavelog_logo_darkly_wide.png',
+
+ // Data file paths
+ IARU_BANDPLANS_PATH: 'assets/json/iaru_bandplans.json',
+
+ // Frequency thresholds (in kHz)
+ LSB_USB_THRESHOLD_KHZ: 10000, // Below 10 MHz = LSB, above = USB
+
+ // Signal bandwidth constants (in kHz)
+ SIGNAL_BANDWIDTHS: {
+ SSB_KHZ: 2.7, // Standard SSB bandwidth
+ SSB_OFFSET_KHZ: 1.35, // Half bandwidth for offset
+ AM_KHZ: 6.0, // AM bandwidth
+ FM_KHZ: 12.0, // FM bandwidth (wide)
+ CW_DETECTION_KHZ: 0.25 // CW detection range
+ },
+
+ // Static FT8 frequencies (in kHz)
+ FT8_FREQUENCIES: [1840, 3573, 7074, 10136, 14074, 18100, 21074, 24915, 28074, 50313, 144174, 432065]
+};
+
+// ========================================
+// UTILITY FUNCTIONS
+// ========================================
+
+var DX_WATERFALL_UTILS = {
+
+ // Frequency conversion utilities
+ frequency: {
+ hzToKhz: function(hz) {
+ return hz / 1000;
+ },
+
+ mhzToKhz: function(mhz) {
+ return mhz * 1000;
+ },
+
+ // Convert any frequency unit to kHz
+ convertToKhz: function(value, unit) {
+ var freqValue = parseFloat(value) || 0;
+ switch (unit.toLowerCase()) {
+ case 'hz':
+ return freqValue / 1000;
+ case 'khz':
+ return freqValue;
+ case 'mhz':
+ return freqValue * 1000;
+ case 'ghz':
+ return freqValue * 1000000;
+ default:
+ return freqValue; // Default to kHz
+ }
+ },
+
+ // 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');
+ }
+ },
+
+ // Mode classification utilities
+ modes: {
+ isCw: function(mode) {
+ return mode && mode.toLowerCase().includes('cw');
+ },
+
+ getModeCategory: function(mode) {
+ if (this.isCw(mode)) return 'cw';
+ return 'other';
+ },
+
+ // 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 'rgba(0, 255, 0, ';
+ case 'cw':
+ return 'rgba(255, 165, 0, ';
+ case 'digi':
+ return 'rgba(0, 150, 255, ';
+ default:
+ return 'rgba(160, 32, 240, ';
+ }
+ },
+
+ /**
+ * Comprehensive mode classification system
+ * Classifies a DX spot into phone, CW, digi, or other categories
+ *
+ * @param {Object} spot - DX spot object with mode and optional message fields
+ * @param {string} spot.mode - The transmission mode
+ * @param {string} [spot.message] - Optional spot comment/message for additional classification hints
+ * @returns {{category: string, submode: string, confidence: number}} Classification result
+ * - category: 'phone', 'cw', 'digi', or 'other'
+ * - submode: Specific mode name (e.g., 'FT8', 'USB', 'CW')
+ * - confidence: 0-1, where 1 is high confidence, 0.3 is low
+ */
+ classifyMode: function(spot) {
+ if (!spot || !spot.mode || spot.mode === '') {
+ return { category: 'other', submode: 'Unknown', confidence: 0 };
+ }
+
+ var mode = spot.mode.toUpperCase();
+ var message = (spot.message || '').toUpperCase();
+ var confidence = 1; // 1 = high confidence, 0.5 = medium, 0.3 = low
+
+ // Check message first for higher accuracy
+ var messageResult = this.classifyFromMessage(message);
+ if (messageResult.category) {
+ return {
+ category: messageResult.category,
+ submode: messageResult.submode,
+ confidence: messageResult.confidence
+ };
+ }
+
+ // Fall back to mode field classification
+ return this.classifyFromMode(mode);
+ },
+
+ classifyFromMessage: function(message) {
+ // CW detection in message
+ if (message.indexOf('CW') !== -1) {
+ return { category: 'cw', submode: 'CW', confidence: 1 };
+ }
+
+ // Digital modes from message
+ var digiModes = [
+ { patterns: ['FT8'], submode: 'FT8' },
+ { patterns: ['FT4'], submode: 'FT4' },
+ { patterns: ['RTTY'], submode: 'RTTY' },
+ { patterns: ['PSK31'], submode: 'PSK31' },
+ { patterns: ['PSK'], submode: 'PSK' },
+ { patterns: ['JT65'], submode: 'JT65' },
+ { patterns: ['JT9'], submode: 'JT9' },
+ { patterns: ['WSPR'], submode: 'WSPR' },
+ { patterns: ['JS8'], submode: 'JS8' }
+ ];
+
+ // Optimized loop - breaks early on first match
+ for (var i = 0; i < digiModes.length; i++) {
+ var mode = digiModes[i];
+ for (var j = 0; j < mode.patterns.length; j++) {
+ if (message.indexOf(mode.patterns[j]) !== -1) {
+ return { category: 'digi', submode: mode.submode, confidence: 1 };
+ }
+ }
+ }
+
+ // Phone modes from message (optimized with regex word boundary)
+ var phoneModes = [
+ { patterns: ['LSB'], submode: 'LSB' },
+ { patterns: ['USB'], submode: 'USB' },
+ { patterns: ['SSB'], submode: 'SSB' },
+ { patterns: ['AM'], submode: 'AM' },
+ { patterns: ['FM'], submode: 'FM' }
+ ];
+
+ // Optimized loop - breaks early on first match
+ for (var i = 0; i < phoneModes.length; i++) {
+ var mode = phoneModes[i];
+ for (var j = 0; j < mode.patterns.length; j++) {
+ // Use word boundary to avoid false matches
+ var pattern = '\\b' + mode.patterns[j] + '\\b';
+ if (new RegExp(pattern).test(message)) {
+ return { category: 'phone', submode: mode.submode, confidence: 1 };
+ }
+ }
+ }
+
+ return { category: null, submode: null, confidence: 0 };
+ },
+
+ classifyFromMode: function(mode) {
+ // CW modes
+ if (mode === 'CW' || mode === 'A1A') {
+ return { category: 'cw', submode: 'CW', confidence: 1 };
+ }
+
+ // Phone modes
+ var phoneModes = ['SSB', 'LSB', 'USB', 'AM', 'FM', 'SAM', 'DSB', 'J3E', 'A3E', 'PHONE'];
+ if (phoneModes.indexOf(mode) !== -1) {
+ return { category: 'phone', submode: mode, confidence: 1 };
+ }
+
+ // Digital modes - WSJT-X family
+ var wsjtModes = ['FT8', 'FT4', 'JT65', 'JT65B', 'JT6C', 'JT6M', 'JT9', 'JT9-1',
+ 'Q65', 'QRA64', 'FST4', 'FST4W', 'WSPR', 'MSK144', 'ISCAT',
+ 'ISCAT-A', 'ISCAT-B', 'JS8', 'JTMS', 'FSK441'];
+ if (wsjtModes.indexOf(mode) !== -1) {
+ return { category: 'digi', submode: mode, confidence: 1 };
+ }
+
+ // PSK variants
+ if (mode.indexOf('PSK') !== -1 || mode.indexOf('QPSK') !== -1 || mode.indexOf('8PSK') !== -1) {
+ return { category: 'digi', submode: mode, confidence: 1 };
+ }
+
+ // Other digital modes
+ var otherDigiModes = ['RTTY', 'NAVTEX', 'SITORB', 'DIGI', 'DYNAMIC'];
+ if (otherDigiModes.indexOf(mode) !== -1) {
+ return { category: 'digi', submode: mode, confidence: 1 };
+ }
+
+ // Pattern-based digital mode detection
+ if (mode.indexOf('HELL') !== -1 || mode.indexOf('FSK') === 0 ||
+ mode.indexOf('THOR') !== -1 || mode.indexOf('THROB') !== -1 ||
+ mode.indexOf('DOM') !== -1 || mode.indexOf('VARA') !== -1) {
+ return { category: 'digi', submode: mode, confidence: 1 };
+ }
+
+ // Unknown mode - ensure we return a valid submode string
+ return { category: 'other', submode: mode || 'Unknown', confidence: 0.3 };
+ },
+
+ // Determine LSB/USB for SSB based on frequency
+ determineSSBMode: function(frequency) {
+ var freq = parseFloat(frequency) || 0;
+ if (freq > 0) {
+ return freq < DX_WATERFALL_CONSTANTS.LSB_USB_THRESHOLD_KHZ ? 'LSB' : 'USB';
+ }
+ return 'SSB';
+ },
+
+ // Enhanced detailed submode information using unified classification
+ getDetailedSubmode: function(spot) {
+ return this.classifyMode(spot);
+ }
+ },
+
+ // 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);
+
+ var spotObj = {
+ callsign: spot.spotted,
+ frequency: spotFreq,
+ mode: spot.mode || ''
+ };
+
+ // 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;
+ }
+
+ // Handle park references (pre-calculated or extract from message)
+ if (spot.sotaRef !== undefined || options.includeParkRefs !== false) {
+ var parkRefs = (spot.sotaRef !== undefined) ? spot : DX_WATERFALL_UTILS.parkRefs.extract(spot);
+ spotObj.sotaRef = parkRefs.sotaRef || '';
+ spotObj.potaRef = parkRefs.potaRef || '';
+ spotObj.iotaRef = parkRefs.iotaRef || '';
+ spotObj.wwffRef = parkRefs.wwffRef || '';
+ } 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
+ };
+ }
+ },
+
+ // Park reference extraction utilities
+ parkRefs: {
+ /**
+ * Extract park references (SOTA/POTA/IOTA/WWFF) from spot data
+ * Uses direct fields if available, otherwise extracts from message
+ * @param {Object} spot - Raw spot object from DX cluster
+ * @returns {Object} Object with sotaRef, potaRef, iotaRef, wwffRef properties
+ */
+ extract: function(spot) {
+ var refs = {
+ sotaRef: '',
+ potaRef: '',
+ iotaRef: '',
+ wwffRef: ''
+ };
+
+ // First check if references are provided directly by the server
+ if (spot.dxcc_spotted) {
+ refs.sotaRef = spot.dxcc_spotted.sota_ref || '';
+ refs.potaRef = spot.dxcc_spotted.pota_ref || '';
+ refs.iotaRef = spot.dxcc_spotted.iota_ref || '';
+ refs.wwffRef = spot.dxcc_spotted.wwff_ref || '';
+ }
+
+ // If any references are missing, try to extract from message
+ var message = spot.message || '';
+ if (message && (!refs.sotaRef || !refs.potaRef || !refs.iotaRef || !refs.wwffRef)) {
+ var upperMessage = message.toUpperCase();
+
+ // SOTA format: XX/YY-### or XX/YY-#### (e.g., "G/LD-001", "W4G/NG-001")
+ if (!refs.sotaRef) {
+ var sotaMatch = upperMessage.match(/\b([A-Z0-9]{1,3}\/[A-Z]{2}-\d{3})\b/);
+ if (sotaMatch) {
+ refs.sotaRef = sotaMatch[1];
+ }
+ }
+
+ // POTA format: XX-#### (e.g., "US-4306", "K-1234")
+ // Must not match WWFF patterns (ending in FF)
+ if (!refs.potaRef) {
+ var potaMatch = upperMessage.match(/\b([A-Z0-9]{1,5}-\d{4,5})\b/);
+ if (potaMatch && !potaMatch[1].match(/FF-/)) {
+ refs.potaRef = potaMatch[1];
+ }
+ }
+
+ // IOTA format: XX-### (e.g., "EU-005", "NA-001", "OC-123")
+ if (!refs.iotaRef) {
+ var iotaMatch = upperMessage.match(/\b((?:AF|AN|AS|EU|NA|OC|SA)-\d{3})\b/);
+ if (iotaMatch) {
+ refs.iotaRef = iotaMatch[1];
+ }
+ }
+
+ // WWFF format: XXFF-#### (e.g., "GIFF-0001", "K1FF-0123", "ON4FF-0050")
+ if (!refs.wwffRef) {
+ var wwffMatch = upperMessage.match(/\b([A-Z0-9]{2,4}FF-\d{4})\b/);
+ if (wwffMatch) {
+ refs.wwffRef = wwffMatch[1];
+ }
+ }
+ }
+
+ return refs;
+ }
+ },
+
+ // QSO form utilities
+ qsoForm: {
+ // Timer for pending population to allow cancellation
+ pendingPopulationTimer: null,
+ pendingLookupTimer: null,
+
+ /**
+ * Populate QSO form with spot data (callsign, mode, and park references)
+ * @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 (this triggers mode change handler which calls $("#callsign").blur())
+ if (spotData.mode) {
+ setMode(spotData.mode);
+ }
+
+ // Populate SOTA reference if available (selectize field)
+ if (spotData.sotaRef && spotData.sotaRef !== '') {
+ var $sotaSelect = $('#sota_ref');
+ if ($sotaSelect.length > 0 && $sotaSelect[0].selectize) {
+ var sotaSelectize = $sotaSelect[0].selectize;
+ sotaSelectize.addOption({name: spotData.sotaRef});
+ sotaSelectize.setValue(spotData.sotaRef, false);
+ }
+ }
+
+ // Populate POTA reference if available (selectize field)
+ if (spotData.potaRef && spotData.potaRef !== '') {
+ var $potaSelect = $('#pota_ref');
+ if ($potaSelect.length > 0 && $potaSelect[0].selectize) {
+ var potaSelectize = $potaSelect[0].selectize;
+ potaSelectize.addOption({name: spotData.potaRef});
+ potaSelectize.setValue(spotData.potaRef, false);
+ }
+ }
+
+ // Populate IOTA reference if available (regular select dropdown)
+ if (spotData.iotaRef && spotData.iotaRef !== '') {
+ var $iotaSelect = $('#iota_ref');
+ if ($iotaSelect.length > 0) {
+ var optionExists = $iotaSelect.find('option[value="' + spotData.iotaRef + '"]').length > 0;
+ if (optionExists) {
+ $iotaSelect.val(spotData.iotaRef);
+ } else {
+ $iotaSelect.append($('