From e90226dc21bd464b72b630dac79ffe1b42304658 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 20 Apr 2026 18:05:30 -0500 Subject: [PATCH] fix(map): tighten edge cases in heatmap, info map, and trail color - Heatmap: filter points with invalid coords or out-of-range intensity so malformed rows can't distort the layer. - InfoMap: validate coords for range (not just NaN) and use an epsilon when comparing positions, so float noise doesn't cause needless re-renders or accept bogus data. - TrailManager: getTrailCenter returns null for empty input instead of the ambiguous (0, 0) null-island; callers guard accordingly. --- assets/js/features/trail_manager.ts | 13 ++++++++----- assets/js/hooks/info_map.ts | 26 ++++++++++++++++++++++--- assets/js/map.ts | 30 +++++++++++++++++++---------- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts index 9dcc47d..94b8c21 100644 --- a/assets/js/features/trail_manager.ts +++ b/assets/js/features/trail_manager.ts @@ -304,12 +304,13 @@ export class TrailManager { return unwrapped; } - // Get the average position of a trail + // Get the average position of a trail. Returns null for empty arrays so + // callers can't accidentally treat null island (0, 0) as a real center. private getTrailCenter(positions: PositionHistory[]): { lat: number; lng: number; - } { - if (positions.length === 0) return { lat: 0, lng: 0 }; + } | null { + if (positions.length === 0) return null; const sum = positions.reduce( (acc, pos) => ({ @@ -341,6 +342,8 @@ export class TrailManager { return; const otherCenter = this.getTrailCenter(trailState.positions); + if (!otherCenter) return; + const distance = this.calculateDistance( center.lat, center.lng, @@ -377,8 +380,8 @@ export class TrailManager { } // All 15 colors in use — pick one not used by nearby trails - if (positions.length > 0) { - const center = this.getTrailCenter(positions); + const center = this.getTrailCenter(positions); + if (center) { const nearbyColors = this.getNearbyTrailColors(baseCallsign, center); for (const color of this.colorPalette) { if (!nearbyColors.has(color)) { diff --git a/assets/js/hooks/info_map.ts b/assets/js/hooks/info_map.ts index dca66ed..155fc98 100644 --- a/assets/js/hooks/info_map.ts +++ b/assets/js/hooks/info_map.ts @@ -2,6 +2,23 @@ declare const L: typeof import("leaflet"); +// ~1.1 m at the equator — enough to ignore float noise but still notice any +// real position change from the server. +const POSITION_EPSILON = 0.00001; + +function isValidLatLng(lat: number, lng: number): boolean { + return ( + !isNaN(lat) && + !isNaN(lng) && + isFinite(lat) && + isFinite(lng) && + lat >= -90 && + lat <= 90 && + lng >= -180 && + lng <= 180 + ); +} + interface InfoMapContext { el: HTMLElement; map: import("leaflet").Map | null; @@ -29,7 +46,7 @@ export const InfoMap = { const symbolHtml = this.el.dataset.symbolHtml || null; const callsign = this.el.dataset.callsign || "unknown"; - if (isNaN(lat) || isNaN(lon)) { + if (!isValidLatLng(lat, lon)) { console.warn( `InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign} during update`, ); @@ -47,7 +64,10 @@ export const InfoMap = { if (this.marker) { const currentPos = this.marker.getLatLng(); - if (currentPos.lat !== lat || currentPos.lng !== lon) { + if ( + Math.abs(currentPos.lat - lat) > POSITION_EPSILON || + Math.abs(currentPos.lng - lon) > POSITION_EPSILON + ) { this.marker.setLatLng([lat, lon]); this.marker.setPopupContent( @@ -97,7 +117,7 @@ function initializeMap(this: InfoMapContext) { const callsign = this.el.dataset.callsign || "unknown"; const symbolHtml = this.el.dataset.symbolHtml || null; - if (isNaN(lat) || isNaN(lon)) { + if (!isValidLatLng(lat, lon)) { console.warn( `InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign}`, ); diff --git a/assets/js/map.ts b/assets/js/map.ts index 857b120..274e830 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -1575,16 +1575,26 @@ let MapAPRSMap = { } } - // Convert heat points to format expected by Leaflet.heat - // Intensity is pre-normalized to 0-1 range by the server - const heatData = data.heat_points.map( - (point) => - [point.lat, point.lng, point.intensity] as [ - number, - number, - number, - ], - ); + // Convert heat points to format expected by Leaflet.heat. + // Server pre-normalizes intensity to [0, 1] but validate defensively + // so malformed rows can't distort the whole heat layer. + const heatData = data.heat_points + .filter( + (point) => + isValidCoordinate(point.lat, point.lng) && + typeof point.intensity === "number" && + isFinite(point.intensity) && + point.intensity >= 0 && + point.intensity <= 1, + ) + .map( + (point) => + [point.lat, point.lng, point.intensity] as [ + number, + number, + number, + ], + ); // Update heat layer data self.heatLayer.setLatLngs(heatData);