feat(paths): add repeater map picker for path configuration

Adds a map button (geo icon) next to the list picker in the path form.
Clicking it opens a modal with a Leaflet map showing repeater locations.
User clicks a repeater marker, then clicks Add to append its ID prefix
to the path hex. Includes Cached toggle to show all DB repeaters vs
only device-known ones. Respects current hash size setting (1B/2B/3B).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-03-23 08:13:20 +01:00
parent 8aff9be570
commit bf00e7c7d3
2 changed files with 212 additions and 1 deletions
+166
View File
@@ -1890,6 +1890,16 @@ function setupPathFormHandlers(pubkey) {
});
}
// Repeater map picker button
const mapBtn = document.getElementById('dmPickRepeaterMapBtn');
if (mapBtn) {
const newMapBtn = mapBtn.cloneNode(true);
mapBtn.parentNode.replaceChild(newMapBtn, mapBtn);
newMapBtn.addEventListener('click', () => {
openRepeaterMapPicker();
});
}
// Reset to FLOOD button
if (resetFloodBtn) {
const newResetBtn = resetFloodBtn.cloneNode(true);
@@ -2037,6 +2047,162 @@ function checkUniquenessWarning(repeaters, hashSize) {
}
}
// ================================================================
// Repeater Map Picker
// ================================================================
let _rptMap = null;
let _rptMapMarkers = null;
let _rptMapSelectedRepeater = null;
function openRepeaterMapPicker() {
_rptMapSelectedRepeater = null;
const modalEl = document.getElementById('repeaterMapModal');
if (!modalEl) return;
const addBtn = document.getElementById('rptMapAddBtn');
const selectedLabel = document.getElementById('rptMapSelected');
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map';
const modal = new bootstrap.Modal(modalEl);
const onShown = async function () {
// Init map once
if (!_rptMap) {
_rptMap = L.map('rptLeafletMap').setView([52.0, 19.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(_rptMap);
_rptMapMarkers = L.layerGroup().addTo(_rptMap);
}
_rptMap.invalidateSize();
await loadRepeaterMapMarkers();
modalEl.removeEventListener('shown.bs.modal', onShown);
};
// Cached toggle
const cachedSwitch = document.getElementById('rptMapCachedSwitch');
if (cachedSwitch) {
cachedSwitch.onchange = () => loadRepeaterMapMarkers();
}
// Add button
if (addBtn) {
addBtn.onclick = () => {
if (!_rptMapSelectedRepeater) return;
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value);
const prefix = _rptMapSelectedRepeater.public_key.substring(0, hashSize * 2).toLowerCase();
const hexInput = document.getElementById('dmPathHexInput');
if (hexInput) {
const current = hexInput.value.replace(/[,\s→]/g, '').trim();
const newVal = current + prefix;
const chunk = hashSize * 2;
const parts = [];
for (let i = 0; i < newVal.length; i += chunk) {
parts.push(newVal.substring(i, i + chunk));
}
hexInput.value = parts.join(',');
if (_repeatersCache) {
checkUniquenessWarning(_repeatersCache, hashSize);
}
}
modal.hide();
};
}
modalEl.addEventListener('shown.bs.modal', onShown);
modal.show();
}
async function loadRepeaterMapMarkers() {
if (!_rptMapMarkers) return;
_rptMapMarkers.clearLayers();
const cachedSwitch = document.getElementById('rptMapCachedSwitch');
const showCached = cachedSwitch && cachedSwitch.checked;
const countEl = document.getElementById('rptMapCount');
const addBtn = document.getElementById('rptMapAddBtn');
const selectedLabel = document.getElementById('rptMapSelected');
// Reset selection
_rptMapSelectedRepeater = null;
if (addBtn) addBtn.disabled = true;
if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map';
// Ensure repeaters cache is loaded
if (!_repeatersCache) {
try {
const response = await fetch('/api/contacts/repeaters');
const data = await response.json();
if (data.success) _repeatersCache = data.repeaters;
} catch (e) {
if (countEl) countEl.textContent = 'Failed to load';
return;
}
}
// Filter: only those with GPS
let repeaters = (_repeatersCache || []).filter(r =>
r.adv_lat && r.adv_lon && (r.adv_lat !== 0 || r.adv_lon !== 0)
);
if (!showCached) {
// Non-cached: only repeaters that are on the device (have recent advert)
// Use detailed contacts to check which are on device
try {
const response = await fetch('/api/contacts/detailed');
const data = await response.json();
if (data.success && data.contacts) {
const deviceKeys = new Set(data.contacts
.filter(c => c.type === 2)
.map(c => c.public_key.toLowerCase()));
repeaters = repeaters.filter(r => deviceKeys.has(r.public_key.toLowerCase()));
}
} catch (e) { /* show all on error */ }
}
if (countEl) countEl.textContent = `${repeaters.length} repeaters`;
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked')?.value || '1');
const bounds = [];
repeaters.forEach(rpt => {
const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase();
const lastSeen = rpt.last_advert ? formatRelativeTimeDm(rpt.last_advert) : '';
const marker = L.circleMarker([rpt.adv_lat, rpt.adv_lon], {
radius: 10,
fillColor: '#4CAF50',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(_rptMapMarkers);
marker.bindPopup(
`<b>${rpt.name}</b><br>` +
`<code>${prefix}</code>` +
(lastSeen ? `<br><small class="text-muted">Last seen: ${lastSeen}</small>` : '')
);
marker.on('click', () => {
_rptMapSelectedRepeater = rpt;
if (addBtn) addBtn.disabled = false;
if (selectedLabel) {
selectedLabel.innerHTML = `<code>${prefix}</code> ${rpt.name}`;
}
});
bounds.push([rpt.adv_lat, rpt.adv_lon]);
});
if (bounds.length > 0) {
_rptMap.fitBounds(bounds, { padding: [20, 20] });
}
}
/**
* Load and setup the No Auto Flood toggle for current contact.
*/