diff --git a/app/database.py b/app/database.py
index ebeb9da..e461a52 100644
--- a/app/database.py
+++ b/app/database.py
@@ -13,6 +13,8 @@ from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
+from app.geo import is_valid_contact_type, sanitize_contact_latlon
+
logger = logging.getLogger(__name__)
SCHEMA_FILE = Path(__file__).parent / 'schema.sql'
@@ -123,16 +125,34 @@ class Database:
def upsert_contact(self, public_key: str, name: str = '', **kwargs) -> None:
public_key = public_key.lower()
+
+ # Adverts are unvalidated mesh input: a corrupted packet can carry a
+ # latitude of 1642 or a type of 210. Coordinates are dropped rather
+ # than stored, because one out-of-range value blows up map bounds for
+ # every other contact. A bogus type is only logged - it already
+ # renders as UNKNOWN and is filtered out of the maps, and keeping the
+ # raw value preserves the evidence that the advert was corrupted.
+ contact_type = kwargs.get('type', 0)
+ if not is_valid_contact_type(contact_type):
+ logger.warning(
+ f"Corrupted advert type for {name or public_key[:12]} "
+ f"({public_key[:8]}...): type={contact_type!r}"
+ )
+ adv_lat, adv_lon = sanitize_contact_latlon(
+ kwargs.get('adv_lat'), kwargs.get('adv_lon'),
+ label=f"{name or public_key[:12]} ({public_key[:8]}...)"
+ )
+
fields = {
'public_key': public_key,
'name': name,
- 'type': kwargs.get('type', 0),
+ 'type': contact_type,
'flags': kwargs.get('flags', 0),
'out_path': kwargs.get('out_path', ''),
'out_path_len': kwargs.get('out_path_len', 0),
'last_advert': kwargs.get('last_advert'),
- 'adv_lat': kwargs.get('adv_lat'),
- 'adv_lon': kwargs.get('adv_lon'),
+ 'adv_lat': adv_lat,
+ 'adv_lon': adv_lon,
'source': kwargs.get('source', 'advert'),
'is_protected': kwargs.get('is_protected', 0),
}
diff --git a/app/geo.py b/app/geo.py
new file mode 100644
index 0000000..78e3a1f
--- /dev/null
+++ b/app/geo.py
@@ -0,0 +1,90 @@
+"""
+Coordinate sanity checks for advert-sourced positions.
+
+Adverts arrive from the mesh unvalidated, and a corrupted packet can carry a
+latitude of 1642 or a longitude of -1768. A single such row poisons every map
+that includes it: Leaflet clamps latitude when projecting but not longitude,
+so fitBounds() zooms out to the whole world and pushes every real marker
+off-screen. Reported by a tester on 2026-08-28 - the cached-contacts map was
+blank because two of 2131 cached contacts carried garbage coordinates.
+
+Coordinates are therefore checked twice: on write, so the cache never stores
+them again, and on API output, so a cache already poisoned by an older build
+heals itself without a migration.
+
+The JavaScript counterpart is static/js/geo-utils.js - keep the two in sync.
+"""
+
+import math
+import logging
+from typing import Any, Dict, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+MAX_LAT = 90.0
+MAX_LON = 180.0
+
+# MeshCore advert types: 0=none, 1=chat/companion, 2=repeater, 3=room, 4=sensor.
+# Anything else means the advert was corrupted in flight.
+VALID_CONTACT_TYPES = frozenset((0, 1, 2, 3, 4))
+
+
+def _coord(value: Any, limit: float) -> Optional[float]:
+ """Return value as a float inside +/-limit, or None if it is unusable."""
+ if value is None:
+ return None
+ try:
+ num = float(value)
+ except (TypeError, ValueError):
+ return None
+ if not math.isfinite(num) or abs(num) > limit:
+ return None
+ return num
+
+
+def sanitize_latlon(lat: Any, lon: Any) -> Tuple[Optional[float], Optional[float]]:
+ """
+ Return (lat, lon) as floats, or (None, None) if either value is unusable.
+
+ Both are dropped together on purpose: half a position is not a position,
+ and leaving one component behind would still skew map bounds.
+ """
+ clean_lat = _coord(lat, MAX_LAT)
+ clean_lon = _coord(lon, MAX_LON)
+ if clean_lat is None or clean_lon is None:
+ return None, None
+ return clean_lat, clean_lon
+
+
+def sanitize_contact_latlon(lat: Any, lon: Any, label: str = '') -> Tuple[Optional[float], Optional[float]]:
+ """sanitize_latlon() that logs whatever it had to throw away."""
+ clean_lat, clean_lon = sanitize_latlon(lat, lon)
+ if clean_lat is None and (lat is not None or lon is not None):
+ logger.warning(
+ f"Dropping out-of-range advert coordinates for {label or 'contact'}: "
+ f"lat={lat!r} lon={lon!r}"
+ )
+ return clean_lat, clean_lon
+
+
+def is_valid_contact_type(value: Any) -> bool:
+ """True for a MeshCore advert type we know how to render."""
+ return isinstance(value, int) and not isinstance(value, bool) and value in VALID_CONTACT_TYPES
+
+
+def scrub_geo(payload: Dict, lat_key: str = 'adv_lat', lon_key: str = 'adv_lon') -> Dict:
+ """
+ Null out unusable coordinates in an outgoing API payload (in place).
+
+ Applied to every response that feeds a map, so a contact cached by an
+ older build cannot blank the map it appears on. Keys the payload does not
+ already carry are left alone.
+ """
+ if lat_key not in payload and lon_key not in payload:
+ return payload
+ lat, lon = sanitize_latlon(payload.get(lat_key), payload.get(lon_key))
+ if lat_key in payload:
+ payload[lat_key] = lat
+ if lon_key in payload:
+ payload[lon_key] = lon
+ return payload
diff --git a/app/routes/api.py b/app/routes/api.py
index dbe96fb..53c663f 100644
--- a/app/routes/api.py
+++ b/app/routes/api.py
@@ -24,6 +24,7 @@ from app.config import config, runtime_config
from app.device_manager import decode_path_len
from app.archiver import manager as archive_manager
from app.contacts_cache import get_all_names, get_all_contacts
+from app.geo import sanitize_latlon, scrub_geo
from app import demo_guard
logger = logging.getLogger(__name__)
@@ -1084,6 +1085,10 @@ def get_cached_contacts():
except (ValueError, TypeError):
last_advert_ts = 0
+ # Scrub on output too: a cache poisoned by an older
+ # build must not be able to blank the map it feeds
+ adv_lat, adv_lon = sanitize_latlon(c.get('adv_lat'), c.get('adv_lon'))
+
contacts.append({
'public_key': pk,
'public_key_prefix': pk[:12],
@@ -1092,8 +1097,8 @@ def get_cached_contacts():
'last_seen': c.get('last_seen', ''),
'last_advert': last_advert_ts,
'source': c.get('source', ''),
- 'adv_lat': c.get('adv_lat'),
- 'adv_lon': c.get('adv_lon'),
+ 'adv_lat': adv_lat,
+ 'adv_lon': adv_lon,
'type_label': {0: 'COM', 1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}.get(c.get('type', 1), 'UNKNOWN'),
'is_ignored': pk in ignored_keys,
'is_blocked': pk in blocked_keys,
@@ -1273,6 +1278,7 @@ def preview_cleanup_contacts():
contacts = []
for public_key, details in contacts_detailed.items():
out_path_len = details.get('out_path_len', -1)
+ adv_lat, adv_lon = sanitize_latlon(details.get('adv_lat'), details.get('adv_lon'))
contacts.append({
'public_key': public_key,
'name': details.get('adv_name', ''),
@@ -1283,8 +1289,8 @@ def preview_cleanup_contacts():
'out_path_len': out_path_len,
'out_path': details.get('out_path', ''),
'out_path_hash_mode': details.get('out_path_hash_mode', 0),
- 'adv_lat': details.get('adv_lat'),
- 'adv_lon': details.get('adv_lon')
+ 'adv_lat': adv_lat,
+ 'adv_lon': adv_lon
})
# Filter contacts
@@ -1446,9 +1452,10 @@ def get_device_info():
info = dm.get_device_info()
if info:
+ # Own position feeds the star marker on the contacts map
return jsonify({
'success': True,
- 'info': info,
+ 'info': scrub_geo(dict(info)),
}), 200
else:
return jsonify({'success': False, 'error': 'No device info available'}), 503
@@ -3318,6 +3325,9 @@ def get_contacts_detailed_api():
out_path_raw = details.get('out_path', '')
out_path_hash_mode = details.get('out_path_hash_mode', 0)
path_or_mode = _format_path_display(out_path_len, out_path_raw, out_path_hash_mode)
+ # Out-of-range coordinates are dropped, not forwarded: one bad
+ # value makes Leaflet fit the map to the whole world (see app.geo)
+ adv_lat, adv_lon = sanitize_latlon(details.get('adv_lat'), details.get('adv_lon'))
contact = {
# All original fields from contact_info
@@ -3328,8 +3338,8 @@ def get_contacts_detailed_api():
'out_path': out_path_raw,
'out_path_hash_mode': out_path_hash_mode,
'last_advert': details.get('last_advert'),
- 'adv_lat': details.get('adv_lat'),
- 'adv_lon': details.get('adv_lon'),
+ 'adv_lat': adv_lat,
+ 'adv_lon': adv_lon,
'lastmod': details.get('lastmod'),
# Computed/convenience fields
'name': details.get('adv_name', ''), # Map adv_name to name for compatibility
@@ -6209,6 +6219,7 @@ def _merged_repeater_entry(row, device_by_key):
'last_advert': None,
}
if details:
+ adv_lat, adv_lon = sanitize_latlon(details.get('adv_lat'), details.get('adv_lon'))
entry.update({
'name': details.get('adv_name', ''),
'out_path_len': details.get('out_path_len', -1),
@@ -6218,8 +6229,8 @@ def _merged_repeater_entry(row, device_by_key):
details.get('out_path_len', -1),
details.get('out_path', ''),
details.get('out_path_hash_mode', 0)),
- 'adv_lat': details.get('adv_lat'),
- 'adv_lon': details.get('adv_lon'),
+ 'adv_lat': adv_lat,
+ 'adv_lon': adv_lon,
'last_advert': details.get('last_advert'),
})
return entry
@@ -6692,18 +6703,16 @@ def repeater_neighbours(public_key):
contact = dm.mc.get_contact_by_key_prefix(prefix) if dm.mc else None
if contact:
entry['name'] = contact.get('adv_name', '') or ''
- lat = contact.get('adv_lat')
- lon = contact.get('adv_lon')
- if lat and lon and (lat != 0 or lon != 0):
+ lat, lon = sanitize_latlon(contact.get('adv_lat'), contact.get('adv_lon'))
+ if lat and lon:
entry['lat'] = lat
entry['lon'] = lon
elif db:
db_contact = db.get_contact_by_prefix(prefix)
if db_contact:
entry['name'] = db_contact.get('name', '') or ''
- lat = db_contact.get('adv_lat')
- lon = db_contact.get('adv_lon')
- if lat and lon and (lat != 0 or lon != 0):
+ lat, lon = sanitize_latlon(db_contact.get('adv_lat'), db_contact.get('adv_lon'))
+ if lat and lon:
entry['lat'] = lat
entry['lon'] = lon
entries.append(entry)
diff --git a/app/static/js/app.js b/app/static/js/app.js
index 3fe254d..ead182d 100644
--- a/app/static/js/app.js
+++ b/app/static/js/app.js
@@ -194,7 +194,7 @@ function updateMapMarkers() {
const bounds = [];
// Add own device marker (star shape, distinct from contacts)
- if (_selfInfo && _selfInfo.adv_lat && _selfInfo.adv_lon && (_selfInfo.adv_lat !== 0 || _selfInfo.adv_lon !== 0)) {
+ if (hasValidGps(_selfInfo)) {
const ownIcon = L.divIcon({
html: '',
iconSize: [20, 20],
@@ -207,48 +207,56 @@ function updateMapMarkers() {
bounds.push([_selfInfo.adv_lat, _selfInfo.adv_lon]);
}
+ // Each marker is added on its own: a contact we still failed to reject
+ // must cost its own pin, never the whole map
filteredContacts.forEach(c => {
- const color = CONTACT_TYPE_COLORS[c.type] || '#2196F3';
- const typeName = CONTACT_TYPE_NAMES[c.type] || t('common.unknown');
- const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : '';
+ try {
+ const color = CONTACT_TYPE_COLORS[c.type] || '#2196F3';
+ const typeName = CONTACT_TYPE_NAMES[c.type] || t('common.unknown');
+ const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : '';
- L.circleMarker([c.adv_lat, c.adv_lon], {
- radius: 10,
- fillColor: color,
- color: '#fff',
- weight: 2,
- opacity: 1,
- fillOpacity: 0.8
- })
- .addTo(markersGroup)
- .bindPopup(`${c.name}
${typeName}${lastSeen ? `
${tHtml('repeaters.map.last_seen', { time: lastSeen })}` : ''}`);
+ L.circleMarker([c.adv_lat, c.adv_lon], {
+ radius: 10,
+ fillColor: color,
+ color: '#fff',
+ weight: 2,
+ opacity: 1,
+ fillOpacity: 0.8
+ })
+ .addTo(markersGroup)
+ .bindPopup(`${c.name}
${typeName}${lastSeen ? `
${tHtml('repeaters.map.last_seen', { time: lastSeen })}` : ''}`);
- bounds.push([c.adv_lat, c.adv_lon]);
+ bounds.push([c.adv_lat, c.adv_lon]);
+ } catch (err) {
+ console.warn('Skipping contact marker:', c.name, err);
+ }
});
cachedFiltered.forEach(c => {
- const typeNum = TYPE_LABEL_TO_NUM[c.type_label] || 1;
- const color = CONTACT_TYPE_COLORS[typeNum] || '#2196F3';
- const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : '';
+ try {
+ const typeNum = TYPE_LABEL_TO_NUM[c.type_label] || 1;
+ const color = CONTACT_TYPE_COLORS[typeNum] || '#2196F3';
+ const lastSeen = c.last_advert ? formatTimeAgo(c.last_advert) : '';
- L.circleMarker([c.adv_lat, c.adv_lon], {
- radius: 8,
- fillColor: color,
- color: '#999',
- weight: 1,
- opacity: 0.8,
- fillOpacity: 0.5
- })
- .addTo(markersGroup)
- .bindPopup(`${c.name}
${tHtml('map.cached_label', { type: c.type_label || t('map.cache') })}${lastSeen ? `
${tHtml('repeaters.map.last_seen', { time: lastSeen })}` : ''}`);
+ L.circleMarker([c.adv_lat, c.adv_lon], {
+ radius: 8,
+ fillColor: color,
+ color: '#999',
+ weight: 1,
+ opacity: 0.8,
+ fillOpacity: 0.5
+ })
+ .addTo(markersGroup)
+ .bindPopup(`${c.name}
${tHtml('map.cached_label', { type: c.type_label || t('map.cache') })}${lastSeen ? `
${tHtml('repeaters.map.last_seen', { time: lastSeen })}` : ''}`);
- bounds.push([c.adv_lat, c.adv_lon]);
+ bounds.push([c.adv_lat, c.adv_lon]);
+ } catch (err) {
+ console.warn('Skipping cached contact marker:', c.name, err);
+ }
});
- if (bounds.length === 1) {
- leafletMap.setView(bounds[0], 13);
- } else if (bounds.length > 1) {
- leafletMap.fitBounds(bounds, { padding: [20, 20] });
+ if (!fitMapToPoints(leafletMap, bounds)) {
+ leafletMap.setView([52.0, 19.0], 6);
}
}
@@ -285,15 +293,11 @@ async function showAllContactsOnMap() {
}
if (deviceData.success && deviceData.contacts) {
- allContactsWithGps = deviceData.contacts.filter(c =>
- c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)
- );
+ allContactsWithGps = withValidGps(deviceData.contacts);
}
if (cachedData.success && cachedData.contacts) {
- allCachedContactsWithGps = cachedData.contacts.filter(c =>
- c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)
- );
+ allCachedContactsWithGps = withValidGps(cachedData.contacts);
}
updateMapMarkers();
@@ -359,7 +363,7 @@ async function loadContactsGeoCache() {
// Process device contacts
if (detailedData.success && detailedData.contacts) {
detailedData.contacts.forEach(c => {
- if (c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)) {
+ if (hasValidGps(c)) {
contactsGeoCache[c.name] = { lat: c.adv_lat, lon: c.adv_lon };
}
if (c.name && c.public_key) {
@@ -376,7 +380,7 @@ async function loadContactsGeoCache() {
// Process cached contacts (fills gaps for contacts not on device)
if (cachedData.success && cachedData.contacts) {
cachedData.contacts.forEach(c => {
- if (!contactsGeoCache[c.name] && c.adv_lat && c.adv_lon && (c.adv_lat !== 0 || c.adv_lon !== 0)) {
+ if (!contactsGeoCache[c.name] && hasValidGps(c)) {
contactsGeoCache[c.name] = { lat: c.adv_lat, lon: c.adv_lon };
}
if (c.name && c.public_key && !contactsPubkeyMap[c.name]) {
@@ -2265,7 +2269,7 @@ async function loadDeviceInfo() {
const shortKey = pubKey.length > 12 ? `${pubKey.slice(0, 6)}...${pubKey.slice(-6)}` : pubKey;
// Location
- const hasLocation = info.adv_lat && info.adv_lon && (info.adv_lat !== 0 || info.adv_lon !== 0);
+ const hasLocation = hasValidGps(info);
const coords = hasLocation ? `${info.adv_lat.toFixed(6)}, ${info.adv_lon.toFixed(6)}` : tHtml('device.info.location_none');
// Build table rows
diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js
index fafc1f9..b61336e 100644
--- a/app/static/js/contacts.js
+++ b/app/static/js/contacts.js
@@ -1352,7 +1352,7 @@ function createContactCard(contact, index) {
actionsDiv.appendChild(approveBtn);
// Map button (only if GPS coordinates available)
- if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
+ if (hasValidGps(contact)) {
const mapBtn = document.createElement('button');
mapBtn.className = 'btn btn-sm btn-outline-primary';
mapBtn.innerHTML = ` ${tHtml('contacts.map.title')}`;
@@ -2210,7 +2210,7 @@ function createExistingContactCard(contact, index) {
actionsDiv.className = 'd-flex gap-2 mt-2';
// Map button - for ANY contact with GPS coordinates
- if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
+ if (hasValidGps(contact)) {
const mapBtn = document.createElement('button');
mapBtn.className = 'btn btn-sm btn-outline-primary';
mapBtn.innerHTML = ` ${tHtml('contacts.map.title')}`;
diff --git a/app/static/js/dm.js b/app/static/js/dm.js
index 2c28a65..cfddb07 100644
--- a/app/static/js/dm.js
+++ b/app/static/js/dm.js
@@ -1286,7 +1286,7 @@ function populateContactInfoModal() {
}
// GPS
- if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
+ if (hasValidGps(contact)) {
const div = document.createElement('div');
div.className = 'small mb-2';
div.innerHTML = ` ${contact.adv_lat.toFixed(4)}, ${contact.adv_lon.toFixed(4)}`;
@@ -2977,10 +2977,8 @@ async function loadRepeaterMapMarkers() {
}
}
- // Filter: only those with GPS
- let repeaters = (_repeatersCache || []).filter(r =>
- r.adv_lat && r.adv_lon && (r.adv_lat !== 0 || r.adv_lon !== 0)
- );
+ // Filter: only those with a usable GPS position
+ let repeaters = withValidGps(_repeatersCache);
if (!showCached) {
// Non-cached: only repeaters that are on the device (have recent advert)
@@ -3032,9 +3030,7 @@ async function loadRepeaterMapMarkers() {
bounds.push([rpt.adv_lat, rpt.adv_lon]);
});
- if (bounds.length > 0) {
- _rptMap.fitBounds(bounds, { padding: [20, 20] });
- }
+ fitMapToPoints(_rptMap, bounds);
}
/**
diff --git a/app/static/js/geo-utils.js b/app/static/js/geo-utils.js
new file mode 100644
index 0000000..c0eebde
--- /dev/null
+++ b/app/static/js/geo-utils.js
@@ -0,0 +1,112 @@
+/**
+ * Geographic Coordinate Utilities
+ *
+ * Advert positions arrive from the mesh unvalidated, and a corrupted packet
+ * can carry a latitude of 1642 or a longitude of -1768. A single such entry
+ * poisons a whole map: Leaflet clamps latitude when projecting but not
+ * longitude, so fitBounds() zooms out to the entire world and pushes every
+ * real marker off-screen.
+ *
+ * The server drops such values on write and on API output (see app/geo.py);
+ * this is the last line of defence, and the single place where "does this
+ * contact have a usable position?" is answered for every map in the app.
+ */
+
+const GEO_MAX_LAT = 90;
+const GEO_MAX_LON = 180;
+
+/**
+ * True when value is a finite number within +/-limit degrees.
+ * @param {*} value
+ * @param {number} limit
+ * @returns {boolean}
+ */
+function isValidCoord(value, limit) {
+ if (value === null || value === undefined || value === '') return false;
+ const num = Number(value);
+ return Number.isFinite(num) && Math.abs(num) <= limit;
+}
+
+/**
+ * True when an object carries a usable advert position.
+ *
+ * Rejects missing values, non-finite values, coordinates outside the
+ * geographic range, and the zero placeholder. A zero in either component
+ * means "no fix" - firmware sends 0/0 for a node without GPS, and a
+ * genuine node sitting exactly on the equator or the prime meridian to six
+ * decimal places does not happen in practice.
+ *
+ * @param {Object} obj - contact / repeater / neighbour entry
+ * @param {string} [latKey='adv_lat']
+ * @param {string} [lonKey='adv_lon']
+ * @returns {boolean}
+ */
+function hasValidGps(obj, latKey, lonKey) {
+ if (!obj) return false;
+ const lat = obj[latKey || 'adv_lat'];
+ const lon = obj[lonKey || 'adv_lon'];
+ if (!isValidCoord(lat, GEO_MAX_LAT) || !isValidCoord(lon, GEO_MAX_LON)) return false;
+ return Number(lat) !== 0 && Number(lon) !== 0;
+}
+
+/**
+ * True when a [lat, lon] pair is safe to hand to Leaflet.
+ * @param {Array} point
+ * @returns {boolean}
+ */
+function isValidLatLng(point) {
+ return Array.isArray(point) && point.length >= 2 &&
+ isValidCoord(point[0], GEO_MAX_LAT) && isValidCoord(point[1], GEO_MAX_LON);
+}
+
+/**
+ * Filter a list down to entries with a usable advert position.
+ * @param {Array} list
+ * @param {string} [latKey]
+ * @param {string} [lonKey]
+ * @returns {Array}
+ */
+function withValidGps(list, latKey, lonKey) {
+ return (list || []).filter(item => hasValidGps(item, latKey, lonKey));
+}
+
+/**
+ * Fit a Leaflet map to a list of [lat, lon] points, skipping any that would
+ * distort the view. Returns false when nothing could be fitted, so callers
+ * can fall back to their own default view.
+ *
+ * @param {L.Map} map
+ * @param {Array>} points
+ * @param {Object} [options] - padding, maxZoom, singleZoom
+ * @returns {boolean}
+ */
+function fitMapToPoints(map, points, options) {
+ const opts = options || {};
+ if (!map) return false;
+
+ const valid = (points || []).filter(isValidLatLng);
+ if (valid.length === 0) return false;
+
+ if (valid.length === 1) {
+ map.setView(valid[0], opts.singleZoom || 13);
+ return true;
+ }
+
+ const bounds = L.latLngBounds(valid);
+ if (!bounds.isValid()) return false;
+
+ map.fitBounds(bounds, {
+ padding: opts.padding || [20, 20],
+ maxZoom: opts.maxZoom || 16
+ });
+ return true;
+}
+
+// Export functions for use in other modules
+window.GeoUtils = {
+ isValidCoord,
+ hasValidGps,
+ isValidLatLng,
+ withValidGps,
+ fitMapToPoints
+};
diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js
index 7fe2f28..0bc1322 100644
--- a/app/static/js/path-analyzer.js
+++ b/app/static/js/path-analyzer.js
@@ -662,9 +662,7 @@ 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);
+ return hasValidGps(c);
}
// Overlay toggles live on the map itself, not in the shared filter bar -
@@ -835,8 +833,12 @@ function paDrawPaths(fit) {
});
}
- if (fit && latlngs.length > 0) {
- paMap.fitBounds(L.latLngBounds(latlngs).pad(0.25), { maxZoom: 13 });
+ if (fit) {
+ const fittable = latlngs.filter(isValidLatLng);
+ if (fittable.length > 0) {
+ const bounds = L.latLngBounds(fittable).pad(0.25);
+ if (bounds.isValid()) paMap.fitBounds(bounds, { maxZoom: 13 });
+ }
}
document.getElementById('paMapClearBtn').classList.remove('d-none');
diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js
index 1942109..fd8f63f 100644
--- a/app/static/js/repeater-manage.js
+++ b/app/static/js/repeater-manage.js
@@ -164,7 +164,7 @@ function renderHeader() {
const ZWSP = String.fromCharCode(0x200B);
document.getElementById('rptPath').textContent = path.replace(/→/g, '→' + ZWSP);
- const loc = (r.adv_lat != null && r.adv_lon != null && (r.adv_lat !== 0 || r.adv_lon !== 0))
+ const loc = hasValidGps(r)
? `${r.adv_lat.toFixed(4)}, ${r.adv_lon.toFixed(4)}`
: '—';
document.getElementById('rptLocation').textContent = loc;
@@ -856,8 +856,8 @@ function renderNeighborsList() {
}
// Map view is offered when the repeater or any neighbour has a position
- const mappable = entries.some(n => n.lat != null && n.lon != null)
- || (_repeater && _repeater.adv_lat && _repeater.adv_lon);
+ const mappable = entries.some(n => hasValidGps(n, 'lat', 'lon'))
+ || hasValidGps(_repeater);
if (toggle) toggle.style.display = mappable ? '' : 'none';
if (!entries.length) {
@@ -923,7 +923,7 @@ function renderNeighborsMap() {
}
_nbMapLayers.clearLayers();
- const hasCenter = _repeater && _repeater.adv_lat && _repeater.adv_lon;
+ const hasCenter = hasValidGps(_repeater);
const center = hasCenter ? [_repeater.adv_lat, _repeater.adv_lon] : null;
const bounds = [];
@@ -943,7 +943,7 @@ function renderNeighborsMap() {
let placed = 0;
let skipped = 0;
(data.entries || []).forEach(n => {
- if (n.lat == null || n.lon == null) { skipped++; return; }
+ if (!hasValidGps(n, 'lat', 'lon')) { skipped++; return; }
const pos = [n.lat, n.lon];
const marker = L.circleMarker(pos, {
radius: 9,
@@ -986,9 +986,7 @@ function renderNeighborsMap() {
// Leaflet needs a size recalc after the container becomes visible
setTimeout(() => {
_nbMap.invalidateSize();
- if (bounds.length > 0) {
- _nbMap.fitBounds(bounds, { padding: [30, 30] });
- }
+ fitMapToPoints(_nbMap, bounds, { padding: [30, 30] });
}, 50);
}
diff --git a/app/static/js/repeaters.js b/app/static/js/repeaters.js
index e30b3ee..16ec50c 100644
--- a/app/static/js/repeaters.js
+++ b/app/static/js/repeaters.js
@@ -1027,9 +1027,7 @@ async function loadRepeaterMapMarkers() {
}
}
- let repeaters = (_repeatersCache || []).filter(r =>
- r.adv_lat && r.adv_lon && (r.adv_lat !== 0 || r.adv_lon !== 0)
- );
+ let repeaters = withValidGps(_repeatersCache);
if (!showCached) {
// Non-cached: only repeaters that are on the device
@@ -1082,9 +1080,7 @@ async function loadRepeaterMapMarkers() {
bounds.push([rpt.adv_lat, rpt.adv_lon]);
});
- if (bounds.length > 0) {
- _rptMap.fitBounds(bounds, { padding: [20, 20] });
- }
+ fitMapToPoints(_rptMap, bounds);
}
// ================================================================
diff --git a/app/static/js/sw.js b/app/static/js/sw.js
index 99a5c50..518c261 100644
--- a/app/static/js/sw.js
+++ b/app/static/js/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = 'mc-webui-v10';
+const CACHE_NAME = 'mc-webui-v11';
const ASSETS_TO_CACHE = [
'/',
'/static/css/style.css',
@@ -7,6 +7,7 @@ const ASSETS_TO_CACHE = [
'/static/js/contacts.js',
'/static/js/message-utils.js',
'/static/js/filter-utils.js',
+ '/static/js/geo-utils.js',
'/static/js/i18n-runtime.js',
'/static/js/datetime-utils.js',
'/static/js/console.js',
diff --git a/app/templates/base.html b/app/templates/base.html
index 4e15c91..b99ae3f 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -1567,6 +1567,9 @@
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin="">
+
+
+
diff --git a/app/templates/contacts_base.html b/app/templates/contacts_base.html
index 7d682b8..243fa74 100644
--- a/app/templates/contacts_base.html
+++ b/app/templates/contacts_base.html
@@ -324,6 +324,9 @@
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin="">
+
+
+
diff --git a/app/templates/dm.html b/app/templates/dm.html
index 5380a6e..14a6c98 100644
--- a/app/templates/dm.html
+++ b/app/templates/dm.html
@@ -415,6 +415,9 @@
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin="">
+
+
+
diff --git a/app/templates/path-analyzer.html b/app/templates/path-analyzer.html
index e0e89b4..46548bd 100644
--- a/app/templates/path-analyzer.html
+++ b/app/templates/path-analyzer.html
@@ -718,6 +718,9 @@
+
+
+