mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-27 12:02:38 +02:00
feat(pathanalyzer): map view with path drawing (stage 5)
- Third view toggle: Map. Leaflet lazy-init on first activation with invalidateSize() once the container is visible; OSM tiles as in the main app map - Base layer: repeater contacts with geo plotted as purple circle markers (name + pubkey prefix popup), from /api/contacts/cached - Sidebar lists filtered messages that have routed echoes; click a message to see its echoes, click an echo to draw it - Hop resolution by pubkey prefix: single geo candidate draws a solid hop point; collisions render all candidates as amber markers plus a legend row with clickable pick-chips (pick resolves the hop and redraws); unknown hops are listed as 'unknown / no position' - Polyline follows path order with the sender (name-matched contact) as origin when available; segments bridging unresolved hops are dashed; map fits bounds to the drawn path - Clear button erases the drawn path; stale selections are dropped when filters change underneath Verified live via Playwright: 252 repeater markers, path + legend render, ambiguous-hop candidate pick adds a polyline segment, clear empties the layer, view toggling stays stable with no page errors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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: '© <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).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(`<strong>${c.name || '?'}</strong><br><code>${(c.public_key || '').slice(0, 12)}</code>`)
|
||||
.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(`<strong>${origin.name}</strong><br>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(`<strong>${contact.name}</strong><br>Hop ${i + 1}: <code>${tok}</code>`).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(`<strong>${c.name}</strong><br>Candidate for hop ${i + 1}: <code>${tok}</code>`).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}. <span class="pa-hash">${tok}</span>`;
|
||||
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 = '<div class="small text-muted p-2">No messages with routed echoes match the current filters.</div>';
|
||||
}
|
||||
|
||||
for (const msg of filtered) {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'pa-map-msg' + (msg.id === paMapSelection.msgId ? ' pa-selected' : '');
|
||||
entry.innerHTML =
|
||||
`<div class="d-flex justify-content-between gap-2">
|
||||
<strong class="text-truncate">${msg.sender || '—'}</strong>
|
||||
<span class="text-muted text-nowrap">${paFormatTime(msg).slice(5, 16)}</span>
|
||||
</div>`;
|
||||
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;
|
||||
|
||||
@@ -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 @@
|
||||
<button id="paViewStatsBtn" class="btn btn-outline-primary" title="Per-repeater statistics">
|
||||
<i class="bi bi-bar-chart"></i> Repeaters
|
||||
</button>
|
||||
<button id="paViewMapBtn" class="btn btn-outline-primary" title="Map view">
|
||||
<i class="bi bi-map"></i> Map
|
||||
</button>
|
||||
</div>
|
||||
<div class="vr d-none d-sm-block"></div>
|
||||
<label for="paDaysSelect" class="form-label mb-0 small text-muted">Time range:</label>
|
||||
@@ -296,6 +400,18 @@
|
||||
<tbody id="paStatsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="paMapWrap" class="d-none">
|
||||
<div id="paMapSidebar">
|
||||
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||
<span class="small text-muted">Click a message, then an echo to draw its path.</span>
|
||||
<button id="paMapClearBtn" class="btn btn-sm btn-outline-secondary d-none" title="Clear drawn path">
|
||||
<i class="bi bi-eraser"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="paMapMsgList"></div>
|
||||
</div>
|
||||
<div id="paMap"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container for notifications (position applied by JS from ui_settings) -->
|
||||
|
||||
Reference in New Issue
Block a user