mirror of
https://github.com/magicbug/Cloudlog
synced 2026-08-13 17:49:35 -04:00
Adds code to update the displayed frequency range when the bandmap view is changed, improving user feedback on the current visible frequency segment.
1250 lines
No EOL
48 KiB
PHP
1250 lines
No EOL
48 KiB
PHP
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>DX Cluster Bandmap</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Consolas', 'Monaco', monospace;
|
||
background: #1e1e1e;
|
||
color: #ffffff;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.bandmap-container {
|
||
height: 100vh;
|
||
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.toolbar {
|
||
background: #2c3e50;
|
||
padding: 10px 20px;
|
||
border-bottom: 2px solid #34495e;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
flex-wrap: wrap;
|
||
min-height: 60px;
|
||
}
|
||
|
||
.bandmap-display {
|
||
flex: 1;
|
||
position: relative;
|
||
background: #1a1a1a;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.toolbar-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toolbar-label {
|
||
color: #3498db;
|
||
font-size: 0.9em;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.band-select {
|
||
background: #34495e;
|
||
color: white;
|
||
border: 1px solid #555;
|
||
border-radius: 4px;
|
||
padding: 6px 8px;
|
||
font-size: 0.9em;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.band-select:focus {
|
||
outline: none;
|
||
border-color: #3498db;
|
||
}
|
||
|
||
.frequency-input {
|
||
width: 100px;
|
||
padding: 6px 8px;
|
||
background: #34495e;
|
||
border: 1px solid #555;
|
||
color: white;
|
||
border-radius: 4px;
|
||
font-size: 0.9em;
|
||
}
|
||
|
||
.frequency-input:focus {
|
||
outline: none;
|
||
border-color: #3498db;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
background: #34495e;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
padding: 6px 10px;
|
||
cursor: pointer;
|
||
font-size: 0.9em;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.toolbar-btn:hover {
|
||
background: #3498db;
|
||
}
|
||
|
||
.zoom-btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-weight: bold;
|
||
font-size: 1.1em;
|
||
}
|
||
|
||
.stats-display {
|
||
display: flex;
|
||
gap: 15px;
|
||
font-size: 0.85em;
|
||
}
|
||
|
||
.stat-item {
|
||
color: #bdc3c7;
|
||
}
|
||
|
||
.stat-value {
|
||
color: #3498db;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.frequency-scale {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 0;
|
||
width: 80px;
|
||
height: 100%;
|
||
background: rgba(44, 62, 80, 0.9);
|
||
border-right: 2px solid #3498db;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.frequency-marker {
|
||
position: absolute;
|
||
right: 5px;
|
||
font-size: 14px;
|
||
color: #3498db;
|
||
font-weight: bold;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.spots-canvas {
|
||
position: absolute;
|
||
left: 80px;
|
||
top: 0;
|
||
right: 0;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.spot-marker {
|
||
position: absolute;
|
||
background: #e74c3c;
|
||
color: white;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
border: 1px solid #c0392b;
|
||
transition: all 0.2s;
|
||
z-index: 10;
|
||
}
|
||
|
||
.spot-marker:hover {
|
||
background: #f39c12;
|
||
border-color: #d68910;
|
||
transform: scale(1.1);
|
||
z-index: 20;
|
||
}
|
||
|
||
.spot-marker.aged {
|
||
background: #7f8c8d;
|
||
color: #ecf0f1;
|
||
border-color: #95a5a6;
|
||
}
|
||
|
||
.spot-marker.aged:hover {
|
||
background: #95a5a6;
|
||
border-color: #bdc3c7;
|
||
}
|
||
|
||
.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;
|
||
left: 0;
|
||
right: 0;
|
||
height: 2px;
|
||
background: #f39c12;
|
||
box-shadow: 0 0 10px #f39c12;
|
||
z-index: 100;
|
||
}
|
||
|
||
.connection-status {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
padding: 5px 10px;
|
||
border-radius: 15px;
|
||
font-size: 0.8em;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.connected {
|
||
background: #27ae60;
|
||
color: white;
|
||
}
|
||
|
||
.disconnected {
|
||
background: #e74c3c;
|
||
color: white;
|
||
}
|
||
|
||
@keyframes spotAppear {
|
||
from {
|
||
opacity: 0;
|
||
transform: scale(0.5) translateY(-10px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: scale(1) translateY(0);
|
||
}
|
||
}
|
||
|
||
.age-indicator {
|
||
position: absolute;
|
||
left: -3px;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 3px;
|
||
border-radius: 1px;
|
||
}
|
||
|
||
.age-new { background: #27ae60; }
|
||
.age-recent { background: #f39c12; }
|
||
.age-old { background: #95a5a6; }
|
||
|
||
.spot-tooltip {
|
||
position: absolute;
|
||
background: #2c3e50;
|
||
border: 2px solid #3498db;
|
||
border-radius: 5px;
|
||
padding: 10px;
|
||
font-size: 12px;
|
||
pointer-events: none;
|
||
z-index: 1000;
|
||
display: none;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.frequency-line {
|
||
position: absolute;
|
||
height: 1px;
|
||
background: #7f8c8d;
|
||
z-index: 5;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.frequency-line.active {
|
||
background: #f39c12;
|
||
height: 2px;
|
||
}
|
||
|
||
/* Toggle switch styles */
|
||
.toggle-container {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.toggle-switch {
|
||
position: relative;
|
||
width: 50px;
|
||
height: 24px;
|
||
background: #34495e;
|
||
border-radius: 12px;
|
||
cursor: pointer;
|
||
transition: background 0.3s;
|
||
border: 1px solid #555;
|
||
}
|
||
|
||
.toggle-switch.active {
|
||
background: #27ae60;
|
||
}
|
||
|
||
.toggle-slider {
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 18px;
|
||
height: 18px;
|
||
background: white;
|
||
border-radius: 50%;
|
||
transition: transform 0.3s;
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.toggle-switch.active .toggle-slider {
|
||
transform: translateX(26px);
|
||
}
|
||
|
||
.auto-track-status {
|
||
font-size: 0.8em;
|
||
color: #bdc3c7;
|
||
}
|
||
|
||
.auto-track-status.active {
|
||
color: #27ae60;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="bandmap-container">
|
||
<div class="toolbar">
|
||
<div class="toolbar-group">
|
||
<span class="toolbar-label">Band:</span>
|
||
<select class="band-select" id="bandSelect" onchange="handleBandChange()">
|
||
<?php
|
||
if (isset($bands) && is_array($bands) && count($bands) > 0) {
|
||
$defaultSelected = false;
|
||
foreach ($bands as $band) {
|
||
// Extract the numeric part from band (e.g., "20" from "20m")
|
||
$bandMeters = str_replace('m', '', $band['band']);
|
||
$selected = (!$defaultSelected && $band['band'] == '20m') ? ' selected' : '';
|
||
if ($selected) {
|
||
$defaultSelected = true;
|
||
}
|
||
echo '<option value="' . $bandMeters . ',' . $band['start'] . ',' . $band['end'] . '"' . $selected . '>' . $band['band'] . '</option>' . "\n";
|
||
}
|
||
} else {
|
||
// Fallback to default bands if no user bands configured
|
||
// HF Bands
|
||
echo '<option value="2190,135700,137800">2190m</option>';
|
||
echo '<option value="630,472000,479000">630m</option>';
|
||
echo '<option value="560,501000,504000">560m</option>';
|
||
echo '<option value="160,1800,2000">160m</option>';
|
||
echo '<option value="80,3500,4000">80m</option>';
|
||
echo '<option value="60,5351500,5366500">60m</option>';
|
||
echo '<option value="40,7000,7300">40m</option>';
|
||
echo '<option value="30,10100,10150">30m</option>';
|
||
echo '<option value="20,14000,14350" selected>20m</option>';
|
||
echo '<option value="17,18068,18168">17m</option>';
|
||
echo '<option value="15,21000,21450">15m</option>';
|
||
echo '<option value="12,24890,24990">12m</option>';
|
||
echo '<option value="10,28000,29700">10m</option>';
|
||
// VHF Bands
|
||
echo '<option value="6,50000,54000">6m</option>';
|
||
echo '<option value="4,70000,70500">4m</option>';
|
||
echo '<option value="2,144000,148000">2m</option>';
|
||
echo '<option value="1.25,222000,225000">1.25m</option>';
|
||
// UHF Bands
|
||
echo '<option value="70,420000,450000">70cm</option>';
|
||
echo '<option value="33,902000,928000">33cm</option>';
|
||
// SHF Bands (Microwave)
|
||
echo '<option value="23,1240000,1300000">23cm</option>';
|
||
echo '<option value="13,2300000,2450000">13cm</option>';
|
||
echo '<option value="9,3300000,3500000">9cm</option>';
|
||
echo '<option value="6,5650000,5925000">6cm</option>';
|
||
echo '<option value="3,10000000,10500000">3cm</option>';
|
||
echo '<option value="1.2,24000000,24250000">1.2cm</option>';
|
||
// EHF Bands (Millimeter wave)
|
||
echo '<option value="6mm,47000000,47200000">6mm</option>';
|
||
echo '<option value="4mm,75500000,81000000">4mm</option>';
|
||
echo '<option value="2.5mm,119980000,120020000">2.5mm</option>';
|
||
echo '<option value="2mm,142000000,149000000">2mm</option>';
|
||
echo '<option value="1mm,241000000,250000000">1mm</option>';
|
||
}
|
||
?>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<span class="toolbar-label">Zoom:</span>
|
||
<button class="toolbar-btn zoom-btn" onclick="zoomOut()" title="Zoom Out">−</button>
|
||
<button class="toolbar-btn zoom-btn" onclick="zoomIn()" title="Zoom In">+</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<span class="toolbar-label">Tune:</span>
|
||
<input type="number" class="frequency-input" id="tuneFreq" placeholder="kHz" title="Tune Frequency">
|
||
<button class="toolbar-btn" onclick="centerOnFrequency()" title="Center on Frequency">Center</button>
|
||
</div>
|
||
|
||
<div class="toolbar-group">
|
||
<span class="toolbar-label">Auto Track:</span>
|
||
<div class="toggle-container">
|
||
<div class="toggle-switch active" id="autoTrackToggle" onclick="toggleAutoTrack()" title="Toggle Radio Auto-Tracking">
|
||
<div class="toggle-slider"></div>
|
||
</div>
|
||
<span class="auto-track-status active" id="autoTrackStatus">ON</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats-display">
|
||
<div class="stat-item">Band: <span class="stat-value" id="currentBand">20m</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>
|
||
</div>
|
||
|
||
<div class="bandmap-display" id="bandmapDisplay">
|
||
<div class="connection-status disconnected" id="connectionStatus">Disconnected</div>
|
||
<div class="frequency-scale" id="frequencyScale"></div>
|
||
<div class="spots-canvas" id="spotsCanvas"></div>
|
||
<div class="tune-indicator" id="tuneIndicator" style="display: none;"></div>
|
||
<div class="spot-tooltip" id="spotTooltip"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<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;
|
||
let viewStart = 14000;
|
||
let viewEnd = 14350;
|
||
let tuneFrequency = null;
|
||
let zoomLevel = 4; // Start zoomed in for better detail
|
||
let lastSelectedRadio = null; // Track the last known value
|
||
let autoTrackEnabled = true; // Control auto-tracking feature
|
||
|
||
const bandmapDisplay = document.getElementById('bandmapDisplay');
|
||
const frequencyScale = document.getElementById('frequencyScale');
|
||
const spotsCanvas = document.getElementById('spotsCanvas');
|
||
const connectionStatus = document.getElementById('connectionStatus');
|
||
const spotTooltip = document.getElementById('spotTooltip');
|
||
|
||
function connect() {
|
||
// Connect to the remote DX Cluster server
|
||
const wsUrl = 'wss://dxc.cloudlog.org';
|
||
|
||
ws = new WebSocket(wsUrl);
|
||
|
||
ws.onopen = () => {
|
||
console.log('Connected to WebSocket server');
|
||
connectionStatus.textContent = 'Connected';
|
||
connectionStatus.className = 'connection-status 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 = 'connection-status disconnected';
|
||
setTimeout(connect, 3000);
|
||
};
|
||
|
||
ws.onerror = (error) => {
|
||
console.error('WebSocket error:', error);
|
||
};
|
||
}
|
||
|
||
function addSpot(spot) {
|
||
const freq = parseFloat(spot.frequency);
|
||
const frequencyTolerance = 1.0; // 1 kHz tolerance for considering spots at "same" frequency
|
||
|
||
// Add timestamp for aging
|
||
spot.receivedAt = Date.now();
|
||
|
||
// Remove any existing spots at the same or very close frequency
|
||
for (let [id, existingSpot] of spots) {
|
||
const existingFreq = parseFloat(existingSpot.frequency);
|
||
if (Math.abs(freq - existingFreq) <= frequencyTolerance) {
|
||
spots.delete(id);
|
||
}
|
||
}
|
||
|
||
// Add the new spot
|
||
const spotId = `${spot.dx}-${freq}-${spot.receivedAt}`;
|
||
spots.set(spotId, spot);
|
||
|
||
// Spot cleanup is now handled in updateDisplay()
|
||
|
||
updateDisplay();
|
||
updateStats();
|
||
|
||
// Check worked status after a short delay (debounce)
|
||
clearTimeout(checkWorkedTimeout);
|
||
checkWorkedTimeout = setTimeout(() => {
|
||
checkWorkedStatus();
|
||
}, 500);
|
||
}
|
||
|
||
function handleBandChange() {
|
||
const select = document.getElementById('bandSelect');
|
||
const [meters, start, end] = select.value.split(',').map(Number);
|
||
selectBand(meters, start, end);
|
||
}
|
||
|
||
// Helper function to format band names properly
|
||
function formatBandName(meters) {
|
||
// Handle all centimeter bands
|
||
if (meters === 70) return '70cm';
|
||
if (meters === 33) return '33cm';
|
||
if (meters === 23) return '23cm';
|
||
if (meters === 13) return '13cm';
|
||
if (meters === 9) return '9cm';
|
||
if (meters === 6) return '6cm';
|
||
if (meters === 3) return '3cm';
|
||
if (meters === 1.2) return '1.2cm';
|
||
|
||
// Handle millimeter bands
|
||
if (meters === '6mm') return '6mm';
|
||
if (meters === '4mm') return '4mm';
|
||
if (meters === '2.5mm') return '2.5mm';
|
||
if (meters === '2mm') return '2mm';
|
||
if (meters === '1mm') return '1mm';
|
||
|
||
// Handle decimal meter bands
|
||
if (meters === 1.25) return '1.25m';
|
||
|
||
// Default meter bands
|
||
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;
|
||
|
||
// 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 = (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);
|
||
document.getElementById('freqRange').textContent = `${(viewStart/1000).toFixed(3)}-${(viewEnd/1000).toFixed(3)}`;
|
||
|
||
updateDisplay();
|
||
updateStats();
|
||
}
|
||
|
||
function updateDisplay() {
|
||
// Clear existing display
|
||
spotsCanvas.innerHTML = '';
|
||
frequencyScale.innerHTML = '';
|
||
|
||
const displayHeight = bandmapDisplay.clientHeight;
|
||
const freqRange = viewEnd - viewStart;
|
||
|
||
// Draw frequency scale with adaptive spacing based on zoom level
|
||
const pixelsPerKHz = displayHeight / freqRange;
|
||
|
||
// Determine step size based on available space
|
||
let stepSize;
|
||
let showMinor = false;
|
||
|
||
if (pixelsPerKHz > 30) {
|
||
// Very zoomed in - show 1 kHz steps with minor markers
|
||
stepSize = 1;
|
||
showMinor = true;
|
||
} else if (pixelsPerKHz > 15) {
|
||
// Medium zoom - show 2 kHz steps
|
||
stepSize = 2;
|
||
} else if (pixelsPerKHz > 8) {
|
||
// Normal zoom - show 5 kHz steps
|
||
stepSize = 5;
|
||
} else if (pixelsPerKHz > 4) {
|
||
// Zoomed out - show 10 kHz steps
|
||
stepSize = 10;
|
||
} else {
|
||
// Very zoomed out - show 25 kHz steps
|
||
stepSize = 25;
|
||
}
|
||
|
||
// Find the first boundary within our view
|
||
const startFreqKHz = Math.ceil(viewStart / stepSize) * stepSize;
|
||
const endFreqKHz = Math.floor(viewEnd / stepSize) * stepSize;
|
||
|
||
for (let freqKHz = startFreqKHz; freqKHz <= endFreqKHz; freqKHz += stepSize) {
|
||
const freq = freqKHz;
|
||
if (freq >= viewStart && freq <= viewEnd) {
|
||
const y = displayHeight - ((freq - viewStart) / freqRange * displayHeight);
|
||
|
||
// Only show if there's enough vertical space (at least 18px between markers)
|
||
let showThisMarker = true;
|
||
if (stepSize === 1 && !showMinor && freq % 5 !== 0) {
|
||
showThisMarker = false;
|
||
}
|
||
|
||
if (showThisMarker) {
|
||
const marker = document.createElement('div');
|
||
marker.className = 'frequency-marker';
|
||
marker.style.top = `${y - 8}px`; // Adjusted for larger text
|
||
|
||
// Major markers (every 5 kHz for fine steps, or the main step size)
|
||
const isMajor = (stepSize <= 2 && freq % 5 === 0) || (stepSize > 2);
|
||
|
||
if (isMajor) {
|
||
marker.style.fontWeight = 'bold';
|
||
marker.style.color = '#3498db';
|
||
marker.style.fontSize = '13px';
|
||
} else {
|
||
marker.style.fontSize = '11px';
|
||
marker.style.color = '#7f8c8d';
|
||
}
|
||
|
||
// Format frequency display
|
||
if (freq >= 1000) {
|
||
const mhz = freq / 1000;
|
||
marker.textContent = mhz.toFixed(3);
|
||
} else {
|
||
marker.textContent = freq.toString();
|
||
}
|
||
|
||
frequencyScale.appendChild(marker);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up old spots (30+ minutes) before drawing
|
||
const thirtyMinutesAgo = Date.now() - (30 * 60 * 1000);
|
||
for (let [id, spot] of spots) {
|
||
if (spot.receivedAt < thirtyMinutesAgo) {
|
||
spots.delete(id);
|
||
}
|
||
}
|
||
|
||
// Draw spots
|
||
const spotsInView = Array.from(spots.values()).filter(spot => {
|
||
const freq = parseFloat(spot.frequency);
|
||
return freq >= viewStart && freq <= viewEnd;
|
||
});
|
||
|
||
spotsInView.forEach((spot, index) => {
|
||
const freq = parseFloat(spot.frequency);
|
||
const y = displayHeight - ((freq - viewStart) / freqRange * displayHeight);
|
||
const spotLeft = 10 + (index % 3) * 80;
|
||
const age = Date.now() - spot.receivedAt;
|
||
const fifteenMinutes = 15 * 60 * 1000;
|
||
|
||
// Create connecting line from frequency scale to spot
|
||
const frequencyLine = document.createElement('div');
|
||
frequencyLine.className = 'frequency-line';
|
||
|
||
// Age the connecting line too
|
||
if (age >= fifteenMinutes) {
|
||
frequencyLine.style.background = '#95a5a6';
|
||
}
|
||
|
||
frequencyLine.style.top = `${y}px`;
|
||
frequencyLine.style.left = '0px'; // Start from frequency scale
|
||
frequencyLine.style.width = `${spotLeft + 40}px`; // Connect all the way to middle of spot
|
||
spotsCanvas.appendChild(frequencyLine);
|
||
|
||
const spotElement = document.createElement('div');
|
||
spotElement.className = 'spot-marker';
|
||
|
||
// Apply aging class if spot is 15+ minutes old
|
||
if (age >= fifteenMinutes) {
|
||
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`;
|
||
|
||
// Age indicator
|
||
const ageIndicator = document.createElement('div');
|
||
ageIndicator.className = 'age-indicator';
|
||
if (age < 5 * 60 * 1000) ageIndicator.classList.add('age-new');
|
||
else if (age < 15 * 60 * 1000) ageIndicator.classList.add('age-recent');
|
||
else ageIndicator.classList.add('age-old');
|
||
spotElement.appendChild(ageIndicator);
|
||
|
||
// Event handlers
|
||
spotElement.addEventListener('mouseenter', (e) => {
|
||
showTooltip(e, spot);
|
||
frequencyLine.classList.add('active'); // Highlight line on hover
|
||
});
|
||
spotElement.addEventListener('mouseleave', () => {
|
||
hideTooltip();
|
||
frequencyLine.classList.remove('active');
|
||
});
|
||
spotElement.addEventListener('click', () => tuneToSpot(freq, spot));
|
||
|
||
spotsCanvas.appendChild(spotElement);
|
||
});
|
||
|
||
// Update tune indicator
|
||
if (tuneFrequency && tuneFrequency >= viewStart && tuneFrequency <= viewEnd) {
|
||
const tuneIndicator = document.getElementById('tuneIndicator');
|
||
const y = displayHeight - ((tuneFrequency - viewStart) / freqRange * displayHeight);
|
||
tuneIndicator.style.top = `${y - 1}px`;
|
||
tuneIndicator.style.display = 'block';
|
||
} else {
|
||
document.getElementById('tuneIndicator').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function showTooltip(event, spot) {
|
||
const tooltip = spotTooltip;
|
||
const age = Math.floor((Date.now() - spot.receivedAt) / 60000);
|
||
|
||
tooltip.innerHTML = `
|
||
<div><strong>${spot.dx}</strong></div>
|
||
<div>Frequency: ${spot.frequency} kHz</div>
|
||
<div>Spotter: ${spot.spotter}</div>
|
||
<div>Time: ${spot.time}Z</div>
|
||
<div>Comment: ${spot.comment}</div>
|
||
<div>Age: ${age} min ago</div>
|
||
`;
|
||
|
||
// Get the spot element's position relative to the bandmap container
|
||
const spotElement = event.target;
|
||
const bandmapRect = bandmapDisplay.getBoundingClientRect();
|
||
const spotRect = spotElement.getBoundingClientRect();
|
||
|
||
// Position tooltip just to the right of the callsign
|
||
const tooltipLeft = spotRect.right - bandmapRect.left + 5;
|
||
const tooltipTop = spotRect.top - bandmapRect.top - 5;
|
||
|
||
tooltip.style.left = `${tooltipLeft}px`;
|
||
tooltip.style.top = `${tooltipTop}px`;
|
||
tooltip.style.display = 'block';
|
||
}
|
||
|
||
function hideTooltip() {
|
||
spotTooltip.style.display = 'none';
|
||
}
|
||
|
||
function tuneToSpot(frequency, spot = null) {
|
||
tuneFrequency = frequency;
|
||
document.getElementById('tuneFreq').value = frequency.toFixed(1);
|
||
updateDisplay();
|
||
|
||
// Check if a radio is selected and send QSY command
|
||
const selectedRadio = localStorage.getItem('selectedRadio');
|
||
if (selectedRadio && selectedRadio !== 'none') {
|
||
// Check if this is an RBN spot (spotter callsign ends with -#)
|
||
let mode = null;
|
||
if (spot && spot.spotter) {
|
||
console.log(`Checking spotter callsign: ${spot.spotter}`);
|
||
if (spot.spotter.endsWith('-#')) {
|
||
mode = 'CW'; // RBN spots are CW
|
||
console.log(`RBN spot detected from ${spot.spotter}, setting mode to CW`);
|
||
} else {
|
||
console.log(`Regular spot from ${spot.spotter}, no mode override`);
|
||
}
|
||
}
|
||
|
||
sendQSYCommand(selectedRadio, frequency, mode);
|
||
}
|
||
}
|
||
|
||
function centerOnFrequency() {
|
||
const freq = parseFloat(document.getElementById('tuneFreq').value);
|
||
if (!isNaN(freq)) {
|
||
tuneFrequency = freq;
|
||
const halfRange = (viewEnd - viewStart) / 2;
|
||
viewStart = Math.max(bandStart, freq - halfRange);
|
||
viewEnd = Math.min(bandEnd, freq + halfRange);
|
||
updateDisplay();
|
||
}
|
||
}
|
||
|
||
function zoomIn() {
|
||
if (zoomLevel < 8) {
|
||
zoomLevel *= 2;
|
||
const center = (viewStart + viewEnd) / 2;
|
||
const newRange = (bandEnd - bandStart) / zoomLevel;
|
||
viewStart = Math.max(bandStart, center - newRange / 2);
|
||
viewEnd = Math.min(bandEnd, center + newRange / 2);
|
||
updateDisplay();
|
||
}
|
||
}
|
||
|
||
function zoomOut() {
|
||
if (zoomLevel > 1) {
|
||
zoomLevel /= 2;
|
||
const center = (viewStart + viewEnd) / 2;
|
||
const newRange = (bandEnd - bandStart) / zoomLevel;
|
||
viewStart = Math.max(bandStart, center - newRange / 2);
|
||
viewEnd = Math.min(bandEnd, center + newRange / 2);
|
||
updateDisplay();
|
||
}
|
||
}
|
||
|
||
function updateStats() {
|
||
const spotsInBand = Array.from(spots.values()).filter(spot => {
|
||
const freq = parseFloat(spot.frequency);
|
||
return freq >= bandStart && freq <= bandEnd;
|
||
});
|
||
|
||
document.getElementById('bandSpotCount').textContent = spotsInBand.length;
|
||
document.getElementById('totalSpots').textContent = spots.size;
|
||
}
|
||
|
||
// Mouse wheel scrolling
|
||
bandmapDisplay.addEventListener('wheel', (event) => {
|
||
event.preventDefault();
|
||
const delta = event.deltaY > 0 ? -1 : 1; // Inverted: scroll down = move down freq scale
|
||
const scrollStep = (viewEnd - viewStart) * 0.1;
|
||
|
||
viewStart += delta * scrollStep;
|
||
viewEnd += delta * scrollStep;
|
||
|
||
// Keep within band limits
|
||
if (viewStart < bandStart) {
|
||
viewEnd += bandStart - viewStart;
|
||
viewStart = bandStart;
|
||
}
|
||
if (viewEnd > bandEnd) {
|
||
viewStart -= viewEnd - bandEnd;
|
||
viewEnd = bandEnd;
|
||
}
|
||
|
||
// Update frequency range display
|
||
document.getElementById('freqRange').textContent = `${(viewStart/1000).toFixed(3)}-${(viewEnd/1000).toFixed(3)}`;
|
||
|
||
updateDisplay();
|
||
});
|
||
|
||
// Handle window resize
|
||
window.addEventListener('resize', () => {
|
||
setTimeout(updateDisplay, 100);
|
||
});
|
||
|
||
// Monitor localStorage for selectedRadio changes
|
||
function monitorSelectedRadio() {
|
||
const currentValue = localStorage.getItem('selectedRadio');
|
||
|
||
// Check if value has changed
|
||
if (currentValue !== lastSelectedRadio) {
|
||
if (currentValue === null && lastSelectedRadio !== null) {
|
||
console.log('selectedRadio removed from localStorage (was:', lastSelectedRadio + ')');
|
||
} else if (lastSelectedRadio === null && currentValue !== null) {
|
||
console.log('selectedRadio added to localStorage:', currentValue);
|
||
} else if (currentValue !== null && lastSelectedRadio !== null) {
|
||
console.log('selectedRadio changed in localStorage:', lastSelectedRadio, '->', currentValue);
|
||
}
|
||
|
||
lastSelectedRadio = currentValue;
|
||
}
|
||
}
|
||
|
||
// Toggle auto-tracking function
|
||
function toggleAutoTrack() {
|
||
autoTrackEnabled = !autoTrackEnabled;
|
||
|
||
const toggle = document.getElementById('autoTrackToggle');
|
||
const status = document.getElementById('autoTrackStatus');
|
||
|
||
if (autoTrackEnabled) {
|
||
toggle.classList.add('active');
|
||
status.classList.add('active');
|
||
status.textContent = 'ON';
|
||
console.log('Radio auto-tracking enabled');
|
||
} else {
|
||
toggle.classList.remove('active');
|
||
status.classList.remove('active');
|
||
status.textContent = 'OFF';
|
||
console.log('Radio auto-tracking disabled');
|
||
}
|
||
}
|
||
|
||
// Poll the radio endpoint for CAT data
|
||
function pollRadioEndpoint() {
|
||
const radioID = localStorage.getItem('selectedRadio');
|
||
|
||
// Only poll if we have a valid radio ID and it's not '0'
|
||
if (!radioID || radioID === '0') {
|
||
return;
|
||
}
|
||
|
||
// Fetch CAT data from the radio endpoint
|
||
fetch(`<?php echo site_url(); ?>/radio/json/${radioID}`)
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
console.log('Radio CAT data for radio', radioID + ':', data);
|
||
|
||
if (data.error) {
|
||
console.log('Error from radio endpoint:', data.error);
|
||
return;
|
||
}
|
||
|
||
// Process frequency data if available and auto-tracking is enabled
|
||
if (data.frequency && autoTrackEnabled) {
|
||
// Check if this is satellite mode - use RX frequency if satellite is active
|
||
let freqHz;
|
||
if (data.satname && data.frequency_rx) {
|
||
freqHz = parseInt(data.frequency_rx);
|
||
console.log('Satellite mode detected (' + data.satname + '), using RX frequency');
|
||
} else {
|
||
freqHz = parseInt(data.frequency);
|
||
}
|
||
|
||
const freqKHz = freqHz / 1000; // Convert Hz to kHz
|
||
|
||
console.log('Radio frequency:', freqKHz, 'kHz' + (data.satname ? ' (RX for ' + data.satname + ')' : ''));
|
||
|
||
// Find which band this frequency belongs to
|
||
const band = findBandForFrequency(freqKHz);
|
||
|
||
if (band) {
|
||
// Check if we need to change bands
|
||
if (currentBandMeters !== band.meters ||
|
||
bandStart !== band.start ||
|
||
bandEnd !== band.end) {
|
||
console.log('Switching to band:', band.meters + 'm');
|
||
|
||
// Update the band select dropdown
|
||
const bandSelect = document.getElementById('bandSelect');
|
||
console.log('Trying to set dropdown to:', `${band.meters},${band.start},${band.end}`);
|
||
|
||
// Find the exact matching option by comparing ranges
|
||
for (let i = 0; i < bandSelect.options.length; i++) {
|
||
const option = bandSelect.options[i];
|
||
const [optMeters, optStart, optEnd] = option.value.split(',').map(Number);
|
||
if (optStart === band.start && optEnd === band.end) {
|
||
bandSelect.selectedIndex = i;
|
||
console.log('Set dropdown to option:', option.text, 'value:', option.value);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Select the band (but don't center yet, we'll do that below)
|
||
currentBandMeters = band.meters;
|
||
bandStart = band.start;
|
||
bandEnd = band.end;
|
||
|
||
// Use the band name from the dropdown if available, otherwise format properly
|
||
if (band.name) {
|
||
document.getElementById('currentBand').textContent = band.name;
|
||
} else {
|
||
document.getElementById('currentBand').textContent = formatBandName(band.meters);
|
||
}
|
||
}
|
||
|
||
// Set tune frequency to current radio frequency
|
||
tuneFrequency = freqKHz;
|
||
|
||
// Always center the view on the radio frequency when auto-tracking
|
||
const currentRange = viewEnd - viewStart;
|
||
viewStart = Math.max(bandStart, freqKHz - currentRange / 2);
|
||
viewEnd = Math.min(bandEnd, freqKHz + currentRange / 2);
|
||
|
||
// Adjust if we hit band boundaries
|
||
if (viewStart < bandStart) {
|
||
viewEnd += bandStart - viewStart;
|
||
viewStart = bandStart;
|
||
}
|
||
if (viewEnd > bandEnd) {
|
||
viewStart -= viewEnd - bandEnd;
|
||
viewEnd = bandEnd;
|
||
}
|
||
|
||
// Update the frequency range display
|
||
document.getElementById('freqRange').textContent = `${(viewStart/1000).toFixed(3)}-${(viewEnd/1000).toFixed(3)}`;
|
||
|
||
// Update display to show tune indicator (will be centered)
|
||
updateDisplay();
|
||
updateStats();
|
||
} else {
|
||
console.log('Frequency', freqKHz, 'kHz not in any configured band');
|
||
}
|
||
} else if (data.frequency && !autoTrackEnabled) {
|
||
console.log('Radio frequency received but auto-tracking is disabled');
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error fetching radio data:', error);
|
||
});
|
||
}
|
||
|
||
// Find which band a frequency belongs to
|
||
function findBandForFrequency(freqKHz) {
|
||
const bandSelect = document.getElementById('bandSelect');
|
||
const options = bandSelect.options;
|
||
|
||
console.log('Looking for band for frequency:', freqKHz, 'kHz');
|
||
console.log('Available band options:', options.length);
|
||
|
||
for (let i = 0; i < options.length; i++) {
|
||
console.log('Raw option value:', options[i].value);
|
||
console.log('Option text:', options[i].text);
|
||
const parts = options[i].value.split(',');
|
||
console.log('Split parts:', parts);
|
||
const [meters, start, end] = parts.map(Number);
|
||
console.log(`Parsed: meters=${meters}, start=${start}, end=${end}`);
|
||
console.log(`Checking band ${meters}m: ${start}-${end} kHz`);
|
||
if (freqKHz >= start && freqKHz <= end) {
|
||
console.log(`Found matching band: ${meters}m (${start}-${end})`);
|
||
// Use the option text instead of trying to reconstruct the band name
|
||
return { meters, start, end, name: options[i].text };
|
||
}
|
||
}
|
||
|
||
console.log('No matching band found for', freqKHz, 'kHz');
|
||
return null;
|
||
}
|
||
|
||
// Send QSY command to radio
|
||
function sendQSYCommand(radioId, frequency, mode = null) {
|
||
// Convert kHz to MHz for the API
|
||
const frequencyMHz = frequency / 1000;
|
||
|
||
console.log(`Sending QSY command: Radio ${radioId}, Frequency ${frequencyMHz} MHz${mode ? ', Mode ' + mode : ''}`);
|
||
|
||
const payload = {
|
||
radio_id: radioId,
|
||
frequency: frequencyMHz
|
||
};
|
||
|
||
if (mode) {
|
||
payload.mode = mode;
|
||
}
|
||
|
||
fetch('<?php echo site_url('dxcluster/qsy'); ?>', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(response => response.json())
|
||
.then(data => {
|
||
if (data.success) {
|
||
console.log('QSY command sent successfully:', data);
|
||
// Optionally show a brief success indicator
|
||
const modeText = data.mode ? ` ${data.mode}` : '';
|
||
showQSYFeedback('success', `QSY to ${frequencyMHz.toFixed(3)} MHz${modeText} sent to radio`);
|
||
} else {
|
||
console.error('QSY command failed:', data.message);
|
||
showQSYFeedback('error', `QSY failed: ${data.message}`);
|
||
}
|
||
})
|
||
.catch(error => {
|
||
console.error('Error sending QSY command:', error);
|
||
showQSYFeedback('error', 'Failed to send QSY command');
|
||
});
|
||
}
|
||
|
||
// Show brief feedback for QSY commands
|
||
function showQSYFeedback(type, message) {
|
||
// Remove any existing feedback
|
||
const existingFeedback = document.getElementById('qsyFeedback');
|
||
if (existingFeedback) {
|
||
existingFeedback.remove();
|
||
}
|
||
|
||
// Create feedback element
|
||
const feedback = document.createElement('div');
|
||
feedback.id = 'qsyFeedback';
|
||
feedback.textContent = message;
|
||
feedback.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
padding: 10px 15px;
|
||
border-radius: 4px;
|
||
color: white;
|
||
font-weight: bold;
|
||
z-index: 1000;
|
||
opacity: 0.9;
|
||
${type === 'success' ? 'background-color: #28a745;' : 'background-color: #dc3545;'}
|
||
`;
|
||
|
||
document.body.appendChild(feedback);
|
||
|
||
// Auto-remove after 3 seconds
|
||
setTimeout(() => {
|
||
feedback.remove();
|
||
}, 3000);
|
||
}
|
||
|
||
// Initialize with proper zoom
|
||
function initializeBandmap() {
|
||
// Check if selectedRadio is in localStorage
|
||
const selectedRadio = localStorage.getItem('selectedRadio');
|
||
if (selectedRadio) {
|
||
console.log('selectedRadio found in localStorage:', selectedRadio);
|
||
lastSelectedRadio = selectedRadio;
|
||
} else {
|
||
console.log('selectedRadio not found in localStorage');
|
||
lastSelectedRadio = null;
|
||
}
|
||
|
||
// Start monitoring for changes (poll every 500ms)
|
||
setInterval(monitorSelectedRadio, 500);
|
||
|
||
// Start polling the radio endpoint (poll every 1 second)
|
||
setInterval(pollRadioEndpoint, 1000);
|
||
|
||
// Set initial 20m band with 4x zoom
|
||
const center = (bandStart + bandEnd) / 2;
|
||
const newRange = (bandEnd - bandStart) / zoomLevel;
|
||
viewStart = Math.max(bandStart, center - newRange / 2);
|
||
viewEnd = Math.min(bandEnd, center + newRange / 2);
|
||
|
||
document.getElementById('freqRange').textContent = `${(viewStart/1000).toFixed(3)} - ${(viewEnd/1000).toFixed(3)}`;
|
||
|
||
connect();
|
||
updateDisplay();
|
||
}
|
||
|
||
// Initialize
|
||
initializeBandmap();
|
||
</script>
|
||
</body>
|
||
</html>
|