aprs.me/assets/js/hooks/info_map.ts
Graham McIntire e90226dc21
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.
2026-04-20 18:05:31 -05:00

197 lines
5.1 KiB
TypeScript

// Simple map hook for displaying a single station on the info page
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;
marker: import("leaflet").Marker | null;
lastSymbolHtml: string | null;
initializing: boolean;
resizeTimer: ReturnType<typeof setTimeout> | null;
destroyed?: boolean;
}
export const InfoMap = {
mounted(this: InfoMapContext) {
this.map = null;
this.marker = null;
this.lastSymbolHtml = null;
this.initializing = false;
this.resizeTimer = null;
this.destroyed = false;
initializeMap.call(this);
},
updated(this: InfoMapContext) {
const lat = parseFloat(this.el.dataset.lat || "");
const lon = parseFloat(this.el.dataset.lon || "");
const symbolHtml = this.el.dataset.symbolHtml || null;
const callsign = this.el.dataset.callsign || "unknown";
if (!isValidLatLng(lat, lon)) {
console.warn(
`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign} during update`,
);
return;
}
if (
!this.map ||
!(this.map as any)._container ||
!(this.map as any)._container.parentNode
) {
initializeMap.call(this);
return;
}
if (this.marker) {
const currentPos = this.marker.getLatLng();
if (
Math.abs(currentPos.lat - lat) > POSITION_EPSILON ||
Math.abs(currentPos.lng - lon) > POSITION_EPSILON
) {
this.marker.setLatLng([lat, lon]);
this.marker.setPopupContent(
`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`,
);
this.map!.panTo([lat, lon], { animate: true, duration: 1 });
}
if (symbolHtml !== this.lastSymbolHtml) {
const markerIcon = createMarkerIcon(symbolHtml);
this.marker.setIcon(markerIcon);
this.lastSymbolHtml = symbolHtml;
}
}
},
destroyed(this: InfoMapContext) {
this.destroyed = true;
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
this.resizeTimer = null;
}
if (this.map) {
this.map.remove();
this.map = null;
}
this.marker = null;
this.lastSymbolHtml = null;
this.initializing = false;
},
};
function initializeMap(this: InfoMapContext) {
if (this.initializing || this.destroyed) return;
if (typeof L === "undefined") {
console.error("Leaflet not loaded for InfoMap");
return;
}
this.initializing = true;
const lat = parseFloat(this.el.dataset.lat || "");
const lon = parseFloat(this.el.dataset.lon || "");
const zoom = parseInt(this.el.dataset.zoom || "13") || 13;
const callsign = this.el.dataset.callsign || "unknown";
const symbolHtml = this.el.dataset.symbolHtml || null;
if (!isValidLatLng(lat, lon)) {
console.warn(
`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign}`,
);
this.initializing = false;
return;
}
const loadingEl = this.el.querySelector(
`#${this.el.id}-loading`,
) as HTMLElement | null;
if (loadingEl) {
loadingEl.style.display = "none";
}
try {
this.map = L.map(this.el, {
center: [lat, lon],
zoom: zoom,
scrollWheelZoom: false,
zoomControl: true,
attributionControl: true,
});
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(this.map);
const markerIcon = createMarkerIcon(symbolHtml);
this.lastSymbolHtml = symbolHtml;
this.marker = L.marker([lat, lon], { icon: markerIcon })
.addTo(this.map)
.bindPopup(
`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`,
);
const map = this.map;
this.resizeTimer = setTimeout(() => {
if (!this.destroyed && this.el.isConnected && map && this.map === map) {
map.invalidateSize();
}
this.resizeTimer = null;
}, 250);
this.initializing = false;
} catch (error) {
console.error("Error initializing InfoMap:", error);
this.initializing = false;
}
}
function createMarkerIcon(symbolHtml: string | null): import("leaflet").DivIcon {
if (symbolHtml) {
return L.divIcon({
html: symbolHtml,
className: "aprs-info-marker",
iconSize: [32, 32],
iconAnchor: [16, 16],
});
} else {
return L.divIcon({
html: `<div style="
width: 24px;
height: 24px;
background-color: #3b82f6;
border: 3px solid white;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
"></div>`,
className: "",
iconSize: [24, 24],
iconAnchor: [12, 12],
});
}
}