From a0659bb31e35474381ea54f008ab8947e6ab34eb Mon Sep 17 00:00:00 2001 From: Szymon Porwolik Date: Fri, 10 Oct 2025 03:02:32 +0200 Subject: [PATCH] Initial commit of DX Waterfall for Wavelog --- application/controllers/Band.php | 12 + application/controllers/Qso.php | 8 + application/controllers/User.php | 13 + application/models/User_model.php | 7 +- application/views/interface_assets/footer.php | 136 +- application/views/qso/index.php | 148 +- application/views/user/edit.php | 10 + assets/js/dxwaterfall.js | 4558 +++++++++++++++++ assets/js/sections/qrg_handler.js | 35 +- assets/js/sections/qso.js | 58 +- assets/json/iaru_bandplans.json | 261 + 11 files changed, 5216 insertions(+), 30 deletions(-) create mode 100644 assets/js/dxwaterfall.js create mode 100644 assets/json/iaru_bandplans.json 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 = ""; var lang_notes_not_found = ""; + /* + DX Waterfall Language + */ + var lang_dxwaterfall_tune_to_spot = ""; + var lang_dxwaterfall_cycle_through = ""; + var lang_dxwaterfall_spots_currently_showing = ""; + var lang_dxwaterfall_log_qso_with = ""; + var lang_dxwaterfall_new_continent = ""; + var lang_dxwaterfall_new_dxcc = ""; + var lang_dxwaterfall_new_callsign = ""; + var lang_dxwaterfall_previous_spot = ""; + var lang_dxwaterfall_no_spots_lower = ""; + var lang_dxwaterfall_next_spot = ""; + var lang_dxwaterfall_no_spots_higher = ""; + var lang_dxwaterfall_no_spots_available = ""; + var lang_dxwaterfall_cycle_unworked = ""; + var lang_dxwaterfall_dx_hunter = ""; + var lang_dxwaterfall_no_unworked = ""; + var lang_dxwaterfall_fetching_spots = ""; + var lang_dxwaterfall_click_to_cycle = ""; + var lang_dxwaterfall_change_continent = ""; + var lang_dxwaterfall_filter_by_mode = ""; + var lang_dxwaterfall_toggle_phone = ""; + var lang_dxwaterfall_phone = ""; + var lang_dxwaterfall_toggle_cw = ""; + var lang_dxwaterfall_cw = ""; + var lang_dxwaterfall_toggle_digi = ""; + var lang_dxwaterfall_digi = ""; + var lang_dxwaterfall_zoom_out = ""; + var lang_dxwaterfall_reset_zoom = ""; + var lang_dxwaterfall_zoom_in = ""; + var lang_dxwaterfall_waiting_data = ""; + var lang_dxwaterfall_comment = ""; + var lang_dxwaterfall_modes_label = ""; + var lang_dxwaterfall_out_of_bandplan = ""; + var lang_dxwaterfall_changing_frequency = ""; + var lang_dxwaterfall_spots_fetched = ""; + var lang_dxwaterfall_fetched_for_band = ""; + var lang_dxwaterfall_displaying = ""; + var lang_dxwaterfall_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 @@ } ?>
+ +
+ + + + +
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($('