This commit is contained in:
root
2026-05-02 18:17:37 +00:00
parent 0b394c9bae
commit 9171a5134d
3 changed files with 0 additions and 679 deletions

View File

@@ -1,55 +0,0 @@
"""
Maidenhead locator helpers.
latlon_to_subsquare(lat, lon) -> str e.g. "JO78ai"
subsquare_bounds(sq) -> dict {lat_min, lon_min, lat_max, lon_max}
"""
def latlon_to_subsquare(lat: float, lon: float) -> str:
"""Convert WGS84 coordinates to 6-character Maidenhead subsquare."""
lon_n = lon + 180.0
lat_n = lat + 90.0
field_lon = int(lon_n / 20)
field_lat = int(lat_n / 10)
lon_n = lon_n % 20
lat_n = lat_n % 10
sq_lon = int(lon_n / 2)
sq_lat = int(lat_n / 1)
lon_n = (lon_n % 2) * 12
lat_n = (lat_n % 1) * 24
sub_lon = int(lon_n)
sub_lat = int(lat_n)
return (
chr(ord("A") + field_lon)
+ chr(ord("A") + field_lat)
+ str(sq_lon)
+ str(sq_lat)
+ chr(ord("a") + sub_lon)
+ chr(ord("a") + sub_lat)
)
def subsquare_bounds(sq: str) -> dict:
"""
Return bounding box for a 6-character Maidenhead subsquare.
Each subsquare is 5' longitude ?? 2.5' latitude (~10 ?? 4.6 km in Sweden).
"""
lon = (ord(sq[0]) - ord("A")) * 20.0
lat = (ord(sq[1]) - ord("A")) * 10.0
lon += int(sq[2]) * 2.0
lat += int(sq[3]) * 1.0
lon += (ord(sq[4]) - ord("a")) * (1.0 / 12.0)
lat += (ord(sq[5]) - ord("a")) * (1.0 / 24.0)
lon -= 180.0
lat -= 90.0
return {
"lat_min": lat,
"lon_min": lon,
"lat_max": lat + (1.0 / 24.0),
"lon_max": lon + (2.0 / 12.0),
}

View File

@@ -1,612 +0,0 @@
<!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; display: flex; flex-direction: column; }
header { display: flex; align-items: center; justify-content: space-between; padding: 10px 18px; background: #0d0900; border-bottom: 1px solid #3a2200; flex-shrink: 0; gap: 16px; }
.logo { font-size: 1.05rem; font-weight: bold; letter-spacing: 0.12em; 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.9em; }
.controls { display: flex; align-items: center; gap: 14px; 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: 4px 12px; cursor: pointer; transition: all 0.15s; white-space: nowrap; }
.btn:hover { background: #2a1500; border-color: #ff8c00; }
#map { flex: 1; }
@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: 280px; }
.sq-popup-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; border-bottom: 1px solid #3a2200; }
.sq-popup-title { font-size: 0.82rem; color: #ff8c00; font-weight: bold; letter-spacing: 0.1em; }
.sq-popup-sub { font-size: 0.68rem; color: #7a5500; }
/* Tabs */
.sq-tabs { display: flex; border-bottom: 1px solid #3a2200; }
.sq-tab { flex: 1; text-align: center; padding: 4px 0; font-size: 0.68rem; letter-spacing: 0.08em; cursor: pointer; color: #7a5500; border-bottom: 2px solid transparent; transition: all 0.1s; }
.sq-tab.active { color: #ff8c00; border-bottom-color: #ff8c00; }
.sq-panel { display: none; padding: 6px 10px; max-height: 200px; overflow-y: auto; }
.sq-panel.active { display: block; }
.sq-panel table { width: 100%; border-collapse: collapse; font-size: 0.68rem; }
.sq-panel td { padding: 2px 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; }
.station-row:hover 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: 6px 10px; border-bottom: 1px solid #3a2200; }
.station-title { font-size: 0.82rem; color: #ffd580; font-weight: bold; }
.back-btn { font-family: inherit; font-size: 0.65rem; background: transparent; border: 1px solid #5a3300; color: #ff8c00; padding: 2px 8px; cursor: pointer; }
.back-btn:hover { background: #2a1500; }
.station-body { padding: 6px 10px; max-height: 240px; overflow-y: auto; }
.detail-section { color: #ff8c00; font-size: 0.63rem; letter-spacing: 0.1em; margin: 6px 0 3px; }
.detail-row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 0.68rem; border-bottom: 1px solid #1a0f00; }
.detail-label { color: #7a5500; }
.detail-value { color: #ffd580; }
.squares-list { font-size: 0.63rem; color: #7a5500; line-height: 1.8; padding-top: 2px; }
.loading-msg { padding: 12px 10px; font-size: 0.68rem; 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: 320px;
max-height: 420px;
overflow-y: auto;
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()">??? 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('mouseleave', clearPathLines);
});
});
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 || '';
tr.addEventListener('mouseenter', () => {
// Always use square center as origin
const lat = (sq.lat_min + sq.lat_max) / 2;
const lon = (sq.lon_min + sq.lon_max) / 2;
drawPath(rawPath, lat, lon);
});
tr.addEventListener('mouseleave', clearPathLines);
});
}
// ---------------------------------------------------------------------------
// 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;
header.addEventListener('mousedown', e => {
e.preventDefault();
e.stopPropagation();
header.style.cursor = 'grabbing';
const el = wrapper.parentElement; // leaflet-popup
const rect = el.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
origLeft = el.offsetLeft;
origTop = el.offsetTop;
function onMove(e) {
el.style.left = (origLeft + e.clientX - startX) + 'px';
el.style.top = (origTop + e.clientY - startY) + 'px';
}
function onUp() {
header.style.cursor = 'grab';
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
map.on('zoomend', renderLayers);
async function refresh() {
document.getElementById('sq-count').textContent = '???';
document.getElementById('pt-count').textContent = '???';
await fetchAll();
}
fetchAll();
</script>
</body>
</html>

View File

@@ -1,12 +0,0 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html;
}
location /api/ {
proxy_pass http://collector:8080/;
}
}