Files
heardlog/web/index.html
2026-05-03 06:39:58 +00:00

625 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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> &mdash; <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()">&#8635; 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()">&times;</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: '&copy; 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}')">&larr; 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(' &nbsp;') || ''}</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(' &nbsp;') || '-'}</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>