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

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.
*/

View File

@@ -17,6 +17,11 @@
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Leaflet CSS (for repeater map picker) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
@@ -173,6 +178,10 @@
color: #dc3545;
font-size: 0.75rem;
}
/* Leaflet z-index fix for Bootstrap modal */
#rptLeafletMap { z-index: 1; }
#rptLeafletMap .leaflet-top,
#rptLeafletMap .leaflet-bottom { z-index: 1000; }
</style>
</head>
<body>
@@ -336,9 +345,13 @@
<input type="text" class="form-control font-monospace" id="dmPathHexInput"
placeholder="e.g. 5e,e7 or 5e34,e761" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterBtn"
title="Pick repeater">
title="Pick repeater from list">
<i class="bi bi-plus-circle"></i>
</button>
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterMapBtn"
title="Pick repeater from map">
<i class="bi bi-geo-alt"></i>
</button>
</div>
<div id="dmPathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
</div>
@@ -393,6 +406,33 @@
</div>
</div>
<!-- Repeater Map Picker Modal -->
<div class="modal fade" id="repeaterMapModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> Select Repeater from Map</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="rptMapCachedSwitch">
<label class="form-check-label small" for="rptMapCachedSwitch">Cached</label>
</div>
<span class="text-muted small ms-auto" id="rptMapCount"></span>
</div>
<div id="rptLeafletMap" style="height: 400px; width: 100%;"></div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small text-muted" id="rptMapSelected">Click a repeater on the map</span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>Add</button>
</div>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed top-0 start-0 p-3">
<div id="notificationToast" class="toast" role="alert">
@@ -413,6 +453,11 @@
<!-- Filter Utilities (must load before dm.js) -->
<script src="{{ url_for('static', filename='js/filter-utils.js') }}"></script>
<!-- Leaflet JS (for repeater map picker) -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- SocketIO for real-time updates -->
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>