625 lines
27 KiB
HTML
625 lines
27 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="sv">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>heardlog – APRS RF Coverage</title>
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: 'Courier New', monospace; background: #0d0900; color: #ffb347; height: 100vh; height: 100dvh; display: flex; flex-direction: column; }
|
||
header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: #0d0900; border-bottom: 1px solid #3a2200; flex-shrink: 0; gap: 8px; flex-wrap: wrap; }
|
||
.logo { font-size: 0.95rem; font-weight: bold; letter-spacing: 0.1em; color: #ff8c00; white-space: nowrap; }
|
||
.logo span { color: #ffb347; font-weight: normal; }
|
||
.logo em { color: #ffd580; font-style: normal; font-weight: normal; font-size: 0.85em; }
|
||
.controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||
.stat { font-size: 0.70rem; color: #7a5500; letter-spacing: 0.06em; white-space: nowrap; }
|
||
.stat strong { color: #ffb347; }
|
||
.btn { font-family: inherit; font-size: 0.70rem; letter-spacing: 0.08em; background: transparent; border: 1px solid #5a3300; color: #ff8c00; padding: 6px 14px; cursor: pointer; transition: all 0.15s; white-space: nowrap; touch-action: manipulation; }
|
||
.btn:hover { background: #2a1500; border-color: #ff8c00; }
|
||
#map { flex: 1; min-height: 0; }
|
||
|
||
@media (max-width: 480px) {
|
||
.logo em { display: none; }
|
||
.stat { font-size: 0.65rem; }
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0% { transform: scale(1); opacity: 0.9; }
|
||
70% { transform: scale(2.2); opacity: 0; }
|
||
100% { transform: scale(1); opacity: 0; }
|
||
}
|
||
.rx-marker { width: 14px; height: 14px; background: #ffe066; border: 2px solid #fff; border-radius: 50%; position: relative; }
|
||
.rx-marker::after { content: ''; position: absolute; top: -2px; left: -2px; width: 14px; height: 14px; border: 2px solid #ffe066; border-radius: 50%; animation: pulse 2s ease-out infinite; }
|
||
|
||
/* Popup base */
|
||
.sq-popup { font-family: 'Courier New', monospace; background: rgba(13,9,0,0.97); border: 1px solid #5a3300; color: #ffb347; width: min(300px, 90vw); }
|
||
.sq-popup-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid #3a2200; }
|
||
.sq-popup-title { font-size: 0.85rem; color: #ff8c00; font-weight: bold; letter-spacing: 0.1em; }
|
||
.sq-popup-sub { font-size: 0.70rem; color: #7a5500; }
|
||
|
||
/* Tabs */
|
||
.sq-tabs { display: flex; border-bottom: 1px solid #3a2200; }
|
||
.sq-tab { flex: 1; text-align: center; padding: 8px 0; font-size: 0.70rem; letter-spacing: 0.08em; cursor: pointer; color: #7a5500; border-bottom: 2px solid transparent; transition: all 0.1s; touch-action: manipulation; }
|
||
.sq-tab.active { color: #ff8c00; border-bottom-color: #ff8c00; }
|
||
.sq-panel { display: none; padding: 6px 10px; max-height: min(220px, 40vh); overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||
.sq-panel.active { display: block; }
|
||
.sq-panel table { width: 100%; border-collapse: collapse; font-size: 0.70rem; }
|
||
.sq-panel td { padding: 4px 0; }
|
||
.sq-panel td:first-child { color: #ff8c00; padding-right: 12px; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.sq-panel td:last-child { text-align: right; color: #ffd580; }
|
||
.station-row { cursor: pointer; touch-action: manipulation; }
|
||
.station-row:active td { background: rgba(255,140,0,0.12); }
|
||
.station-row td:first-child { color: #ffd580; }
|
||
|
||
/* Station detail view */
|
||
.station-view { display: none; }
|
||
.station-view.active { display: block; }
|
||
.station-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid #3a2200; }
|
||
.station-title { font-size: 0.85rem; color: #ffd580; font-weight: bold; }
|
||
.back-btn { font-family: inherit; font-size: 0.70rem; background: transparent; border: 1px solid #5a3300; color: #ff8c00; padding: 6px 10px; cursor: pointer; touch-action: manipulation; }
|
||
.station-body { padding: 6px 10px; max-height: min(260px, 45vh); overflow-y: auto; -webkit-overflow-scrolling: touch; }
|
||
.detail-section { color: #ff8c00; font-size: 0.65rem; letter-spacing: 0.1em; margin: 8px 0 3px; }
|
||
.detail-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 0.70rem; border-bottom: 1px solid #1a0f00; }
|
||
.detail-label { color: #7a5500; }
|
||
.detail-value { color: #ffd580; }
|
||
.squares-list { font-size: 0.65rem; color: #7a5500; line-height: 1.8; padding-top: 2px; }
|
||
.loading-msg { padding: 12px 10px; font-size: 0.70rem; color: #7a5500; }
|
||
|
||
/* Squares feed panel */
|
||
#squares-panel {
|
||
display: none;
|
||
position: absolute;
|
||
top: 47px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 1000;
|
||
background: rgba(13,9,0,0.97);
|
||
border: 1px solid #5a3300;
|
||
width: min(340px, 95vw);
|
||
max-height: min(420px, 70vh);
|
||
overflow-y: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
font-family: 'Courier New', monospace;
|
||
}
|
||
#squares-panel.open { display: block; }
|
||
.sq-feed-header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 6px 12px; border-bottom: 1px solid #3a2200;
|
||
font-size: 0.72rem; color: #ff8c00; letter-spacing: 0.1em;
|
||
}
|
||
.sq-feed-close { cursor: pointer; color: #7a5500; font-size: 1rem; line-height: 1; }
|
||
.sq-feed-close:hover { color: #ff8c00; }
|
||
.sq-feed-row {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 5px 12px; border-bottom: 1px solid #1a0f00;
|
||
cursor: pointer; font-size: 0.72rem;
|
||
}
|
||
.sq-feed-row:hover { background: rgba(255,140,0,0.1); }
|
||
.sq-feed-call { color: #ff8c00; letter-spacing: 0.05em; }
|
||
.sq-feed-meta { color: #7a5500; font-size: 0.65rem; text-align: right; }
|
||
.sq-feed-dist { color: #ffd580; }
|
||
|
||
/* Clickable squares count */
|
||
#sq-count { cursor: pointer; text-decoration: underline dotted #5a3300; }
|
||
#sq-count:hover { color: #ff8c00; }
|
||
|
||
/* Leaflet overrides */
|
||
.leaflet-popup-content-wrapper { background: transparent !important; box-shadow: 0 4px 24px rgba(0,0,0,0.7) !important; border-radius: 0 !important; padding: 0 !important; }
|
||
.leaflet-popup-content { margin: 0 !important; }
|
||
.leaflet-popup-tip-container { display: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="logo">heard<span>log</span> — <em>APRS RF COVERAGE</em></div>
|
||
<div class="controls">
|
||
<div class="stat">SQUARES <strong id="sq-count" onclick="toggleSquaresPanel()">–</strong></div>
|
||
<div class="stat">POSITIONS <strong id="pt-count">–</strong></div>
|
||
<div class="stat">RX <strong>SA6ANW-1</strong></div>
|
||
<button class="btn" onclick="refresh()">↻ REFRESH</button>
|
||
</div>
|
||
</header>
|
||
<div id="map"></div>
|
||
<div id="squares-panel">
|
||
<div class="sq-feed-header">
|
||
RECENT SQUARES
|
||
<span class="sq-feed-close" onclick="toggleSquaresPanel()">×</span>
|
||
</div>
|
||
<div id="squares-feed-list"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const API = '';
|
||
const ZOOM_SQUARES_MIN = 7;
|
||
const ZOOM_POINTS_MIN = 14;
|
||
const RX_LAT = 58.35, RX_LON = 14.05, RX_CALL = 'SA6ANW-1';
|
||
|
||
const map = L.map('map', { zoomControl: true }).setView([RX_LAT, RX_LON], 8);
|
||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap contributors', maxZoom: 19,
|
||
}).addTo(map);
|
||
|
||
let squareDotLayer = L.layerGroup().addTo(map);
|
||
let squareRectLayer = L.layerGroup().addTo(map);
|
||
let pointLayer = L.layerGroup().addTo(map);
|
||
let squaresData = [], pointsData = [], digiCatalog = {}, rxMarker = null;
|
||
|
||
const rxIcon = L.divIcon({ className: '', html: '<div class="rx-marker"></div>', iconSize: [14,14], iconAnchor: [7,7] });
|
||
rxMarker = L.marker([RX_LAT, RX_LON], { icon: rxIcon, zIndexOffset: 1000 }).addTo(map);
|
||
|
||
async function fetchAll() {
|
||
const [sqRes, ptRes, dgRes, rxRes] = await Promise.all([
|
||
fetch(`${API}/api/coverage/squares`),
|
||
fetch(`${API}/api/coverage/points`),
|
||
fetch(`${API}/api/coverage/digis`),
|
||
fetch(`${API}/api/coverage/rx-stats`),
|
||
]);
|
||
squaresData = (await sqRes.json()).squares || [];
|
||
pointsData = (await ptRes.json()).points || [];
|
||
digiCatalog = (await dgRes.json()).digis || {};
|
||
const rxPaths = (await rxRes.json()).paths || [];
|
||
buildRxPopup(rxPaths);
|
||
document.getElementById('sq-count').textContent = squaresData.length;
|
||
document.getElementById('pt-count').textContent = pointsData.length;
|
||
renderLayers();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Popup HTML – square view + station view both in DOM, toggled with CSS
|
||
// ---------------------------------------------------------------------------
|
||
function makePopupHTML(sq) {
|
||
const pathRows = sq.paths.map(p => {
|
||
const raw = p.path || 'DIRECT';
|
||
const hops = raw === 'DIRECT' ? ['DIRECT'] : raw.split(',');
|
||
const coloredHops = hops.map(hop =>
|
||
(hop === 'DIRECT' || digiCatalog[hop.trim()])
|
||
? `<span style="color:#ff8c00">${hop}</span>`
|
||
: `<span style="color:#ff2200" title="No position known">${hop}</span>`
|
||
).join(',');
|
||
return `<tr><td title="${raw}">${coloredHops}</td><td>${p.count}</td></tr>`;
|
||
}).join('');
|
||
|
||
const stationRows = (sq.stations||[]).map(s =>
|
||
`<tr class="station-row" onclick="showStation('${sq.square}','${s.call}')">
|
||
<td>${s.call}</td><td>${s.count}</td>
|
||
</tr>`
|
||
).join('');
|
||
|
||
return `<div class="sq-popup">
|
||
|
||
<!-- Square overview -->
|
||
<div id="sq-view-${sq.square}">
|
||
<div class="sq-popup-header">
|
||
<span class="sq-popup-title">${sq.square}</span>
|
||
<span class="sq-popup-sub">${sq.hits} frames</span>
|
||
</div>
|
||
<div class="sq-tabs">
|
||
<div class="sq-tab active" onclick="switchTab('${sq.square}','paths')">PATHS</div>
|
||
<div class="sq-tab" onclick="switchTab('${sq.square}','stations')">STATIONS</div>
|
||
</div>
|
||
<div id="${sq.square}-paths" class="sq-panel active"><table>${pathRows}</table></div>
|
||
<div id="${sq.square}-stations" class="sq-panel"><table>${stationRows}</table></div>
|
||
</div>
|
||
|
||
<!-- Station detail (hidden until clicked) -->
|
||
<div id="st-view-${sq.square}" class="station-view">
|
||
<div class="station-header">
|
||
<span class="station-title" id="st-title-${sq.square}"></span>
|
||
<button class="back-btn" onclick="backToSquare('${sq.square}')">← BACK</button>
|
||
</div>
|
||
<div class="station-body" id="st-body-${sq.square}">
|
||
<div class="loading-msg">Loading...</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>`;
|
||
}
|
||
|
||
function switchTab(sq, tab) {
|
||
['paths','stations'].forEach(t => {
|
||
document.getElementById(`${sq}-${t}`)?.classList.toggle('active', t === tab);
|
||
});
|
||
const tabs = document.querySelectorAll(`#sq-view-${sq} .sq-tab`);
|
||
tabs.forEach((t,i) => t.classList.toggle('active', (i===0&&tab==='paths')||(i===1&&tab==='stations')));
|
||
}
|
||
|
||
function backToSquare(sqId) {
|
||
document.getElementById(`sq-view-${sqId}`).style.display = '';
|
||
document.getElementById(`st-view-${sqId}`).classList.remove('active');
|
||
}
|
||
|
||
async function showStation(sqId, callsign) {
|
||
// Switch views
|
||
document.getElementById(`sq-view-${sqId}`).style.display = 'none';
|
||
const stView = document.getElementById(`st-view-${sqId}`);
|
||
stView.classList.add('active');
|
||
document.getElementById(`st-title-${sqId}`).textContent = callsign;
|
||
const body = document.getElementById(`st-body-${sqId}`);
|
||
body.innerHTML = '<div class="loading-msg">Loading...</div>';
|
||
|
||
try {
|
||
const res = await fetch(`${API}/api/coverage/station/${encodeURIComponent(callsign)}`);
|
||
const d = await res.json();
|
||
const fmt = iso => iso ? new Date(iso).toLocaleString('sv-SE',{dateStyle:'short',timeStyle:'short'}) : '–';
|
||
const topPaths = (d.top_paths||[]).map(p =>
|
||
`<div class="detail-row"><span class="detail-label">${p.path}</span><span class="detail-value">${p.count}</span></div>`
|
||
).join('');
|
||
|
||
body.innerHTML = `
|
||
<div class="detail-section">STATISTIK</div>
|
||
<div class="detail-row"><span class="detail-label">Frames totalt</span><span class="detail-value">${d.total_frames}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Unique positions</span><span class="detail-value">${d.unique_positions}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Unique paths</span><span class="detail-value">${d.unique_paths}</span></div>
|
||
<div class="detail-section">DISTANCE (LINE OF SIGHT)</div>
|
||
<div class="detail-row"><span class="detail-label">Closest</span><span class="detail-value">${d.distance_min_km??'–'} km</span></div>
|
||
<div class="detail-row"><span class="detail-label">Farthest</span><span class="detail-value">${d.distance_max_km??'–'} km</span></div>
|
||
<div class="detail-row"><span class="detail-label">Average</span><span class="detail-value">${d.distance_avg_km??'–'} km</span></div>
|
||
<div class="detail-section">TIMESTAMPS</div>
|
||
<div class="detail-row"><span class="detail-label">First heard</span><span class="detail-value">${fmt(d.first_heard)}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Last heard</span><span class="detail-value">${fmt(d.last_heard)}</span></div>
|
||
<div class="detail-section">TOP PATHS</div>
|
||
${topPaths}
|
||
<div class="detail-section">SQUARES</div>
|
||
<div class="squares-list">${(d.squares||[]).join(' ') || '–'}</div>`;
|
||
} catch(e) {
|
||
body.innerHTML = '<div class="loading-msg">Failed to load data.</div>';
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Render
|
||
// ---------------------------------------------------------------------------
|
||
function makeStationPopup(callsign) {
|
||
const id = 'pt-' + callsign.replace(/[^a-zA-Z0-9]/g, '_');
|
||
const html = `<div class="sq-popup">
|
||
<div id="sq-view-${id}" style="display:none"></div>
|
||
<div id="st-view-${id}" class="station-view active">
|
||
<div class="station-header">
|
||
<span class="station-title" id="st-title-${id}">${callsign}</span>
|
||
</div>
|
||
<div class="station-body" id="st-body-${id}">
|
||
<div class="loading-msg">Loading...</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
const popup = L.popup({ maxWidth: 320 }).setContent(html);
|
||
popup.on('add', () => {
|
||
fetchStationInto(id, callsign);
|
||
});
|
||
return popup;
|
||
}
|
||
|
||
async function fetchStationInto(id, callsign) {
|
||
const body = document.getElementById(`st-body-${id}`);
|
||
if (!body) return;
|
||
try {
|
||
const res = await fetch(`${API}/api/coverage/station/${encodeURIComponent(callsign)}`);
|
||
const d = await res.json();
|
||
const fmt = iso => iso ? new Date(iso).toLocaleString('sv-SE',{dateStyle:'short',timeStyle:'short'}) : '-';
|
||
const topPaths = (d.top_paths||[]).map(p =>
|
||
`<div class="detail-row"><span class="detail-label">${p.path}</span><span class="detail-value">${p.count}</span></div>`
|
||
).join('');
|
||
body.innerHTML = `
|
||
<div class="detail-section">STATISTICS</div>
|
||
<div class="detail-row"><span class="detail-label">Total frames</span><span class="detail-value">${d.total_frames}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Unique positions</span><span class="detail-value">${d.unique_positions}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Unique paths</span><span class="detail-value">${d.unique_paths}</span></div>
|
||
<div class="detail-section">DISTANCE (LINE OF SIGHT)</div>
|
||
<div class="detail-row"><span class="detail-label">Closest</span><span class="detail-value">${d.distance_min_km??'-'} km</span></div>
|
||
<div class="detail-row"><span class="detail-label">Farthest</span><span class="detail-value">${d.distance_max_km??'-'} km</span></div>
|
||
<div class="detail-row"><span class="detail-label">Average</span><span class="detail-value">${d.distance_avg_km??'-'} km</span></div>
|
||
<div class="detail-section">TIMESTAMPS</div>
|
||
<div class="detail-row"><span class="detail-label">First heard</span><span class="detail-value">${fmt(d.first_heard)}</span></div>
|
||
<div class="detail-row"><span class="detail-label">Last heard</span><span class="detail-value">${fmt(d.last_heard)}</span></div>
|
||
<div class="detail-section">TOP PATHS</div>
|
||
${topPaths}
|
||
<div class="detail-section">SQUARES</div>
|
||
<div class="squares-list">${(d.squares||[]).join(' ') || '-'}</div>`;
|
||
} catch(e) {
|
||
body.innerHTML = '<div class="loading-msg">Failed to load data.</div>';
|
||
}
|
||
}
|
||
|
||
function bindSquarePopup(layer, sq) {
|
||
layer.bindPopup(makePopupHTML(sq), { maxWidth: 320 });
|
||
}
|
||
|
||
function renderLayers() {
|
||
squareDotLayer.clearLayers();
|
||
squareRectLayer.clearLayers();
|
||
pointLayer.clearLayers();
|
||
const zoom = map.getZoom();
|
||
|
||
if (zoom >= ZOOM_POINTS_MIN) {
|
||
for (const pt of pointsData) {
|
||
const dot = L.circleMarker([pt.lat, pt.lon], {
|
||
radius: 4, color: '#ff8c00', weight: 1, fillColor: '#ffb347', fillOpacity: 0.8,
|
||
});
|
||
dot.bindPopup(makeStationPopup(pt.call), { maxWidth: 320 });
|
||
pointLayer.addLayer(dot);
|
||
}
|
||
} else if (zoom >= ZOOM_SQUARES_MIN) {
|
||
for (const sq of squaresData) {
|
||
const rect = L.rectangle(
|
||
[[sq.lat_min, sq.lon_min], [sq.lat_max, sq.lon_max]],
|
||
{ color: '#ff8c00', weight: 1, opacity: 0.9, fillColor: '#ff8c00', fillOpacity: 0.28 }
|
||
);
|
||
bindSquarePopup(rect, sq);
|
||
rect._sq = sq;
|
||
rect.on('popupopen', () => { rect.setStyle({ weight: 3, fillOpacity: 0.45 }); setTimeout(() => attachPathHover(sq), 50); });
|
||
rect.on('popupclose', () => { rect.setStyle({ weight: 1, fillOpacity: 0.28 }); clearPathLines(); });
|
||
squareRectLayer.addLayer(rect);
|
||
}
|
||
} else {
|
||
for (const sq of squaresData) {
|
||
const lat = (sq.lat_min + sq.lat_max) / 2;
|
||
const lon = (sq.lon_min + sq.lon_max) / 2;
|
||
const dot = L.circleMarker([lat, lon], {
|
||
radius: 5, color: '#ff8c00', weight: 1, fillColor: '#ffb347', fillOpacity: 0.9,
|
||
});
|
||
bindSquarePopup(dot, sq);
|
||
dot._sq = sq;
|
||
dot.on('popupopen', () => setTimeout(() => attachPathHover(sq), 50));
|
||
dot.on('popupclose', () => clearPathLines());
|
||
squareDotLayer.addLayer(dot);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Squares feed panel
|
||
// ---------------------------------------------------------------------------
|
||
function toggleSquaresPanel() {
|
||
const panel = document.getElementById('squares-panel');
|
||
panel.classList.toggle('open');
|
||
if (panel.classList.contains('open')) renderSquaresFeed();
|
||
}
|
||
|
||
function renderSquaresFeed() {
|
||
const list = document.getElementById('squares-feed-list');
|
||
const sorted = [...squaresData].sort((a, b) => {
|
||
if (!a.first_seen) return 1;
|
||
if (!b.first_seen) return -1;
|
||
return b.first_seen.localeCompare(a.first_seen);
|
||
});
|
||
|
||
list.innerHTML = sorted.map(sq => {
|
||
const ts = sq.first_seen
|
||
? new Date(sq.first_seen).toLocaleString('sv-SE', {dateStyle:'short', timeStyle:'short'})
|
||
: '-';
|
||
return `<div class="sq-feed-row" onclick="focusSquare('${sq.square}')">
|
||
<span class="sq-feed-call">${sq.square}</span>
|
||
<span class="sq-feed-meta">
|
||
<span class="sq-feed-dist">${sq.dist_km} km</span><br/>
|
||
${ts}
|
||
</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
function focusSquare(sqId) {
|
||
document.getElementById('squares-panel').classList.remove('open');
|
||
const sq = squaresData.find(s => s.square === sqId);
|
||
if (!sq) return;
|
||
const lat = (sq.lat_min + sq.lat_max) / 2;
|
||
const lon = (sq.lon_min + sq.lon_max) / 2;
|
||
|
||
// Zoom to square level and center
|
||
map.setView([lat, lon], Math.max(map.getZoom(), 10));
|
||
|
||
// Open popup after layers have rendered
|
||
setTimeout(() => {
|
||
let found = false;
|
||
squareRectLayer.eachLayer(l => {
|
||
if (l._sq && l._sq.square === sqId) { l.openPopup(); found = true; }
|
||
});
|
||
if (!found) {
|
||
squareDotLayer.eachLayer(l => {
|
||
if (l._sq && l._sq.square === sqId) { l.openPopup(); }
|
||
});
|
||
}
|
||
}, 300);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// RX station popup
|
||
// ---------------------------------------------------------------------------
|
||
function buildRxPopup(paths) {
|
||
const rows = paths.map(p => {
|
||
const hops = p.path === 'DIRECT' ? ['DIRECT'] : p.path.split(',');
|
||
const coloredHops = hops.map(hop =>
|
||
(hop === 'DIRECT' || digiCatalog[hop.trim()])
|
||
? `<span style="color:#ff8c00">${hop}</span>`
|
||
: `<span style="color:#ff2200" title="No position known">${hop}</span>`
|
||
).join(',');
|
||
return `<tr class="rx-path-row" data-path="${p.path}">
|
||
<td style="padding-right:14px;max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${p.path}">${coloredHops}</td>
|
||
<td style="text-align:right;color:#ffd580;white-space:nowrap">${p.count}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
const html = `<div class="sq-popup" style="width:280px">
|
||
<div class="sq-popup-header">
|
||
<span class="sq-popup-title">${RX_CALL}</span>
|
||
<span class="sq-popup-sub">RX station</span>
|
||
</div>
|
||
<div style="padding:4px 10px 2px;font-size:0.63rem;letter-spacing:0.1em;color:#ff8c00;border-bottom:1px solid #3a2200">PATHS HEARD</div>
|
||
<div style="padding:6px 10px;max-height:280px;overflow-y:auto">
|
||
<table style="width:100%;border-collapse:collapse;font-size:0.68rem">${rows}</table>
|
||
</div>
|
||
</div>`;
|
||
|
||
if (rxMarker) rxMarker.unbindPopup();
|
||
if (rxMarker) {
|
||
const popup = L.popup({ maxWidth: 320 }).setContent(html);
|
||
popup.on('add', () => {
|
||
document.querySelectorAll('.rx-path-row').forEach(tr => {
|
||
const path = tr.dataset.path;
|
||
tr.style.cursor = 'default';
|
||
tr.addEventListener('mouseenter', () => drawRxPath(path));
|
||
tr.addEventListener('touchstart', e => { e.stopPropagation(); drawRxPath(path); }, { passive: true });
|
||
tr.addEventListener('mouseleave', clearPathLines);
|
||
tr.addEventListener('touchend', () => setTimeout(clearPathLines, 2000), { passive: true });
|
||
});
|
||
});
|
||
rxMarker.bindPopup(popup);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Path drawing on hover
|
||
// ---------------------------------------------------------------------------
|
||
let pathLines = [];
|
||
|
||
function drawRxPath(rawPath) {
|
||
clearPathLines();
|
||
if (!rawPath || rawPath === 'DIRECT') return;
|
||
|
||
const waypoints = [];
|
||
rawPath.split(',').forEach(hop => {
|
||
const d = digiCatalog[hop.trim()];
|
||
if (d) waypoints.push({ lat: d.lat, lon: d.lon });
|
||
});
|
||
waypoints.push({ lat: RX_LAT, lon: RX_LON });
|
||
|
||
if (waypoints.length < 2) return;
|
||
|
||
const line = L.polyline(waypoints.map(w => [w.lat, w.lon]), {
|
||
color: '#000000', weight: 2, opacity: 0.85, dashArray: '6,4',
|
||
}).addTo(map);
|
||
pathLines.push(line);
|
||
|
||
waypoints.forEach(w => {
|
||
const m = L.circleMarker([w.lat, w.lon], {
|
||
radius: 4, color: '#000000', weight: 2,
|
||
fillColor: '#0d0900', fillOpacity: 0.6,
|
||
}).addTo(map);
|
||
pathLines.push(m);
|
||
});
|
||
}
|
||
|
||
function clearPathLines() {
|
||
pathLines.forEach(l => map.removeLayer(l));
|
||
pathLines = [];
|
||
}
|
||
|
||
function drawPath(rawPath, stationLat, stationLon) {
|
||
clearPathLines();
|
||
|
||
// Build waypoints: station → each digi → RX
|
||
const waypoints = [{ lat: stationLat, lon: stationLon, label: 'Station' }];
|
||
|
||
if (rawPath && rawPath !== 'DIRECT') {
|
||
rawPath.split(',').forEach(hop => {
|
||
const d = digiCatalog[hop.trim()];
|
||
if (d) waypoints.push({ lat: d.lat, lon: d.lon, label: hop.trim() });
|
||
});
|
||
}
|
||
|
||
waypoints.push({ lat: RX_LAT, lon: RX_LON, label: RX_CALL });
|
||
|
||
if (waypoints.length < 2) return;
|
||
|
||
// Draw polyline
|
||
const latlngs = waypoints.map(w => [w.lat, w.lon]);
|
||
const line = L.polyline(latlngs, {
|
||
color: '#000000', weight: 2, opacity: 0.85,
|
||
dashArray: '6,4',
|
||
}).addTo(map);
|
||
pathLines.push(line);
|
||
|
||
// Draw circle markers at each waypoint
|
||
waypoints.forEach((w, i) => {
|
||
const isRx = i === waypoints.length - 1;
|
||
const isStation = i === 0;
|
||
const m = L.circleMarker([w.lat, w.lon], {
|
||
radius: isRx || isStation ? 5 : 4,
|
||
color: '#000000',
|
||
weight: 2,
|
||
fillColor: isRx ? '#ffe066' : '#0d0900',
|
||
fillOpacity: isRx ? 1 : 0.6,
|
||
}).addTo(map);
|
||
pathLines.push(m);
|
||
});
|
||
}
|
||
|
||
// Attach hover to path rows after popup opens
|
||
function attachPathHover(sq) {
|
||
const panel = document.getElementById(`${sq.square}-paths`);
|
||
if (!panel) return;
|
||
const rows = panel.querySelectorAll('tr');
|
||
rows.forEach((tr, i) => {
|
||
const rawPath = sq.paths[i]?.path || '';
|
||
const drawForRow = () => {
|
||
const lat = (sq.lat_min + sq.lat_max) / 2;
|
||
const lon = (sq.lon_min + sq.lon_max) / 2;
|
||
drawPath(rawPath, lat, lon);
|
||
};
|
||
tr.addEventListener('mouseenter', drawForRow);
|
||
tr.addEventListener('touchstart', e => { e.stopPropagation(); drawForRow(); }, { passive: true });
|
||
tr.addEventListener('mouseleave', clearPathLines);
|
||
tr.addEventListener('touchend', () => setTimeout(clearPathLines, 2000), { passive: true });
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Draggable popup
|
||
// ---------------------------------------------------------------------------
|
||
map.on('popupopen', e => {
|
||
const wrapper = e.popup._wrapper;
|
||
if (!wrapper) return;
|
||
|
||
const header = wrapper.querySelector('.sq-popup-header, .station-header');
|
||
if (!header) return;
|
||
|
||
header.style.cursor = 'grab';
|
||
|
||
let startX, startY, origLeft, origTop;
|
||
|
||
function startDrag(clientX, clientY) {
|
||
header.style.cursor = 'grabbing';
|
||
const el = wrapper.parentElement;
|
||
startX = clientX;
|
||
startY = clientY;
|
||
origLeft = el.offsetLeft;
|
||
origTop = el.offsetTop;
|
||
|
||
function onMove(e) {
|
||
const cx = e.touches ? e.touches[0].clientX : e.clientX;
|
||
const cy = e.touches ? e.touches[0].clientY : e.clientY;
|
||
el.style.left = (origLeft + cx - startX) + 'px';
|
||
el.style.top = (origTop + cy - startY) + 'px';
|
||
}
|
||
function onUp() {
|
||
header.style.cursor = 'grab';
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
document.removeEventListener('touchmove', onMove);
|
||
document.removeEventListener('touchend', onUp);
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
document.addEventListener('touchmove', onMove, { passive: true });
|
||
document.addEventListener('touchend', onUp);
|
||
}
|
||
|
||
header.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); startDrag(e.clientX, e.clientY); });
|
||
header.addEventListener('touchstart', e => { e.stopPropagation(); startDrag(e.touches[0].clientX, e.touches[0].clientY); }, { passive: true });
|
||
});
|
||
|
||
map.on('zoomend', renderLayers);
|
||
|
||
async function refresh() {
|
||
document.getElementById('sq-count').textContent = '…';
|
||
document.getElementById('pt-count').textContent = '…';
|
||
await fetchAll();
|
||
}
|
||
|
||
fetchAll();
|
||
</script>
|
||
</body>
|
||
</html>
|