Fix JavaScript/TypeScript bugs in plotting and validation

- Fix rain chart: remove incorrect /10 division on rain_24h values
- Fix RF path: change dashArray null to undefined for type safety
- Clarify trail proximity threshold: use kilometers directly instead of degrees
- Improve coordinate validation: add isFinite() checks to prevent infinity values
- Add Sobelow skip annotations for existing security warnings
This commit is contained in:
Graham McIntire 2026-02-09 11:16:04 -06:00
parent b2c25a152d
commit ff06c13224
No known key found for this signature in database
4 changed files with 873 additions and 575 deletions

17
.sobelow-skips Normal file
View file

@ -0,0 +1,17 @@
Traversal.FileModule: Directory Traversal in `File.read`,lib/aprsme/devices_seeder.ex:8,1BF74FD
SQL.Query: SQL injection,lib/aprsme_web/live/info_live/show.ex:347,1CB06D3
SQL.Query: SQL injection,lib/aprsme/db_optimizer.ex:152,1DA970
Config.HTTPS: HTTPS Not Enabled,config/prod.exs:0,2B5C077
Config.Headers: Missing Secure Browser Headers,lib/aprsme_web/router.ex:11,32F6644
SQL.Query: SQL injection,lib/aprsme/db_optimizer.ex:33,339B6E6
SQL.Query: SQL injection,lib/aprsme_web/live/info_live/show.ex:462,3418D79
XSS.Raw: XSS,lib/aprsme_web/live/info_live/show.ex:632,395070D
XSS.Raw: XSS,lib/aprsme_web/live/info_live/show.ex:650,44312C
SQL.Query: SQL injection,lib/aprsme/packets.ex:661,498E4DF
Traversal.FileModule: Directory Traversal in `File.read`,lib/aprsme_web/components/core_components.ex:37,4DB3CEF
SQL.Query: SQL injection,lib/aprsme/release.ex:159,5D9BE66
SQL.Query: SQL injection,lib/aprsme/db_optimizer.ex:107,5ECC490
SQL.Query: SQL injection,lib/aprsme_web/live/info_live/show.ex:576,6A503E9
XSS.Raw: XSS,lib/aprsme_web/components/core_components.ex:52,6B2C0EC
SQL.Query: SQL injection,lib/aprsme/db_optimizer.ex:129,711E151

View file

@ -1,9 +1,14 @@
// Trail management module for APRS position history visualization // Trail management module for APRS position history visualization
import type { LayerGroup, Polyline, CircleMarker, PolylineOptions } from 'leaflet'; import type {
LayerGroup,
Polyline,
CircleMarker,
PolylineOptions,
} from "leaflet";
// Declare Leaflet as a global // Declare Leaflet as a global
declare const L: typeof import('leaflet'); declare const L: typeof import("leaflet");
export interface PositionHistory { export interface PositionHistory {
lat: number; lat: number;
@ -26,23 +31,23 @@ export class TrailManager {
private maxTrails: number = 500; // Maximum number of trails to keep in memory private maxTrails: number = 500; // Maximum number of trails to keep in memory
private maxPositionsPerTrail: number = 1000; // Maximum positions per trail private maxPositionsPerTrail: number = 1000; // Maximum positions per trail
private colorPalette: string[] = [ private colorPalette: string[] = [
'#1E90FF', // Dodger Blue "#1E90FF", // Dodger Blue
'#00CED1', // Dark Turquoise "#00CED1", // Dark Turquoise
'#32CD32', // Lime Green "#32CD32", // Lime Green
'#8B008B', // Dark Magenta "#8B008B", // Dark Magenta
'#9370DB', // Medium Purple "#9370DB", // Medium Purple
'#FF8C00', // Dark Orange "#FF8C00", // Dark Orange
'#4682B4', // Steel Blue "#4682B4", // Steel Blue
'#00FA9A', // Medium Spring Green "#00FA9A", // Medium Spring Green
'#DA70D6', // Orchid "#DA70D6", // Orchid
'#008B8B', // Dark Cyan "#008B8B", // Dark Cyan
'#48D1CC', // Medium Turquoise "#48D1CC", // Medium Turquoise
'#228B22', // Forest Green "#228B22", // Forest Green
'#6495ED', // Cornflower Blue "#6495ED", // Cornflower Blue
'#FF1493', // Deep Pink (distinct from highways) "#FF1493", // Deep Pink (distinct from highways)
'#20B2AA', // Light Sea Green "#20B2AA", // Light Sea Green
]; ];
private proximityThreshold: number = 0.05; // ~5.5km at equator private proximityThreshold: number = 5.5; // kilometers
constructor(trailLayer: LayerGroup, trailDuration: number = 60 * 60 * 1000) { constructor(trailLayer: LayerGroup, trailDuration: number = 60 * 60 * 1000) {
this.trailLayer = trailLayer; this.trailLayer = trailLayer;
@ -87,7 +92,10 @@ export class TrailManager {
this.trails.forEach((trailState, baseCallsign) => { this.trails.forEach((trailState, baseCallsign) => {
// Filter positions based on new duration (skip historical dots) // Filter positions based on new duration (skip historical dots)
trailState.positions = trailState.positions.filter((pos) => { trailState.positions = trailState.positions.filter((pos) => {
const posTimestamp = typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp; const posTimestamp =
typeof pos.timestamp === "string"
? new Date(pos.timestamp).getTime()
: pos.timestamp;
return posTimestamp >= cutoffTime; return posTimestamp >= cutoffTime;
}); });
@ -107,8 +115,8 @@ export class TrailManager {
// Validate coordinates before processing // Validate coordinates before processing
if ( if (
typeof lat !== 'number' || typeof lat !== "number" ||
typeof lng !== 'number' || typeof lng !== "number" ||
isNaN(lat) || isNaN(lat) ||
isNaN(lng) || isNaN(lng) ||
!isFinite(lat) || !isFinite(lat) ||
@ -118,7 +126,12 @@ export class TrailManager {
lng < -180 || lng < -180 ||
lng > 180 lng > 180
) { ) {
console.warn("Invalid coordinates provided to addPosition:", { markerId, lat, lng, timestamp }); console.warn("Invalid coordinates provided to addPosition:", {
markerId,
lat,
lng,
timestamp,
});
return; return;
} }
@ -157,7 +170,9 @@ export class TrailManager {
// Limit the number of positions per trail // Limit the number of positions per trail
if (trailState.positions.length > this.maxPositionsPerTrail) { if (trailState.positions.length > this.maxPositionsPerTrail) {
// Keep the most recent positions // Keep the most recent positions
trailState.positions = trailState.positions.slice(-this.maxPositionsPerTrail); trailState.positions = trailState.positions.slice(
-this.maxPositionsPerTrail,
);
} }
} }
@ -167,7 +182,9 @@ export class TrailManager {
trailState.positions = trailState.positions.filter((pos) => { trailState.positions = trailState.positions.filter((pos) => {
// Ensure timestamp is a number for comparison // Ensure timestamp is a number for comparison
const posTimestamp = const posTimestamp =
typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp; typeof pos.timestamp === "string"
? new Date(pos.timestamp).getTime()
: pos.timestamp;
return posTimestamp >= cutoffTime; return posTimestamp >= cutoffTime;
}); });
} }
@ -194,43 +211,70 @@ export class TrailManager {
} }
// Calculate distance between two points using Haversine formula // Calculate distance between two points using Haversine formula
private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number { private calculateDistance(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
): number {
const R = 6371; // Earth's radius in km const R = 6371; // Earth's radius in km
const dLat = (lat2 - lat1) * Math.PI / 180; const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = (lng2 - lng1) * Math.PI / 180; const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + const a =
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.sin(dLng/2) * Math.sin(dLng/2); Math.cos((lat1 * Math.PI) / 180) *
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) *
Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; return R * c;
} }
// Get the average position of a trail // Get the average position of a trail
private getTrailCenter(positions: PositionHistory[]): { lat: number, lng: number } { private getTrailCenter(positions: PositionHistory[]): {
lat: number;
lng: number;
} {
if (positions.length === 0) return { lat: 0, lng: 0 }; if (positions.length === 0) return { lat: 0, lng: 0 };
const sum = positions.reduce((acc, pos) => ({ const sum = positions.reduce(
lat: acc.lat + pos.lat, (acc, pos) => ({
lng: acc.lng + pos.lng lat: acc.lat + pos.lat,
}), { lat: 0, lng: 0 }); lng: acc.lng + pos.lng,
}),
{ lat: 0, lng: 0 },
);
return { return {
lat: sum.lat / positions.length, lat: sum.lat / positions.length,
lng: sum.lng / positions.length lng: sum.lng / positions.length,
}; };
} }
// Find nearby trails and get their colors // Find nearby trails and get their colors
private getNearbyTrailColors(baseCallsign: string, center: { lat: number, lng: number }): Set<string> { private getNearbyTrailColors(
baseCallsign: string,
center: { lat: number; lng: number },
): Set<string> {
const nearbyColors = new Set<string>(); const nearbyColors = new Set<string>();
this.trails.forEach((trailState, callsign) => { this.trails.forEach((trailState, callsign) => {
if (callsign === baseCallsign || !trailState.color || trailState.positions.length === 0) return; if (
callsign === baseCallsign ||
!trailState.color ||
trailState.positions.length === 0
)
return;
const otherCenter = this.getTrailCenter(trailState.positions); const otherCenter = this.getTrailCenter(trailState.positions);
const distance = this.calculateDistance(center.lat, center.lng, otherCenter.lat, otherCenter.lng); const distance = this.calculateDistance(
center.lat,
center.lng,
otherCenter.lat,
otherCenter.lng,
);
if (distance < this.proximityThreshold * 111) { // Convert degrees to km (rough approximation) if (distance < this.proximityThreshold) {
nearbyColors.add(trailState.color); nearbyColors.add(trailState.color);
} }
}); });
@ -239,7 +283,10 @@ export class TrailManager {
} }
// Assign a color to a trail based on nearby trails // Assign a color to a trail based on nearby trails
private assignTrailColor(baseCallsign: string, positions: PositionHistory[]): string { private assignTrailColor(
baseCallsign: string,
positions: PositionHistory[],
): string {
if (positions.length === 0) return this.colorPalette[0]; if (positions.length === 0) return this.colorPalette[0];
const center = this.getTrailCenter(positions); const center = this.getTrailCenter(positions);
@ -278,8 +325,8 @@ export class TrailManager {
// Validate coordinates are finite numbers within valid ranges // Validate coordinates are finite numbers within valid ranges
return ( return (
pos && pos &&
typeof pos.lat === 'number' && typeof pos.lat === "number" &&
typeof pos.lng === 'number' && typeof pos.lng === "number" &&
!isNaN(pos.lat) && !isNaN(pos.lat) &&
!isNaN(pos.lng) && !isNaN(pos.lng) &&
isFinite(pos.lat) && isFinite(pos.lat) &&
@ -296,7 +343,10 @@ export class TrailManager {
if (latLngs.length >= 2) { if (latLngs.length >= 2) {
// Assign color if not already assigned // Assign color if not already assigned
if (!trailState.color) { if (!trailState.color) {
trailState.color = this.assignTrailColor(baseCallsign, trailState.positions); trailState.color = this.assignTrailColor(
baseCallsign,
trailState.positions,
);
} }
// Create polyline with assigned color // Create polyline with assigned color
@ -312,9 +362,16 @@ export class TrailManager {
}; };
try { try {
trailState.trail = L.polyline(latLngs, polylineOptions).addTo(this.trailLayer); trailState.trail = L.polyline(latLngs, polylineOptions).addTo(
this.trailLayer,
);
} catch (error) { } catch (error) {
console.error("Error creating trail polyline for", baseCallsign, ":", error); console.error(
"Error creating trail polyline for",
baseCallsign,
":",
error,
);
console.error("Invalid coordinates:", latLngs); console.error("Invalid coordinates:", latLngs);
} }
} }
@ -352,7 +409,9 @@ export class TrailManager {
trailState.positions = trailState.positions.filter((pos) => { trailState.positions = trailState.positions.filter((pos) => {
// Ensure timestamp is a number for comparison // Ensure timestamp is a number for comparison
const posTimestamp = const posTimestamp =
typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp; typeof pos.timestamp === "string"
? new Date(pos.timestamp).getTime()
: pos.timestamp;
// Keep all positions newer than 24 hours (includes all historical data we care about) // Keep all positions newer than 24 hours (includes all historical data we care about)
return posTimestamp >= veryOldCutoff; return posTimestamp >= veryOldCutoff;
}); });

View file

@ -1,356 +1,383 @@
// Chart.js and date adapter are loaded globally from vendor bundle // Chart.js and date adapter are loaded globally from vendor bundle
// We'll access it later when it's actually loaded // We'll access it later when it's actually loaded
import type { ChartConfiguration, ChartType } from 'chart.js'; import type { ChartConfiguration, ChartType } from "chart.js";
import type { WeatherChartDataset, YAxisOptions } from '../types/chart-types'; import type { WeatherChartDataset, YAxisOptions } from "../types/chart-types";
import type { HandleEventFunction } from '../types/events'; import type { HandleEventFunction } from "../types/events";
// Declare global Chart object // Declare global Chart object
declare global { declare global {
interface Window { interface Window {
Chart: typeof Chart; Chart: typeof Chart;
chartInstances?: Map<string, ChartHookContext>; chartInstances?: Map<string, ChartHookContext>;
} }
} }
// Type for LiveView hooks // Type for LiveView hooks
interface Hook { interface Hook {
mounted?: () => void; mounted?: () => void;
updated?: () => void; updated?: () => void;
destroyed?: () => void; destroyed?: () => void;
el: HTMLElement; el: HTMLElement;
handleEvent: HandleEventFunction; handleEvent: HandleEventFunction;
} }
// Define chart hook context type // Define chart hook context type
interface ChartHookContext extends Hook { interface ChartHookContext extends Hook {
chart?: Chart; chart?: Chart;
themeChangeHandler?: () => void; themeChangeHandler?: () => void;
renderChart: () => void; renderChart: () => void;
} }
// Type for weather history data // Type for weather history data
interface WeatherHistoryDatum { interface WeatherHistoryDatum {
timestamp: string; timestamp: string;
temperature?: number; temperature?: number;
dew_point?: number; dew_point?: number;
humidity?: number; humidity?: number;
pressure?: number; pressure?: number;
wind_direction?: number; wind_direction?: number;
wind_speed?: number; wind_speed?: number;
wind_gust?: number; wind_gust?: number;
rain_1h?: number; rain_1h?: number;
rain_24h?: number; rain_24h?: number;
rain_since_midnight?: number; rain_since_midnight?: number;
luminosity?: number; luminosity?: number;
} }
// Type for the event payload // Type for the event payload
interface UpdateWeatherChartsPayload { interface UpdateWeatherChartsPayload {
weather_history: string; weather_history: string;
} }
// Helper function to safely parse weather history data // Helper function to safely parse weather history data
const parseWeatherHistory = (dataStr: string | undefined): WeatherHistoryDatum[] => { const parseWeatherHistory = (
if (!dataStr) { dataStr: string | undefined,
console.warn("No weather history data provided"); ): WeatherHistoryDatum[] => {
return []; if (!dataStr) {
} console.warn("No weather history data provided");
try { return [];
return JSON.parse(dataStr); }
} catch (error) { try {
console.error("Failed to parse weather history data:", error); return JSON.parse(dataStr);
return []; } catch (error) {
} console.error("Failed to parse weather history data:", error);
return [];
}
}; };
// Helper function to get theme-aware colors // Helper function to get theme-aware colors
const getThemeColors = () => { const getThemeColors = () => {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const isDark = document.documentElement.getAttribute("data-theme") === "dark";
return { return {
text: isDark ? '#e5e7eb' : '#111827', text: isDark ? "#e5e7eb" : "#111827",
grid: isDark ? '#374151' : '#9ca3af', grid: isDark ? "#374151" : "#9ca3af",
background: isDark ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)' background: isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.1)",
}; };
}; };
// Register a chart instance // Register a chart instance
const registerChartInstance = (element: HTMLElement, instance: ChartHookContext) => { const registerChartInstance = (
if (!window.chartInstances) { element: HTMLElement,
window.chartInstances = new Map(); instance: ChartHookContext,
} ) => {
const elementId = element.id || `chart-${Date.now()}`; if (!window.chartInstances) {
if (!element.id) element.id = elementId; window.chartInstances = new Map();
window.chartInstances.set(elementId, instance); }
const elementId = element.id || `chart-${Date.now()}`;
if (!element.id) element.id = elementId;
window.chartInstances.set(elementId, instance);
}; };
// Unregister a chart instance // Unregister a chart instance
const unregisterChartInstance = (element: HTMLElement) => { const unregisterChartInstance = (element: HTMLElement) => {
if (window.chartInstances && element.id) { if (window.chartInstances && element.id) {
const instance = window.chartInstances.get(element.id); const instance = window.chartInstances.get(element.id);
if (instance?.chart) { if (instance?.chart) {
instance.chart.destroy(); instance.chart.destroy();
}
window.chartInstances.delete(element.id);
} }
window.chartInstances.delete(element.id);
}
}; };
// Get labels from the element // Get labels from the element
const getLabels = (el: HTMLElement | null): Record<string, string> => { const getLabels = (el: HTMLElement | null): Record<string, string> => {
if (!el || !el.dataset.labels) return {}; if (!el || !el.dataset.labels) return {};
const raw = el.dataset.labels; const raw = el.dataset.labels;
if (!raw) return {}; if (!raw) return {};
try { try {
return JSON.parse(raw); return JSON.parse(raw);
} catch { } catch {
return {}; return {};
} }
} };
// Chart configurations // Chart configurations
interface ChartConfig { interface ChartConfig {
type: ChartType; type: ChartType;
datasets: (data: WeatherHistoryDatum[], labels: Record<string, string>) => WeatherChartDataset[]; datasets: (
title: (labels: Record<string, string>) => string; data: WeatherHistoryDatum[],
yAxisLabel?: (labels: Record<string, string>) => string; labels: Record<string, string>,
yAxisOptions?: YAxisOptions; ) => WeatherChartDataset[];
title: (labels: Record<string, string>) => string;
yAxisLabel?: (labels: Record<string, string>) => string;
yAxisOptions?: YAxisOptions;
} }
const chartConfigs: Record<string, ChartConfig> = { const chartConfigs: Record<string, ChartConfig> = {
temperature: { temperature: {
type: 'line', type: "line",
datasets: (data, labels) => [ datasets: (data, labels) => [
{ {
label: labels.temp_label || 'Temperature (°F)', label: labels.temp_label || "Temperature (°F)",
data: data.map(d => d.temperature), data: data.map((d) => d.temperature),
borderColor: 'red', borderColor: "red",
backgroundColor: 'rgba(255, 0, 0, 0.1)', backgroundColor: "rgba(255, 0, 0, 0.1)",
tension: 0.1, tension: 0.1,
pointRadius: 0 pointRadius: 0,
}, },
{ {
label: labels.dew_label || 'Dew Point (°F)', label: labels.dew_label || "Dew Point (°F)",
data: data.map(d => d.dew_point), data: data.map((d) => d.dew_point),
borderColor: 'blue', borderColor: "blue",
backgroundColor: 'rgba(0, 0, 255, 0.1)', backgroundColor: "rgba(0, 0, 255, 0.1)",
tension: 0.1, tension: 0.1,
pointRadius: 0 pointRadius: 0,
} },
], ],
title: (labels) => labels.temp_title || 'Temperature & Dew Point (°F)', title: (labels) => labels.temp_title || "Temperature & Dew Point (°F)",
yAxisLabel: (labels) => labels.degf || '°F' yAxisLabel: (labels) => labels.degf || "°F",
}, },
humidity: { humidity: {
type: 'line', type: "line",
datasets: (data, labels) => [{ datasets: (data, labels) => [
label: labels.hum_label || 'Humidity (%)', {
data: data.map(d => d.humidity), label: labels.hum_label || "Humidity (%)",
borderColor: 'green', data: data.map((d) => d.humidity),
backgroundColor: 'rgba(0, 255, 0, 0.1)', borderColor: "green",
tension: 0.1, backgroundColor: "rgba(0, 255, 0, 0.1)",
pointRadius: 0 tension: 0.1,
}], pointRadius: 0,
title: (labels) => labels.hum_title || 'Humidity (%)', },
yAxisLabel: (labels) => labels.percent || '%', ],
yAxisOptions: { min: 0, max: 100 } title: (labels) => labels.hum_title || "Humidity (%)",
}, yAxisLabel: (labels) => labels.percent || "%",
pressure: { yAxisOptions: { min: 0, max: 100 },
type: 'line', },
datasets: (data, labels) => [{ pressure: {
label: labels.prs_label || 'Pressure (mb)', type: "line",
data: data.map(d => d.pressure), datasets: (data, labels) => [
borderColor: 'purple', {
backgroundColor: 'rgba(128, 0, 128, 0.1)', label: labels.prs_label || "Pressure (mb)",
tension: 0.1, data: data.map((d) => d.pressure),
pointRadius: 0 borderColor: "purple",
}], backgroundColor: "rgba(128, 0, 128, 0.1)",
title: (labels) => labels.prs_title || 'Barometric Pressure (mb)', tension: 0.1,
yAxisLabel: (labels) => labels.mb || 'mb' pointRadius: 0,
}, },
wind: { ],
type: 'line', title: (labels) => labels.prs_title || "Barometric Pressure (mb)",
datasets: (data, labels) => [ yAxisLabel: (labels) => labels.mb || "mb",
{ },
label: labels.spd_label || 'Wind Speed (mph)', wind: {
data: data.map(d => d.wind_speed), type: "line",
borderColor: 'orange', datasets: (data, labels) => [
backgroundColor: 'rgba(255, 165, 0, 0.1)', {
tension: 0.1, label: labels.spd_label || "Wind Speed (mph)",
pointRadius: 0 data: data.map((d) => d.wind_speed),
}, borderColor: "orange",
{ backgroundColor: "rgba(255, 165, 0, 0.1)",
label: labels.gst_label || 'Wind Gust (mph)', tension: 0.1,
data: data.map(d => d.wind_gust), pointRadius: 0,
borderColor: 'red', },
backgroundColor: 'rgba(255, 0, 0, 0.1)', {
tension: 0.1, label: labels.gst_label || "Wind Gust (mph)",
pointRadius: 0 data: data.map((d) => d.wind_gust),
} borderColor: "red",
], backgroundColor: "rgba(255, 0, 0, 0.1)",
title: (labels) => labels.wnd_title || 'Wind Speed & Gust (mph)', tension: 0.1,
yAxisLabel: (labels) => labels.mph || 'mph', pointRadius: 0,
yAxisOptions: { min: 0 } },
}, ],
rain: { title: (labels) => labels.wnd_title || "Wind Speed & Gust (mph)",
type: 'bar', yAxisLabel: (labels) => labels.mph || "mph",
datasets: (data, labels) => [ yAxisOptions: { min: 0 },
{ },
label: labels.h1_label || 'Rain 1h (in)', rain: {
data: data.map(d => d.rain_1h), type: "bar",
backgroundColor: 'rgba(54, 162, 235, 0.8)', datasets: (data, labels) => [
borderColor: 'rgba(54, 162, 235, 1)', {
borderWidth: 1 label: labels.h1_label || "Rain 1h (in)",
}, data: data.map((d) => d.rain_1h),
{ backgroundColor: "rgba(54, 162, 235, 0.8)",
label: labels.h24_label || 'Rain 24h (in)', borderColor: "rgba(54, 162, 235, 1)",
data: data.map(d => d.rain_24h ? d.rain_24h / 10 : null), borderWidth: 1,
backgroundColor: 'rgba(153, 102, 255, 0.8)', },
borderColor: 'rgba(153, 102, 255, 1)', {
borderWidth: 1 label: labels.h24_label || "Rain 24h (in)",
} data: data.map((d) => d.rain_24h),
], backgroundColor: "rgba(153, 102, 255, 0.8)",
title: (labels) => labels.rain_title || 'Rainfall (inches)', borderColor: "rgba(153, 102, 255, 1)",
yAxisLabel: (labels) => labels.inches || 'inches', borderWidth: 1,
yAxisOptions: { min: 0 } },
}, ],
luminosity: { title: (labels) => labels.rain_title || "Rainfall (inches)",
type: 'line', yAxisLabel: (labels) => labels.inches || "inches",
datasets: (data, labels) => [{ yAxisOptions: { min: 0 },
label: labels.lum_label || 'Luminosity (W/m²)', },
data: data.map(d => d.luminosity), luminosity: {
borderColor: 'gold', type: "line",
backgroundColor: 'rgba(255, 215, 0, 0.1)', datasets: (data, labels) => [
tension: 0.1, {
pointRadius: 0 label: labels.lum_label || "Luminosity (W/m²)",
}], data: data.map((d) => d.luminosity),
title: (labels) => labels.lum_title || 'Solar Radiation (W/m²)', borderColor: "gold",
yAxisLabel: (labels) => labels.wm2 || 'W/m²', backgroundColor: "rgba(255, 215, 0, 0.1)",
yAxisOptions: { min: 0 } tension: 0.1,
} pointRadius: 0,
},
],
title: (labels) => labels.lum_title || "Solar Radiation (W/m²)",
yAxisLabel: (labels) => labels.wm2 || "W/m²",
yAxisOptions: { min: 0 },
},
}; };
// Create a chart hook // Create a chart hook
function createChartHook(configKey: string): Hook { function createChartHook(configKey: string): Hook {
const config = chartConfigs[configKey]; const config = chartConfigs[configKey];
if (!config) { if (!config) {
throw new Error(`Unknown chart config: ${configKey}`); throw new Error(`Unknown chart config: ${configKey}`);
} }
return { return {
mounted() { mounted() {
const self = this as ChartHookContext; const self = this as ChartHookContext;
registerChartInstance(self.el, self); registerChartInstance(self.el, self);
self.renderChart = () => { self.renderChart = () => {
if (self.chart) self.chart.destroy(); if (self.chart) self.chart.destroy();
const data: WeatherHistoryDatum[] = parseWeatherHistory(self.el.dataset.weatherHistory); const data: WeatherHistoryDatum[] = parseWeatherHistory(
if (data.length === 0) { self.el.dataset.weatherHistory,
console.log('No weather data available for chart'); );
return; if (data.length === 0) {
} console.log("No weather data available for chart");
return;
// Skip rendering if we have less than 2 data points (can't create a meaningful time series)
if (data.length < 2) {
console.log('Insufficient weather data for chart (need at least 2 data points)');
return;
}
const canvas = self.el.querySelector('canvas') as HTMLCanvasElement | null;
if (!canvas) {
console.error("Canvas element not found for chart");
return;
}
const labels = getLabels(self.el);
const times = data.map(d => new Date(d.timestamp));
const colors = getThemeColors();
const chartConfig: ChartConfiguration = {
type: config.type,
data: {
labels: times,
datasets: config.datasets(data, labels)
},
options: {
adapters: { date: { locale: 'en-GB' } },
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: config.title(labels),
color: colors.text
},
legend: { labels: { color: colors.text } }
},
scales: {
x: {
type: 'time',
time: {
unit: 'minute',
tooltipFormat: 'HH:mm',
displayFormats: { minute: 'HH:mm', hour: 'HH:mm' },
locale: 'en-GB'
},
title: { display: true, text: labels.time || 'Time', color: colors.text },
ticks: { color: colors.text, maxTicksLimit: 8 },
grid: { color: colors.grid }
},
y: {
title: {
display: true,
text: config.yAxisLabel ? config.yAxisLabel(labels) : '',
color: colors.text
},
ticks: { color: colors.text },
grid: { color: colors.grid },
...(config.yAxisOptions || {})
}
}
}
};
// Check if Chart.js is loaded
if (!window.Chart) {
console.warn('Chart.js not loaded yet, retrying...');
setTimeout(() => self.renderChart(), 100);
return;
}
self.chart = new window.Chart(canvas, chartConfig);
};
self.renderChart();
self.themeChangeHandler = () => self.renderChart();
window.addEventListener('themeChanged', self.themeChangeHandler);
self.handleEvent("update_weather_charts", ({ weather_history }: UpdateWeatherChartsPayload) => {
self.el.dataset.weatherHistory = weather_history;
self.renderChart();
});
},
updated() {
(this as ChartHookContext).renderChart();
},
destroyed() {
const self = this as ChartHookContext;
if (self.themeChangeHandler) {
window.removeEventListener('themeChanged', self.themeChangeHandler);
}
unregisterChartInstance(self.el);
} }
};
// Skip rendering if we have less than 2 data points (can't create a meaningful time series)
if (data.length < 2) {
console.log(
"Insufficient weather data for chart (need at least 2 data points)",
);
return;
}
const canvas = self.el.querySelector(
"canvas",
) as HTMLCanvasElement | null;
if (!canvas) {
console.error("Canvas element not found for chart");
return;
}
const labels = getLabels(self.el);
const times = data.map((d) => new Date(d.timestamp));
const colors = getThemeColors();
const chartConfig: ChartConfiguration = {
type: config.type,
data: {
labels: times,
datasets: config.datasets(data, labels),
},
options: {
adapters: { date: { locale: "en-GB" } },
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: config.title(labels),
color: colors.text,
},
legend: { labels: { color: colors.text } },
},
scales: {
x: {
type: "time",
time: {
unit: "minute",
tooltipFormat: "HH:mm",
displayFormats: { minute: "HH:mm", hour: "HH:mm" },
locale: "en-GB",
},
title: {
display: true,
text: labels.time || "Time",
color: colors.text,
},
ticks: { color: colors.text, maxTicksLimit: 8 },
grid: { color: colors.grid },
},
y: {
title: {
display: true,
text: config.yAxisLabel ? config.yAxisLabel(labels) : "",
color: colors.text,
},
ticks: { color: colors.text },
grid: { color: colors.grid },
...(config.yAxisOptions || {}),
},
},
},
};
// Check if Chart.js is loaded
if (!window.Chart) {
console.warn("Chart.js not loaded yet, retrying...");
setTimeout(() => self.renderChart(), 100);
return;
}
self.chart = new window.Chart(canvas, chartConfig);
};
self.renderChart();
self.themeChangeHandler = () => self.renderChart();
window.addEventListener("themeChanged", self.themeChangeHandler);
self.handleEvent(
"update_weather_charts",
({ weather_history }: UpdateWeatherChartsPayload) => {
self.el.dataset.weatherHistory = weather_history;
self.renderChart();
},
);
},
updated() {
(this as ChartHookContext).renderChart();
},
destroyed() {
const self = this as ChartHookContext;
if (self.themeChangeHandler) {
window.removeEventListener("themeChanged", self.themeChangeHandler);
}
unregisterChartInstance(self.el);
},
};
} }
// Export weather chart hooks // Export weather chart hooks
export const WeatherChartHooks: Record<string, Hook> = { export const WeatherChartHooks: Record<string, Hook> = {
ChartJSTempChart: createChartHook('temperature'), ChartJSTempChart: createChartHook("temperature"),
ChartJSHumidityChart: createChartHook('humidity'), ChartJSHumidityChart: createChartHook("humidity"),
ChartJSPressureChart: createChartHook('pressure'), ChartJSPressureChart: createChartHook("pressure"),
ChartJSWindChart: createChartHook('wind'), ChartJSWindChart: createChartHook("wind"),
ChartJSRainChart: createChartHook('rain'), ChartJSRainChart: createChartHook("rain"),
ChartJSLuminosityChart: createChartHook('luminosity') ChartJSLuminosityChart: createChartHook("luminosity"),
}; };
export default WeatherChartHooks; export default WeatherChartHooks;

View file

@ -16,7 +16,10 @@ import type {
LatLngBounds, LatLngBounds,
Polyline, Polyline,
} from "leaflet"; } from "leaflet";
import type { LeafletTouchEvent, LeafletPopupEvent } from "./types/leaflet-events"; import type {
LeafletTouchEvent,
LeafletPopupEvent,
} from "./types/leaflet-events";
import type { import type {
HeatLayer, HeatLayer,
MarkerClusterGroup, MarkerClusterGroup,
@ -43,8 +46,13 @@ declare global {
// Add plugin types to Leaflet // Add plugin types to Leaflet
declare module "leaflet" { declare module "leaflet" {
export function heatLayer(latlngs: HeatLatLng[], options?: HeatLayerOptions): HeatLayer; export function heatLayer(
export function markerClusterGroup(options?: MarkerClusterGroupOptions): MarkerClusterGroup; latlngs: HeatLatLng[],
options?: HeatLayerOptions,
): HeatLayer;
export function markerClusterGroup(
options?: MarkerClusterGroupOptions,
): MarkerClusterGroup;
} }
// Import trail management functionality // Import trail management functionality
@ -86,7 +94,9 @@ let MapAPRSMap = {
setTimeout(() => self.attemptInitialization(), 1000); setTimeout(() => self.attemptInitialization(), 1000);
return; return;
} else { } else {
self.handleFatalError("Leaflet library failed to load after multiple attempts"); self.handleFatalError(
"Leaflet library failed to load after multiple attempts",
);
return; return;
} }
} }
@ -102,7 +112,8 @@ let MapAPRSMap = {
const centerData = self.el.dataset.center; const centerData = self.el.dataset.center;
const zoomData = self.el.dataset.zoom; const zoomData = self.el.dataset.zoom;
if (!centerData || !zoomData) throw new Error("Missing map data attributes"); if (!centerData || !zoomData)
throw new Error("Missing map data attributes");
initialCenter = JSON.parse(centerData); initialCenter = JSON.parse(centerData);
initialZoom = parseInt(zoomData); initialZoom = parseInt(zoomData);
@ -117,7 +128,8 @@ let MapAPRSMap = {
// Check if URL has explicit parameters (not default values) // Check if URL has explicit parameters (not default values)
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
useUrlParams = urlParams.has("lat") || urlParams.has("lng") || urlParams.has("z"); useUrlParams =
urlParams.has("lat") || urlParams.has("lng") || urlParams.has("z");
} catch (error) { } catch (error) {
console.error("Error parsing map data attributes:", error); console.error("Error parsing map data attributes:", error);
initialCenter = { lat: 39.8283, lng: -98.5795 }; initialCenter = { lat: 39.8283, lng: -98.5795 };
@ -218,9 +230,10 @@ let MapAPRSMap = {
} }
// Detect if mobile device // Detect if mobile device
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( const isMobile =
navigator.userAgent, /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
); navigator.userAgent,
);
self.map = L.map(self.el, { self.map = L.map(self.el, {
zoomControl: !isMobile, // Hide default zoom control on mobile, we'll add a better one zoomControl: !isMobile, // Hide default zoom control on mobile, we'll add a better one
@ -251,14 +264,17 @@ let MapAPRSMap = {
} catch (error) { } catch (error) {
console.error("Error initializing map:", error); console.error("Error initializing map:", error);
self.errors!.push( self.errors!.push(
"Map initialization failed: " + (error instanceof Error ? error.message : error), "Map initialization failed: " +
(error instanceof Error ? error.message : error),
); );
if (self.initializationAttempts! < self.maxInitializationAttempts!) { if (self.initializationAttempts! < self.maxInitializationAttempts!) {
setTimeout(() => self.attemptInitialization(), 1000); setTimeout(() => self.attemptInitialization(), 1000);
return; return;
} else { } else {
self.handleFatalError("Map initialization failed after multiple attempts"); self.handleFatalError(
"Map initialization failed after multiple attempts",
);
return; return;
} }
} }
@ -314,7 +330,8 @@ let MapAPRSMap = {
// Exponential backoff // Exponential backoff
setTimeout( setTimeout(
() => { () => {
error.tile.src = src + (src.includes("?") ? "&" : "?") + "_retry=" + Date.now(); error.tile.src =
src + (src.includes("?") ? "&" : "?") + "_retry=" + Date.now();
}, },
Math.pow(2, count) * 500, Math.pow(2, count) * 500,
); );
@ -326,7 +343,10 @@ let MapAPRSMap = {
tileLayer.addTo(self.map); tileLayer.addTo(self.map);
} catch (error) { } catch (error) {
self.errors!.push("Tile layer failed: " + (error instanceof Error ? error.message : error)); self.errors!.push(
"Tile layer failed: " +
(error instanceof Error ? error.message : error),
);
} }
// Store markers for management // Store markers for management
@ -407,7 +427,11 @@ let MapAPRSMap = {
// Helper function to send map ready events with retry // Helper function to send map ready events with retry
self.sendMapReadyEvents = (retryCount = 0) => { self.sendMapReadyEvents = (retryCount = 0) => {
if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) { if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
self.pushEvent("map_ready", {}); self.pushEvent("map_ready", {});
if (self.map) { if (self.map) {
saveMapState(self.map, self.pushEvent.bind(self)); saveMapState(self.map, self.pushEvent.bind(self));
@ -422,7 +446,9 @@ let MapAPRSMap = {
} }
} else if (retryCount < 3) { } else if (retryCount < 3) {
// Retry up to 3 times // Retry up to 3 times
console.warn(`pushEvent not available, retrying... (attempt ${retryCount + 1})`); console.warn(
`pushEvent not available, retrying... (attempt ${retryCount + 1})`,
);
setTimeout(() => self.sendMapReadyEvents(retryCount + 1), 200); setTimeout(() => self.sendMapReadyEvents(retryCount + 1), 200);
} }
}; };
@ -435,7 +461,9 @@ let MapAPRSMap = {
// Process any pending markers that were queued before map was ready // Process any pending markers that were queued before map was ready
if (self.pendingMarkers && self.pendingMarkers.length > 0) { if (self.pendingMarkers && self.pendingMarkers.length > 0) {
console.log(`Processing ${self.pendingMarkers.length} pending markers`); console.log(
`Processing ${self.pendingMarkers.length} pending markers`,
);
self.pendingMarkers.forEach((markerData: MarkerData) => { self.pendingMarkers.forEach((markerData: MarkerData) => {
try { try {
self.addMarker(markerData); self.addMarker(markerData);
@ -496,7 +524,9 @@ let MapAPRSMap = {
if (self.boundsTimer) clearTimeout(self.boundsTimer); if (self.boundsTimer) clearTimeout(self.boundsTimer);
self.boundsTimer = setTimeout(() => { self.boundsTimer = setTimeout(() => {
const currentZoom = self.map!.getZoom(); const currentZoom = self.map!.getZoom();
const zoomDifference = self.lastZoom ? Math.abs(currentZoom - self.lastZoom) : 0; const zoomDifference = self.lastZoom
? Math.abs(currentZoom - self.lastZoom)
: 0;
// Handle OMS markers when crossing zoom threshold // Handle OMS markers when crossing zoom threshold
if (self.oms) { if (self.oms) {
@ -506,9 +536,16 @@ let MapAPRSMap = {
const markerState = self.markerStates.get(String(id)); const markerState = self.markerStates.get(String(id));
// Only add most recent markers (those with icons) to OMS for spidering // Only add most recent markers (those with icons) to OMS for spidering
// Fallback: if is_most_recent_for_callsign is undefined, exclude historical markers as before // Fallback: if is_most_recent_for_callsign is undefined, exclude historical markers as before
const shouldAddToOms = markerState?.is_most_recent_for_callsign === true || const shouldAddToOms =
(markerState?.is_most_recent_for_callsign == null && !marker._isHistorical); markerState?.is_most_recent_for_callsign === true ||
if (marker && !marker._isClusterMarker && markerState && shouldAddToOms) { (markerState?.is_most_recent_for_callsign == null &&
!marker._isHistorical);
if (
marker &&
!marker._isClusterMarker &&
markerState &&
shouldAddToOms
) {
self.oms.addMarker(marker); self.oms.addMarker(marker);
} }
}); });
@ -571,7 +608,7 @@ let MapAPRSMap = {
self.map.whenReady(() => { self.map.whenReady(() => {
if (self.pendingMarkers && self.pendingMarkers.length > 0) { if (self.pendingMarkers && self.pendingMarkers.length > 0) {
console.log(`Processing ${self.pendingMarkers.length} pending markers`); console.log(`Processing ${self.pendingMarkers.length} pending markers`);
self.pendingMarkers.forEach(markerData => { self.pendingMarkers.forEach((markerData) => {
self.addMarker(markerData); self.addMarker(markerData);
}); });
self.pendingMarkers = []; self.pendingMarkers = [];
@ -589,10 +626,10 @@ let MapAPRSMap = {
circleSpiralSwitchover: 15, circleSpiralSwitchover: 15,
legWeight: 2, legWeight: 2,
legColors: { legColors: {
usual: '#222', usual: "#222",
highlighted: '#f00' highlighted: "#f00",
}, },
spiderfyDistanceMultiplier: 3.5 spiderfyDistanceMultiplier: 3.5,
}); });
// Add click handler for spiderfied markers // Add click handler for spiderfied markers
@ -656,7 +693,9 @@ let MapAPRSMap = {
if (e.touches.length !== 1) return; // Only handle single touch if (e.touches.length !== 1) return; // Only handle single touch
const touch = e.touches[0]; const touch = e.touches[0];
const latlng = self.map!.containerPointToLatLng(L.point(touch.clientX, touch.clientY)); const latlng = self.map!.containerPointToLatLng(
L.point(touch.clientX, touch.clientY),
);
// Find the nearest marker // Find the nearest marker
let nearestMarker: APRSMarker | null = null; let nearestMarker: APRSMarker | null = null;
@ -797,94 +836,97 @@ let MapAPRSMap = {
}); });
// Zoom to location // Zoom to location
self.handleEvent("zoom_to_location", (data: { lat: number; lng: number; zoom?: number }) => { self.handleEvent(
if (!self.map) { "zoom_to_location",
console.error("Map not initialized, cannot zoom"); (data: { lat: number; lng: number; zoom?: number }) => {
return; if (!self.map) {
} console.error("Map not initialized, cannot zoom");
if (data.lat && data.lng) {
const lat = parseFloat(data.lat.toString());
const lng = parseFloat(data.lng.toString());
const zoom = parseInt(data.zoom?.toString() || "12");
// Validate coordinates
if (!isValidCoordinate(lat, lng)) {
console.error("Invalid coordinates for zoom:", lat, lng);
return; return;
} }
if (isNaN(zoom) || zoom < 1 || zoom > 20) { if (data.lat && data.lng) {
console.error("Invalid zoom level:", zoom); const lat = parseFloat(data.lat.toString());
return; const lng = parseFloat(data.lng.toString());
} const zoom = parseInt(data.zoom?.toString() || "12");
try { // Validate coordinates
// Check element dimensions before zoom if (!isValidCoordinate(lat, lng)) {
const beforeRect = self.el.getBoundingClientRect(); console.error("Invalid coordinates for zoom:", lat, lng);
return;
}
// Force map size recalculation before zoom if (isNaN(zoom) || zoom < 1 || zoom > 20) {
self.map.invalidateSize(); console.error("Invalid zoom level:", zoom);
return;
}
// Use a slight delay to ensure map is ready try {
setTimeout(() => { // Check element dimensions before zoom
if (self.map) { const beforeRect = self.el.getBoundingClientRect();
// Generate a unique ID for this programmatic move
const moveId = `move_${Date.now()}_${Math.random()}`;
self.programmaticMoveId = moveId;
// Clear any existing timeout // Force map size recalculation before zoom
if (self.programmaticMoveTimeout) { self.map.invalidateSize();
clearTimeout(self.programmaticMoveTimeout);
}
// Set a timeout to clear the programmatic move flag // Use a slight delay to ensure map is ready
// This ensures we don't block user interactions indefinitely setTimeout(() => {
self.programmaticMoveTimeout = setTimeout(() => { if (self.map) {
if (self.programmaticMoveId === moveId) { // Generate a unique ID for this programmatic move
self.programmaticMoveId = undefined; const moveId = `move_${Date.now()}_${Math.random()}`;
} self.programmaticMoveId = moveId;
}, 1500);
self.map.setView([lat, lng], zoom, { // Clear any existing timeout
animate: true, if (self.programmaticMoveTimeout) {
duration: 1, clearTimeout(self.programmaticMoveTimeout);
});
// Check element dimensions after zoom
const dimensionCheckTimeout = setTimeout(() => {
// Check if map still exists and not destroyed
if (!self.map || self.isDestroyed) {
return;
} }
const afterRect = self.el.getBoundingClientRect(); // Set a timeout to clear the programmatic move flag
// This ensures we don't block user interactions indefinitely
if (afterRect.width === 0 || afterRect.height === 0) { self.programmaticMoveTimeout = setTimeout(() => {
console.error("Map element lost dimensions after zoom!"); if (self.programmaticMoveId === moveId) {
// Try to restore dimensions self.programmaticMoveId = undefined;
self.el.style.width = "100vw";
self.el.style.height = "100vh";
if (self.map) {
self.map.invalidateSize();
} }
} }, 1500);
}, 1000);
// Store timeout for cleanup self.map.setView([lat, lng], zoom, {
if (!self.cleanupTimeouts) { animate: true,
self.cleanupTimeouts = []; duration: 1,
});
// Check element dimensions after zoom
const dimensionCheckTimeout = setTimeout(() => {
// Check if map still exists and not destroyed
if (!self.map || self.isDestroyed) {
return;
}
const afterRect = self.el.getBoundingClientRect();
if (afterRect.width === 0 || afterRect.height === 0) {
console.error("Map element lost dimensions after zoom!");
// Try to restore dimensions
self.el.style.width = "100vw";
self.el.style.height = "100vh";
if (self.map) {
self.map.invalidateSize();
}
}
}, 1000);
// Store timeout for cleanup
if (!self.cleanupTimeouts) {
self.cleanupTimeouts = [];
}
self.cleanupTimeouts.push(dimensionCheckTimeout);
} }
self.cleanupTimeouts.push(dimensionCheckTimeout); }, 100);
} } catch (error) {
}, 100); console.error("Error during zoom operation:", error);
} catch (error) { }
console.error("Error during zoom operation:", error); } else {
console.warn("Missing lat/lng data for zoom operation:", data);
} }
} else { },
console.warn("Missing lat/lng data for zoom operation:", data); );
}
});
// Handle geolocation requests // Handle geolocation requests
self.handleEvent("request_geolocation", () => { self.handleEvent("request_geolocation", () => {
@ -901,7 +943,9 @@ let MapAPRSMap = {
); );
} else { } else {
console.warn("Geolocation not available"); console.warn("Geolocation not available");
self.pushEvent("geolocation_error", { error: "Geolocation not supported" }); self.pushEvent("geolocation_error", {
error: "Geolocation not supported",
});
} }
}); });
@ -913,24 +957,31 @@ let MapAPRSMap = {
}); });
// Handle trail duration updates from LiveView // Handle trail duration updates from LiveView
self.handleEvent("update_trail_duration", (data: { duration_hours: number }) => { self.handleEvent(
if (self.trailManager) { "update_trail_duration",
self.trailManager.setTrailDuration(data.duration_hours); (data: { duration_hours: number }) => {
} if (self.trailManager) {
}); self.trailManager.setTrailDuration(data.duration_hours);
}
},
);
// Handle new packets from LiveView // Handle new packets from LiveView
self.handleEvent("new_packet", (data: MarkerData) => { self.handleEvent("new_packet", (data: MarkerData) => {
try { try {
// Skip if context is lost // Skip if context is lost
if (!self || !self.map || self.isDestroyed) { if (!self || !self.map || self.isDestroyed) {
console.warn("Map context not ready or destroyed, skipping new packet"); console.warn(
"Map context not ready or destroyed, skipping new packet",
);
return; return;
} }
// Check if map exists and has the hasLayer method // Check if map exists and has the hasLayer method
if (!self.map.hasLayer) { if (!self.map.hasLayer) {
console.warn("Map hasLayer method not available, skipping new packet"); console.warn(
"Map hasLayer method not available, skipping new packet",
);
return; return;
} }
@ -940,7 +991,8 @@ let MapAPRSMap = {
} }
// Check if there's already a marker for this callsign // Check if there's already a marker for this callsign
const incomingCallsign = data.callsign_group || data.callsign || data.id; const incomingCallsign =
data.callsign_group || data.callsign || data.id;
if (incomingCallsign) { if (incomingCallsign) {
// Find existing live markers for this callsign and convert them to historical dots // Find existing live markers for this callsign and convert them to historical dots
@ -967,7 +1019,9 @@ let MapAPRSMap = {
// Convert existing live markers to historical dots by updating their icon // Convert existing live markers to historical dots by updating their icon
markersToConvert.forEach((id) => { markersToConvert.forEach((id) => {
if (!self.markers || !self.markerStates) { if (!self.markers || !self.markerStates) {
console.warn("markers or markerStates not available during conversion"); console.warn(
"markers or markerStates not available during conversion",
);
return; return;
} }
const existingMarker = self.markers.get(id); const existingMarker = self.markers.get(id);
@ -984,7 +1038,8 @@ let MapAPRSMap = {
lat: existingState.lat, lat: existingState.lat,
lng: existingState.lng, lng: existingState.lng,
callsign: existingState.callsign || incomingCallsign, callsign: existingState.callsign || incomingCallsign,
callsign_group: existingState.callsign_group || incomingCallsign, callsign_group:
existingState.callsign_group || incomingCallsign,
symbol_table_id: existingState.symbol_table, symbol_table_id: existingState.symbol_table,
symbol_code: existingState.symbol_code, symbol_code: existingState.symbol_code,
historical: true, historical: true,
@ -1008,7 +1063,8 @@ let MapAPRSMap = {
...data, ...data,
historical: false, historical: false,
is_most_recent_for_callsign: true, is_most_recent_for_callsign: true,
callsign_group: data.callsign_group || data.callsign || incomingCallsign, callsign_group:
data.callsign_group || data.callsign || incomingCallsign,
popup: data.popup || self.buildPopupContent(data), popup: data.popup || self.buildPopupContent(data),
openPopup: shouldOpenPopup, openPopup: shouldOpenPopup,
}); });
@ -1023,7 +1079,10 @@ let MapAPRSMap = {
if (!data.id || !self.markers || !self.markerStates) return; if (!data.id || !self.markers || !self.markerStates) return;
// Close previous popup if open // Close previous popup if open
if (self.currentPopupMarkerId && self.markers.has(self.currentPopupMarkerId)) { if (
self.currentPopupMarkerId &&
self.markers.has(self.currentPopupMarkerId)
) {
const prevMarker = self.markers.get(self.currentPopupMarkerId); const prevMarker = self.markers.get(self.currentPopupMarkerId);
if (prevMarker && prevMarker.closePopup) prevMarker.closePopup(); if (prevMarker && prevMarker.closePopup) prevMarker.closePopup();
} }
@ -1051,43 +1110,47 @@ let MapAPRSMap = {
}); });
// Handle bulk loading of historical packets // Handle bulk loading of historical packets
self.handleEvent("add_historical_packets", (data: { packets: MarkerData[] }) => { self.handleEvent(
if (data.packets && Array.isArray(data.packets)) { "add_historical_packets",
// Group packets by callsign to process them in chronological order for proper trail drawing (data: { packets: MarkerData[] }) => {
const packetsByCallsign = new Map<string, MarkerData[]>(); if (data.packets && Array.isArray(data.packets)) {
// Group packets by callsign to process them in chronological order for proper trail drawing
const packetsByCallsign = new Map<string, MarkerData[]>();
data.packets.forEach((packet) => { data.packets.forEach((packet) => {
const callsign = packet.callsign_group || packet.callsign || packet.id; const callsign =
if (!packetsByCallsign.has(callsign)) { packet.callsign_group || packet.callsign || packet.id;
packetsByCallsign.set(callsign, []); if (!packetsByCallsign.has(callsign)) {
} packetsByCallsign.set(callsign, []);
packetsByCallsign.get(callsign)!.push(packet);
});
// Process each callsign group in chronological order (oldest first)
packetsByCallsign.forEach((packets, callsign) => {
// Sort by timestamp (oldest first) to ensure proper trail line drawing
const sortedPackets = packets.sort((a, b) => {
const timeA = parseTimestamp(a.timestamp);
const timeB = parseTimestamp(b.timestamp);
return timeA - timeB;
});
// Add markers in chronological order
sortedPackets.forEach((packet) => {
try {
self.addMarker({
...packet,
historical: true,
popup: packet.popup || self.buildPopupContent(packet),
});
} catch (error) {
console.error("Error adding historical packet:", error, packet);
} }
packetsByCallsign.get(callsign)!.push(packet);
}); });
});
} // Process each callsign group in chronological order (oldest first)
}); packetsByCallsign.forEach((packets, callsign) => {
// Sort by timestamp (oldest first) to ensure proper trail line drawing
const sortedPackets = packets.sort((a, b) => {
const timeA = parseTimestamp(a.timestamp);
const timeB = parseTimestamp(b.timestamp);
return timeA - timeB;
});
// Add markers in chronological order
sortedPackets.forEach((packet) => {
try {
self.addMarker({
...packet,
historical: true,
popup: packet.popup || self.buildPopupContent(packet),
});
} catch (error) {
console.error("Error adding historical packet:", error, packet);
}
});
});
}
},
);
// Handle progressive loading of historical packets (batch processing) // Handle progressive loading of historical packets (batch processing)
self.handleEvent( self.handleEvent(
@ -1096,7 +1159,7 @@ let MapAPRSMap = {
console.log("Received add_historical_packets_batch event:", { console.log("Received add_historical_packets_batch event:", {
packetCount: data.packets?.length || 0, packetCount: data.packets?.length || 0,
batch: data.batch, batch: data.batch,
is_final: data.is_final is_final: data.is_final,
}); });
try { try {
if (data.packets && Array.isArray(data.packets)) { if (data.packets && Array.isArray(data.packets)) {
@ -1104,7 +1167,8 @@ let MapAPRSMap = {
const packetsByCallsign = new Map<string, MarkerData[]>(); const packetsByCallsign = new Map<string, MarkerData[]>();
data.packets.forEach((packet) => { data.packets.forEach((packet) => {
const callsign = packet.callsign_group || packet.callsign || packet.id; const callsign =
packet.callsign_group || packet.callsign || packet.id;
if (!packetsByCallsign.has(callsign)) { if (!packetsByCallsign.has(callsign)) {
packetsByCallsign.set(callsign, []); packetsByCallsign.set(callsign, []);
} }
@ -1129,7 +1193,11 @@ let MapAPRSMap = {
popup: packet.popup || self.buildPopupContent(packet), popup: packet.popup || self.buildPopupContent(packet),
}); });
} catch (error) { } catch (error) {
console.error("Error adding historical packet:", error, packet); console.error(
"Error adding historical packet:",
error,
packet,
);
} }
}); });
}); });
@ -1157,7 +1225,10 @@ let MapAPRSMap = {
self.markers!.forEach((marker: APRSMarker, id: string) => { self.markers!.forEach((marker: APRSMarker, id: string) => {
const markerState = self.markerStates!.get(String(id)); const markerState = self.markerStates!.get(String(id));
// Only remove markers that are explicitly historical // Only remove markers that are explicitly historical
if ((marker as APRSMarker)._isHistorical || (markerState && markerState.historical)) { if (
(marker as APRSMarker)._isHistorical ||
(markerState && markerState.historical)
) {
markersToRemove.push(String(id)); markersToRemove.push(String(id));
} }
}); });
@ -1192,11 +1263,15 @@ let MapAPRSMap = {
station_lng: number; station_lng: number;
path_stations: Array<{ callsign: string; lat: number; lng: number }>; path_stations: Array<{ callsign: string; lat: number; lng: number }>;
}) => { }) => {
if (!self.map || !data.path_stations || data.path_stations.length === 0) return; if (!self.map || !data.path_stations || data.path_stations.length === 0)
return;
// Validate initial station coordinates // Validate initial station coordinates
if (!isFinite(data.station_lat) || !isFinite(data.station_lng)) { if (!isFinite(data.station_lat) || !isFinite(data.station_lng)) {
console.warn("Invalid initial station coordinates for RF path:", { lat: data.station_lat, lng: data.station_lng }); console.warn("Invalid initial station coordinates for RF path:", {
lat: data.station_lat,
lng: data.station_lng,
});
return; return;
} }
@ -1212,8 +1287,17 @@ let MapAPRSMap = {
data.path_stations.forEach((station, index) => { data.path_stations.forEach((station, index) => {
// Validate coordinates before drawing // Validate coordinates before drawing
if (!isFinite(prevLat) || !isFinite(prevLng) || !isFinite(station.lat) || !isFinite(station.lng)) { if (
console.warn("Invalid coordinates for RF path:", { prevLat, prevLng, station }); !isFinite(prevLat) ||
!isFinite(prevLng) ||
!isFinite(station.lat) ||
!isFinite(station.lng)
) {
console.warn("Invalid coordinates for RF path:", {
prevLat,
prevLng,
station,
});
return; return;
} }
@ -1227,7 +1311,7 @@ let MapAPRSMap = {
color: "#FF6B6B", color: "#FF6B6B",
weight: 3, weight: 3,
opacity: 0.8, opacity: 0.8,
dashArray: index === 0 ? null : "5, 10", // Solid line for first hop, dashed for subsequent dashArray: index === 0 ? undefined : "5, 10", // Solid line for first hop, dashed for subsequent
}, },
); );
@ -1266,16 +1350,19 @@ let MapAPRSMap = {
}); });
// Handle bounds-based marker filtering // Handle bounds-based marker filtering
self.handleEvent("filter_markers_by_bounds", (data: { bounds: BoundsData }) => { self.handleEvent(
if (data.bounds) { "filter_markers_by_bounds",
// Create Leaflet bounds object from server data (data: { bounds: BoundsData }) => {
const bounds = L.latLngBounds( if (data.bounds) {
[data.bounds.south, data.bounds.west], // Create Leaflet bounds object from server data
[data.bounds.north, data.bounds.east], const bounds = L.latLngBounds(
); [data.bounds.south, data.bounds.west],
self.removeMarkersOutsideBounds(bounds); [data.bounds.north, data.bounds.east],
} );
}); self.removeMarkersOutsideBounds(bounds);
}
},
);
// Handle clearing all markers and reloading visible ones // Handle clearing all markers and reloading visible ones
self.handleEvent("clear_and_reload_markers", () => { self.handleEvent("clear_and_reload_markers", () => {
@ -1373,7 +1460,12 @@ let MapAPRSMap = {
sendBoundsToServer() { sendBoundsToServer() {
const self = this as unknown as LiveViewHookContext; const self = this as unknown as LiveViewHookContext;
console.log("sendBoundsToServer called, map:", !!self.map, "isDestroyed:", self.isDestroyed); console.log(
"sendBoundsToServer called, map:",
!!self.map,
"isDestroyed:",
self.isDestroyed,
);
if (!self.map || self.isDestroyed) return; if (!self.map || self.isDestroyed) return;
try { try {
@ -1411,7 +1503,14 @@ let MapAPRSMap = {
addMarker(data: MarkerData & { openPopup?: boolean }) { addMarker(data: MarkerData & { openPopup?: boolean }) {
const self = this as unknown as LiveViewHookContext; const self = this as unknown as LiveViewHookContext;
const L = window.L; const L = window.L;
if (!data || !data.id || !data.lat || !data.lng || typeof data.lat !== 'number' || typeof data.lng !== 'number') { if (
!data ||
!data.id ||
!data.lat ||
!data.lng ||
typeof data.lat !== "number" ||
typeof data.lng !== "number"
) {
console.warn("Invalid marker data:", data); console.warn("Invalid marker data:", data);
return; return;
} }
@ -1423,7 +1522,11 @@ let MapAPRSMap = {
} }
// Additional check to ensure map is fully ready // Additional check to ensure map is fully ready
if (!self.map || !self.map._container || typeof self.map.getZoom !== 'function') { if (
!self.map ||
!self.map._container ||
typeof self.map.getZoom !== "function"
) {
console.warn("Map not fully initialized, queueing marker:", data.id); console.warn("Map not fully initialized, queueing marker:", data.id);
if (!self.pendingMarkers) { if (!self.pendingMarkers) {
self.pendingMarkers = []; self.pendingMarkers = [];
@ -1438,7 +1541,14 @@ let MapAPRSMap = {
// Validate coordinates // Validate coordinates
if (!isValidCoordinate(lat, lng)) { if (!isValidCoordinate(lat, lng)) {
console.warn("Invalid coordinates for marker:", { id: data.id, lat, lng, callsign: data.callsign, rawLat: data.lat, rawLng: data.lng }); console.warn("Invalid coordinates for marker:", {
id: data.id,
lat,
lng,
callsign: data.callsign,
rawLat: data.lat,
rawLng: data.lng,
});
return; return;
} }
@ -1450,7 +1560,8 @@ let MapAPRSMap = {
// Check if marker needs updating // Check if marker needs updating
const currentPos = existingMarker.getLatLng(); const currentPos = existingMarker.getLatLng();
const positionChanged = const positionChanged =
Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001; Math.abs(currentPos.lat - lat) > 0.0001 ||
Math.abs(currentPos.lng - lng) > 0.0001;
const dataChanged = const dataChanged =
existingState.symbol_table !== data.symbol_table_id || existingState.symbol_table !== data.symbol_table_id ||
existingState.symbol_code !== data.symbol_code || existingState.symbol_code !== data.symbol_code ||
@ -1458,12 +1569,19 @@ let MapAPRSMap = {
if (positionChanged && self.trailManager) { if (positionChanged && self.trailManager) {
// Position changed, update trail // Position changed, update trail
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; const isHistoricalDot =
data.historical && !data.is_most_recent_for_callsign;
const timestamp = parseTimestamp(data.timestamp); const timestamp = parseTimestamp(data.timestamp);
// Use callsign_group for proper trail grouping // Use callsign_group for proper trail grouping
const trailId = getTrailId(data); const trailId = getTrailId(data);
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); self.trailManager.addPosition(
trailId,
lat,
lng,
timestamp,
isHistoricalDot,
);
} }
if (!positionChanged && !dataChanged) { if (!positionChanged && !dataChanged) {
@ -1491,7 +1609,11 @@ let MapAPRSMap = {
// Handle popup close events - check if hook is still connected // Handle popup close events - check if hook is still connected
marker.on("popupclose", () => { marker.on("popupclose", () => {
// Only send event if not destroyed and pushEvent is still the original function // Only send event if not destroyed and pushEvent is still the original function
if (!self.isDestroyed && self.pushEvent && typeof self.pushEvent === "function") { if (
!self.isDestroyed &&
self.pushEvent &&
typeof self.pushEvent === "function"
) {
try { try {
self.pushEvent("popup_closed", {}); self.pushEvent("popup_closed", {});
} catch (e) { } catch (e) {
@ -1522,7 +1644,10 @@ let MapAPRSMap = {
// Find the highest z-index among all markers // Find the highest z-index among all markers
let maxZIndex = 1000; let maxZIndex = 1000;
document.querySelectorAll(".leaflet-marker-icon").forEach((el) => { document.querySelectorAll(".leaflet-marker-icon").forEach((el) => {
const zIndex = parseInt((el as HTMLElement).style.zIndex || "0", 10); const zIndex = parseInt(
(el as HTMLElement).style.zIndex || "0",
10,
);
if (zIndex > maxZIndex) maxZIndex = zIndex; if (zIndex > maxZIndex) maxZIndex = zIndex;
}); });
@ -1532,7 +1657,11 @@ let MapAPRSMap = {
} }
// Use bound pushEvent function to preserve context // Use bound pushEvent function to preserve context
if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) { if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
safePushEvent(self.pushEvent.bind(self), "marker_clicked", { safePushEvent(self.pushEvent.bind(self), "marker_clicked", {
id: data.id, id: data.id,
callsign: data.callsign, callsign: data.callsign,
@ -1547,7 +1676,11 @@ let MapAPRSMap = {
if (data.path && data.path.trim() !== "" && !data.path.includes("TCPIP")) { if (data.path && data.path.trim() !== "" && !data.path.includes("TCPIP")) {
marker.on("mouseover", () => { marker.on("mouseover", () => {
// Check if LiveView is still connected before sending event // Check if LiveView is still connected before sending event
if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) { if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
try { try {
self.pushEvent.call(self, "marker_hover_start", { self.pushEvent.call(self, "marker_hover_start", {
id: data.id, id: data.id,
@ -1566,7 +1699,11 @@ let MapAPRSMap = {
marker.on("mouseout", () => { marker.on("mouseout", () => {
// Check if LiveView is still connected before sending event // Check if LiveView is still connected before sending event
if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) { if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
try { try {
self.pushEvent.call(self, "marker_hover_end", { self.pushEvent.call(self, "marker_hover_end", {
id: data.id, id: data.id,
@ -1609,12 +1746,19 @@ let MapAPRSMap = {
// Initialize trail for new marker - always add to trail for line drawing // Initialize trail for new marker - always add to trail for line drawing
if (self.trailManager) { if (self.trailManager) {
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; const isHistoricalDot =
data.historical && !data.is_most_recent_for_callsign;
const timestamp = parseTimestamp(data.timestamp); const timestamp = parseTimestamp(data.timestamp);
// Use callsign_group for proper trail grouping // Use callsign_group for proper trail grouping
const trailId = getTrailId(data); const trailId = getTrailId(data);
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); self.trailManager.addPosition(
trailId,
lat,
lng,
timestamp,
isHistoricalDot,
);
} }
// Open popup if requested // Open popup if requested
@ -1624,9 +1768,17 @@ let MapAPRSMap = {
// Add to OMS for overlapping marker handling (only most recent packets with icons) // Add to OMS for overlapping marker handling (only most recent packets with icons)
// Fallback: if is_most_recent_for_callsign is undefined, exclude historical markers as before // Fallback: if is_most_recent_for_callsign is undefined, exclude historical markers as before
const shouldAddToOms = data.is_most_recent_for_callsign === true || const shouldAddToOms =
(data.is_most_recent_for_callsign == null && !(marker as APRSMarker)._isHistorical); data.is_most_recent_for_callsign === true ||
if (self.oms && marker && self.map && !marker._isClusterMarker && shouldAddToOms) { (data.is_most_recent_for_callsign == null &&
!(marker as APRSMarker)._isHistorical);
if (
self.oms &&
marker &&
self.map &&
!marker._isClusterMarker &&
shouldAddToOms
) {
self.oms.addMarker(marker); self.oms.addMarker(marker);
} }
}, },
@ -1642,12 +1794,21 @@ let MapAPRSMap = {
if (marker) { if (marker) {
try { try {
// Remove marker from appropriate layer with safety checks // Remove marker from appropriate layer with safety checks
if (self.markerClusterGroup && self.markerClusterGroup.hasLayer(marker)) { if (
self.markerClusterGroup &&
self.markerClusterGroup.hasLayer(marker)
) {
// Check if cluster group is ready before removing // Check if cluster group is ready before removing
if (self.markerClusterGroup._map && self.markerClusterGroup._topClusterLevel) { if (
self.markerClusterGroup._map &&
self.markerClusterGroup._topClusterLevel
) {
self.markerClusterGroup.removeLayer(marker); self.markerClusterGroup.removeLayer(marker);
} else { } else {
console.warn("Cluster group not ready, skipping marker removal:", markerId); console.warn(
"Cluster group not ready, skipping marker removal:",
markerId,
);
} }
} else if (self.markerLayer && self.markerLayer.hasLayer(marker)) { } else if (self.markerLayer && self.markerLayer.hasLayer(marker)) {
self.markerLayer.removeLayer(marker); self.markerLayer.removeLayer(marker);
@ -1665,7 +1826,8 @@ let MapAPRSMap = {
// Remove trail - use callsign_group for proper trail identification // Remove trail - use callsign_group for proper trail identification
if (self.trailManager) { if (self.trailManager) {
const trailId = markerState?.callsign_group || markerState?.callsign || markerId; const trailId =
markerState?.callsign_group || markerState?.callsign || markerId;
self.trailManager.removeTrail(trailId); self.trailManager.removeTrail(trailId);
} }
@ -1691,16 +1853,24 @@ let MapAPRSMap = {
if (self.isValidCoordinate(lat, lng)) { if (self.isValidCoordinate(lat, lng)) {
const currentPos = existingMarker.getLatLng(); const currentPos = existingMarker.getLatLng();
const positionChanged = const positionChanged =
Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001; Math.abs(currentPos.lat - lat) > 0.0001 ||
Math.abs(currentPos.lng - lng) > 0.0001;
if (positionChanged) { if (positionChanged) {
existingMarker.setLatLng([lat, lng]); existingMarker.setLatLng([lat, lng]);
if (self.trailManager) { if (self.trailManager) {
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; const isHistoricalDot =
data.historical && !data.is_most_recent_for_callsign;
const timestamp = parseTimestamp(data.timestamp); const timestamp = parseTimestamp(data.timestamp);
// Use callsign_group for proper trail grouping // Use callsign_group for proper trail grouping
const trailId = getTrailId(data); const trailId = getTrailId(data);
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); self.trailManager.addPosition(
trailId,
lat,
lng,
timestamp,
isHistoricalDot,
);
} }
} }
} }
@ -1733,8 +1903,10 @@ let MapAPRSMap = {
self.markers!.forEach((marker, id) => { self.markers!.forEach((marker, id) => {
const markerState = self.markerStates!.get(String(id)); const markerState = self.markerStates!.get(String(id));
const isHistorical = const isHistorical =
(marker as APRSMarker)._isHistorical || (markerState && markerState.historical); (marker as APRSMarker)._isHistorical ||
const isMostRecent = markerState && markerState.is_most_recent_for_callsign; (markerState && markerState.historical);
const isMostRecent =
markerState && markerState.is_most_recent_for_callsign;
// Keep historical markers and current position markers // Keep historical markers and current position markers
if (isHistorical || isMostRecent) { if (isHistorical || isMostRecent) {
@ -1781,8 +1953,10 @@ let MapAPRSMap = {
// Check if this is a historical marker or the most recent marker for a callsign // Check if this is a historical marker or the most recent marker for a callsign
const markerState = self.markerStates!.get(String(id)); const markerState = self.markerStates!.get(String(id));
const isHistorical = const isHistorical =
(marker as APRSMarker)._isHistorical || (markerState && markerState.historical); (marker as APRSMarker)._isHistorical ||
const isMostRecent = markerState && markerState.is_most_recent_for_callsign; (markerState && markerState.historical);
const isMostRecent =
markerState && markerState.is_most_recent_for_callsign;
// Always preserve historical markers and the most recent marker for a callsign // Always preserve historical markers and the most recent marker for a callsign
if (isHistorical || isMostRecent) { if (isHistorical || isMostRecent) {
@ -1842,12 +2016,21 @@ let MapAPRSMap = {
if (marker) { if (marker) {
try { try {
// Remove marker from appropriate layer with safety checks // Remove marker from appropriate layer with safety checks
if (self.markerClusterGroup && self.markerClusterGroup.hasLayer(marker)) { if (
self.markerClusterGroup &&
self.markerClusterGroup.hasLayer(marker)
) {
// Check if cluster group is ready before removing // Check if cluster group is ready before removing
if (self.markerClusterGroup._map && self.markerClusterGroup._topClusterLevel) { if (
self.markerClusterGroup._map &&
self.markerClusterGroup._topClusterLevel
) {
self.markerClusterGroup.removeLayer(marker); self.markerClusterGroup.removeLayer(marker);
} else { } else {
console.warn("Cluster group not ready, skipping marker removal:", markerId); console.warn(
"Cluster group not ready, skipping marker removal:",
markerId,
);
} }
} else if (self.markerLayer && self.markerLayer.hasLayer(marker)) { } else if (self.markerLayer && self.markerLayer.hasLayer(marker)) {
self.markerLayer.removeLayer(marker); self.markerLayer.removeLayer(marker);
@ -2122,12 +2305,12 @@ function extractCoordinate(value: any): number {
} }
// Handle numbers // Handle numbers
if (typeof value === 'number') { if (typeof value === "number") {
return value; return value;
} }
// Handle strings // Handle strings
if (typeof value === 'string') { if (typeof value === "string") {
return parseFloat(value); return parseFloat(value);
} }
@ -2136,15 +2319,27 @@ function extractCoordinate(value: any): number {
// Helper to validate coordinates // Helper to validate coordinates
function isValidCoordinate(lat: number, lng: number): boolean { function isValidCoordinate(lat: number, lng: number): boolean {
return !isNaN(lat) && !isNaN(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; return (
!isNaN(lat) &&
!isNaN(lng) &&
isFinite(lat) &&
isFinite(lng) &&
lat >= -90 &&
lat <= 90 &&
lng >= -180 &&
lng <= 180
);
} }
// Helper to create divIcon with common defaults // Helper to create divIcon with common defaults
function createDivIcon(html: string, options: Partial<{ function createDivIcon(
className: string; html: string,
iconSize: [number, number]; options: Partial<{
iconAnchor: [number, number]; className: string;
}> = {}) { iconSize: [number, number];
iconAnchor: [number, number];
}> = {},
) {
return L.divIcon({ return L.divIcon({
html, html,
className: options.className || "", className: options.className || "",