diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 2fbd252..2070e07 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -621,12 +621,33 @@ function paRenderRoutes() { let paMap = null; let paBaseLayer = null; // repeater contact markers +let paAltLayer = null; // the selected message's other echoes let paPathLayer = null; // drawn path for the selected echo +// Both map overlays are opt-in: the map opens showing just the picked +// route, everything else is noise until the user asks for it +let paShowAllRepeaters = false; +let paShowAltPaths = false; + // Path drawing color - distinct from the purple base markers so the -// route stands out (origin stays green, ambiguous candidates amber) +// route stands out (origin stays green, ambiguous candidates amber). const PA_PATH_COLOR = '#dc3545'; +// Alternative echoes get one light hue each, cycled by echo index, so two +// alternatives running near each other stay tellable apart. Kept clear of +// the primary red and of the marker colors above. +const PA_ALT_PATH_COLORS = [ + '#4dabf7', // light blue + '#20c997', // teal + '#ff922b', // orange + '#e599f7', // violet + '#94d82d', // lime +]; + +function paAltColor(echoIdx) { + return PA_ALT_PATH_COLORS[echoIdx % PA_ALT_PATH_COLORS.length]; +} + function paHopIcon(n) { return L.divIcon({ className: 'pa-hop-icon', @@ -644,18 +665,49 @@ function paGeoContact(c) { !(Number(c.adv_lat) === 0 && Number(c.adv_lon) === 0); } +// Overlay toggles live on the map itself, not in the shared filter bar - +// they only make sense for this view +function paAddMapToggles() { + const ctl = L.control({ position: 'topright' }); + ctl.onAdd = () => { + const box = L.DomUtil.create('div', 'pa-map-toggles'); + box.innerHTML = + '' + + ''; + L.DomEvent.disableClickPropagation(box); + L.DomEvent.disableScrollPropagation(box); + return box; + }; + ctl.addTo(paMap); + + // Re-render without refitting: toggling an overlay must not throw away + // the viewport the user panned/zoomed to + document.getElementById('paToggleRepeaters').addEventListener('change', (e) => { + paShowAllRepeaters = e.target.checked; + paRenderMapView(false); + }); + document.getElementById('paToggleAltPaths').addEventListener('change', (e) => { + paShowAltPaths = e.target.checked; + paRenderMapView(false); + }); +} + function paInitMap() { if (paMap) return; paMap = L.map('paMap').setView([52.0, 19.0], 6); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap' }).addTo(paMap); + // Added in draw order: base markers at the bottom, the selected path on top paBaseLayer = L.layerGroup().addTo(paMap); + paAltLayer = L.layerGroup().addTo(paMap); paPathLayer = L.layerGroup().addTo(paMap); + paAddMapToggles(); } function paPlotBaseMarkers() { paBaseLayer.clearLayers(); + if (!paShowAllRepeaters) return; paContacts.filter(c => c.type_label === 'REP' && paGeoContact(c)).forEach(c => { L.circleMarker([c.adv_lat, c.adv_lon], { radius: 5, color: '#6f42c1', weight: 1.5, fillOpacity: 0.5 @@ -678,58 +730,111 @@ function paResolveHop(token) { return { contact: null, candidates: geoCandidates }; } -function paDrawSelectedEcho() { - paPathLayer.clearLayers(); - const msg = paMessages.find(m => m.id === paMapSelection.msgId); - if (!msg || paMapSelection.echoIdx === null) return; - const echo = msg.echoView[paMapSelection.echoIdx]; - if (!echo) return; - +// Draw one echo's route. The primary echo gets numbered hop badges, name +// labels and candidate markers; alternatives get a plain coloured line - +// several fully decorated paths at once would be unreadable. +// Returns the latlngs it contributed, for bounds fitting. +function paDrawEcho(msg, echo, primary, seen, altColor) { + const layer = primary ? paPathLayer : paAltLayer; const points = []; // {latlng, gapBefore} let gapPending = false; // Origin: sender name -> contact with geo (channel messages carry no sender pubkey) const origin = paContacts.find(c => c.name === msg.sender && paGeoContact(c)); if (origin) { - L.circleMarker([origin.adv_lat, origin.adv_lon], { - radius: 7, color: '#198754', weight: 2, fillOpacity: 0.8 - }).bindPopup(`${origin.name}
Origin (sender)`) - .bindTooltip(origin.name, { permanent: true, direction: 'right', offset: [8, 0], className: 'pa-hop-label' }) - .addTo(paPathLayer); + if (primary) { + L.circleMarker([origin.adv_lat, origin.adv_lon], { + radius: 7, color: '#198754', weight: 2, fillOpacity: 0.8 + }).bindPopup(`${origin.name}
Origin (sender)`) + .bindTooltip(origin.name, { permanent: true, direction: 'right', offset: [8, 0], className: 'pa-hop-label' }) + .addTo(layer); + } points.push({ latlng: [origin.adv_lat, origin.adv_lon], gapBefore: false }); } echo.tokens.forEach((tok, i) => { const { contact, candidates } = paResolveHop(tok); if (contact) { - L.marker([contact.adv_lat, contact.adv_lon], { icon: paHopIcon(i + 1) }) - .bindPopup(`${contact.name}
Hop ${i + 1}: ${tok}`) - .bindTooltip(contact.name, { permanent: true, direction: 'right', offset: [13, 0], className: 'pa-hop-label' }) - .addTo(paPathLayer); + if (primary) { + L.marker([contact.adv_lat, contact.adv_lon], { icon: paHopIcon(i + 1) }) + .bindPopup(`${contact.name}
Hop ${i + 1}: ${tok}`) + .bindTooltip(contact.name, { permanent: true, direction: 'right', offset: [13, 0], className: 'pa-hop-label' }) + .addTo(layer); + } points.push({ latlng: [contact.adv_lat, contact.adv_lon], gapBefore: gapPending }); gapPending = false; } else { // Ambiguous: show all geo candidates as amber markers, excluded from the line - candidates.forEach(c => { - L.circleMarker([c.adv_lat, c.adv_lon], { - radius: 6, color: '#d39e00', weight: 2, fillOpacity: 0.5, dashArray: '3' - }).bindPopup(`${c.name}
Candidate for hop ${i + 1}: ${tok}`).addTo(paPathLayer); - }); + if (primary) { + candidates.forEach(c => { + L.circleMarker([c.adv_lat, c.adv_lon], { + radius: 6, color: '#d39e00', weight: 2, fillOpacity: 0.5, dashArray: '3' + }).bindPopup(`${c.name}
Candidate for hop ${i + 1}: ${tok}`).addTo(layer); + }); + } gapPending = true; } }); - // Polyline: solid between consecutive resolved hops, dashed across skipped ones + // Polyline: solid between consecutive resolved hops, dashed across skipped ones. + // Echoes of one message usually share a long prefix, so a segment is drawn + // only once - an alternative then shows exactly where it diverges instead of + // hiding under the primary route. + const snr = (echo.snr === null || echo.snr === undefined) ? '?' : `${Number(echo.snr).toFixed(1)} dB`; + const popup = `Alternative path
${echo.tokens.join(' → ')}
${snr}`; + let drawn = 0; for (let i = 1; i < points.length; i++) { - L.polyline([points[i - 1].latlng, points[i].latlng], { + const key = [String(points[i - 1].latlng), String(points[i].latlng)].sort().join('|'); + if (!primary && seen.has(key)) continue; + seen.add(key); + const line = L.polyline([points[i - 1].latlng, points[i].latlng], primary ? { color: PA_PATH_COLOR, weight: 3, opacity: 0.85, dashArray: points[i].gapBefore ? '6 8' : null - }).addTo(paPathLayer); + } : { + color: altColor, weight: 3, opacity: 0.9, + dashArray: points[i].gapBefore ? '4 6' : null + }); + // Alternatives carry no labels, so name them in a popup instead + if (!primary) line.bindPopup(popup); + line.addTo(layer); + drawn++; } - const allLatLngs = points.map(p => p.latlng); - if (allLatLngs.length > 0) { - paMap.fitBounds(L.latLngBounds(allLatLngs).pad(0.25), { maxZoom: 13 }); + // Mark where an alternative ends - often its only difference is the last hop + if (!primary && drawn > 0 && points.length > 1) { + L.circleMarker(points[points.length - 1].latlng, { + radius: 5, color: altColor, weight: 2, fillOpacity: 0.9 + }).bindPopup(popup).addTo(layer); + } + + return points.map(p => p.latlng); +} + +// Redraw the selected message's paths. `fit` recentres the map - skipped +// when only the overlay toggles changed, so the user keeps their viewport. +function paDrawPaths(fit) { + paAltLayer.clearLayers(); + paPathLayer.clearLayers(); + const msg = paMessages.find(m => m.id === paMapSelection.msgId); + if (!msg || paMapSelection.echoIdx === null) return; + const echo = msg.echoView[paMapSelection.echoIdx]; + if (!echo) return; + + // The primary route is drawn first so it claims the shared segments; + // alternatives then only add the parts that differ + const seen = new Set(); + const latlngs = paDrawEcho(msg, echo, true, seen); + if (paShowAltPaths) { + // Colour by echo index, so an alternative keeps its hue when the + // user switches which echo is the primary one + msg.echoView.forEach((alt, idx) => { + if (idx === paMapSelection.echoIdx || alt.hops === 0) return; + latlngs.push(...paDrawEcho(msg, alt, false, seen, paAltColor(idx))); + }); + } + + if (fit && latlngs.length > 0) { + paMap.fitBounds(L.latLngBounds(latlngs).pad(0.25), { maxZoom: 13 }); } document.getElementById('paMapClearBtn').classList.remove('d-none'); @@ -819,7 +924,9 @@ function paShortestEchoIdx(msg) { return best; } -function paRenderMapView() { +// `fit` recentres the map on the drawn paths - suppressed when only an +// overlay toggle changed, so the user keeps their viewport +function paRenderMapView(fit = true) { paInitMap(); paPlotBaseMarkers(); @@ -832,6 +939,7 @@ function paRenderMapView() { if (paMapSelection.msgId !== null && !filtered.some(m => m.id === paMapSelection.msgId)) { paMapSelection = { msgId: null, echoIdx: null }; paPathLayer.clearLayers(); + paAltLayer.clearLayers(); document.getElementById('paMapClearBtn').classList.add('d-none'); } @@ -886,7 +994,14 @@ function paRenderMapView() { const eEl = document.createElement('div'); eEl.className = 'pa-map-echo' + (idx === paMapSelection.echoIdx ? ' pa-selected' : ''); const snr = (echo.snr === null || echo.snr === undefined) ? '?' : `${Number(echo.snr).toFixed(1)} dB`; - eEl.textContent = `${echo.tokens.join(' → ')} (${snr})`; + // While alternatives are drawn, a swatch ties each row to its line + if (paShowAltPaths && idx !== paMapSelection.echoIdx) { + const swatch = document.createElement('span'); + swatch.className = 'pa-echo-swatch'; + swatch.style.backgroundColor = paAltColor(idx); + eEl.appendChild(swatch); + } + eEl.appendChild(document.createTextNode(`${echo.tokens.join(' → ')} (${snr})`)); eEl.addEventListener('click', (e) => { e.stopPropagation(); paMapSelection.echoIdx = idx; @@ -904,11 +1019,11 @@ function paRenderMapView() { paSetView('map'); // Leaflet cannot size itself while the container was display:none - - // and fitBounds in paDrawSelectedEcho needs the corrected size, so + // and fitBounds in paDrawPaths needs the corrected size, so // drawing must happen after invalidateSize in the same deferred step setTimeout(() => { paMap.invalidateSize(); - paDrawSelectedEcho(); + paDrawPaths(fit); }, 60); // Keep the selected message visible (e.g. after jumping from the table) @@ -924,6 +1039,7 @@ function paRenderMapView() { function paClearMapSelection() { paMapSelection = { msgId: null, echoIdx: null }; paPathLayer.clearLayers(); + paAltLayer.clearLayers(); document.getElementById('paMapClearBtn').classList.add('d-none'); paRenderMapView(); } diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index 9f87b51..7925b7c 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -175,6 +175,16 @@ font-weight: 600; } + /* Ties an echo row to the colour of its line on the map */ + .pa-echo-swatch { + display: inline-block; + width: 0.6rem; + height: 0.6rem; + border-radius: 50%; + margin-right: 0.3rem; + vertical-align: baseline; + } + .pa-legend { margin-top: 0.4rem; padding: 0.4rem 0.5rem; @@ -243,6 +253,35 @@ box-shadow: none; } + /* Map overlay toggles (Leaflet control, top-right corner) - these + only apply to the Map view, hence not in the shared filter bar */ + .pa-map-toggles { + background-color: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 0.35rem; + padding: 0.35rem 0.55rem; + font-size: 0.78rem; + box-shadow: 0 1px 5px rgba(0, 0, 0, 0.3); + } + + .pa-map-toggles label { + display: flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + white-space: nowrap; + margin: 0; + } + + .pa-map-toggles label + label { + margin-top: 0.25rem; + } + + .pa-map-toggles input { + cursor: pointer; + } + /* Packet hash inside the expanded detail row - narrow screens only, where the Hash column is hidden */ .pa-detail-hash {