/**
* DX Cluster Bandmap - Real-time spot filtering with CAT control
*
* Features:
* - Smart filtering (server for bands/continents, client for modes/flags)
* - CAT radio integration with frequency visualization
* - Multi-select filters with status color coding
* - Auto-refresh with activity references (POTA/SOTA/WWFF/IOTA/Contest)
* - Works with radiohelpers.js for mode/band/frequency utilities
*/
'use strict';
// ========================================
// CONFIGURATION & CONSTANTS
// ========================================
const SPOT_REFRESH_INTERVAL = 60; // Auto-refresh interval in seconds
const QSO_SEND_DEBOUNCE_MS = 3000; // Debounce for sending callsign to QSO form (milliseconds)
// Mode display capitalization lookup (API returns lowercase)
const MODE_CAPITALIZATION = { 'phone': 'Phone', 'cw': 'CW', 'digi': 'Digi' };
// Filter button configurations
const BAND_BUTTONS = [
{ id: '#toggle160mFilter', band: '160m' },
{ id: '#toggle80mFilter', band: '80m' },
{ id: '#toggle60mFilter', band: '60m' },
{ id: '#toggle40mFilter', band: '40m' },
{ id: '#toggle30mFilter', band: '30m' },
{ id: '#toggle20mFilter', band: '20m' },
{ id: '#toggle17mFilter', band: '17m' },
{ id: '#toggle15mFilter', band: '15m' },
{ id: '#toggle12mFilter', band: '12m' },
{ id: '#toggle10mFilter', band: '10m' },
{ id: '#toggle6mFilter', band: '6m' }
];
const BAND_GROUP_BUTTONS = [
{ id: '#toggleVHFFilter', group: 'VHF' },
{ id: '#toggleUHFFilter', group: 'UHF' },
{ id: '#toggleSHFFilter', group: 'SHF' }
];
const MODE_BUTTONS = [
{ id: '#toggleCwFilter', mode: 'cw', icon: 'fa-wave-square' },
{ id: '#toggleDigiFilter', mode: 'digi', icon: 'fa-keyboard' },
{ id: '#togglePhoneFilter', mode: 'phone', icon: 'fa-microphone' }
];
const CONTINENT_BUTTONS = [
{ id: '#toggleAfricaFilter', continent: 'AF' },
{ id: '#toggleAntarcticaFilter', continent: 'AN' },
{ id: '#toggleAsiaFilter', continent: 'AS' },
{ id: '#toggleEuropeFilter', continent: 'EU' },
{ id: '#toggleNorthAmericaFilter', continent: 'NA' },
{ id: '#toggleOceaniaFilter', continent: 'OC' },
{ id: '#toggleSouthAmericaFilter', continent: 'SA' }
];
const GEO_FLAGS = ['POTA', 'SOTA', 'IOTA', 'WWFF'];
// Performance optimization: Pre-computed band to group lookup map
// Note: 6m is NOT in VHF group - it has its own separate button
const BAND_TO_GROUP_MAP = {
'4m': 'VHF', '2m': 'VHF', '1.25m': 'VHF',
'70cm': 'UHF', '33cm': 'UHF', '23cm': 'UHF',
'13cm': 'SHF', '9cm': 'SHF', '6cm': 'SHF', '3cm': 'SHF'
};
// ========================================
// MAIN APPLICATION
// ========================================
$(function() {
// ========================================
// PERFORMANCE: DOM CACHE & DEBOUNCING
// ========================================
// Cache frequently accessed DOM elements
const domCache = { badges: {} };
// Get or cache badge element
function getCachedBadge(selector) {
if (!domCache.badges[selector]) {
domCache.badges[selector] = $(selector);
}
return domCache.badges[selector];
}
// Debounced applyFilters
let applyFiltersTimer = null;
function debouncedApplyFilters(delay = 150) {
if (applyFiltersTimer) clearTimeout(applyFiltersTimer);
applyFiltersTimer = setTimeout(() => {
// Safety check: only call if applyFilters is defined
if (typeof applyFilters === 'function') {
applyFilters(false);
}
}, delay);
}
// ========================================
// MAP VARIABLES (declared early for drawCallback access)
// ========================================
let dxMap = null;
let dxMapVisible = false;
let dxccMarkers = [];
let spotterMarkers = [];
let connectionLines = [];
let userHomeMarker = null;
let showSpotters = false;
let showDayNight = true;
let terminatorLayer = null;
let hoverSpottersData = new Map();
let hoverSpotterMarkers = [];
let hoverConnectionLines = [];
// ========================================
// GLOBAL ERROR HANDLING FOR BOOTSTRAP TOOLTIPS
// ========================================
// Suppress Bootstrap tooltip _isWithActiveTrigger errors (known bug with dynamic content)
window.addEventListener('error', function(e) {
if (e.message && e.message.includes('_isWithActiveTrigger')) {
e.preventDefault();
return true;
}
});
// ========================================
// DATATABLES ERROR HANDLING
// ========================================
// Configure DataTables to log errors to console instead of showing alert dialogs
// MUST be set before any DataTable is initialized
if ($.fn.dataTable) {
$.fn.dataTable.ext.errMode = function(settings, helpPage, message) {
console.error('=== DataTables Error ===');
console.error('Message:', message);
console.error('Help page:', helpPage);
console.error('Settings:', settings);
// Also log which row/column caused the issue
if (message.indexOf('parameter') !== -1) {
console.error('This usually means the data array has wrong number of columns');
console.error('Expected columns: 16 (Age, Band, Freq, Mode, Submode, Spotted, Cont, CQZ, Flag, Entity, Spotter, de Cont, de CQZ, Last QSO, Special, Message)');
}
};
} else {
console.error('$.fn.dataTable not available!');
}
// ========================================
// UTILITY FUNCTIONS
// ========================================
/**
* Dispose of all Bootstrap tooltips in the table before clearing
*/
function disposeTooltips() {
try {
$('.spottable [data-bs-toggle="tooltip"]').each(function() {
try {
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
// Use safeDisposeTooltip if available (from cat.js), otherwise dispose directly
if (typeof window.safeDisposeTooltip === 'function') {
window.safeDisposeTooltip(this);
} else {
const tooltipInstance = bootstrap.Tooltip.getInstance(this);
if (tooltipInstance) {
// Set _activeTrigger to empty object to prevent _isWithActiveTrigger error
if (tooltipInstance._activeTrigger) {
tooltipInstance._activeTrigger = {};
}
try { tooltipInstance.dispose(); } catch(e) {}
}
}
}
} catch (err) {
// Skip individual tooltip errors
}
});
} catch (e) {
// Silently catch tooltip disposal errors
}
}
/**
* Get current values from all filter selects
* @returns {Object} Object containing all filter values
*/
function getAllFilterValues() {
return {
cwn: $('#cwnSelect').val() || [],
deCont: $('#decontSelect').val() || [],
continent: $('#continentSelect').val() || [],
band: $('#band').val() || [],
mode: $('#mode').val() || [],
additionalFlags: $('#additionalFlags').val() || [],
requiredFlags: ($('#requiredFlags').val() || []).filter(v => v !== 'None')
};
}
/**
* Check if a filter array contains default "All" or "Any" value
* @param {Array} values - Filter values array
* @param {string} defaultValue - Default value to check ('All' or 'Any')
* @returns {boolean} True if array contains only the default value
*/
function isDefaultFilterValue(values, defaultValue = 'All') {
return values.length === 1 && values.includes(defaultValue);
}
/**
* Update button visual state (active/inactive)
* @param {string} buttonId - jQuery selector for button
* @param {boolean} isActive - Whether button should appear active
*/
function updateButtonState(buttonId, isActive) {
const $btn = $(buttonId);
$btn.removeClass('btn-secondary btn-success');
$btn.addClass(isActive ? 'btn-success' : 'btn-secondary');
}
// ========================================
// FILTER UI MANAGEMENT
// ========================================
/**
* Check if any filters are active (not default "All"/"Any" values)
* @returns {boolean} True if any non-default filters are applied
*/
function areFiltersApplied() {
const filters = getAllFilterValues();
const isDefaultCwn = isDefaultFilterValue(filters.cwn);
const isDefaultDecont = isDefaultFilterValue(filters.deCont, 'Any');
const isDefaultContinent = isDefaultFilterValue(filters.continent, 'Any');
const isDefaultBand = isDefaultFilterValue(filters.band);
const isDefaultMode = isDefaultFilterValue(filters.mode);
const isDefaultFlags = isDefaultFilterValue(filters.additionalFlags);
const isDefaultRequired = filters.requiredFlags.length === 0;
return !(isDefaultCwn && isDefaultDecont && isDefaultContinent && isDefaultBand && isDefaultMode && isDefaultFlags && isDefaultRequired);
}
/**
* Update filter icon and button colors based on whether filters are active
*/
function updateFilterIcon() {
if (areFiltersApplied()) {
// When filters are active:
// - Advanced Filters button: colored (btn-success/greenish) with filter icon only
$('#filterDropdown').removeClass('btn-secondary').addClass('btn-success');
} else {
// When no filters are active:
// - Advanced Filters button: secondary color with filter icon
$('#filterDropdown').removeClass('btn-success').addClass('btn-secondary');
}
// Note: Clear Filters buttons always keep their reddish eraser icon (set in HTML)
}
/**
* Toggle a value in a multi-select filter
* @param {string} selectId - jQuery selector for the select element
* @param {string} value - Value to toggle in the selection
* @param {string} defaultValue - Default value to restore if selection becomes empty (default: 'All')
* @param {boolean} applyFiltersAfter - Whether to trigger filter application (default: true)
* @param {number} debounceMs - Debounce delay in milliseconds (default: 0 for no debounce)
* @param {boolean} updateBadges - Whether to call updateBandCountBadges() (default: false)
*/
function toggleFilterValue(selectId, value, defaultValue = 'All', applyFiltersAfter = true, debounceMs = 0, updateBadges = false) {
let currentValues = $(selectId).val() || [];
// Remove default value if present
if (currentValues.includes(defaultValue)) {
currentValues = currentValues.filter(v => v !== defaultValue);
}
// Toggle the target value
if (currentValues.includes(value)) {
currentValues = currentValues.filter(v => v !== value);
// Restore default if empty
if (currentValues.length === 0) {
currentValues = [defaultValue];
}
} else {
currentValues.push(value);
}
// Update selectize
$(selectId).val(currentValues).trigger('change');
syncQuickFilterButtons();
// Update badge counts if requested
if (updateBadges && typeof updateBandCountBadges === 'function') {
updateBandCountBadges();
}
// Apply filters with optional debounce
if (applyFiltersAfter) {
if (debounceMs > 0) {
clearTimeout(window.filterDebounceTimer);
window.filterDebounceTimer = setTimeout(() => {
applyFilters(false);
}, debounceMs);
} else {
applyFilters(false);
}
}
}
/**
* Sync quick filter button states with their corresponding dropdown values
*/
function syncQuickFilterButtons() {
const filters = getAllFilterValues();
// Required flags buttons
const requiredFlagButtons = [
{ id: '#toggleMySubmodesFilter', flag: 'mysubmodes' },
{ id: '#toggleLotwFilter', flag: 'lotw' },
{ id: '#toggleDxSpotFilter', flag: 'dxspot' },
{ id: '#toggleNewContinentFilter', flag: 'newcontinent' },
{ id: '#toggleDxccNeededFilter', flag: 'newcountry' },
{ id: '#toggleNewCallsignFilter', flag: 'newcallsign' },
{ id: '#toggleContestFilter', flag: 'Contest' }
];
requiredFlagButtons.forEach(btn => {
updateButtonState(btn.id, filters.requiredFlags.includes(btn.flag));
});
// Geo Hunter button (stays in Additional Flags)
const hasGeoFlag = GEO_FLAGS.some(flag => filters.additionalFlags.includes(flag));
updateButtonState('#toggleGeoHunterFilter', hasGeoFlag);
// Fresh filter button
updateButtonState('#toggleFreshFilter', filters.additionalFlags.includes('Fresh'));
// Mode buttons
MODE_BUTTONS.forEach(btn => {
updateButtonState(btn.id, filters.mode.includes(btn.mode));
});
// Check if "All" is selected for bands, modes, and continents
const allBandsSelected = isDefaultFilterValue(filters.band);
const allModesSelected = isDefaultFilterValue(filters.mode) ||
(filters.mode.includes('cw') && filters.mode.includes('digi') && filters.mode.includes('phone'));
const allContinentsSelected = isDefaultFilterValue(filters.deCont, 'Any') ||
(filters.deCont.includes('AF') && filters.deCont.includes('AN') &&
filters.deCont.includes('AS') && filters.deCont.includes('EU') &&
filters.deCont.includes('NA') && filters.deCont.includes('OC') &&
filters.deCont.includes('SA'));
// Band filter buttons - always update colors (for CAT Control visibility)
BAND_BUTTONS.forEach(btn => {
const isActive = allBandsSelected || filters.band.includes(btn.band);
updateButtonState(btn.id, isActive);
});
// Band group buttons (VHF, UHF, SHF)
BAND_GROUP_BUTTONS.forEach(btn => {
const groupBands = getBandsInGroup(btn.group);
const allGroupBandsSelected = groupBands.every(b => filters.band.includes(b));
const isActive = allBandsSelected || allGroupBandsSelected;
updateButtonState(btn.id, isActive);
});
// Mode buttons
MODE_BUTTONS.forEach(btn => {
const isActive = allModesSelected || filters.mode.includes(btn.mode);
updateButtonState(btn.id, isActive);
});
// "All Continents" button
updateButtonState('#toggleAllContinentsFilter', allContinentsSelected);
// Individual continent buttons
CONTINENT_BUTTONS.forEach(btn => {
const isActive = allContinentsSelected || filters.deCont.includes(btn.continent);
updateButtonState(btn.id, isActive);
});
}
/**
* Add checkbox-style indicators (☑/☐) to multi-select dropdowns
* @param {string} selectId - ID of the select element
*/
function updateSelectCheckboxes(selectId) {
let $select = $('#' + selectId);
$select.find('option').each(function() {
let $option = $(this);
let originalText = $option.data('original-text');
if (!originalText) {
originalText = $option.html();
$option.data('original-text', originalText);
}
if ($option.is(':selected')) {
$option.html('☑ ' + originalText);
} else {
$option.html('☐ ' + originalText);
}
});
}
// List of all filter select IDs
const FILTER_SELECT_IDS = ['cwnSelect', 'decontSelect', 'continentSelect', 'band', 'mode', 'additionalFlags', 'requiredFlags'];
// Map of storage keys to select IDs
const FILTER_KEY_TO_SELECT = {
cwn: 'cwnSelect',
deCont: 'decontSelect',
continent: 'continentSelect',
band: 'band',
mode: 'mode',
additionalFlags: 'additionalFlags',
requiredFlags: 'requiredFlags'
};
// Map currentFilters keys to storage keys
const CURRENT_TO_STORAGE_KEY = {
cwn: 'cwn',
deContinent: 'deCont',
spottedContinent: 'continent',
band: 'band',
mode: 'mode',
additionalFlags: 'additionalFlags',
requiredFlags: 'requiredFlags'
};
/**
* Build filter data object from currentFilters for storage
* @param {string} [favName] - Optional favorite name to include
* @returns {Object} Filter data with storage keys
*/
function buildFilterDataFromCurrent(favName) {
let filterData = {};
if (favName) filterData.fav_name = favName;
Object.entries(CURRENT_TO_STORAGE_KEY).forEach(([currentKey, storageKey]) => {
filterData[storageKey] = currentFilters[currentKey];
});
// Include My Submodes filter state
filterData.mySubmodesActive = isMySubmodesFilterActive;
return filterData;
}
/**
* Set all filter values from an object
* @param {Object} filterData - Object with filter keys (cwn, deCont, continent, band, mode, additionalFlags, requiredFlags)
*/
function setAllFilterValues(filterData) {
Object.entries(FILTER_KEY_TO_SELECT).forEach(([key, selectId]) => {
if (filterData[key] !== undefined) {
$('#' + selectId).val(filterData[key]);
}
});
}
/**
* Update checkbox indicators for all filter selects
*/
function updateAllSelectCheckboxes() {
FILTER_SELECT_IDS.forEach(selectId => updateSelectCheckboxes(selectId));
}
// Initialize checkbox indicators for all filter selects
function initFilterCheckboxes() {
FILTER_SELECT_IDS.forEach(selectId => {
updateSelectCheckboxes(selectId);
$(`#${selectId}`).on('change', () => updateSelectCheckboxes(selectId));
});
}
// Handle "All"/"Any" option behavior in multi-selects
// If "All" is selected with other options, keep only "All"
// If nothing selected, default back to "All"/"Any"
function handleAllOption(selectId) {
$('#' + selectId).on('change', function() {
let selected = $(this).val() || [];
if (selected.includes('All') || selected.includes('Any')) {
let allValue = selected.includes('All') ? 'All' : 'Any';
if (selected.length > 1) {
$(this).val([allValue]);
}
} else if (selected.length === 0) {
let allValue = (selectId === 'decontSelect' || selectId === 'continentSelect') ? 'Any' : 'All';
$(this).val([allValue]);
}
updateFilterIcon();
// Sync button states when band, mode, or continent filters change
if (selectId === 'band' || selectId === 'mode' || selectId === 'decontSelect' || selectId === 'continentSelect') {
syncQuickFilterButtons();
}
// Apply filters with debouncing
debouncedApplyFilters(150);
});
}
// Apply "All" handler to all filter dropdowns
['cwnSelect', 'decontSelect', 'continentSelect', 'band', 'mode', 'additionalFlags'].forEach(handleAllOption);
// Required flags filter - handle "None" option
$('#requiredFlags').on('change', function() {
let currentValues = $(this).val() || [];
// If "None" is selected, deselect all others
if (currentValues.includes('None')) {
if (currentValues.length > 1) {
// User selected something else, remove "None"
currentValues = currentValues.filter(v => v !== 'None');
}
} else if (currentValues.length === 0) {
// If nothing is selected, select "None"
currentValues = ['None'];
}
$(this).val(currentValues);
updateFilterIcon();
// Sync My Submodes button state from requiredFlags
syncMySubmodesFromRequiredFlags();
// Apply filters with debouncing
debouncedApplyFilters(150);
});
// ========================================
// DATATABLE CONFIGURATION
// ========================================
// Sort spots by frequency (ascending)
function SortByQrg(a, b) {
return a.frequency - b.frequency;
}
// Initialize DataTables instance with custom row click handlers
function get_dtable() {
var table = $('.spottable').DataTable({
paging: false,
searching: true,
dom: 'rt',
retrieve: true,
language: {
url: getDataTablesLanguageUrl(),
"emptyTable": " " + lang_bandmap_loading_spots,
"zeroRecords": lang_bandmap_no_spots_found
},
'columnDefs': [
{
'targets': 2,
// Frequency is now column 3 (0-indexed = 2)
"type":"num",
'render': function (data, type, row) {
// For sorting and filtering, return numeric value
if (type === 'sort' || type === 'type') {
return parseFloat(data) || 0;
}
// For display, return the string as-is
return data;
},
'createdCell': function (td, cellData, rowData, row, col) {
$(td).addClass("MHz");
}
},
{
'targets': 3, // Mode column is now column 4 (0-indexed = 3)
'createdCell': function (td, cellData, rowData, row, col) {
$(td).addClass("mode");
}
},
{
'targets': [6, 8, 15], // Cont, Flag, Message - disable sorting
'orderable': false
}
],
search: { smart: true },
drawCallback: function(settings) {
// Update status bar after table is drawn (including after search)
let totalRows = cachedSpotData ? cachedSpotData.length : 0;
let displayedRows = this.api().rows({ search: 'applied' }).count();
updateStatusBar(totalRows, displayedRows, getServerFilterText(), getClientFilterText(), false, false);
// Update map markers when table is redrawn (including after search)
if (dxMapVisible && dxMap) {
updateDxMap();
}
// Note: CAT frequency gradient is now updated only from updateCATui (every 3s)
// to prevent recursion issues with table redraws
}
}); $('.spottable tbody').off('click', 'tr').on('click', 'tr', function(e) {
// Don't trigger row click if clicking on a link or image (LoTW, POTA, SOTA, WWFF, callstats, QRZ icon, etc.)
if ($(e.target).is('a') || $(e.target).is('img') || $(e.target).closest('a').length) {
return;
}
// Default row click: prepare QSO logging with callsign, frequency, mode
let rowData = table.row(this).data();
if (!rowData) return;
let callsignHtml = rowData[5]; // Callsign is column 6 (0-indexed = 5)
let tempDiv = $('
').html(callsignHtml);
let call = tempDiv.find('a').html().trim();
if (!call) return;
let qrg = parseFloat(rowData[2]) * 1000000; // Frequency in MHz, convert to Hz
let modeHtml = rowData[3]; // Mode is column 4 (0-indexed = 3)
let modeDiv = $('
').html(modeHtml);
let mode = modeDiv.html().trim(); // Extract clean mode text from HTML
// Ctrl+click: Only tune radio, don't prepare logging form
if (e.ctrlKey || e.metaKey) {
if (isCatTrackingEnabled) {
tuneRadio(qrg, mode);
} else {
if (typeof showToast === 'function') {
showToast(lang_bandmap_cat_required, lang_bandmap_enable_cat, 'bg-warning text-dark', 3000);
}
}
return;
}
// Find the original spot data to get reference information
let spotData = null;
if (cachedSpotData) {
// Note: spot.frequency is in kHz, so multiply by 1000 to get Hz
spotData = cachedSpotData.find(spot =>
spot.spotted === call &&
Math.abs(spot.frequency * 1000 - qrg) < 100 // Match within 100 Hz tolerance
);
}
prepareLogging(call, qrg, mode, spotData);
});
return table;
}
// ========================================
// FILTER STATE TRACKING
// ========================================
// Track what backend parameters were used for last data fetch
// NOTE: Changed architecture - only de continent affects backend now
// Band and Mode are now client-side filters only
// UPDATE: Band becomes backend filter when CAT Control is active (single-band fetch mode)
var loadedBackendFilters = {
continent: 'Any',
band: 'All'
};
// Initialize backend filter state from form values
function initializeBackendFilters() {
const decontSelect = $('#decontSelect').val();
loadedBackendFilters.continent = (decontSelect && decontSelect.length > 0) ? decontSelect[0] : 'Any';
}
// Track all current filter selections (both client and server-side)
var currentFilters = {
band: ['All'],
deContinent: ['Any'],
spottedContinent: ['Any'],
cwn: ['All'],
mode: ['All'],
additionalFlags: ['All'],
requiredFlags: []
};
// ========================================
// DATA CACHING & FETCH STATE
// ========================================
var cachedSpotData = null; // Raw spot data from last backend fetch
var cachedUserFavorites = null; // Cached user favorites (bands and modes)
var isFetchInProgress = false; // Prevent multiple simultaneous fetches
var currentAjaxRequest = null; // Track active AJAX request for cancellation
var lastFetchParams = { // Track last successful fetch parameters
continent: 'Any',
maxAge: 60,
timestamp: null
};
// TTL (Time To Live) management for spots
// Key: "callsign_frequency_spotter", Value: TTL count
var spotTTLMap = new Map();
// Generate unique key for spot identification
function getSpotKey(spot) {
return `${spot.spotted}_${spot.frequency}_${spot.spotter}`;
}
// Convert array to Set for O(1) lookups, handle 'All'/'Any' values
function arrayToFilterSet(arr, defaultValue = 'All') {
if (!arr || arr.length === 0 || arr.includes(defaultValue)) {
return null; // null means "accept all"
}
return new Set(arr);
}
// Auto-refresh timer state
var refreshCountdown = SPOT_REFRESH_INTERVAL;
var refreshTimerInterval = null;
// Helper function to update refresh timer display (respects compact width)
function updateRefreshTimerDisplay() {
let isCompactWidth = window.matchMedia('(max-width: 1200px)').matches;
$('#refreshIcon').removeClass('fa-spinner fa-spin').addClass('fa-hourglass-half');
$('#refreshTimer').html(isCompactWidth ? `${refreshCountdown}s` : (lang_bandmap_next_update + ' ' + refreshCountdown + 's'));
}
// ========================================
// STATUS BAR & UI UPDATES
// ========================================
// Update status bar with spot counts, filter info, and fetch status
function updateStatusBar(totalSpots, displayedSpots, serverFilters, clientFilters, isFetching, isInitialLoad) {
if (isFetching) {
let allFilters = [];
if (serverFilters && serverFilters.length > 0) {
allFilters = allFilters.concat(serverFilters.map(f => 'de ' + f));
}
if (clientFilters && clientFilters.length > 0) {
allFilters = allFilters.concat(clientFilters);
}
let loadingMessage = lang_bandmap_loading_data;
if (allFilters.length > 0) {
loadingMessage += '...';
} else {
loadingMessage += '...';
}
$('#statusMessage').html(loadingMessage).attr('title', '');
$('#statusFilterInfo').remove();
$('#refreshIcon').removeClass('fa-hourglass-half').addClass('fa-spinner fa-spin');
$('#refreshTimer').html('');
return;
}
if (lastFetchParams.timestamp === null) {
$('#statusMessage').html('');
$('#statusFilterInfo').remove();
$('#refreshTimer').html('');
return;
}
let now = new Date();
let timeStr = `${now.getUTCHours().toString().padStart(2, '0')}:${now.getUTCMinutes().toString().padStart(2, '0')}Z`;
// Check if we're at compact breakpoint (≤1200px)
let isCompactWidth = window.matchMedia('(max-width: 1200px)').matches;
let statusMessage;
if (isCompactWidth) {
// Compact format: (i) xxx/yyy @ HH:MMZ
statusMessage = `${displayedSpots}/${totalSpots} @ ${timeStr}`;
} else {
// Full format
statusMessage = `${totalSpots} ${lang_bandmap_spots_fetched} @ ${timeStr}`;
}
let allFilters = []; if (serverFilters && serverFilters.length > 0) {
allFilters = allFilters.concat(serverFilters.map(f => `de "${f}"`));
}
if (clientFilters && clientFilters.length > 0) {
allFilters = allFilters.concat(clientFilters);
}
var table = get_dtable();
var searchValue = table.search();
if (searchValue) {
allFilters.push(`search: "${searchValue}"`);
}
// Build status message - only add "showing" text in full mode (compact already has displayed/total)
if (!isCompactWidth) {
if (allFilters.length > 0) {
statusMessage += `, ${lang_bandmap_showing} ${displayedSpots}`;
} else if (displayedSpots < totalSpots) {
statusMessage += `, ${lang_bandmap_showing} ${displayedSpots}`;
} else if (totalSpots > 0) {
statusMessage += `, ${lang_bandmap_showing_all}`;
}
} // Build tooltip for status message (fetch information)
let fetchTooltipLines = [`${lang_bandmap_last_fetched}:`];
fetchTooltipLines.push(`${lang_bandmap_band}: ${lastFetchParams.band || lang_bandmap_all}`);
fetchTooltipLines.push(`${lang_bandmap_continent}: ${lastFetchParams.continent || lang_bandmap_all}`);
fetchTooltipLines.push(`${lang_bandmap_mode}: ${lastFetchParams.mode || lang_bandmap_all}`);
fetchTooltipLines.push(`${lang_bandmap_max_age}: ${lastFetchParams.maxAge || '120'} min`);
if (lastFetchParams.timestamp) {
let fetchTime = new Date(lastFetchParams.timestamp);
let h = fetchTime.getUTCHours().toString().padStart(2, '0');
let m = fetchTime.getUTCMinutes().toString().padStart(2, '0');
let s = fetchTime.getUTCSeconds().toString().padStart(2, '0');
fetchTooltipLines.push(`${lang_bandmap_fetched_at}: ${h}:${m}:${s}Z`);
}
$('#statusMessage').html(statusMessage).attr('title', fetchTooltipLines.join('\n'));
// Add info icon if filters are active (with separate tooltip for active filters)
$('#statusFilterInfo').remove();
if (allFilters.length > 0) {
let filterTooltip = lang_bandmap_active_filters + ':\n' + allFilters.join('\n');
if (isCompactWidth) {
// In compact mode, prepend (i) icon before the status message
$('#statusMessage').prepend('');
} else {
$('#statusMessage').after(' ');
}
}
if (isFetching) {
$('#refreshIcon').removeClass('fa-hourglass-half').addClass('fa-spinner fa-spin');
$('#refreshTimer').html(isCompactWidth ? '...' : lang_bandmap_fetching);
} else {
$('#refreshIcon').removeClass('fa-spinner fa-spin').addClass('fa-hourglass-half');
$('#refreshTimer').html(isCompactWidth ? `${refreshCountdown}s` : (lang_bandmap_next_update + ' ' + refreshCountdown + 's'));
}
} function getDisplayedSpotCount() {
var table = get_dtable();
return table.rows({search: 'applied'}).count();
}
// Start/restart the auto-refresh countdown timer (60 seconds)
function startRefreshTimer() {
if (refreshTimerInterval) {
clearInterval(refreshTimerInterval);
}
refreshCountdown = SPOT_REFRESH_INTERVAL;
refreshTimerInterval = setInterval(function() {
refreshCountdown--;
if (refreshCountdown <= 0) {
let table = get_dtable();
disposeTooltips();
table.clear();
// In purple mode, fetch only the active band; otherwise fetch all bands
let bandForRefresh = 'All';
if (catState === 'on+marker') {
let currentBand = $('#band').val() || [];
if (currentBand.length === 1 && !currentBand.includes('All')) {
bandForRefresh = currentBand[0];
}
}
fill_list(currentFilters.deContinent, dxcluster_maxage, bandForRefresh);
refreshCountdown = SPOT_REFRESH_INTERVAL;
} else {
if (!isFetchInProgress && lastFetchParams.timestamp !== null) {
updateRefreshTimerDisplay();
}
}
}, 1000);
}
// Handle page visibility changes (tab switching, minimize, etc.)
// Remove expiring spots when hidden, fetch fresh data when returning (if away > 1 minute)
document.addEventListener('visibilitychange', function() {
if (document.hidden) {
// Dispose tooltips to prevent Bootstrap errors
disposeTooltips();
// Remove TTL<=0 (red/expiring) spots - they'll be stale when we return
let keysToDelete = [];
spotTTLMap.forEach(function(ttl, key) {
if (ttl <= 0) keysToDelete.push(key);
});
keysToDelete.forEach(function(key) {
spotTTLMap.delete(key);
});
// Also remove from cachedSpotData and redraw table
if (cachedSpotData && keysToDelete.length > 0) {
let keySet = new Set(keysToDelete);
cachedSpotData = cachedSpotData.filter(function(spot) {
return !keySet.has(getSpotKey(spot));
});
renderFilteredSpots();
}
} else if (lastFetchParams.timestamp) {
// Only refresh if last fetch was more than 60 seconds ago
const timeSinceLastFetch = Date.now() - lastFetchParams.timestamp.getTime();
if (timeSinceLastFetch > 60000) {
fill_list(lastFetchParams.continent, lastFetchParams.maxAge, lastFetchParams.band || 'All');
refreshCountdown = SPOT_REFRESH_INTERVAL;
}
}
});
// Build array of server-side filter labels for display
function getServerFilterText() {
let filters = [];
// Only de continent is a server filter now
if (loadedBackendFilters.continent !== 'Any') {
filters.push(loadedBackendFilters.continent);
}
return filters;
}
// Build array of client-side filter labels for display
function getClientFilterText() {
let filters = [];
// DXCC Status
if (!currentFilters.cwn.includes('All')) {
let cwnLabels = currentFilters.cwn.map(function(status) {
switch(status) {
case 'notwkd': return lang_bandmap_not_worked;
case 'wkd': return lang_bandmap_worked;
case 'cnf': return lang_bandmap_confirmed;
case 'ucnf': return lang_bandmap_worked_not_confirmed;
default: return status;
}
});
filters.push('"' + lang_bandmap_dxcc + ': ' + cwnLabels.join('/') + '"');
}
// Additional Flags - special handling for OR logic
if (!currentFilters.additionalFlags.includes('All')) {
let flagsList = currentFilters.additionalFlags.filter(f => f !== 'All');
if (flagsList.length > 0) {
if (flagsList.length === 1) {
filters.push('"' + flagsList[0] + '"');
} else {
filters.push('("' + flagsList.join('" or "') + '")');
}
}
}
// De continent
// Only show in client filter when multiple continents are selected
// Single continent is already shown in server filter
if (!currentFilters.deContinent.includes('Any') && currentFilters.deContinent.length > 1) {
filters.push('"' + lang_bandmap_de + ': ' + currentFilters.deContinent.join('/') + '"');
}
// Spotted continent
if (!currentFilters.spottedContinent.includes('Any')) {
filters.push('"' + lang_bandmap_spotted + ': ' + currentFilters.spottedContinent.join('/') + '"');
}
// Band
if (!currentFilters.band.includes('All')) {
filters.push('"' + lang_bandmap_band + ': ' + currentFilters.band.join('/') + '"');
}
// Mode
if (!currentFilters.mode.includes('All')) {
let modeLabels = currentFilters.mode.map(function(m) {
return m.charAt(0).toUpperCase() + m.slice(1);
});
filters.push('"' + lang_bandmap_mode + ': ' + modeLabels.join('/') + '"');
}
// Required flags - each one is shown individually with "and"
if (currentFilters.requiredFlags && currentFilters.requiredFlags.length > 0) {
currentFilters.requiredFlags.forEach(function(flag) {
if (flag === 'lotw') {
filters.push('"' + lang_bandmap_lotw_user + '"');
} else if (flag === 'notworked') {
filters.push('"' + lang_bandmap_new_callsign + '"');
} else {
filters.push('"' + flag + '"');
}
});
}
return filters;
}
// ========================================
// CLIENT-SIDE FILTERING & RENDERING
// ========================================
// Render spots from cached data, applying client-side filters
// Client filters: spottedContinent, cwn status, additionalFlags
function renderFilteredSpots() {
var table = get_dtable();
if (!cachedSpotData || cachedSpotData.length === 0) {
// Only show "no data" if not currently fetching
// During fetch, keep showing current table contents
if (!isFetchInProgress) {
disposeTooltips();
table.clear();
table.settings()[0].oLanguage.sEmptyTable = lang_bandmap_no_data;
table.draw();
}
return;
}
// Convert filter arrays to Sets for O(1) lookup performance
let bandSet = arrayToFilterSet(currentFilters.band);
let deContinentSet = arrayToFilterSet(currentFilters.deContinent, 'Any');
let spottedContinentSet = arrayToFilterSet(currentFilters.spottedContinent, 'Any');
let cwnSet = arrayToFilterSet(currentFilters.cwn);
let modeSet = arrayToFilterSet(currentFilters.mode);
let flagSet = arrayToFilterSet(currentFilters.additionalFlags);
let requiredFlags = currentFilters.requiredFlags || [];
const hasRequiredFlags = requiredFlags.length > 0;
const hasCwnFilter = cwnSet !== null;
const hasFlagFilter = flagSet !== null;
disposeTooltips();
table.clear();
let oldtable = table.data();
let spots2render = 0;
cachedSpotData.forEach((single) => {
// Check TTL - skip spots with TTL < 0 (completely hidden)
let spotKey = getSpotKey(single);
let ttl = spotTTLMap.get(spotKey);
// Skip if TTL is undefined or < 0
if (ttl === undefined || ttl < 0) {
return;
}
// Extract time from spot data - use 'when' field
let timeOnly = single.when;
// Cache DXCC references to avoid repeated property access
const dxccSpotted = single.dxcc_spotted;
const dxccSpotter = single.dxcc_spotter;
// Apply required flags FIRST (must have ALL selected required flags)
if (hasRequiredFlags) {
for (let i = 0; i < requiredFlags.length; i++) {
const reqFlag = requiredFlags[i];
switch (reqFlag) {
case 'mysubmodes':
// My Submodes: spot's submode must match user's enabled submodes
if (!matchesUserSubmodes(single.submode)) return;
break;
case 'lotw':
if (!dxccSpotted || !dxccSpotted.lotw_user) return;
break;
case 'dxspot':
// DX Spot: spotted continent must be different from spotter continent
if (!dxccSpotted?.cont || !dxccSpotter?.cont || dxccSpotted.cont === dxccSpotter.cont) return;
break;
case 'newcontinent':
if (single.worked_continent !== false) return;
break;
case 'newcountry':
if (single.worked_dxcc !== false) return;
break;
case 'newcallsign':
if (single.worked_call !== false) return;
break;
case 'workedcallsign':
if (single.worked_call === false) return;
break;
case 'Contest':
if (!dxccSpotted || !dxccSpotted.isContest) return;
break;
}
}
} // Apply CWN (Confirmed/Worked/New) filter
if (hasCwnFilter) {
const workedDxcc = single.worked_dxcc;
const cnfmdDxcc = single.cnfmd_dxcc;
let passesCwnFilter = false;
if ((cwnSet.has('notwkd') && !workedDxcc) ||
(cwnSet.has('wkd') && workedDxcc) ||
(cwnSet.has('cnf') && cnfmdDxcc) ||
(cwnSet.has('ucnf') && workedDxcc && !cnfmdDxcc)) {
passesCwnFilter = true;
}
if (!passesCwnFilter) return;
}
// Apply band filter (band always provided by API)
if (bandSet && !bandSet.has(single.band)) return;
// Apply de continent filter (which continent the spotter is in)
if (deContinentSet && (!dxccSpotter || !dxccSpotter.cont || !deContinentSet.has(dxccSpotter.cont))) return;
// Apply spotted continent filter (which continent the DX station is in)
if (spottedContinentSet && (!dxccSpotted || !dxccSpotted.cont || !spottedContinentSet.has(dxccSpotted.cont))) return;
// Apply mode filter (API already returns mode categories)
if (modeSet && (!single.mode || !modeSet.has(single.mode))) return;
// Apply additional flags filter (POTA, SOTA, WWFF, IOTA, Contest, Fresh)
if (hasFlagFilter) {
let passesFlagsFilter = false;
const age = single.age || 0;
if ((flagSet.has('SOTA') && dxccSpotted && dxccSpotted.sota_ref) ||
(flagSet.has('POTA') && dxccSpotted && dxccSpotted.pota_ref) ||
(flagSet.has('WWFF') && dxccSpotted && dxccSpotted.wwff_ref) ||
(flagSet.has('IOTA') && dxccSpotted && dxccSpotted.iota_ref) ||
(flagSet.has('Contest') && dxccSpotted && dxccSpotted.isContest) ||
(flagSet.has('Fresh') && age < 5)) {
passesFlagsFilter = true;
}
if (!passesFlagsFilter) return;
}
// All filters passed - validate essential data exists (reuse cached references)
if (!dxccSpotted) {
console.warn('Spot missing dxcc_spotted - creating placeholder:', single.spotted, single.frequency);
single.dxcc_spotted = { dxcc_id: 0, cont: '', cqz: '', flag: '', entity: 'Unknown' };
}
if (!dxccSpotter) {
console.warn('Spot missing dxcc_spotter - creating placeholder:', single.spotted, single.frequency);
single.dxcc_spotter = { dxcc_id: 0, cont: '', cqz: '', flag: '', entity: 'Unknown' };
}
// Build table row data
spots2render++;
var data = [];
var dxcc_wked_info, wked_info; // Color code DXCC entity: green=confirmed, yellow=worked, red=new
if (single.cnfmd_dxcc) {
dxcc_wked_info = "text-success";
} else if (single.worked_dxcc) {
dxcc_wked_info = "text-warning";
} else {
dxcc_wked_info = "text-danger";
}
// Color code callsign: green=confirmed, yellow=worked, red=new
if (single.cnfmd_call) {
wked_info = "text-success";
} else if (single.worked_call) {
wked_info = "text-warning";
} else {
wked_info = "text-danger";
} // Build LoTW badge with color coding based on last upload age
var lotw_badge = '';
if (single.dxcc_spotted && single.dxcc_spotted.lotw_user) {
let lclass = '';
if (single.dxcc_spotted.lotw_user > 365) {
lclass = 'lotw_info_red';
} else if (single.dxcc_spotted.lotw_user > 30) {
lclass = 'lotw_info_orange';
} else if (single.dxcc_spotted.lotw_user > 7) {
lclass = 'lotw_info_yellow';
}
let lotw_title = lang_bandmap_lotw_last_upload.replace('%d', single.dxcc_spotted.lotw_user);
lotw_badge = '' + buildBadge('success ' + lclass, null, lotw_title, 'L', false, "Helvetica") + '';
}
// Build activity badges (POTA, SOTA, WWFF, IOTA, Contest, Worked)
let activity_flags = '';
if (single.dxcc_spotted && single.dxcc_spotted.pota_ref) {
let pota_title = 'POTA: ' + single.dxcc_spotted.pota_ref;
if (single.dxcc_spotted.pota_mode) {
pota_title += ' (' + single.dxcc_spotted.pota_mode + ')';
}
pota_title += ' - ' + lang_bandmap_click_to_view_pota;
let pota_url = 'https://pota.app/#/park/' + single.dxcc_spotted.pota_ref;
activity_flags += '' + buildBadge('success', 'fa-tree', pota_title) + '';
}
if (single.dxcc_spotted && single.dxcc_spotted.sota_ref) {
let sota_title = 'SOTA: ' + single.dxcc_spotted.sota_ref + ' - ' + lang_bandmap_click_to_view_sotl;
let sota_url = 'https://sotl.as/summits/' + single.dxcc_spotted.sota_ref;
activity_flags += '' + buildBadge('primary', 'fa-mountain', sota_title) + '';
}
if (single.dxcc_spotted && single.dxcc_spotted.wwff_ref) {
let wwff_title = 'WWFF: ' + single.dxcc_spotted.wwff_ref + ' - ' + lang_bandmap_click_to_view_wwff;
let wwff_url = 'https://www.cqgma.org/zinfo.php?ref=' + single.dxcc_spotted.wwff_ref;
activity_flags += '' + buildBadge('success', 'fa-leaf', wwff_title) + '';
}
if (single.dxcc_spotted && single.dxcc_spotted.iota_ref) {
let iota_title = 'IOTA: ' + single.dxcc_spotted.iota_ref + ' - ' + lang_bandmap_click_to_view_iota;
let iota_url = 'https://www.iota-world.org/';
activity_flags += '' + buildBadge('info', 'fa-water', iota_title) + '';
}
if (single.dxcc_spotted && single.dxcc_spotted.isContest) {
// Build contest badge with contest name in tooltip if available
let contestTitle = lang_bandmap_contest;
if (single.dxcc_spotted.contestName && single.dxcc_spotted.contestName !== '') {
contestTitle = lang_bandmap_contest_name + ': ' + single.dxcc_spotted.contestName;
}
activity_flags += buildBadge('warning', 'fa-trophy', contestTitle);
}
// Add "Fresh" badge for spots less than 5 minutes old
let ageMinutesCheck = single.age || 0;
let isFresh = ageMinutesCheck < 5;
if (single.worked_call) {
let worked_title = lang_bandmap_worked_before;
if (single.last_wked && single.last_wked.LAST_QSO && single.last_wked.LAST_MODE) {
worked_title = lang_bandmap_worked_details.replace('%s', single.last_wked.LAST_QSO).replace('%s', single.last_wked.LAST_MODE);
}
let worked_badge_type = single.cnfmd_call ? 'success' : 'warning';
// isLast is true only if fresh badge won't be added
activity_flags += buildBadge(worked_badge_type, 'fa-check-circle', worked_title, null, !isFresh);
}
if (isFresh) {
activity_flags += buildBadge('danger', 'fa-bolt', lang_bandmap_fresh_spot, null, true);
} // Build table row array
data[0] = []; // Age column: show age in minutes with auto-update attribute
let ageMinutes = single.age || 0;
let spotTimestamp = single.when ? new Date(single.when).getTime() : Date.now();
data[0].push('' + ageMinutes + '');
// Band column: show band designation
data[0].push(single.band || '');
// Frequency column: convert kHz to MHz with 3 decimal places
let freqMHz = (single.frequency / 1000).toFixed(3);
data[0].push(freqMHz);
// Mode column: capitalize properly (API returns lowercase categories)
let displayMode = single.mode || '';
displayMode = MODE_CAPITALIZATION[displayMode] || displayMode;
data[0].push(displayMode);
// Submode column: show submode if available
let submode = (single.submode && single.submode !== '') ? single.submode : '';
data[0].push(submode); // Callsign column: wrap in callstats link with color coding
let callstatsLink = '' + single.spotted + '';
wked_info = ((wked_info != '' ? '' : '') + callstatsLink + (wked_info != '' ? '' : ''));
var spotted = wked_info;
data[0].push(spotted);
// Continent column: color code based on worked/confirmed status
var continent_wked_info;
if (single.cnfmd_continent) {
continent_wked_info = "text-success";
} else if (single.worked_continent) {
continent_wked_info = "text-warning";
} else {
continent_wked_info = "text-danger";
}
let continent_value = (single.dxcc_spotted && single.dxcc_spotted.cont) ? single.dxcc_spotted.cont : '';
if (continent_value) {
let continent_display = (continent_wked_info != '' ? '' : '') + continent_value + (continent_wked_info != '' ? '' : '');
continent_wked_info = '' + continent_display + '';
} else {
continent_wked_info = '';
}
data[0].push(continent_wked_info);
// CQ Zone column: show CQ Zone (moved here, right after Cont)
let cqz_value = (single.dxcc_spotted && single.dxcc_spotted.cqz) ? single.dxcc_spotted.cqz : '';
if (cqz_value) {
data[0].push('' + cqz_value + '');
} else {
data[0].push('');
} // Flag column: just the flag emoji without entity name
let flag_only = '';
if (single.dxcc_spotted && single.dxcc_spotted.flag) {
// Has flag emoji - show it
flag_only = '' + single.dxcc_spotted.flag + '';
} else if (single.dxcc_spotted && single.dxcc_spotted.entity) {
// Valid entity but flag missing from library - show white flag
flag_only = '🏳️';
} else if (!single.dxcc_spotted || !single.dxcc_spotted.entity) {
// No DXCC entity (invalid/unrecognized) - show pirate flag
flag_only = '🏴☠️';
}
data[0].push(flag_only);
// Entity column: entity name with color coding (no flag)
let dxcc_entity_full = single.dxcc_spotted ? (single.dxcc_spotted.entity || '') : '';
let entity_colored = dxcc_entity_full ? ((dxcc_wked_info != '' ? '' : '') + dxcc_entity_full + (dxcc_wked_info != '' ? '' : '')) : '';
if (single.dxcc_spotted && single.dxcc_spotted.dxcc_id && dxcc_entity_full) {
data[0].push('' + entity_colored + '');
} else {
data[0].push(entity_colored);
}
// de Callsign column (Spotter) - clickable callstats link
let spotterCallstatsLink = '' + single.spotter + '';
data[0].push(spotterCallstatsLink);
// de Cont column: spotter's continent
data[0].push((single.dxcc_spotter && single.dxcc_spotter.cont) ? single.dxcc_spotter.cont : '');
// de CQZ column: spotter's CQ Zone
data[0].push((single.dxcc_spotter && single.dxcc_spotter.cqz) ? single.dxcc_spotter.cqz : '');
// Last QSO column: show last QSO date if available
let lastQsoDate = (single.last_wked && single.last_wked.LAST_QSO) ? single.last_wked.LAST_QSO : '';
data[0].push(lastQsoDate); // Build medal badge - show only highest priority: continent > country > callsign
let medals = '';
if (single.worked_continent === false) {
// New Continent (not worked before) - Gold medal
medals += buildBadge('gold', 'fa-medal', lang_bandmap_new_continent);
} else if (single.worked_dxcc === false) {
// New DXCC (not worked before) - Silver medal
medals += buildBadge('silver', 'fa-medal', lang_bandmap_new_country);
} else if (single.worked_call === false) {
// New Callsign (not worked before) - Bronze medal
medals += buildBadge('bronze', 'fa-medal', lang_bandmap_new_callsign);
}
// Special column: combine medals, LoTW and activity badges
let flags_column = medals + lotw_badge + activity_flags;
data[0].push(flags_column);
// Message column: add tooltip with full message text
let message = single.message || '';
let messageDisplay = message;
if (message) {
// Escape HTML for tooltip to prevent XSS
let messageTooltip = message.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''');
messageDisplay = '' + message + '';
}
data[0].push(messageDisplay);
// Debug: Validate data array has exactly 16 columns
if (data[0].length !== 16) {
console.error('INVALID DATA ARRAY LENGTH:', data[0].length, 'Expected: 16');
console.error('Spot:', single.spotted, 'Frequency:', single.frequency);
console.error('Data array:', data[0]);
console.error('Missing columns:', 16 - data[0].length);
// Pad array with empty strings to prevent DataTables error
while (data[0].length < 16) {
data[0].push('');
}
}
// Add row to table with appropriate styling based on TTL and age
// Priority: TTL=0 (expiring) > age < 1 min (very new) > fresh
let rowClass = '';
let ageMinutesForStyling = single.age || 0;
if (ttl === 0) {
// Expiring spot (gone from cluster but visible for one more cycle)
rowClass = 'spot-expiring';
} else if (ageMinutesForStyling < 1) {
// Very new spot (less than 1 minute old)
rowClass = 'spot-very-new';
} else if (oldtable.length > 0) {
// Check if this is a new spot (not in old table)
let update = false;
oldtable.each(function (srow) {
if (JSON.stringify(srow) === JSON.stringify(data[0])) {
update = true;
}
});
if (!update) {
rowClass = 'fresh'; // Fresh spot animation
}
}
// Add row with appropriate class
let addedRow = table.rows.add(data).draw().nodes().to$();
if (rowClass) {
addedRow.addClass(rowClass);
}
// Apply CAT frequency gradient AFTER adding lifecycle classes to ensure it overrides
if (isCatTrackingEnabled && currentRadioFrequency) {
const spotFreqKhz = single.frequency * 1000; // Convert MHz to kHz
const gradientColor = getFrequencyGradientColor(spotFreqKhz, currentRadioFrequency);
if (gradientColor) {
// Store gradient color and frequency for later reapplication
addedRow.attr('data-spot-frequency', spotFreqKhz);
addedRow.attr('data-gradient-color', gradientColor);
// Use setProperty with priority 'important' to force override
addedRow.each(function() {
this.style.setProperty('--bs-table-bg', gradientColor, 'important');
this.style.setProperty('--bs-table-accent-bg', gradientColor, 'important');
this.style.setProperty('background-color', gradientColor, 'important');
});
addedRow.addClass('cat-frequency-gradient');
}
}
});
// Remove "fresh" highlight after 10 seconds
// (CAT gradient is updated every 3s from updateCATui, no need to force here)
setTimeout(function () {
$(".fresh").removeClass("fresh");
}, 10000);
if (spots2render == 0) {
disposeTooltips();
table.clear();
table.settings()[0].oLanguage.sEmptyTable = lang_bandmap_no_data;
table.draw();
}
// Parse emoji flags for proper rendering
if (typeof twemoji !== 'undefined') {
twemoji.parse(document.querySelector('.spottable'), {
folder: 'svg',
ext: '.svg'
});
}
// Apply responsive column visibility after rendering
if (typeof handleResponsiveColumns === 'function') {
handleResponsiveColumns();
}
// Add hover tooltips to all rows
$('.spottable tbody tr').each(function() {
$(this).attr('title', decodeHtml(lang_click_to_prepare_logging));
$(this).attr('data-bs-toggle', 'tooltip');
$(this).attr('data-bs-placement', 'top');
});
// Initialize tooltips with error handling
try {
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
$('[data-bs-toggle="tooltip"]').each(function() {
if (!this || !$(this).attr('title')) return;
try {
// Dispose existing tooltip instance if it exists
if (typeof window.safeDisposeTooltip === 'function') {
window.safeDisposeTooltip(this);
} else {
const existingTooltip = bootstrap.Tooltip.getInstance(this);
if (existingTooltip) {
if (existingTooltip._activeTrigger) {
existingTooltip._activeTrigger = {};
}
try { existingTooltip.dispose(); } catch(e) {}
}
}
// Create new tooltip instance with proper configuration
new bootstrap.Tooltip(this, {
boundary: 'window',
trigger: 'hover',
sanitize: false,
html: false,
animation: true,
delay: { show: 100, hide: 100 }
});
} catch (err) {
// Skip if tooltip fails to initialize
}
});
}
} catch (e) {
// Fallback if tooltip initialization fails
} let displayedCount = spots2render || 0;
// Update band count badges after rendering
updateBandCountBadges();
// Update CAT frequency gradient colors/borders after rendering if CAT is enabled
// Force update to ensure borders appear even if frequency hasn't changed
if (isCatTrackingEnabled && currentRadioFrequency) {
updateFrequencyGradientColors(true);
}
// Update status bar after render completes
setTimeout(function() {
if (!isFetchInProgress) {
let actualDisplayedCount = table.rows({search: 'applied'}).count();
updateStatusBar(cachedSpotData.length, actualDisplayedCount, getServerFilterText(), getClientFilterText(), false, false);
updateRefreshTimerDisplay();
}
// Update DX Map only if visible (don't waste resources)
if (dxMapVisible && dxMap) {
updateDxMap();
}
}, 100);
}
// ========================================
// BAND COUNT BADGES
// ========================================
// Update badge counts on band and mode filter buttons
function updateBandCountBadges() {
// Check if we fetched only a specific band (single band fetch mode)
// This happens when CAT Control is active and limited the API fetch to current band
let fetchedBand = null;
if (loadedBackendFilters && loadedBackendFilters.band && loadedBackendFilters.band !== 'All') {
fetchedBand = loadedBackendFilters.band;
}
if (!cachedSpotData || cachedSpotData.length === 0) {
// Clear all badges when no data
if (fetchedBand) {
// Set all to "-" when in single band fetch mode but no data
$('.band-count-badge, .mode-count-badge').html('-');
} else {
$('.band-count-badge, .mode-count-badge').html('0');
}
return;
}
// Get current filter values (excluding band and mode since we're counting those)
let deContinent = currentFilters.deContinent || ['Any'];
let spottedContinents = currentFilters.spottedContinent || ['Any'];
let cwnStatuses = currentFilters.cwn || ['All'];
let flags = currentFilters.additionalFlags || ['All'];
let requiredFlags = (currentFilters.requiredFlags || []).filter(v => v !== 'None'); // Remove "None"
// Convert to Sets for O(1) lookups
const deContinentSet = arrayToFilterSet(deContinent, 'Any');
const spottedContinentSet = arrayToFilterSet(spottedContinents, 'Any');
const cwnSet = arrayToFilterSet(cwnStatuses);
const flagSet = arrayToFilterSet(flags);
// Get current mode and band selections to apply when counting
let selectedModes = $('#mode').val() || ['All'];
let selectedBands = $('#band').val() || ['All'];
const selectedModeSet = arrayToFilterSet(selectedModes);
const selectedBandSet = arrayToFilterSet(selectedBands);
// Count spots per band and mode, applying all OTHER filters
let bandCounts = {};
let modeCounts = { cw: 0, digi: 0, phone: 0 };
let totalSpots = 0;
cachedSpotData.forEach((spot) => {
// Apply required flags FIRST (must have ALL selected required flags)
if (requiredFlags.length > 0) {
for (let reqFlag of requiredFlags) {
if (reqFlag === 'lotw') {
if (!spot.dxcc_spotted || !spot.dxcc_spotted.lotw_user) return;
}
if (reqFlag === 'newcontinent') {
if (spot.worked_continent !== false) return;
}
if (reqFlag === 'newcountry') {
if (spot.worked_dxcc !== false) return;
}
if (reqFlag === 'newcallsign') {
if (spot.worked_call !== false) return;
}
if (reqFlag === 'workedcallsign') {
if (spot.worked_call === false) return;
}
if (reqFlag === 'Contest') {
if (!spot.dxcc_spotted || !spot.dxcc_spotted.isContest) return;
}
}
}
// Apply CWN (Confirmed/Worked/New) filter
let passesCwnFilter = cwnStatuses.includes('All');
if (!passesCwnFilter) {
if (cwnStatuses.includes('notwkd') && !spot.worked_dxcc) passesCwnFilter = true;
if (cwnStatuses.includes('wkd') && spot.worked_dxcc) passesCwnFilter = true;
if (cwnStatuses.includes('cnf') && spot.cnfmd_dxcc) passesCwnFilter = true;
if (cwnStatuses.includes('ucnf') && spot.worked_dxcc && !spot.cnfmd_dxcc) passesCwnFilter = true;
}
if (!passesCwnFilter) return;
// Apply de continent filter (which continent the spotter is in)
let passesDeContFilter = deContinent.includes('Any');
if (!passesDeContFilter && spot.dxcc_spotter && spot.dxcc_spotter.cont) {
passesDeContFilter = deContinent.includes(spot.dxcc_spotter.cont);
}
if (!passesDeContFilter) return;
// Apply spotted continent filter (which continent the DX station is in)
let passesContinentFilter = spottedContinents.includes('Any');
if (!passesContinentFilter) {
passesContinentFilter = spottedContinents.includes(spot.dxcc_spotted.cont);
}
if (!passesContinentFilter) return;
// Apply additional flags filter (POTA, SOTA, WWFF, IOTA, Fresh)
let passesFlagsFilter = flags.includes('All');
if (!passesFlagsFilter) {
for (let flag of flags) {
if (flag === 'SOTA' && spot.dxcc_spotted && spot.dxcc_spotted.sota_ref) {
passesFlagsFilter = true;
break;
}
if (flag === 'POTA' && spot.dxcc_spotted && spot.dxcc_spotted.pota_ref) {
passesFlagsFilter = true;
break;
}
if (flag === 'WWFF' && spot.dxcc_spotted && spot.dxcc_spotted.wwff_ref) {
passesFlagsFilter = true;
break;
}
if (flag === 'IOTA' && spot.dxcc_spotted && spot.dxcc_spotted.iota_ref) {
passesFlagsFilter = true;
break;
}
if (flag === 'Fresh' && (spot.age || 0) < 5) {
passesFlagsFilter = true;
break;
}
}
}
if (!passesFlagsFilter) return;
// Get spot's band and mode for filtering (both always provided by API)
const band = spot.band;
const modeCategory = spot.mode;
// Count by band (applying MODE filter when counting bands)
if (band && (!selectedModeSet || (modeCategory && selectedModeSet.has(modeCategory)))) {
bandCounts[band] = (bandCounts[band] || 0) + 1;
totalSpots++;
}
// Count by mode (applying BAND filter when counting modes)
if (modeCategory && modeCounts.hasOwnProperty(modeCategory)) {
let passesBandFilter = !selectedBandSet;
if (!passesBandFilter && band) {
if (selectedBandSet.has(band)) {
passesBandFilter = true;
} else {
// Check if band is in a selected group (VHF, UHF, SHF)
const bandGroup = getBandGroup(band);
if (bandGroup && selectedBandSet.has(bandGroup)) {
passesBandFilter = true;
}
}
}
if (passesBandFilter) {
modeCounts[modeCategory]++;
}
}
});
// Count band groups (VHF, UHF, SHF)
let groupCounts = {
'VHF': 0,
'UHF': 0,
'SHF': 0
};
Object.keys(bandCounts).forEach(band => {
let group = getBandGroup(band);
if (group) {
groupCounts[group] += bandCounts[band];
}
});
// Update individual MF/HF/6m band button badges
// Note: 6m has its own separate button (not part of VHF group)
const mfHfBands = [
'160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'
];
mfHfBands.forEach(band => {
let displayText = (fetchedBand && band !== fetchedBand) ? '-' : (bandCounts[band] || 0).toString();
let selector = '#toggle' + band + 'Filter .band-count-badge';
let $badge = getCachedBadge(selector);
if ($badge.length === 0) {
$('#toggle' + band + 'Filter').append(' ' + displayText + '');
domCache.badges[selector] = $('#toggle' + band + 'Filter .band-count-badge');
} else {
$badge.html(displayText);
}
});
// Update band group button badges (VHF, UHF, SHF)
['VHF', 'UHF', 'SHF'].forEach(group => {
let isActiveGroup = fetchedBand && (getBandGroup(fetchedBand) === group);
let displayText = (fetchedBand && !isActiveGroup) ? '-' : (groupCounts[group] || 0).toString();
let selector = '#toggle' + group + 'Filter .band-count-badge';
let $badge = getCachedBadge(selector);
if ($badge.length === 0) {
$('#toggle' + group + 'Filter').append(' ' + displayText + '');
domCache.badges[selector] = $('#toggle' + group + 'Filter .band-count-badge');
} else {
$badge.html(displayText);
}
}); // Update mode button badges
['Cw', 'Digi', 'Phone'].forEach(mode => {
let count = modeCounts[mode.toLowerCase()] || 0;
let selector = '#toggle' + mode + 'Filter .mode-count-badge';
let $badge = getCachedBadge(selector);
if ($badge.length === 0) {
$('#toggle' + mode + 'Filter').append(' ' + count + '');
domCache.badges[selector] = $('#toggle' + mode + 'Filter .mode-count-badge');
} else {
$badge.html(count);
}
});
// Count spots for quick filter badges
let quickFilterCounts = {
mysubmodes: 0,
lotw: 0,
dxspot: 0,
newcontinent: 0,
newcountry: 0,
newcallsign: 0,
contest: 0,
geohunter: 0,
fresh: 0
};
cachedSpotData.forEach((spot) => {
// Cache DXCC references
const dxccSpotted = spot.dxcc_spotted;
const dxccSpotter = spot.dxcc_spotter;
// Apply de continent filter
if (deContinentSet && (!dxccSpotter || !dxccSpotter.cont || !deContinentSet.has(dxccSpotter.cont))) return;
// Apply spotted continent filter
if (spottedContinentSet && (!dxccSpotted || !dxccSpotted.cont || !spottedContinentSet.has(dxccSpotted.cont))) return;
// Apply CWN status filter
if (cwnSet) {
const workedDxcc = spot.worked_dxcc;
const cnfmdDxcc = spot.cnfmd_dxcc;
if (!((cwnSet.has('notwkd') && !workedDxcc) ||
(cwnSet.has('wkd') && workedDxcc) ||
(cwnSet.has('cnf') && cnfmdDxcc) ||
(cwnSet.has('ucnf') && workedDxcc && !cnfmdDxcc))) {
return;
}
}
// Apply band filter
if (selectedBandSet && !selectedBandSet.has(spot.band)) return;
// Apply mode filter
if (selectedModeSet && (!spot.mode || !selectedModeSet.has(spot.mode))) return;
// Count quick filter matches (use cached references)
if (spot.submode && userEnabledSubmodes.length > 0 && userEnabledSubmodes.some(m => m.toUpperCase() === spot.submode.toUpperCase())) quickFilterCounts.mysubmodes++;
if (dxccSpotted && dxccSpotted.lotw_user) quickFilterCounts.lotw++;
if (dxccSpotted?.cont && dxccSpotter?.cont && dxccSpotted.cont !== dxccSpotter.cont) quickFilterCounts.dxspot++;
if (spot.worked_continent === false) quickFilterCounts.newcontinent++;
if (spot.worked_dxcc === false) quickFilterCounts.newcountry++;
if (spot.worked_call === false) quickFilterCounts.newcallsign++;
if (dxccSpotted && dxccSpotted.isContest) quickFilterCounts.contest++;
if (dxccSpotted && (dxccSpotted.pota_ref || dxccSpotted.sota_ref || dxccSpotted.wwff_ref || dxccSpotted.iota_ref)) quickFilterCounts.geohunter++;
if ((spot.age || 0) < 5) quickFilterCounts.fresh++;
});
// Update quick filter badges
const quickFilters = [
{ id: 'toggleMySubmodesFilter', count: quickFilterCounts.mysubmodes },
{ id: 'toggleLotwFilter', count: quickFilterCounts.lotw },
{ id: 'toggleDxSpotFilter', count: quickFilterCounts.dxspot },
{ id: 'toggleNewContinentFilter', count: quickFilterCounts.newcontinent },
{ id: 'toggleDxccNeededFilter', count: quickFilterCounts.newcountry },
{ id: 'toggleNewCallsignFilter', count: quickFilterCounts.newcallsign },
{ id: 'toggleContestFilter', count: quickFilterCounts.contest },
{ id: 'toggleGeoHunterFilter', count: quickFilterCounts.geohunter },
{ id: 'toggleFreshFilter', count: quickFilterCounts.fresh }
];
quickFilters.forEach(filter => {
let $badge = $('#' + filter.id + ' .quick-filter-count-badge');
if ($badge.length === 0) {
// Badge doesn't exist yet, create it
$('#' + filter.id).append(' ' + filter.count + '');
} else {
// Update existing badge
$badge.html(filter.count);
}
});
}
// ========================================
// BACKEND DATA FETCH
// ========================================
// Fetch spot data from DX cluster API
// Backend filters: band, de continent (where spotter is), mode
// Client filters applied after fetch: cwn, spotted continent, additionalFlags
function fill_list(de, maxAgeMinutes, bandForAPI = 'All') {
var table = get_dtable();
// Normalize de continent parameter to array
let deContinent = Array.isArray(de) ? de : [de];
if (deContinent.includes('Any') || deContinent.length === 0) deContinent = ['Any'];
// Backend API only accepts single values for continent
let continentForAPI = 'Any';
if (deContinent.length === 1 && !deContinent.includes('Any')) continentForAPI = deContinent[0];
// bandForAPI is now passed as a parameter from applyFilters()
// Update backend filter state
loadedBackendFilters = {
continent: continentForAPI,
band: bandForAPI
};
lastFetchParams.continent = continentForAPI;
lastFetchParams.band = bandForAPI;
lastFetchParams.maxAge = maxAgeMinutes;
// Build API URL: /spots/{band}/{maxAge}/{continent}/{mode}
// Mode is always 'All' - filtering happens client-side
let dxurl = dxcluster_provider + "/spots/" + bandForAPI + "/" + maxAgeMinutes + "/" + continentForAPI + "/All";
// Cancel any in-flight request before starting new one
if (currentAjaxRequest) {
currentAjaxRequest.abort();
currentAjaxRequest = null;
}
isFetchInProgress = true;
updateStatusBar(0, 0, getServerFilterText(), getClientFilterText(), true, false);
currentAjaxRequest = $.ajax({
url: dxurl,
cache: false,
dataType: "json"
}).done(function(dxspots) {
currentAjaxRequest = null;
disposeTooltips();
table.page.len(50);
// Check if response is an error object
if (dxspots && dxspots.error) {
console.warn('Backend returned error:', dxspots.error);
cachedSpotData = [];
table.clear();
table.settings()[0].oLanguage.sEmptyTable = lang_bandmap_no_spots_filters;
table.draw();
updateStatusBar(0, 0, getServerFilterText(), getClientFilterText(), false, false);
isFetchInProgress = false;
startRefreshTimer();
return;
}
if (dxspots.length > 0) {
dxspots.sort(SortByQrg); // Sort by frequency
// TTL Management: Process new spots and update TTL values
// In single-band fetch mode, only update TTL for spots in the fetched band
let newSpotKeys = new Set();
// First pass: identify all spots in the new data
dxspots.forEach(function(spot) {
let key = getSpotKey(spot);
newSpotKeys.add(key);
});
// Second pass: Update TTL for existing spots
// - In single-band mode (bandForAPI != 'All'), only decrement TTL for spots in the fetched band
// - In all-band mode, decrement all TTL values
// - If spot exists in new data, set TTL back to 1 (stays valid)
// - Remove spots with TTL < -1
let ttlStats = { stillValid: 0, expiring: 0, removed: 0, added: 0 };
let expiringSpots = []; // Store spots with TTL=0 that need to be shown
for (let [key, ttl] of spotTTLMap.entries()) {
let newTTL = ttl;
// Only decrement TTL if:
// - We fetched all bands (bandForAPI === 'All'), OR
// - This spot is in the band we just fetched
let shouldDecrementTTL = (bandForAPI === 'All');
if (!shouldDecrementTTL && cachedSpotData) {
// Look up band from cached spot data (band always provided by API)
let cachedSpot = cachedSpotData.find(s => getSpotKey(s) === key);
if (cachedSpot && cachedSpot.band) {
shouldDecrementTTL = (cachedSpot.band === bandForAPI);
}
}
if (shouldDecrementTTL) {
newTTL = ttl - 1; // Decrement only if in scope of this fetch
}
if (newSpotKeys.has(key)) {
newTTL = 1; // Reset to 1 if spot still exists (keeps it valid)
ttlStats.stillValid++;
} else if (shouldDecrementTTL) {
if (newTTL === 0) {
ttlStats.expiring++;
// Find the spot in previous cachedSpotData to keep it for display
if (cachedSpotData) {
let expiringSpot = cachedSpotData.find(s => getSpotKey(s) === key);
if (expiringSpot) {
expiringSpots.push(expiringSpot);
}
}
}
}
if (newTTL < -1) {
spotTTLMap.delete(key); // Remove completely hidden spots
ttlStats.removed++;
} else {
spotTTLMap.set(key, newTTL);
}
}
// Third pass: Add new spots that weren't in the map
dxspots.forEach(function(spot) {
let key = getSpotKey(spot);
if (!spotTTLMap.has(key)) {
spotTTLMap.set(key, 1); // New spot starts with TTL = 1
ttlStats.added++;
}
});
if (expiringSpots.length > 0) {
}
// Merge new spots with expiring spots (TTL=0) for display
cachedSpotData = dxspots.concat(expiringSpots);
cachedSpotData.sort(SortByQrg); // Re-sort after merging
} else {
cachedSpotData = [];
}
lastFetchParams.timestamp = new Date();
isFetchInProgress = false;
renderFilteredSpots(); // Apply client-side filters and render
startRefreshTimer(); // Start 10s countdown - TEMPORARY
}).fail(function(jqXHR, textStatus) {
currentAjaxRequest = null;
// Don't show error if user cancelled the request
if (textStatus === 'abort') {
return;
}
cachedSpotData = null;
isFetchInProgress = false;
disposeTooltips();
table.clear();
table.settings()[0].oLanguage.sEmptyTable = lang_bandmap_error_loading;
table.draw();
updateStatusBar(0, 0, getServerFilterText(), getClientFilterText(), false, false);
startRefreshTimer();
});
}
// Highlight rows within ±20 kHz of specified frequency (for CAT integration)
// Old highlight_current_qrg function removed - now using updateFrequencyGradientColors
// Initialize DataTable
var table=get_dtable();
table.order([1, 'asc']); // Sort by frequency column
disposeTooltips();
table.clear();
// ========================================
// HELPER FUNCTIONS
// ========================================
// Build a badge HTML string with consistent styling
// type: badge color (success/primary/info/warning/danger)
// icon: FontAwesome icon class (e.g., 'fa-tree')
// title: tooltip text
// text: optional text content instead of icon
// isLast: if true, uses margin: 0 instead of negative margin
function buildBadge(type, icon, title, text = null, isLast = false, fontFamily = null) {
const margin = isLast ? '0' : '0 2px 0 0';
const fontSize = text ? '0.75rem' : '0.7rem';
const fontFamilyStyle = fontFamily ? 'font-family: ' + fontFamily + ';' : '';
const content = text ? '' + text + '' : '';
return '' + content + '';
}
// Use BAND_GROUPS from radiohelpers.js (loaded globally in footer)
// Note: These functions are now available globally, but we keep local references for consistency
// If radiohelpers not loaded, fallback to local definition (shouldn't happen in production)
// Get selected values from multi-select dropdown
function getSelectedValues(selectId) {
let values = $('#' + selectId).val();
if (!values || values.length === 0) {
return ['All'];
}
return values;
}
// ========================================
// SMART FILTER APPLICATION
// ========================================
// Get band group (VHF/UHF/SHF) for a given band - memoized with O(1) lookup
function getBandGroup(band) {
return BAND_TO_GROUP_MAP[band] || null;
}
// Intelligently decide whether to reload from backend or filter client-side
// Backend filter (requires new API call): de continent only
// Client filters (use cached data): band, mode, cwn, spotted continent, requiredFlags, additionalFlags
function applyFilters(forceReload = false) {
let band = getSelectedValues('band');
let de = getSelectedValues('decontSelect');
let continent = getSelectedValues('continentSelect');
let cwn = getSelectedValues('cwnSelect');
let mode = getSelectedValues('mode');
let additionalFlags = getSelectedValues('additionalFlags');
let requiredFlags = ($('#requiredFlags').val() || []).filter(v => v !== 'None'); // Remove "None"
let continentForAPI = 'Any';
if (de.length === 1 && !de.includes('Any')) {
// Single continent selected - use backend filter
continentForAPI = de[0];
}
// If multiple continents selected, fetch 'Any' from backend and filter client-side
// Band filtering: In purple mode (on+marker), fetch only the active band from backend
let bandForAPI = 'All';
if (catState === 'on+marker' && band.length === 1 && !band.includes('All')) {
// Purple mode with single band selected - fetch only that band from backend
bandForAPI = band[0];
}
// Check if backend parameters changed (requires new data fetch)
// Continent and band filters affect server fetch
let backendParamsChanged = forceReload ||
loadedBackendFilters.continent !== continentForAPI ||
loadedBackendFilters.band !== bandForAPI;
// Always update current filters for client-side filtering
currentFilters = {
band: band,
deContinent: de, // Spots FROM continent (server filter)
spottedContinent: continent, // Spotted STATION continent (client filter)
cwn: cwn,
mode: mode,
requiredFlags: requiredFlags,
additionalFlags: additionalFlags
};
if (backendParamsChanged) {
disposeTooltips();
table.clear();
fill_list(de, dxcluster_maxage, bandForAPI);
} else {
renderFilteredSpots();
updateBandCountBadges();
}
updateFilterIcon();
}
initializeBackendFilters();
initFilterCheckboxes();
applyFilters(true);
// Sync button states on initial load
syncQuickFilterButtons();
updateFilterIcon();
$("#applyFiltersButtonPopup").on("click", function() {
applyFilters(false);
$('#filterDropdown').dropdown('hide');
});
$("#clearFiltersButton").on("click", function() {
// Preserve current band selection only if band lock (purple mode) is active
let currentBand = window.isFrequencyMarkerEnabled === true ? $('#band').val() : null;
setAllFilterValues({
cwn: ['All'],
deCont: ['Any'],
continent: ['Any'],
band: currentBand || ['All'],
mode: ['All'],
additionalFlags: ['All'],
requiredFlags: []
});
// Update checkbox indicators for all selects
updateAllSelectCheckboxes();
// Clear text search
$('#spotSearchInput').val('');
table.search('').draw();
$('#clearSearchBtn').hide();
syncQuickFilterButtons();
updateFilterIcon();
applyFilters(true);
$('#filterDropdown').dropdown('hide');
if (window.isFrequencyMarkerEnabled === true && typeof showToast === 'function') {
showToast(lang_bandmap_clear_filters, lang_bandmap_band_preserved, 'bg-info text-white', 5000);
}
});
// Clear Filters Quick Button (preserves De Continent)
$("#clearFiltersButtonQuick").on("click", function() {
// Preserve current De Continent selection
let currentDecont = $('#decontSelect').val();
// Preserve current band selection only if band lock (purple mode) is active
let currentBand = window.isFrequencyMarkerEnabled === true ? $('#band').val() : null;
// Reset all other filters
$('#cwnSelect').val(['All']).trigger('change');
$('#continentSelect').val(['Any']).trigger('change');
$('#band').val(currentBand || ['All']).trigger('change'); // Preserve band if band lock is active
$('#mode').val(['All']).trigger('change');
$('#additionalFlags').val(['All']).trigger('change');
$('#requiredFlags').val([]).trigger('change');
// Restore De Continent
$('#decontSelect').val(currentDecont).trigger('change');
// Clear text search
$('#spotSearchInput').val('');
table.search('').draw();
$('#clearSearchBtn').hide();
syncQuickFilterButtons();
updateFilterIcon();
applyFilters(false); // Don't refetch from server since De Continent is preserved
if (window.isFrequencyMarkerEnabled === true && typeof showToast === 'function') {
showToast(lang_bandmap_clear_filters, lang_bandmap_band_preserved, 'bg-info text-white', 5000);
}
});
// ========================================
// DX CLUSTER FILTER FAVORITES
// ========================================
let dxclusterFavs = {};
/**
* Apply saved filter values to UI and trigger filter application
* When band lock is active, band filter is preserved (not restored from favorites)
*/
function applyDxClusterFilterValues(filterData) {
// If band lock is active, preserve current band filter
// window.isFrequencyMarkerEnabled is set to true when lock mode is enabled
if (window.isFrequencyMarkerEnabled === true) {
// Create a copy without the band filter
let filteredData = Object.assign({}, filterData);
delete filteredData.band;
setAllFilterValues(filteredData);
// Show toast that band filter was preserved
if (typeof showToast === 'function') {
showToast(lang_bandmap_filter_favorites, lang_bandmap_band_preserved, 'bg-info text-white', 5000);
}
} else {
setAllFilterValues(filterData);
}
// Restore My Submodes filter state if stored (and user has submodes enabled)
if (filterData.mySubmodesActive !== undefined && userEnabledSubmodes.length > 0) {
isMySubmodesFilterActive = filterData.mySubmodesActive;
updateMySubmodesButtonVisual();
updateModeButtonsForSubmodes();
}
updateAllSelectCheckboxes();
syncQuickFilterButtons();
updateFilterIcon();
applyFilters(true);
}
function saveDxClusterFav() {
// Check preset limit (max 20)
if (Object.keys(dxclusterFavs).length >= 20) {
showToast && showToast(lang_bandmap_filter_favorites, lang_bandmap_preset_limit_reached, 'bg-warning text-dark', 4000);
return;
}
let favName = prompt(lang_bandmap_filter_preset_name);
if (!favName || favName.trim() === '') return;
// Build filter data from currentFilters using helper
let filterData = buildFilterDataFromCurrent(favName.trim());
$.ajax({
url: base_url + 'index.php/user_options/add_edit_dxcluster_fav',
method: 'POST',
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify(filterData),
success: function(result) {
if (result.success) {
getDxClusterFavs();
showToast && showToast(lang_bandmap_filter_favorites, lang_bandmap_filter_preset_saved, 'bg-success text-white', 2000);
}
},
error: function() {
showToast && showToast(lang_bandmap_filter_favorites, lang_bandmap_favorites_failed, 'bg-danger text-white', 3000);
}
});
}
function getDxClusterFavs() {
$.ajax({
url: base_url + 'index.php/user_options/get_dxcluster_user_favs_and_settings',
method: 'GET',
dataType: 'json',
success: function(result) {
// Handle combined response with favorites and userConfig
dxclusterFavs = result.favorites || {};
renderDxClusterFavMenu();
// Process user config (bands/modes/submodes)
if (result.userConfig) {
processUserConfig(result.userConfig);
}
}
});
}
function renderDxClusterFavMenu() {
let $menu = $('#dxcluster_fav_menu').empty();
let keys = Object.keys(dxclusterFavs);
if (keys.length === 0) {
$menu.append('' + lang_bandmap_no_filter_presets + '');
return;
}
keys.forEach(function(key) {
// Build the menu item with data attribute on the parent div for easier click handling
let $item = $('').attr('data-fav-name', key);
let $nameSpan = $('').text(key);
let $deleteBtn = $('').attr('data-fav-name', key).attr('title', lang_general_word_delete).html('');
$menu.append($item.append($nameSpan).append($deleteBtn));
});
}
function delDxClusterFav(name) {
if (!confirm(lang_bandmap_delete_filter_confirm)) return;
$.ajax({
url: base_url + 'index.php/user_options/del_dxcluster_fav',
method: 'POST',
dataType: 'json',
contentType: 'application/json; charset=utf-8',
data: JSON.stringify({ option_name: name }),
success: function(result) {
if (result.success) {
getDxClusterFavs();
showToast && showToast(lang_bandmap_filter_favorites, lang_bandmap_filter_preset_deleted, 'bg-info text-white', 2000);
}
}
});
}
// Event handlers
$('#dxcluster_fav_add').on('click', function(e) {
e.preventDefault();
saveDxClusterFav();
});
$(document).on('click', '.dxcluster_fav_del', function(e) {
e.preventDefault();
e.stopPropagation();
delDxClusterFav($(this).data('fav-name'));
});
// Click on the entire favorite item row (but not the delete button)
$(document).on('click', '.dxcluster_fav_item', function(e) {
// Don't trigger if clicking the delete button
if ($(e.target).closest('.dxcluster_fav_del').length) return;
e.preventDefault();
let name = $(this).data('fav-name');
if (dxclusterFavs[name]) {
applyDxClusterFilterValues(dxclusterFavs[name]);
// Escape name for toast display (showToast uses innerHTML)
let safeName = $('