diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 94a7ab0..10686a5 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -305,6 +305,7 @@ function paSetView(state) { document.getElementById('paEmpty').classList.toggle('d-none', state !== 'empty'); document.getElementById('paTableWrap').classList.toggle('d-none', state !== 'table'); document.getElementById('paStatsWrap').classList.toggle('d-none', state !== 'stats'); + document.getElementById('paMapWrap').classList.toggle('d-none', state !== 'map'); } // ================================================================ @@ -422,9 +423,230 @@ function paRenderStats() { } } +// ================================================================ +// Map view +// ================================================================ + +let paMap = null; +let paBaseLayer = null; // repeater contact markers +let paPathLayer = null; // drawn path for the selected echo +let paMapSelection = { msgId: null, echoIdx: null }; +let paPicks = {}; // token -> public_key chosen by the user (collision disambiguation) + +function paGeoContact(c) { + return c.adv_lat !== null && c.adv_lat !== undefined && + c.adv_lon !== null && c.adv_lon !== undefined && + !(Number(c.adv_lat) === 0 && Number(c.adv_lon) === 0); +} + +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); + paBaseLayer = L.layerGroup().addTo(paMap); + paPathLayer = L.layerGroup().addTo(paMap); +} + +function paPlotBaseMarkers() { + paBaseLayer.clearLayers(); + 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 + }).bindPopup(`${c.name || '?'}
${(c.public_key || '').slice(0, 12)}`) + .addTo(paBaseLayer); + }); +} + +// Resolve a hop token to a contact: manual pick wins, else the single +// geo-located candidate. Returns {contact, candidates} - contact null +// when unknown or ambiguous. +function paResolveHop(token) { + const candidates = paMatchContacts(token); + const geoCandidates = candidates.filter(paGeoContact); + if (paPicks[token]) { + const picked = geoCandidates.find(c => c.public_key === paPicks[token]); + if (picked) return { contact: picked, candidates: geoCandidates }; + } + if (geoCandidates.length === 1) return { contact: geoCandidates[0], candidates: geoCandidates }; + 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; + + 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)`).addTo(paPathLayer); + points.push({ latlng: [origin.adv_lat, origin.adv_lon], gapBefore: false }); + } + + echo.tokens.forEach((tok, i) => { + const { contact, candidates } = paResolveHop(tok); + if (contact) { + L.circleMarker([contact.adv_lat, contact.adv_lon], { + radius: 7, color: '#6f42c1', weight: 2, fillOpacity: 0.9 + }).bindPopup(`${contact.name}
Hop ${i + 1}: ${tok}`).addTo(paPathLayer); + 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); + }); + gapPending = true; + } + }); + + // Polyline: solid between consecutive resolved hops, dashed across skipped ones + for (let i = 1; i < points.length; i++) { + L.polyline([points[i - 1].latlng, points[i].latlng], { + color: '#6f42c1', weight: 3, opacity: 0.8, + dashArray: points[i].gapBefore ? '6 8' : null + }).addTo(paPathLayer); + } + + const allLatLngs = points.map(p => p.latlng); + if (allLatLngs.length > 0) { + paMap.fitBounds(L.latLngBounds(allLatLngs).pad(0.25), { maxZoom: 13 }); + } + + document.getElementById('paMapClearBtn').classList.remove('d-none'); +} + +function paBuildLegend(echo) { + const legend = document.createElement('div'); + legend.className = 'pa-legend'; + echo.tokens.forEach((tok, i) => { + const row = document.createElement('div'); + row.className = 'pa-legend-hop'; + const { contact, candidates } = paResolveHop(tok); + const label = document.createElement('span'); + label.innerHTML = `${i + 1}. ${tok}`; + row.appendChild(label); + if (contact) { + const name = document.createElement('span'); + name.textContent = '— ' + contact.name; + row.appendChild(name); + } else if (candidates.length > 1) { + const amb = document.createElement('span'); + amb.className = 'pa-ambiguous'; + amb.textContent = `— ${candidates.length} candidates:`; + row.appendChild(amb); + candidates.forEach(c => { + const chip = document.createElement('span'); + chip.className = 'pa-pick-chip' + (paPicks[tok] === c.public_key ? ' pa-picked' : ''); + chip.textContent = c.name || c.public_key.slice(0, 8); + chip.title = 'Use this contact for the hop'; + chip.addEventListener('click', (e) => { + e.stopPropagation(); + paPicks[tok] = c.public_key; + paRenderMapView(); + }); + row.appendChild(chip); + }); + } else { + const unk = document.createElement('span'); + unk.className = 'pa-ambiguous'; + unk.textContent = '— unknown / no position'; + row.appendChild(unk); + } + legend.appendChild(row); + }); + return legend; +} + +function paRenderMapView() { + paInitMap(); + paPlotBaseMarkers(); + + const list = document.getElementById('paMapMsgList'); + list.innerHTML = ''; + const filtered = paMessages.filter(paMessageMatchesFilters) + .filter(m => m.echoView.some(e => e.hops > 0)); + + // Drop a stale selection (filters changed underneath it) + if (paMapSelection.msgId !== null && !filtered.some(m => m.id === paMapSelection.msgId)) { + paMapSelection = { msgId: null, echoIdx: null }; + paPathLayer.clearLayers(); + document.getElementById('paMapClearBtn').classList.add('d-none'); + } + + if (filtered.length === 0) { + list.innerHTML = '
No messages with routed echoes match the current filters.
'; + } + + for (const msg of filtered) { + const entry = document.createElement('div'); + entry.className = 'pa-map-msg' + (msg.id === paMapSelection.msgId ? ' pa-selected' : ''); + entry.innerHTML = + `
+ ${msg.sender || '—'} + ${paFormatTime(msg).slice(5, 16)} +
`; + entry.addEventListener('click', () => { + paMapSelection = { msgId: msg.id, echoIdx: null }; + paRenderMapView(); + }); + + if (msg.id === paMapSelection.msgId) { + msg.echoView.forEach((echo, idx) => { + if (echo.hops === 0) return; + 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})`; + eEl.addEventListener('click', (e) => { + e.stopPropagation(); + paMapSelection.echoIdx = idx; + paRenderMapView(); + }); + entry.appendChild(eEl); + + if (idx === paMapSelection.echoIdx) { + entry.appendChild(paBuildLegend(echo)); + } + }); + } + list.appendChild(entry); + } + + paSetView('map'); + // Leaflet cannot size itself while the container was display:none + setTimeout(() => paMap.invalidateSize(), 60); + paDrawSelectedEcho(); + + const counter = document.getElementById('paCounter'); + counter.textContent = paFiltersActive() + ? `${filtered.length} of ${paMessages.length} messages` + : `${filtered.length} routed message${filtered.length === 1 ? '' : 's'}`; +} + +function paClearMapSelection() { + paMapSelection = { msgId: null, echoIdx: null }; + paPathLayer.clearLayers(); + document.getElementById('paMapClearBtn').classList.add('d-none'); + paRenderMapView(); +} + function paRender() { if (paCurrentView === 'stats') { paRenderStats(); + } else if (paCurrentView === 'map') { + paRenderMapView(); } else { paRenderTable(); } @@ -432,10 +654,12 @@ function paRender() { function paSwitchView(view) { paCurrentView = view; - const msgBtn = document.getElementById('paViewMessagesBtn'); - const statsBtn = document.getElementById('paViewStatsBtn'); - msgBtn.className = 'btn ' + (view === 'messages' ? 'btn-primary' : 'btn-outline-primary'); - statsBtn.className = 'btn ' + (view === 'stats' ? 'btn-primary' : 'btn-outline-primary'); + for (const [btnId, v] of [['paViewMessagesBtn', 'messages'], + ['paViewStatsBtn', 'stats'], + ['paViewMapBtn', 'map']]) { + document.getElementById(btnId).className = + 'btn ' + (view === v ? 'btn-primary' : 'btn-outline-primary'); + } paApplyFilters(); } @@ -517,6 +741,8 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('paViewMessagesBtn').addEventListener('click', () => paSwitchView('messages')); document.getElementById('paViewStatsBtn').addEventListener('click', () => paSwitchView('stats')); + document.getElementById('paViewMapBtn').addEventListener('click', () => paSwitchView('map')); + document.getElementById('paMapClearBtn').addEventListener('click', paClearMapSelection); document.querySelectorAll('.pa-sortable').forEach(th => { th.addEventListener('click', () => { const k = th.dataset.sort; diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html index 4cc653b..4f69e0d 100644 --- a/app/templates/path-analyzer.html +++ b/app/templates/path-analyzer.html @@ -60,11 +60,112 @@ .pa-content { flex: 1 1 0; min-height: 0; + display: flex; + flex-direction: column; overflow-y: auto; -webkit-overflow-scrolling: touch; padding: 0.75rem 1rem; } + #paMapWrap { + flex: 1 1 0; + min-height: 0; + display: flex; + gap: 0.75rem; + } + + #paMapSidebar { + width: 340px; + min-width: 280px; + overflow-y: auto; + flex-shrink: 0; + } + + #paMap { + flex: 1 1 0; + min-height: 300px; + border-radius: 0.5rem; + border: 1px solid var(--border-color); + } + + @media (max-width: 768px) { + #paMapWrap { + flex-direction: column; + } + + #paMapSidebar { + width: auto; + min-width: 0; + max-height: 40%; + flex-shrink: 1; + } + } + + .pa-map-msg { + border: 1px solid var(--border-color); + border-radius: 0.4rem; + padding: 0.4rem 0.6rem; + margin-bottom: 0.4rem; + cursor: pointer; + font-size: 0.82rem; + } + + .pa-map-msg:hover { + background-color: var(--hover-bg, rgba(0, 0, 0, 0.05)); + } + + .pa-map-msg.pa-selected { + border-color: #6f42c1; + } + + .pa-map-echo { + border-top: 1px dashed var(--border-color); + margin-top: 0.35rem; + padding: 0.25rem 0.15rem 0; + cursor: pointer; + font-size: 0.78rem; + } + + .pa-map-echo:hover { + background-color: var(--hover-bg, rgba(0, 0, 0, 0.05)); + } + + .pa-map-echo.pa-selected { + color: #6f42c1; + font-weight: 600; + } + + .pa-legend { + margin-top: 0.4rem; + padding: 0.4rem 0.5rem; + background-color: var(--bg-surface, rgba(0, 0, 0, 0.03)); + border-radius: 0.4rem; + font-size: 0.78rem; + } + + .pa-legend-hop { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.15rem 0; + } + + .pa-pick-chip { + border: 1px solid #d39e00; + color: #b58900; + border-radius: 0.25rem; + padding: 0 0.3rem; + cursor: pointer; + font-size: 0.72rem; + } + + .pa-pick-chip:hover, + .pa-pick-chip.pa-picked { + background-color: #ffc107; + color: #212529; + } + .pa-table-wrap { overflow-x: auto; } @@ -219,6 +320,9 @@ +
@@ -296,6 +400,18 @@ +
+
+
+ Click a message, then an echo to draw its path. + +
+
+
+
+