2025-10-02 21:43:35 +01:00
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
<div class="container">
|
|
|
|
|
<div class="row">
|
|
|
|
|
<div class="col-md-12">
|
|
|
|
|
<div class="card">
|
|
|
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
|
|
|
<h3 class="mb-0"><?php echo $page_title; ?></h3>
|
|
|
|
|
<div>
|
|
|
|
|
<span id="connectionStatus" class="badge bg-secondary me-2">Connecting...</span>
|
|
|
|
|
<a href="<?php echo site_url('dxcluster/bandmap'); ?>" class="btn btn-primary btn-sm" target="_blank">
|
|
|
|
|
<i class="fas fa-chart-line"></i> Bandmap
|
|
|
|
|
</a>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-body">
|
2025-11-17 22:40:07 +00:00
|
|
|
<!-- Filters and Radio Control Panel -->
|
|
|
|
|
<div class="card mb-3">
|
|
|
|
|
<div class="card-body py-2">
|
|
|
|
|
<div id="radio_status"></div>
|
|
|
|
|
<div class="d-flex align-items-center gap-3 flex-wrap">
|
|
|
|
|
<!-- Radio Selection -->
|
|
|
|
|
<div class="d-flex align-items-center">
|
|
|
|
|
<label for="radio" class="form-label mb-0 me-2" style="white-space: nowrap;">
|
|
|
|
|
<i class="fas fa-broadcast-tower"></i> Radio:
|
|
|
|
|
</label>
|
|
|
|
|
<select class="form-select form-select-sm radios" id="radio" name="radio" style="min-width: 150px;">
|
|
|
|
|
<option value="0" selected="selected"><?php echo lang('general_word_none'); ?></option>
|
|
|
|
|
<?php foreach ($radios->result() as $row) { ?>
|
|
|
|
|
<option value="<?php echo $row->id; ?>" <?php if ($this->session->userdata('radio') == $row->id) { echo "selected=\"selected\""; } ?>><?php echo $row->radio; ?></option>
|
|
|
|
|
<?php } ?>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Band Filter -->
|
|
|
|
|
<div class="d-flex align-items-center">
|
|
|
|
|
<label for="bandFilter" class="form-label mb-0 me-2" style="white-space: nowrap;">
|
|
|
|
|
<i class="fas fa-wave-square"></i> Band:
|
|
|
|
|
</label>
|
|
|
|
|
<select class="form-select form-select-sm" id="bandFilter" style="min-width: 120px;">
|
|
|
|
|
<option value="all">All Bands</option>
|
|
|
|
|
<option value="160m">160m</option>
|
|
|
|
|
<option value="80m">80m</option>
|
|
|
|
|
<option value="60m">60m</option>
|
|
|
|
|
<option value="40m">40m</option>
|
|
|
|
|
<option value="30m">30m</option>
|
|
|
|
|
<option value="20m">20m</option>
|
|
|
|
|
<option value="17m">17m</option>
|
|
|
|
|
<option value="15m">15m</option>
|
|
|
|
|
<option value="12m">12m</option>
|
|
|
|
|
<option value="10m">10m</option>
|
|
|
|
|
<option value="6m">6m</option>
|
|
|
|
|
<option value="4m">4m</option>
|
|
|
|
|
<option value="2m">2m</option>
|
|
|
|
|
<option value="70cm">70cm</option>
|
|
|
|
|
<option value="23cm">23cm</option>
|
|
|
|
|
<option value="ghz">GHz+</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-29 11:31:31 +01:00
|
|
|
<!-- Mode Filter -->
|
|
|
|
|
<div class="d-flex align-items-center">
|
|
|
|
|
<label for="modeFilter" class="form-label mb-0 me-2" style="white-space: nowrap;">
|
|
|
|
|
<i class="fas fa-sliders-h"></i> Mode:
|
|
|
|
|
</label>
|
|
|
|
|
<select class="form-select form-select-sm" id="modeFilter" style="min-width: 110px;">
|
|
|
|
|
<option value="all">All Modes</option>
|
|
|
|
|
<option value="cw">CW</option>
|
|
|
|
|
<option value="ssb">SSB</option>
|
|
|
|
|
<option value="digi">Digital</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2025-11-17 22:40:07 +00:00
|
|
|
<!-- RBN Filter -->
|
|
|
|
|
<div class="form-check">
|
|
|
|
|
<input class="form-check-input" type="checkbox" id="hideRbnSpots" checked>
|
|
|
|
|
<label class="form-check-label" for="hideRbnSpots">
|
|
|
|
|
<i class="fas fa-filter"></i> Hide RBN Spots
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
2026-03-29 11:31:31 +01:00
|
|
|
|
|
|
|
|
<!-- New DXCC Filter -->
|
|
|
|
|
<div class="d-flex align-items-center">
|
|
|
|
|
<label for="newDxccFilter" class="form-label mb-0 me-2" style="white-space: nowrap;">
|
|
|
|
|
<i class="fas fa-star"></i> New:
|
|
|
|
|
</label>
|
|
|
|
|
<select class="form-select form-select-sm" id="newDxccFilter" style="min-width: 150px;">
|
|
|
|
|
<option value="all">All Spots</option>
|
|
|
|
|
<option value="new_any">New DXCC or Band</option>
|
|
|
|
|
<option value="new_dxcc">New DXCC only</option>
|
|
|
|
|
<option value="new_band">New Band only</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2025-11-17 22:40:07 +00:00
|
|
|
|
|
|
|
|
<!-- Info -->
|
|
|
|
|
<small class="text-muted ms-auto">
|
|
|
|
|
<i class="fas fa-info-circle"></i> Filters active
|
|
|
|
|
</small>
|
|
|
|
|
</div>
|
2025-11-17 17:20:55 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-11-17 22:40:07 +00:00
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
<div class="table-responsive">
|
|
|
|
|
<table id="dxSpotsTable" class="table table-striped table-hover">
|
|
|
|
|
<thead class="table-dark">
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Time</th>
|
|
|
|
|
<th>DX Call</th>
|
|
|
|
|
<th>Frequency</th>
|
|
|
|
|
<th>Spotter</th>
|
|
|
|
|
<th>Comment</th>
|
|
|
|
|
<th>Age</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody id="spotsTableBody">
|
|
|
|
|
<!-- Spots will be populated here -->
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-3">
|
2025-11-17 22:40:07 +00:00
|
|
|
<div class="alert alert-info mb-2">
|
|
|
|
|
<strong><i class="fas fa-info-circle"></i> Legend:</strong>
|
|
|
|
|
<div class="mt-2">
|
|
|
|
|
<span class="me-3"><i class="fas fa-check text-success"></i> = Worked on this band</span>
|
|
|
|
|
<span class="me-3"><i class="fas fa-check text-info"></i> = Worked on another band</span>
|
|
|
|
|
<span class="me-3"><i class="fas fa-times text-secondary"></i> = Not worked</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-1">
|
|
|
|
|
<span class="me-3"><span class="badge bg-danger">New Band</span> = New DXCC on this band</span>
|
|
|
|
|
<span class="me-3"><span class="badge bg-warning text-dark">New DXCC</span> = New DXCC entity (never worked)</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="mt-2 pt-2 border-top">
|
|
|
|
|
<small><i class="fas fa-mouse-pointer"></i> <strong>Tip:</strong> Click any frequency to QSY your radio (if selected above)</small>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-04 14:53:43 +01:00
|
|
|
<small class="text-muted">
|
|
|
|
|
<i class="fas fa-info-circle"></i>
|
|
|
|
|
Real-time DX cluster spots. Data refreshes automatically via WebSocket connection.
|
|
|
|
|
</small>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-10-02 14:19:39 +01:00
|
|
|
</div>
|
2025-10-04 14:53:43 +01:00
|
|
|
</div>
|
2025-10-02 14:19:39 +01:00
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
<style>
|
|
|
|
|
.connection-status.connected {
|
|
|
|
|
background-color: #28a745 !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.connection-status.disconnected {
|
|
|
|
|
background-color: #dc3545 !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.connection-status.connecting {
|
|
|
|
|
background-color: #ffc107 !important;
|
|
|
|
|
color: #212529 !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.spot-age {
|
|
|
|
|
font-size: 0.8em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.spot-fresh {
|
|
|
|
|
background-color: rgba(40, 167, 69, 0.1) !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.spot-old {
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.frequency-link {
|
|
|
|
|
color: #0d6efd;
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
font-family: 'Courier New', 'Monaco', 'Menlo', monospace;
|
|
|
|
|
font-size: 0.95em;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.frequency-link:hover {
|
|
|
|
|
color: #0a58ca;
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.frequency-cell {
|
|
|
|
|
font-family: 'Courier New', 'Monaco', 'Menlo', monospace;
|
|
|
|
|
font-size: 0.95em;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|
2026-03-29 11:31:31 +01:00
|
|
|
<script src="<?php echo base_url(); ?>assets/js/dxcluster-utils.js"></script>
|
|
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
<script>
|
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
|
|
|
let ws = null;
|
|
|
|
|
let spots = new Map();
|
|
|
|
|
let dataTable = null;
|
2025-11-17 17:20:55 +00:00
|
|
|
let hideRbnSpots = true; // Default to hiding RBN spots
|
|
|
|
|
let selectedBand = 'all'; // Default to all bands
|
2026-03-29 11:31:31 +01:00
|
|
|
let selectedMode = 'all'; // Default to all modes
|
|
|
|
|
let selectedNewDxcc = 'all'; // Default to all spots
|
2025-11-17 17:31:20 +00:00
|
|
|
let workedStatus = {}; // Cache for worked status
|
|
|
|
|
let checkWorkedTimeout = null;
|
2025-10-04 14:53:43 +01:00
|
|
|
|
|
|
|
|
const connectionStatus = document.getElementById('connectionStatus');
|
|
|
|
|
const spotsTableBody = document.getElementById('spotsTableBody');
|
2025-11-17 17:20:55 +00:00
|
|
|
const hideRbnCheckbox = document.getElementById('hideRbnSpots');
|
|
|
|
|
const bandFilterSelect = document.getElementById('bandFilter');
|
2026-03-29 11:31:31 +01:00
|
|
|
const modeFilterSelect = document.getElementById('modeFilter');
|
|
|
|
|
const newDxccFilterSelect = document.getElementById('newDxccFilter');
|
2025-11-17 17:20:55 +00:00
|
|
|
|
|
|
|
|
// Load RBN filter preference from localStorage
|
|
|
|
|
const savedRbnPreference = localStorage.getItem('cloudlog_hideRbnSpots');
|
|
|
|
|
if (savedRbnPreference !== null) {
|
|
|
|
|
hideRbnSpots = savedRbnPreference === 'true';
|
|
|
|
|
hideRbnCheckbox.checked = hideRbnSpots;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Load band filter preference from localStorage
|
|
|
|
|
const savedBandPreference = localStorage.getItem('cloudlog_bandFilter');
|
|
|
|
|
if (savedBandPreference !== null) {
|
|
|
|
|
selectedBand = savedBandPreference;
|
|
|
|
|
bandFilterSelect.value = selectedBand;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:31:31 +01:00
|
|
|
// Load mode filter preference from localStorage
|
|
|
|
|
const savedModePreference = localStorage.getItem('cloudlog_modeFilter');
|
|
|
|
|
if (savedModePreference !== null) {
|
|
|
|
|
selectedMode = savedModePreference;
|
|
|
|
|
modeFilterSelect.value = selectedMode;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 13:37:16 +01:00
|
|
|
// Apply CW lock in case CW mode was saved from a previous session
|
|
|
|
|
applyCwRbnLock();
|
|
|
|
|
|
2026-03-29 11:31:31 +01:00
|
|
|
// Load new DXCC filter preference from localStorage
|
|
|
|
|
const savedNewDxccPreference = localStorage.getItem('cloudlog_newDxccFilter');
|
|
|
|
|
if (savedNewDxccPreference !== null) {
|
|
|
|
|
selectedNewDxcc = savedNewDxccPreference;
|
|
|
|
|
newDxccFilterSelect.value = selectedNewDxcc;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 17:20:55 +00:00
|
|
|
// Listen for checkbox changes
|
|
|
|
|
hideRbnCheckbox.addEventListener('change', function() {
|
|
|
|
|
hideRbnSpots = this.checked;
|
|
|
|
|
localStorage.setItem('cloudlog_hideRbnSpots', hideRbnSpots.toString());
|
|
|
|
|
updateTable();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Listen for band filter changes
|
|
|
|
|
bandFilterSelect.addEventListener('change', function() {
|
|
|
|
|
selectedBand = this.value;
|
|
|
|
|
localStorage.setItem('cloudlog_bandFilter', selectedBand);
|
|
|
|
|
updateTable();
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-29 11:31:31 +01:00
|
|
|
// Listen for mode filter changes
|
|
|
|
|
modeFilterSelect.addEventListener('change', function() {
|
|
|
|
|
selectedMode = this.value;
|
|
|
|
|
localStorage.setItem('cloudlog_modeFilter', selectedMode);
|
2026-03-29 13:37:16 +01:00
|
|
|
applyCwRbnLock();
|
2026-03-29 11:31:31 +01:00
|
|
|
updateTable();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Listen for new DXCC filter changes
|
|
|
|
|
newDxccFilterSelect.addEventListener('change', function() {
|
|
|
|
|
selectedNewDxcc = this.value;
|
|
|
|
|
localStorage.setItem('cloudlog_newDxccFilter', selectedNewDxcc);
|
|
|
|
|
updateTable();
|
|
|
|
|
});
|
2025-11-17 17:20:55 +00:00
|
|
|
|
2026-03-29 13:37:16 +01:00
|
|
|
// When CW mode is active, Hide RBN must be off (most CW spots ARE RBN).
|
|
|
|
|
// Disables the checkbox so the user cannot re-hide RBN while on CW.
|
|
|
|
|
function applyCwRbnLock() {
|
|
|
|
|
if (selectedMode === 'cw') {
|
|
|
|
|
if (hideRbnSpots) {
|
|
|
|
|
hideRbnSpots = false;
|
|
|
|
|
hideRbnCheckbox.checked = false;
|
|
|
|
|
localStorage.setItem('cloudlog_hideRbnSpots', 'false');
|
|
|
|
|
}
|
|
|
|
|
hideRbnCheckbox.disabled = true;
|
|
|
|
|
hideRbnCheckbox.title = 'Cannot hide RBN while CW mode is selected';
|
|
|
|
|
} else {
|
|
|
|
|
hideRbnCheckbox.disabled = false;
|
|
|
|
|
hideRbnCheckbox.title = '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:31:31 +01:00
|
|
|
// Shared utility aliases (implementations in assets/js/dxcluster-utils.js)
|
|
|
|
|
const isRbnSpot = DXCluster.isRbnSpot;
|
|
|
|
|
const getBandFromFrequency = DXCluster.getBandFromFrequency;
|
|
|
|
|
const detectMode = DXCluster.detectMode;
|
2025-11-17 17:20:55 +00:00
|
|
|
|
|
|
|
|
// Check if spot matches selected band filter
|
|
|
|
|
function matchesBandFilter(freqKhz) {
|
|
|
|
|
if (selectedBand === 'all') return true;
|
|
|
|
|
const spotBand = getBandFromFrequency(freqKhz);
|
|
|
|
|
return spotBand === selectedBand;
|
|
|
|
|
}
|
2025-10-04 14:53:43 +01:00
|
|
|
|
2026-03-29 11:31:31 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
// Check if spot matches selected mode filter
|
|
|
|
|
function matchesModeFilter(spot) {
|
|
|
|
|
if (selectedMode === 'all') return true;
|
|
|
|
|
return detectMode(spot) === selectedMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if spot matches the new DXCC / new band filter.
|
|
|
|
|
// Spots whose worked status hasn't been fetched yet are hidden until
|
|
|
|
|
// checkWorkedStatus() populates the data and re-renders the table.
|
|
|
|
|
function matchesNewDxccFilter(spot) {
|
|
|
|
|
if (selectedNewDxcc === 'all') return true;
|
|
|
|
|
const status = workedStatus[spot.dx];
|
|
|
|
|
if (!status) return false; // Hold back until status is known
|
|
|
|
|
if (!status.dxcc) return false; // No DXCC data available
|
|
|
|
|
|
|
|
|
|
if (selectedNewDxcc === 'new_any') {
|
|
|
|
|
// Either never worked this entity at all, or not yet on this band
|
|
|
|
|
return !status.dxcc_worked_overall || !status.dxcc_worked_on_band;
|
|
|
|
|
}
|
|
|
|
|
if (selectedNewDxcc === 'new_dxcc') {
|
|
|
|
|
// Never worked this DXCC entity on any band
|
|
|
|
|
return !status.dxcc_worked_overall;
|
|
|
|
|
}
|
|
|
|
|
if (selectedNewDxcc === 'new_band') {
|
|
|
|
|
// Worked before, but not on this specific band
|
|
|
|
|
return status.dxcc_worked_overall && !status.dxcc_worked_on_band;
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
// Initialize DataTable
|
|
|
|
|
dataTable = $('#dxSpotsTable').DataTable({
|
|
|
|
|
order: [[0, 'desc']], // Order by time (newest first)
|
|
|
|
|
pageLength: 25,
|
|
|
|
|
responsive: true,
|
|
|
|
|
language: {
|
|
|
|
|
url: getDataTablesLanguageUrl()
|
|
|
|
|
},
|
|
|
|
|
columnDefs: [
|
|
|
|
|
{
|
|
|
|
|
targets: 0, // Time column
|
2025-11-18 13:22:34 +00:00
|
|
|
width: '8%',
|
2025-10-04 14:53:43 +01:00
|
|
|
render: function(data, type, row) {
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
},
|
2025-11-17 17:31:20 +00:00
|
|
|
{
|
|
|
|
|
targets: 1, // DX Call column
|
2025-11-18 13:22:34 +00:00
|
|
|
width: '35%',
|
2025-11-17 17:31:20 +00:00
|
|
|
render: function(data, type, row) {
|
|
|
|
|
if (type === 'display') {
|
|
|
|
|
const callsign = data;
|
|
|
|
|
const status = workedStatus[callsign];
|
2025-11-17 22:40:07 +00:00
|
|
|
let html = `<span class="dx-callsign" data-callsign="${callsign}">${callsign}</span>`;
|
2025-11-17 17:31:20 +00:00
|
|
|
|
|
|
|
|
if (status) {
|
2025-11-21 17:52:16 +00:00
|
|
|
// Worked status icon (right after callsign)
|
2025-11-17 17:31:20 +00:00
|
|
|
if (status.worked_on_band) {
|
2025-11-17 22:40:07 +00:00
|
|
|
html += `<i class="fas fa-check text-success ms-2" title="Worked on ${status.band}"></i>`;
|
2025-11-17 17:31:20 +00:00
|
|
|
} else if (status.worked_overall) {
|
2025-11-17 22:40:07 +00:00
|
|
|
html += `<i class="fas fa-check text-info ms-2" title="Worked on another band"></i>`;
|
2025-11-17 17:31:20 +00:00
|
|
|
} else {
|
2025-11-17 22:40:07 +00:00
|
|
|
html += `<i class="fas fa-times text-secondary ms-2" title="Not worked yet"></i>`;
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-21 17:52:16 +00:00
|
|
|
// Country name badge
|
|
|
|
|
if (status.country) {
|
|
|
|
|
html += `<span class="badge bg-secondary ms-2" title="Country: ${status.country}">${status.country}</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:31:31 +01:00
|
|
|
// New country badges — check truly new first, then new-band
|
|
|
|
|
if (status.dxcc && !status.dxcc_worked_overall) {
|
2025-11-17 22:40:07 +00:00
|
|
|
html += `<span class="badge bg-warning text-dark ms-2" title="New DXCC entity">New DXCC</span>`;
|
2026-03-29 11:31:31 +01:00
|
|
|
} else if (status.dxcc && !status.dxcc_worked_on_band) {
|
|
|
|
|
html += `<span class="badge bg-danger ms-2" title="New DXCC entity on this band">New Band</span>`;
|
2025-11-17 17:31:20 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 22:40:07 +00:00
|
|
|
return html;
|
2025-11-17 17:31:20 +00:00
|
|
|
}
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
},
|
2025-10-04 14:53:43 +01:00
|
|
|
{
|
|
|
|
|
targets: 2, // Frequency column
|
2025-11-18 13:22:34 +00:00
|
|
|
width: '12%',
|
2025-10-04 14:53:43 +01:00
|
|
|
className: 'frequency-cell',
|
|
|
|
|
render: function(data, type, row) {
|
|
|
|
|
if (type === 'display') {
|
|
|
|
|
return `<a href="#" class="frequency-link" onclick="handleFrequencyClick('${data}')">${data}</a>`;
|
|
|
|
|
}
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
},
|
2025-11-18 13:22:34 +00:00
|
|
|
{
|
|
|
|
|
targets: 3, // Spotter column
|
|
|
|
|
width: '10%'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
targets: 4, // Comment column
|
|
|
|
|
width: '25%'
|
|
|
|
|
},
|
2025-10-04 14:53:43 +01:00
|
|
|
{
|
|
|
|
|
targets: 5, // Age column
|
2025-11-18 13:22:34 +00:00
|
|
|
width: '10%',
|
2025-10-04 14:53:43 +01:00
|
|
|
render: function(data, type, row) {
|
|
|
|
|
if (type === 'display') {
|
|
|
|
|
return `<span class="spot-age">${data}</span>`;
|
|
|
|
|
}
|
|
|
|
|
return data;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function connect() {
|
|
|
|
|
connectionStatus.textContent = 'Connecting...';
|
|
|
|
|
connectionStatus.className = 'badge bg-warning text-dark me-2 connecting';
|
|
|
|
|
|
|
|
|
|
// Connect to the same WebSocket server as the bandmap
|
|
|
|
|
const wsUrl = 'wss://dxc.cloudlog.org';
|
|
|
|
|
|
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
console.log('Connected to DX Cluster WebSocket server');
|
|
|
|
|
connectionStatus.textContent = 'Connected';
|
|
|
|
|
connectionStatus.className = 'badge bg-success me-2 connected';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onmessage = (event) => {
|
|
|
|
|
const data = JSON.parse(event.data);
|
|
|
|
|
if (data.type === 'spot') {
|
|
|
|
|
addSpot(data);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onclose = () => {
|
|
|
|
|
console.log('Disconnected from WebSocket server');
|
|
|
|
|
connectionStatus.textContent = 'Disconnected';
|
|
|
|
|
connectionStatus.className = 'badge bg-danger me-2 disconnected';
|
|
|
|
|
setTimeout(connect, 3000); // Reconnect after 3 seconds
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onerror = (error) => {
|
|
|
|
|
console.error('WebSocket error:', error);
|
|
|
|
|
connectionStatus.textContent = 'Error';
|
|
|
|
|
connectionStatus.className = 'badge bg-danger me-2 disconnected';
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addSpot(spot) {
|
|
|
|
|
const spotId = `${spot.dx}-${spot.frequency}-${Date.now()}`;
|
|
|
|
|
spot.receivedAt = Date.now();
|
|
|
|
|
spots.set(spotId, spot);
|
|
|
|
|
|
|
|
|
|
// Clean up old spots (older than 2 hours)
|
|
|
|
|
const cutoffTime = Date.now() - (2 * 60 * 60 * 1000);
|
|
|
|
|
for (let [id, existingSpot] of spots) {
|
|
|
|
|
if (existingSpot.receivedAt < cutoffTime) {
|
|
|
|
|
spots.delete(id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updateTable();
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 17:31:20 +00:00
|
|
|
// Check worked status for callsigns
|
|
|
|
|
async function checkWorkedStatus() {
|
|
|
|
|
// Get unique callsigns from visible spots
|
|
|
|
|
const callsignsToCheck = [];
|
|
|
|
|
const spotArray = Array.from(spots.values());
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
|
|
|
|
spotArray.forEach(spot => {
|
|
|
|
|
// Skip if already checked or if filtered out
|
|
|
|
|
if (workedStatus[spot.dx]) return;
|
|
|
|
|
if (hideRbnSpots && isRbnSpot(spot.spotter)) return;
|
|
|
|
|
if (!matchesBandFilter(spot.frequency)) return;
|
2026-03-29 11:31:31 +01:00
|
|
|
if (!matchesModeFilter(spot)) return;
|
2025-11-17 17:31:20 +00:00
|
|
|
if (seen.has(spot.dx)) return; // Avoid duplicates
|
|
|
|
|
|
|
|
|
|
const band = getBandFromFrequency(spot.frequency);
|
|
|
|
|
callsignsToCheck.push({
|
|
|
|
|
callsign: spot.dx,
|
|
|
|
|
band: band
|
|
|
|
|
});
|
|
|
|
|
seen.add(spot.dx);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
console.log('Checking worked status for:', callsignsToCheck.length, 'callsigns');
|
|
|
|
|
|
|
|
|
|
if (callsignsToCheck.length === 0) return;
|
|
|
|
|
|
|
|
|
|
// Limit to 30 callsigns per request to reduce load
|
|
|
|
|
const batch = callsignsToCheck.slice(0, 30);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('<?php echo site_url('dxcluster/check_worked'); ?>', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ callsigns: batch })
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
console.log('Worked status response:', data);
|
|
|
|
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
// Update worked status cache
|
|
|
|
|
Object.assign(workedStatus, data.results);
|
|
|
|
|
|
|
|
|
|
// Redraw table to show badges
|
|
|
|
|
updateTable();
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error checking worked status:', error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update worked status badges in the table (legacy function - now handled in render)
|
|
|
|
|
function updateWorkedBadges() {
|
|
|
|
|
// No longer needed - badges are rendered directly in DataTable column render function
|
|
|
|
|
console.log('Badges updated via table redraw');
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
function updateTable() {
|
|
|
|
|
// Clear existing data
|
|
|
|
|
dataTable.clear();
|
|
|
|
|
|
|
|
|
|
// Convert spots to array and sort by received time (most recent first)
|
|
|
|
|
const spotArray = Array.from(spots.values()).sort((a, b) => {
|
|
|
|
|
return b.receivedAt - a.receivedAt;
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-17 17:20:55 +00:00
|
|
|
// Filter and add spots to table
|
2025-10-04 14:53:43 +01:00
|
|
|
spotArray.forEach(spot => {
|
2025-11-17 17:20:55 +00:00
|
|
|
// Skip RBN spots if filter is enabled
|
|
|
|
|
if (hideRbnSpots && isRbnSpot(spot.spotter)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip spots that don't match band filter
|
|
|
|
|
if (!matchesBandFilter(spot.frequency)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 11:31:31 +01:00
|
|
|
// Skip spots that don't match mode filter
|
|
|
|
|
if (!matchesModeFilter(spot)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip spots that don't match new DXCC / band filter
|
|
|
|
|
if (!matchesNewDxccFilter(spot)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
const age = calculateAge(spot.receivedAt);
|
|
|
|
|
const timeFormatted = formatTime(spot.time);
|
|
|
|
|
const frequencyFormatted = `${parseFloat(spot.frequency).toFixed(1)} kHz`;
|
2025-10-02 14:19:39 +01:00
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
const rowData = [
|
|
|
|
|
timeFormatted,
|
|
|
|
|
spot.dx || '',
|
|
|
|
|
frequencyFormatted,
|
|
|
|
|
spot.spotter || '',
|
|
|
|
|
spot.comment || '',
|
|
|
|
|
age
|
|
|
|
|
];
|
2025-10-02 14:19:39 +01:00
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
dataTable.row.add(rowData);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
dataTable.draw();
|
|
|
|
|
|
|
|
|
|
// Apply age-based styling
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
applyAgeBasedStyling();
|
|
|
|
|
}, 100);
|
2025-11-17 17:31:20 +00:00
|
|
|
|
|
|
|
|
// Check worked status after a short delay (debounce)
|
|
|
|
|
clearTimeout(checkWorkedTimeout);
|
|
|
|
|
checkWorkedTimeout = setTimeout(() => {
|
|
|
|
|
checkWorkedStatus();
|
|
|
|
|
}, 500);
|
2025-10-04 14:53:43 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatTime(timeString) {
|
|
|
|
|
if (!timeString) return '';
|
|
|
|
|
|
|
|
|
|
// Just return the original time string with Z if it doesn't already have it
|
|
|
|
|
// This matches what the bandmap does: ${spot.time}Z
|
|
|
|
|
if (timeString.toString().trim() === '' || timeString.toString() === '0' || timeString.toString() === 'null' || timeString.toString() === 'undefined') {
|
|
|
|
|
return 'N/A';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const timeStr = timeString.toString();
|
|
|
|
|
if (timeStr.endsWith('Z')) {
|
|
|
|
|
return timeStr;
|
|
|
|
|
} else {
|
|
|
|
|
return timeStr + 'Z';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function calculateAge(receivedAt) {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const ageMs = now - receivedAt;
|
|
|
|
|
const ageMinutes = Math.floor(ageMs / (1000 * 60));
|
|
|
|
|
|
|
|
|
|
if (ageMinutes < 1) {
|
|
|
|
|
return 'Just now';
|
|
|
|
|
} else if (ageMinutes < 60) {
|
|
|
|
|
return `${ageMinutes}m`;
|
|
|
|
|
} else {
|
|
|
|
|
const ageHours = Math.floor(ageMinutes / 60);
|
|
|
|
|
const remainingMinutes = ageMinutes % 60;
|
|
|
|
|
return `${ageHours}h ${remainingMinutes}m`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyAgeBasedStyling() {
|
|
|
|
|
const rows = dataTable.rows().nodes();
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
Array.from(spots.values()).forEach((spot, index) => {
|
|
|
|
|
if (index < rows.length) {
|
|
|
|
|
const row = rows[index];
|
|
|
|
|
const age = now - spot.receivedAt;
|
|
|
|
|
|
|
|
|
|
if (age < 5 * 60 * 1000) { // Less than 5 minutes
|
|
|
|
|
row.classList.add('spot-fresh');
|
|
|
|
|
} else if (age > 60 * 60 * 1000) { // More than 1 hour
|
|
|
|
|
row.classList.add('spot-old');
|
|
|
|
|
}
|
2025-10-02 14:19:39 +01:00
|
|
|
}
|
2025-10-04 14:53:43 +01:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update ages every minute
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
if (spots.size > 0) {
|
|
|
|
|
updateTable();
|
2025-10-02 14:19:39 +01:00
|
|
|
}
|
2025-10-04 14:53:43 +01:00
|
|
|
}, 60000);
|
|
|
|
|
|
|
|
|
|
// Start the WebSocket connection
|
|
|
|
|
connect();
|
|
|
|
|
});
|
2025-10-02 14:19:39 +01:00
|
|
|
|
2025-10-04 14:53:43 +01:00
|
|
|
function handleFrequencyClick(frequency) {
|
2025-11-17 22:40:07 +00:00
|
|
|
const radioId = document.getElementById('radio').value;
|
2025-10-04 14:53:43 +01:00
|
|
|
|
2025-11-17 22:40:07 +00:00
|
|
|
if (radioId === '0') {
|
|
|
|
|
// No radio selected - copy to clipboard
|
|
|
|
|
if (navigator.clipboard) {
|
|
|
|
|
const freqInMHz = (parseFloat(frequency) / 1000).toFixed(3);
|
|
|
|
|
navigator.clipboard.writeText(freqInMHz).then(() => {
|
|
|
|
|
showToast('Frequency ' + freqInMHz + ' MHz copied to clipboard', 'info');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return;
|
2025-10-04 14:53:43 +01:00
|
|
|
}
|
2025-11-17 22:40:07 +00:00
|
|
|
|
|
|
|
|
// QSY the radio
|
|
|
|
|
const freqInMHz = (parseFloat(frequency) / 1000).toFixed(3);
|
|
|
|
|
|
|
|
|
|
fetch('<?php echo site_url('dxcluster/qsy'); ?>', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
radio_id: radioId,
|
|
|
|
|
frequency: freqInMHz
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.then(response => response.json())
|
|
|
|
|
.then(data => {
|
|
|
|
|
if (data.success) {
|
|
|
|
|
showToast('QSY command sent to radio: ' + freqInMHz + ' MHz', 'success');
|
|
|
|
|
} else {
|
|
|
|
|
showToast('Failed to QSY radio: ' + data.message, 'danger');
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(error => {
|
|
|
|
|
console.error('Error sending QSY command:', error);
|
|
|
|
|
showToast('Error sending QSY command', 'danger');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showToast(message, type = 'primary') {
|
|
|
|
|
const toast = document.createElement('div');
|
|
|
|
|
toast.className = `toast align-items-center text-white bg-${type} border-0 position-fixed top-0 end-0 m-3`;
|
|
|
|
|
toast.setAttribute('role', 'alert');
|
|
|
|
|
toast.style.zIndex = '9999';
|
|
|
|
|
toast.innerHTML = `
|
|
|
|
|
<div class="d-flex">
|
|
|
|
|
<div class="toast-body">
|
|
|
|
|
${message}
|
|
|
|
|
</div>
|
|
|
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
document.body.appendChild(toast);
|
|
|
|
|
|
|
|
|
|
const bsToast = new bootstrap.Toast(toast);
|
|
|
|
|
bsToast.show();
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
toast.remove();
|
|
|
|
|
}, 3000);
|
2025-10-04 14:53:43 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function for DataTables language URL (if it exists in Cloudlog)
|
|
|
|
|
function getDataTablesLanguageUrl() {
|
|
|
|
|
// This function should match the one used in other Cloudlog views
|
|
|
|
|
if (typeof lang_datatables_language !== 'undefined' && lang_datatables_language !== 'english') {
|
|
|
|
|
return "<?php echo base_url(); ?>assets/js/datatables/" + lang_datatables_language + ".json";
|
|
|
|
|
}
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
</script>
|