2025-10-02 14:19:39 +01:00
|
|
|
|
<!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;
|
|
|
|
|
|
}
|
2025-11-17 22:40:07 +00:00
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
2025-10-02 14:19:39 +01:00
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2025-10-02 18:03:10 +01:00
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
2025-10-02 14:19:39 +01:00
|
|
|
|
</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()">
|
2025-10-02 17:40:49 +01:00
|
|
|
|
<?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
|
2025-10-04 23:05:20 +01:00
|
|
|
|
// 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>';
|
2025-10-02 17:40:49 +01:00
|
|
|
|
echo '<option value="160,1800,2000">160m</option>';
|
|
|
|
|
|
echo '<option value="80,3500,4000">80m</option>';
|
2025-10-04 23:05:20 +01:00
|
|
|
|
echo '<option value="60,5351500,5366500">60m</option>';
|
2025-10-02 17:40:49 +01:00
|
|
|
|
echo '<option value="40,7000,7300">40m</option>';
|
2025-10-04 23:05:20 +01:00
|
|
|
|
echo '<option value="30,10100,10150">30m</option>';
|
2025-10-02 17:40:49 +01:00
|
|
|
|
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>';
|
2025-10-04 23:05:20 +01:00
|
|
|
|
// VHF Bands
|
2025-10-02 17:40:49 +01:00
|
|
|
|
echo '<option value="6,50000,54000">6m</option>';
|
2025-10-04 23:05:20 +01:00
|
|
|
|
echo '<option value="4,70000,70500">4m</option>';
|
2025-10-02 18:20:54 +01:00
|
|
|
|
echo '<option value="2,144000,148000">2m</option>';
|
2025-10-04 23:05:20 +01:00
|
|
|
|
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)
|
2025-10-02 18:20:54 +01:00
|
|
|
|
echo '<option value="23,1240000,1300000">23cm</option>';
|
2025-10-04 23:05:20 +01:00
|
|
|
|
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>';
|
2025-10-02 17:40:49 +01:00
|
|
|
|
}
|
|
|
|
|
|
?>
|
2025-10-02 14:19:39 +01:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2025-10-02 18:22:44 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-10-02 18:03:10 +01:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-10-02 14:19:39 +01:00
|
|
|
|
<div class="stats-display">
|
|
|
|
|
|
<div class="stat-item">Band: <span class="stat-value" id="currentBand">20m</span></div>
|
2025-11-17 22:40:07 +00:00
|
|
|
|
<div class="stat-item">Viewing: <span class="stat-value" id="freqRange">14.000-14.350</span> MHz</div>
|
2025-10-02 14:19:39 +01:00
|
|
|
|
<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();
|
2025-11-17 22:40:07 +00:00
|
|
|
|
let workedStatus = {}; // Cache for worked status
|
|
|
|
|
|
let checkWorkedTimeout = null;
|
2025-10-02 14:19:39 +01:00
|
|
|
|
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
|
2025-10-02 18:03:10 +01:00
|
|
|
|
let lastSelectedRadio = null; // Track the last known value
|
|
|
|
|
|
let autoTrackEnabled = true; // Control auto-tracking feature
|
2025-10-02 14:19:39 +01:00
|
|
|
|
|
|
|
|
|
|
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();
|
2025-11-17 22:40:07 +00:00
|
|
|
|
|
|
|
|
|
|
// Check worked status after a short delay (debounce)
|
|
|
|
|
|
clearTimeout(checkWorkedTimeout);
|
|
|
|
|
|
checkWorkedTimeout = setTimeout(() => {
|
|
|
|
|
|
checkWorkedStatus();
|
|
|
|
|
|
}, 500);
|
2025-10-02 14:19:39 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleBandChange() {
|
|
|
|
|
|
const select = document.getElementById('bandSelect');
|
|
|
|
|
|
const [meters, start, end] = select.value.split(',').map(Number);
|
|
|
|
|
|
selectBand(meters, start, end);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-04 23:05:20 +01:00
|
|
|
|
// 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`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-17 22:40:07 +00:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-02 14:19:39 +01:00
|
|
|
|
function selectBand(meters, start, end) {
|
|
|
|
|
|
currentBandMeters = meters;
|
2025-11-17 22:40:07 +00:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2025-10-02 14:19:39 +01:00
|
|
|
|
|
|
|
|
|
|
// Apply default zoom level for better initial view
|
|
|
|
|
|
const center = (start + end) / 2;
|
2025-11-17 22:40:07 +00:00
|
|
|
|
const newRange = (bandEnd - bandStart) / zoomLevel;
|
|
|
|
|
|
viewStart = Math.max(bandStart, center - newRange / 2);
|
|
|
|
|
|
viewEnd = Math.min(bandEnd, center + newRange / 2);
|
2025-10-02 14:19:39 +01:00
|
|
|
|
|
2025-10-04 23:05:20 +01:00
|
|
|
|
// Handle band display properly for all bands
|
|
|
|
|
|
document.getElementById('currentBand').textContent = formatBandName(meters);
|
2025-10-02 14:19:39 +01:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-17 22:40:07 +00:00
|
|
|
|
// 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-02 14:19:39 +01:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
2025-10-04 14:06:19 +01:00
|
|
|
|
spotElement.addEventListener('click', () => tuneToSpot(freq, spot));
|
2025-10-02 14:19:39 +01:00
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-04 14:06:19 +01:00
|
|
|
|
function tuneToSpot(frequency, spot = null) {
|
2025-10-02 14:19:39 +01:00
|
|
|
|
tuneFrequency = frequency;
|
|
|
|
|
|
document.getElementById('tuneFreq').value = frequency.toFixed(1);
|
|
|
|
|
|
updateDisplay();
|
2025-10-04 13:34:42 +01:00
|
|
|
|
|
|
|
|
|
|
// Check if a radio is selected and send QSY command
|
|
|
|
|
|
const selectedRadio = localStorage.getItem('selectedRadio');
|
|
|
|
|
|
if (selectedRadio && selectedRadio !== 'none') {
|
2025-10-04 14:06:19 +01:00
|
|
|
|
// 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);
|
2025-10-04 13:34:42 +01:00
|
|
|
|
}
|
2025-10-02 14:19:39 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2025-11-17 22:40:07 +00:00
|
|
|
|
const delta = event.deltaY > 0 ? -1 : 1; // Inverted: scroll down = move down freq scale
|
2025-10-02 14:19:39 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-17 22:42:22 +00:00
|
|
|
|
// Update frequency range display
|
|
|
|
|
|
document.getElementById('freqRange').textContent = `${(viewStart/1000).toFixed(3)}-${(viewEnd/1000).toFixed(3)}`;
|
|
|
|
|
|
|
2025-10-02 14:19:39 +01:00
|
|
|
|
updateDisplay();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Handle window resize
|
|
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
|
setTimeout(updateDisplay, 100);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-02 18:03:10 +01:00
|
|
|
|
// 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) {
|
2025-10-02 18:18:12 +01:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-02 18:03:10 +01:00
|
|
|
|
const freqKHz = freqHz / 1000; // Convert Hz to kHz
|
|
|
|
|
|
|
2025-10-02 18:18:12 +01:00
|
|
|
|
console.log('Radio frequency:', freqKHz, 'kHz' + (data.satname ? ' (RX for ' + data.satname + ')' : ''));
|
2025-10-02 18:03:10 +01:00
|
|
|
|
|
|
|
|
|
|
// 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');
|
2025-10-02 18:28:05 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-02 18:03:10 +01:00
|
|
|
|
|
|
|
|
|
|
// Select the band (but don't center yet, we'll do that below)
|
|
|
|
|
|
currentBandMeters = band.meters;
|
|
|
|
|
|
bandStart = band.start;
|
|
|
|
|
|
bandEnd = band.end;
|
|
|
|
|
|
|
2025-10-04 23:05:20 +01:00
|
|
|
|
// Use the band name from the dropdown if available, otherwise format properly
|
2025-10-02 18:25:46 +01:00
|
|
|
|
if (band.name) {
|
|
|
|
|
|
document.getElementById('currentBand').textContent = band.name;
|
2025-10-02 18:24:27 +01:00
|
|
|
|
} else {
|
2025-10-04 23:05:20 +01:00
|
|
|
|
document.getElementById('currentBand').textContent = formatBandName(band.meters);
|
2025-10-02 18:24:27 +01:00
|
|
|
|
}
|
2025-10-02 18:03:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
2025-10-02 18:24:27 +01:00
|
|
|
|
console.log('Looking for band for frequency:', freqKHz, 'kHz');
|
|
|
|
|
|
console.log('Available band options:', options.length);
|
|
|
|
|
|
|
2025-10-02 18:03:10 +01:00
|
|
|
|
for (let i = 0; i < options.length; i++) {
|
2025-10-02 18:25:46 +01:00
|
|
|
|
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}`);
|
2025-10-02 18:24:27 +01:00
|
|
|
|
console.log(`Checking band ${meters}m: ${start}-${end} kHz`);
|
2025-10-02 18:03:10 +01:00
|
|
|
|
if (freqKHz >= start && freqKHz <= end) {
|
2025-10-02 18:24:27 +01:00
|
|
|
|
console.log(`Found matching band: ${meters}m (${start}-${end})`);
|
2025-10-02 18:25:46 +01:00
|
|
|
|
// Use the option text instead of trying to reconstruct the band name
|
|
|
|
|
|
return { meters, start, end, name: options[i].text };
|
2025-10-02 18:03:10 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-02 18:24:27 +01:00
|
|
|
|
console.log('No matching band found for', freqKHz, 'kHz');
|
2025-10-02 18:03:10 +01:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-04 13:34:42 +01:00
|
|
|
|
// Send QSY command to radio
|
2025-10-04 14:06:19 +01:00
|
|
|
|
function sendQSYCommand(radioId, frequency, mode = null) {
|
2025-10-04 13:34:42 +01:00
|
|
|
|
// Convert kHz to MHz for the API
|
|
|
|
|
|
const frequencyMHz = frequency / 1000;
|
|
|
|
|
|
|
2025-10-04 14:06:19 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2025-10-04 13:34:42 +01:00
|
|
|
|
|
|
|
|
|
|
fetch('<?php echo site_url('dxcluster/qsy'); ?>', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
|
},
|
2025-10-04 14:06:19 +01:00
|
|
|
|
body: JSON.stringify(payload)
|
2025-10-04 13:34:42 +01:00
|
|
|
|
})
|
|
|
|
|
|
.then(response => response.json())
|
|
|
|
|
|
.then(data => {
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
|
console.log('QSY command sent successfully:', data);
|
|
|
|
|
|
// Optionally show a brief success indicator
|
2025-10-04 14:06:19 +01:00
|
|
|
|
const modeText = data.mode ? ` ${data.mode}` : '';
|
|
|
|
|
|
showQSYFeedback('success', `QSY to ${frequencyMHz.toFixed(3)} MHz${modeText} sent to radio`);
|
2025-10-04 13:34:42 +01:00
|
|
|
|
} 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-02 14:19:39 +01:00
|
|
|
|
// Initialize with proper zoom
|
|
|
|
|
|
function initializeBandmap() {
|
2025-10-02 18:03:10 +01:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2025-10-02 14:19:39 +01:00
|
|
|
|
// 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>
|