webui
This commit is contained in:
624
web/index.html
Normal file
624
web/index.html
Normal file
@@ -0,0 +1,624 @@
|
||||
<!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>
|
||||
612
web/index.html~
Normal file
612
web/index.html~
Normal file
@@ -0,0 +1,612 @@
|
||||
<!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> — <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('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>
|
||||
14
web/nginx.conf
Normal file
14
web/nginx.conf
Normal file
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 80;
|
||||
charset utf-8;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
add_header Content-Type "text/html; charset=utf-8";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://collector:8080/;
|
||||
}
|
||||
}
|
||||
12
web/nginx.conf~
Normal file
12
web/nginx.conf~
Normal file
@@ -0,0 +1,12 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://collector:8080/;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user