Add DXCC worked status and radio QSY to DX Cluster

Enhances DX Cluster by showing DXCC entity and worked status per spot, including new band/country indicators. Adds radio selection and QSY functionality, updates UI with improved filters and spot marker styling, and provides user feedback for frequency actions.
This commit is contained in:
Peter Goodhall 2025-11-17 22:40:07 +00:00
parent 38b3003f90
commit d26068bd3b
3 changed files with 322 additions and 76 deletions

View file

@ -14,6 +14,10 @@ class Dxcluster extends CI_Controller {
function index()
{
$data['page_title'] = "DX Cluster Spots";
// Load radio data for CAT control
$this->load->model('cat');
$data['radios'] = $this->cat->radios();
/// Load layout
@ -166,6 +170,10 @@ class Dxcluster extends CI_Controller {
$callsign = $item['callsign'];
$band = isset($item['band']) ? $item['band'] : null;
// Get DXCC entity for this callsign
$dxcc_info = $this->logbook_model->dxcc_lookup($callsign, date('Ymd'));
$dxcc = isset($dxcc_info['adif']) ? $dxcc_info['adif'] : null;
// Check if worked on this band
$worked_on_band = false;
if ($band) {
@ -175,10 +183,37 @@ class Dxcluster extends CI_Controller {
// Check if worked on any band
$worked_overall = $this->logbook_model->check_if_callsign_worked_in_logbook($callsign, $logbooks_locations_array, null) > 0;
// Check if DXCC entity is worked on this band
$dxcc_worked_on_band = false;
if ($dxcc && $band) {
$this->db->select('COL_DXCC');
$this->db->where_in('station_id', $logbooks_locations_array);
$this->db->where('COL_DXCC', $dxcc);
$this->db->where('COL_BAND', $band);
$this->db->limit(1);
$query = $this->db->get($this->config->item('table_name'));
$dxcc_worked_on_band = $query->num_rows() > 0;
}
// Check if DXCC entity is worked on any band
$dxcc_worked_overall = false;
if ($dxcc) {
$this->db->select('COL_DXCC');
$this->db->where_in('station_id', $logbooks_locations_array);
$this->db->where('COL_DXCC', $dxcc);
$this->db->limit(1);
$query = $this->db->get($this->config->item('table_name'));
$dxcc_worked_overall = $query->num_rows() > 0;
}
$results[$callsign] = [
'worked_on_band' => $worked_on_band,
'worked_overall' => $worked_overall,
'band' => $band
'band' => $band,
'dxcc' => $dxcc,
'dxcc_worked_on_band' => $dxcc_worked_on_band,
'dxcc_worked_overall' => $dxcc_worked_overall,
'country' => isset($dxcc_info['entity']) ? $dxcc_info['entity'] : null
];
}

View file

@ -191,6 +191,42 @@
.spot-marker.new {
animation: spotAppear 0.5s ease-out;
}
/* Worked status styling */
.spot-marker.worked-on-band {
background: #27ae60;
border-color: #229954;
}
.spot-marker.worked-on-band:hover {
background: #2ecc71;
border-color: #27ae60;
}
.spot-marker.worked-other-band {
background: #3498db;
border-color: #2980b9;
}
.spot-marker.worked-other-band:hover {
background: #5dade2;
border-color: #3498db;
}
.spot-marker.not-worked {
background: #e74c3c;
border-color: #c0392b;
}
.spot-marker.new-dxcc {
box-shadow: 0 0 10px #f39c12, 0 0 20px #f39c12;
border: 2px solid #f39c12;
}
.spot-marker.new-band-dxcc {
box-shadow: 0 0 10px #e74c3c, 0 0 20px #e74c3c;
border: 2px solid #e74c3c;
}
.tune-indicator {
position: absolute;
@ -404,7 +440,7 @@
<div class="stats-display">
<div class="stat-item">Band: <span class="stat-value" id="currentBand">20m</span></div>
<div class="stat-item">Range: <span class="stat-value" id="freqRange">14.000-14.350</span></div>
<div class="stat-item">Viewing: <span class="stat-value" id="freqRange">14.000-14.350</span> MHz</div>
<div class="stat-item">Spots: <span class="stat-value" id="bandSpotCount">0</span></div>
<div class="stat-item">Total: <span class="stat-value" id="totalSpots">0</span></div>
</div>
@ -422,6 +458,8 @@
<script>
let ws;
let spots = new Map();
let workedStatus = {}; // Cache for worked status
let checkWorkedTimeout = null;
let currentBandMeters = 20;
let bandStart = 14000;
let bandEnd = 14350;
@ -492,6 +530,12 @@
updateDisplay();
updateStats();
// Check worked status after a short delay (debounce)
clearTimeout(checkWorkedTimeout);
checkWorkedTimeout = setTimeout(() => {
checkWorkedStatus();
}, 500);
}
function handleBandChange() {
@ -526,16 +570,87 @@
return `${meters}m`;
}
// Determine band from frequency (in kHz)
function getBandFromFrequency(freqKhz) {
const freq = parseFloat(freqKhz);
if (freq >= 1800 && freq <= 2000) return '160m';
if (freq >= 3500 && freq <= 4000) return '80m';
if (freq >= 5250 && freq <= 5450) return '60m';
if (freq >= 7000 && freq <= 7300) return '40m';
if (freq >= 10100 && freq <= 10150) return '30m';
if (freq >= 14000 && freq <= 14350) return '20m';
if (freq >= 18068 && freq <= 18168) return '17m';
if (freq >= 21000 && freq <= 21450) return '15m';
if (freq >= 24890 && freq <= 24990) return '12m';
if (freq >= 28000 && freq <= 29700) return '10m';
if (freq >= 50000 && freq <= 54000) return '6m';
if (freq >= 70000 && freq <= 71000) return '4m';
if (freq >= 144000 && freq <= 148000) return '2m';
if (freq >= 420000 && freq <= 450000) return '70cm';
if (freq >= 1240000 && freq <= 1300000) return '23cm';
if (freq >= 1000000) return 'ghz';
return 'unknown';
}
// Check worked status for callsigns
async function checkWorkedStatus() {
const callsignsToCheck = [];
const seen = new Set();
for (let [id, spot] of spots) {
if (workedStatus[spot.dx]) continue;
if (seen.has(spot.dx)) continue;
const band = getBandFromFrequency(spot.frequency);
callsignsToCheck.push({
callsign: spot.dx,
band: band
});
seen.add(spot.dx);
}
if (callsignsToCheck.length === 0) return;
// Limit to 30 callsigns per request
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();
if (data.success) {
Object.assign(workedStatus, data.results);
updateDisplay();
}
} catch (error) {
console.error('Error checking worked status:', error);
}
}
function selectBand(meters, start, end) {
currentBandMeters = meters;
bandStart = start;
bandEnd = end;
// Add 5% buffer padding on each end so spots near edges are visible
const bandRange = end - start;
const padding = bandRange * 0.05;
bandStart = start - padding;
bandEnd = end + padding;
// Apply default zoom level for better initial view
const center = (start + end) / 2;
const newRange = (end - start) / zoomLevel;
viewStart = Math.max(start, center - newRange / 2);
viewEnd = Math.min(end, center + newRange / 2);
const newRange = (bandEnd - bandStart) / zoomLevel;
viewStart = Math.max(bandStart, center - newRange / 2);
viewEnd = Math.min(bandEnd, center + newRange / 2);
// Handle band display properly for all bands
document.getElementById('currentBand').textContent = formatBandName(meters);
@ -666,6 +781,25 @@
spotElement.classList.add('aged');
}
// Add worked status styling
const status = workedStatus[spot.dx];
if (status) {
if (status.worked_on_band) {
spotElement.classList.add('worked-on-band');
} else if (status.worked_overall) {
spotElement.classList.add('worked-other-band');
} else {
spotElement.classList.add('not-worked');
}
// Add DXCC new badge styling
if (status.dxcc && !status.dxcc_worked_on_band) {
spotElement.classList.add('new-band-dxcc');
} else if (status.dxcc && !status.dxcc_worked_overall) {
spotElement.classList.add('new-dxcc');
}
}
spotElement.textContent = spot.dx;
spotElement.style.top = `${y - 10}px`;
spotElement.style.left = `${spotLeft}px`;
@ -804,7 +938,7 @@
// Mouse wheel scrolling
bandmapDisplay.addEventListener('wheel', (event) => {
event.preventDefault();
const delta = event.deltaY > 0 ? 1 : -1;
const delta = event.deltaY > 0 ? -1 : 1; // Inverted: scroll down = move down freq scale
const scrollStep = (viewEnd - viewStart) * 0.1;
viewStart += delta * scrollStep;

View file

@ -13,42 +13,66 @@
</div>
</div>
<div class="card-body">
<div class="mb-3 d-flex flex-wrap align-items-center gap-3">
<div class="form-check form-check-inline mb-0">
<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>
<!-- 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>
<!-- 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>
<!-- Info -->
<small class="text-muted ms-auto">
<i class="fas fa-info-circle"></i> Filters active
</small>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<label for="bandFilter" class="form-label mb-0">
<i class="fas fa-wave-square"></i> Band:
</label>
<select class="form-select form-select-sm" id="bandFilter" style="width: auto; 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>
<small class="text-muted">
<i class="fas fa-info-circle"></i>
Filter spots by band and hide RBN spots
</small>
</div>
<div class="table-responsive">
<table id="dxSpotsTable" class="table table-striped table-hover">
<thead class="table-dark">
@ -67,6 +91,21 @@
</table>
</div>
<div class="mt-3">
<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>
<small class="text-muted">
<i class="fas fa-info-circle"></i>
Real-time DX cluster spots. Data refreshes automatically via WebSocket connection.
@ -227,19 +266,27 @@ document.addEventListener('DOMContentLoaded', function() {
if (type === 'display') {
const callsign = data;
const status = workedStatus[callsign];
let iconHtml = '';
let html = `<span class="dx-callsign" data-callsign="${callsign}">${callsign}</span>`;
if (status) {
// Worked status icon
if (status.worked_on_band) {
iconHtml = `<i class="fas fa-check text-success ms-2" title="Worked on ${status.band}"></i>`;
html += `<i class="fas fa-check text-success ms-2" title="Worked on ${status.band}"></i>`;
} else if (status.worked_overall) {
iconHtml = `<i class="fas fa-check text-info ms-2" title="Worked on another band"></i>`;
html += `<i class="fas fa-check text-info ms-2" title="Worked on another band"></i>`;
} else {
iconHtml = `<i class="fas fa-times text-secondary ms-2" title="Not worked yet"></i>`;
html += `<i class="fas fa-times text-secondary ms-2" title="Not worked yet"></i>`;
}
// New country badges
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>`;
} else if (status.dxcc && !status.dxcc_worked_overall) {
html += `<span class="badge bg-warning text-dark ms-2" title="New DXCC entity">New DXCC</span>`;
}
}
return `<span class="dx-callsign" data-callsign="${callsign}">${callsign}</span>${iconHtml}`;
return html;
}
return data;
}
@ -491,37 +538,67 @@ document.addEventListener('DOMContentLoaded', function() {
});
function handleFrequencyClick(frequency) {
// You can implement frequency tuning functionality here
// For example, send to radio or copy to clipboard
console.log('Frequency clicked:', frequency);
const radioId = document.getElementById('radio').value;
// Copy to clipboard as example
if (navigator.clipboard) {
const freqInMHz = (parseFloat(frequency) / 1000).toFixed(3);
navigator.clipboard.writeText(freqInMHz).then(() => {
// Show a temporary notification
const toast = document.createElement('div');
toast.className = 'toast align-items-center text-white bg-primary 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">
Frequency ${freqInMHz} MHz copied to clipboard
</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);
});
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;
}
// 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);
}
// Helper function for DataTables language URL (if it exists in Cloudlog)