Files
mc-webui/app/static/js/geo-utils.js
T
MarekWoandClaude Opus 5 8f1334ee00 fix(map): reject impossible advert coordinates before they blank the map
A tester's cached-contacts map came up empty and Path Analyzer drew routes
across the globe. Two of his 2131 cached contacts carried garbage positions
from corrupted adverts - lat 1642 / lon -323 and lat 309 / lon -1768.

Leaflet clamps latitude when projecting but not longitude, so fitBounds()
spanned 1797 degrees, dropped to zoom 0 and centred on lon -870; all 2273
real markers were rendered several world-widths outside the viewport.
Reproduced against his DB: zoom 0, bounds -1431..-309. The device-only view
was fine because both bad rows are cache-only.

Four layers, so no single miss can blank a map again:

- app/geo.py: one definition of a usable coordinate, applied on write in
  Database.upsert_contact() so the cache stops storing garbage, and on API
  output so a cache poisoned by an older build heals itself with no
  migration. Corrupted advert types (61, 126, 152, 210 seen in the wild)
  are logged but kept raw - they already render as UNKNOWN.
- static/js/geo-utils.js: hasValidGps()/withValidGps()/fitMapToPoints()
  replace eight copies of the same check across six files. Path Analyzer
  had a looser variant that also accepted lon == 0, which put nodes off
  west Africa and stretched routes across the map; it now shares the rule.
- Per-marker try/catch and a bounds validity check, so an entry that still
  slips through costs its own pin rather than the whole map.

Verified against the tester's DB in a container: cached map 2273 markers,
lon span 1797 -> 19 degrees, centred on Poland; the route that drew the
world-spanning line (msg 29499, token 72AF) now renders as a real
Krakow-Kielce path with the bad hop left unresolved. Live local instance
regression-checked on all five map pages. tests/test_geo.py: 43 passed;
full suite 290 passed, 2 pre-existing failures unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:35:53 +02:00

113 lines
3.4 KiB
JavaScript

/**
* 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<Array<number>>} 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
};