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 = '