From ff06c13224cf0b9d6e0274d47a67eae3e563d530 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 9 Feb 2026 11:16:04 -0600 Subject: [PATCH] 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 --- .sobelow-skips | 17 + assets/js/features/trail_manager.ts | 183 +++++--- assets/js/features/weather_charts.ts | 621 +++++++++++++------------- assets/js/map.ts | 627 ++++++++++++++++++--------- 4 files changed, 873 insertions(+), 575 deletions(-) create mode 100644 .sobelow-skips diff --git a/.sobelow-skips b/.sobelow-skips new file mode 100644 index 0000000..5fd2ef2 --- /dev/null +++ b/.sobelow-skips @@ -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 \ No newline at end of file diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts index 20d1be6..1e17aeb 100644 --- a/assets/js/features/trail_manager.ts +++ b/assets/js/features/trail_manager.ts @@ -1,9 +1,14 @@ // 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 const L: typeof import('leaflet'); +declare const L: typeof import("leaflet"); export interface PositionHistory { lat: number; @@ -26,23 +31,23 @@ export class TrailManager { private maxTrails: number = 500; // Maximum number of trails to keep in memory private maxPositionsPerTrail: number = 1000; // Maximum positions per trail private colorPalette: string[] = [ - '#1E90FF', // Dodger Blue - '#00CED1', // Dark Turquoise - '#32CD32', // Lime Green - '#8B008B', // Dark Magenta - '#9370DB', // Medium Purple - '#FF8C00', // Dark Orange - '#4682B4', // Steel Blue - '#00FA9A', // Medium Spring Green - '#DA70D6', // Orchid - '#008B8B', // Dark Cyan - '#48D1CC', // Medium Turquoise - '#228B22', // Forest Green - '#6495ED', // Cornflower Blue - '#FF1493', // Deep Pink (distinct from highways) - '#20B2AA', // Light Sea Green + "#1E90FF", // Dodger Blue + "#00CED1", // Dark Turquoise + "#32CD32", // Lime Green + "#8B008B", // Dark Magenta + "#9370DB", // Medium Purple + "#FF8C00", // Dark Orange + "#4682B4", // Steel Blue + "#00FA9A", // Medium Spring Green + "#DA70D6", // Orchid + "#008B8B", // Dark Cyan + "#48D1CC", // Medium Turquoise + "#228B22", // Forest Green + "#6495ED", // Cornflower Blue + "#FF1493", // Deep Pink (distinct from highways) + "#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) { this.trailLayer = trailLayer; @@ -80,17 +85,20 @@ export class TrailManager { setTrailDuration(durationHours: number) { this.trailDuration = durationHours * 60 * 60 * 1000; // Convert hours to milliseconds - + // Clean up existing trails based on new duration const cutoffTime = Date.now() - this.trailDuration; - + this.trails.forEach((trailState, baseCallsign) => { // Filter positions based on new duration (skip historical dots) 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; }); - + // Update trail visualization this.updateTrailVisualization(baseCallsign, trailState, true); }); @@ -107,8 +115,8 @@ export class TrailManager { // Validate coordinates before processing if ( - typeof lat !== 'number' || - typeof lng !== 'number' || + typeof lat !== "number" || + typeof lng !== "number" || isNaN(lat) || isNaN(lng) || !isFinite(lat) || @@ -118,7 +126,12 @@ export class TrailManager { 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; } @@ -153,11 +166,13 @@ export class TrailManager { // Sort positions by timestamp to maintain chronological order trailState.positions.sort((a, b) => a.timestamp - b.timestamp); - + // Limit the number of positions per trail if (trailState.positions.length > this.maxPositionsPerTrail) { // 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) => { // Ensure timestamp is a number for comparison 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; }); } @@ -194,64 +211,94 @@ export class TrailManager { } // 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 dLat = (lat2 - lat1) * Math.PI / 180; - const dLng = (lng2 - lng1) * Math.PI / 180; - const a = Math.sin(dLat/2) * Math.sin(dLat/2) + - Math.cos(lat1 * Math.PI / 180) * 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)); + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + 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; } // 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 }; - - const sum = positions.reduce((acc, pos) => ({ - lat: acc.lat + pos.lat, - lng: acc.lng + pos.lng - }), { lat: 0, lng: 0 }); - + + const sum = positions.reduce( + (acc, pos) => ({ + lat: acc.lat + pos.lat, + lng: acc.lng + pos.lng, + }), + { lat: 0, lng: 0 }, + ); + return { lat: sum.lat / positions.length, - lng: sum.lng / positions.length + lng: sum.lng / positions.length, }; } // Find nearby trails and get their colors - private getNearbyTrailColors(baseCallsign: string, center: { lat: number, lng: number }): Set { + private getNearbyTrailColors( + baseCallsign: string, + center: { lat: number; lng: number }, + ): Set { const nearbyColors = new Set(); - + 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 distance = this.calculateDistance(center.lat, center.lng, otherCenter.lat, otherCenter.lng); - - if (distance < this.proximityThreshold * 111) { // Convert degrees to km (rough approximation) + const distance = this.calculateDistance( + center.lat, + center.lng, + otherCenter.lat, + otherCenter.lng, + ); + + if (distance < this.proximityThreshold) { nearbyColors.add(trailState.color); } }); - + return nearbyColors; } // 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]; - + const center = this.getTrailCenter(positions); const nearbyColors = this.getNearbyTrailColors(baseCallsign, center); - + // Find the first available color not used by nearby trails for (const color of this.colorPalette) { if (!nearbyColors.has(color)) { return color; } } - + // If all colors are used, return the first color return this.colorPalette[0]; } @@ -278,8 +325,8 @@ export class TrailManager { // Validate coordinates are finite numbers within valid ranges return ( pos && - typeof pos.lat === 'number' && - typeof pos.lng === 'number' && + typeof pos.lat === "number" && + typeof pos.lng === "number" && !isNaN(pos.lat) && !isNaN(pos.lng) && isFinite(pos.lat) && @@ -296,7 +343,10 @@ export class TrailManager { if (latLngs.length >= 2) { // Assign color if not already assigned if (!trailState.color) { - trailState.color = this.assignTrailColor(baseCallsign, trailState.positions); + trailState.color = this.assignTrailColor( + baseCallsign, + trailState.positions, + ); } // Create polyline with assigned color @@ -310,11 +360,18 @@ export class TrailManager { lineJoin: "round", className: "historical-trail-line", }; - + try { - trailState.trail = L.polyline(latLngs, polylineOptions).addTo(this.trailLayer); + trailState.trail = L.polyline(latLngs, polylineOptions).addTo( + this.trailLayer, + ); } 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); } } @@ -352,7 +409,9 @@ export class TrailManager { trailState.positions = trailState.positions.filter((pos) => { // Ensure timestamp is a number for comparison 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) return posTimestamp >= veryOldCutoff; }); diff --git a/assets/js/features/weather_charts.ts b/assets/js/features/weather_charts.ts index 75d20f5..28ea905 100644 --- a/assets/js/features/weather_charts.ts +++ b/assets/js/features/weather_charts.ts @@ -1,356 +1,383 @@ // Chart.js and date adapter are loaded globally from vendor bundle // We'll access it later when it's actually loaded -import type { ChartConfiguration, ChartType } from 'chart.js'; -import type { WeatherChartDataset, YAxisOptions } from '../types/chart-types'; -import type { HandleEventFunction } from '../types/events'; +import type { ChartConfiguration, ChartType } from "chart.js"; +import type { WeatherChartDataset, YAxisOptions } from "../types/chart-types"; +import type { HandleEventFunction } from "../types/events"; // Declare global Chart object declare global { - interface Window { - Chart: typeof Chart; - chartInstances?: Map; - } + interface Window { + Chart: typeof Chart; + chartInstances?: Map; + } } // Type for LiveView hooks interface Hook { - mounted?: () => void; - updated?: () => void; - destroyed?: () => void; - el: HTMLElement; - handleEvent: HandleEventFunction; + mounted?: () => void; + updated?: () => void; + destroyed?: () => void; + el: HTMLElement; + handleEvent: HandleEventFunction; } // Define chart hook context type interface ChartHookContext extends Hook { - chart?: Chart; - themeChangeHandler?: () => void; - renderChart: () => void; + chart?: Chart; + themeChangeHandler?: () => void; + renderChart: () => void; } // Type for weather history data interface WeatherHistoryDatum { - timestamp: string; - temperature?: number; - dew_point?: number; - humidity?: number; - pressure?: number; - wind_direction?: number; - wind_speed?: number; - wind_gust?: number; - rain_1h?: number; - rain_24h?: number; - rain_since_midnight?: number; - luminosity?: number; + timestamp: string; + temperature?: number; + dew_point?: number; + humidity?: number; + pressure?: number; + wind_direction?: number; + wind_speed?: number; + wind_gust?: number; + rain_1h?: number; + rain_24h?: number; + rain_since_midnight?: number; + luminosity?: number; } // Type for the event payload interface UpdateWeatherChartsPayload { - weather_history: string; + weather_history: string; } // Helper function to safely parse weather history data -const parseWeatherHistory = (dataStr: string | undefined): WeatherHistoryDatum[] => { - if (!dataStr) { - console.warn("No weather history data provided"); - return []; - } - try { - return JSON.parse(dataStr); - } catch (error) { - console.error("Failed to parse weather history data:", error); - return []; - } +const parseWeatherHistory = ( + dataStr: string | undefined, +): WeatherHistoryDatum[] => { + if (!dataStr) { + console.warn("No weather history data provided"); + return []; + } + try { + return JSON.parse(dataStr); + } catch (error) { + console.error("Failed to parse weather history data:", error); + return []; + } }; // Helper function to get theme-aware colors const getThemeColors = () => { - const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; - return { - text: isDark ? '#e5e7eb' : '#111827', - grid: isDark ? '#374151' : '#9ca3af', - background: isDark ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)' - }; + const isDark = document.documentElement.getAttribute("data-theme") === "dark"; + return { + text: isDark ? "#e5e7eb" : "#111827", + grid: isDark ? "#374151" : "#9ca3af", + background: isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.1)", + }; }; // Register a chart instance -const registerChartInstance = (element: HTMLElement, instance: ChartHookContext) => { - if (!window.chartInstances) { - window.chartInstances = new Map(); - } - const elementId = element.id || `chart-${Date.now()}`; - if (!element.id) element.id = elementId; - window.chartInstances.set(elementId, instance); +const registerChartInstance = ( + element: HTMLElement, + instance: ChartHookContext, +) => { + if (!window.chartInstances) { + window.chartInstances = new Map(); + } + const elementId = element.id || `chart-${Date.now()}`; + if (!element.id) element.id = elementId; + window.chartInstances.set(elementId, instance); }; // Unregister a chart instance const unregisterChartInstance = (element: HTMLElement) => { - if (window.chartInstances && element.id) { - const instance = window.chartInstances.get(element.id); - if (instance?.chart) { - instance.chart.destroy(); - } - window.chartInstances.delete(element.id); + if (window.chartInstances && element.id) { + const instance = window.chartInstances.get(element.id); + if (instance?.chart) { + instance.chart.destroy(); } + window.chartInstances.delete(element.id); + } }; // Get labels from the element const getLabels = (el: HTMLElement | null): Record => { - if (!el || !el.dataset.labels) return {}; - const raw = el.dataset.labels; - if (!raw) return {}; - try { - return JSON.parse(raw); - } catch { - return {}; - } -} + if (!el || !el.dataset.labels) return {}; + const raw = el.dataset.labels; + if (!raw) return {}; + try { + return JSON.parse(raw); + } catch { + return {}; + } +}; // Chart configurations interface ChartConfig { - type: ChartType; - datasets: (data: WeatherHistoryDatum[], labels: Record) => WeatherChartDataset[]; - title: (labels: Record) => string; - yAxisLabel?: (labels: Record) => string; - yAxisOptions?: YAxisOptions; + type: ChartType; + datasets: ( + data: WeatherHistoryDatum[], + labels: Record, + ) => WeatherChartDataset[]; + title: (labels: Record) => string; + yAxisLabel?: (labels: Record) => string; + yAxisOptions?: YAxisOptions; } const chartConfigs: Record = { - temperature: { - type: 'line', - datasets: (data, labels) => [ - { - label: labels.temp_label || 'Temperature (°F)', - data: data.map(d => d.temperature), - borderColor: 'red', - backgroundColor: 'rgba(255, 0, 0, 0.1)', - tension: 0.1, - pointRadius: 0 - }, - { - label: labels.dew_label || 'Dew Point (°F)', - data: data.map(d => d.dew_point), - borderColor: 'blue', - backgroundColor: 'rgba(0, 0, 255, 0.1)', - tension: 0.1, - pointRadius: 0 - } - ], - title: (labels) => labels.temp_title || 'Temperature & Dew Point (°F)', - yAxisLabel: (labels) => labels.degf || '°F' - }, - humidity: { - type: 'line', - datasets: (data, labels) => [{ - label: labels.hum_label || 'Humidity (%)', - data: data.map(d => d.humidity), - borderColor: 'green', - backgroundColor: 'rgba(0, 255, 0, 0.1)', - tension: 0.1, - pointRadius: 0 - }], - title: (labels) => labels.hum_title || 'Humidity (%)', - yAxisLabel: (labels) => labels.percent || '%', - yAxisOptions: { min: 0, max: 100 } - }, - pressure: { - type: 'line', - datasets: (data, labels) => [{ - label: labels.prs_label || 'Pressure (mb)', - data: data.map(d => d.pressure), - borderColor: 'purple', - backgroundColor: 'rgba(128, 0, 128, 0.1)', - tension: 0.1, - pointRadius: 0 - }], - title: (labels) => labels.prs_title || 'Barometric Pressure (mb)', - yAxisLabel: (labels) => labels.mb || 'mb' - }, - wind: { - type: 'line', - datasets: (data, labels) => [ - { - label: labels.spd_label || 'Wind Speed (mph)', - data: data.map(d => d.wind_speed), - borderColor: 'orange', - backgroundColor: 'rgba(255, 165, 0, 0.1)', - tension: 0.1, - pointRadius: 0 - }, - { - label: labels.gst_label || 'Wind Gust (mph)', - data: data.map(d => d.wind_gust), - borderColor: 'red', - backgroundColor: 'rgba(255, 0, 0, 0.1)', - tension: 0.1, - pointRadius: 0 - } - ], - title: (labels) => labels.wnd_title || 'Wind Speed & Gust (mph)', - yAxisLabel: (labels) => labels.mph || 'mph', - yAxisOptions: { min: 0 } - }, - rain: { - type: 'bar', - datasets: (data, labels) => [ - { - label: labels.h1_label || 'Rain 1h (in)', - data: data.map(d => d.rain_1h), - backgroundColor: 'rgba(54, 162, 235, 0.8)', - borderColor: 'rgba(54, 162, 235, 1)', - borderWidth: 1 - }, - { - label: labels.h24_label || 'Rain 24h (in)', - data: data.map(d => d.rain_24h ? d.rain_24h / 10 : null), - backgroundColor: 'rgba(153, 102, 255, 0.8)', - borderColor: 'rgba(153, 102, 255, 1)', - borderWidth: 1 - } - ], - title: (labels) => labels.rain_title || 'Rainfall (inches)', - yAxisLabel: (labels) => labels.inches || 'inches', - yAxisOptions: { min: 0 } - }, - luminosity: { - type: 'line', - datasets: (data, labels) => [{ - label: labels.lum_label || 'Luminosity (W/m²)', - data: data.map(d => d.luminosity), - borderColor: 'gold', - backgroundColor: 'rgba(255, 215, 0, 0.1)', - tension: 0.1, - pointRadius: 0 - }], - title: (labels) => labels.lum_title || 'Solar Radiation (W/m²)', - yAxisLabel: (labels) => labels.wm2 || 'W/m²', - yAxisOptions: { min: 0 } - } + temperature: { + type: "line", + datasets: (data, labels) => [ + { + label: labels.temp_label || "Temperature (°F)", + data: data.map((d) => d.temperature), + borderColor: "red", + backgroundColor: "rgba(255, 0, 0, 0.1)", + tension: 0.1, + pointRadius: 0, + }, + { + label: labels.dew_label || "Dew Point (°F)", + data: data.map((d) => d.dew_point), + borderColor: "blue", + backgroundColor: "rgba(0, 0, 255, 0.1)", + tension: 0.1, + pointRadius: 0, + }, + ], + title: (labels) => labels.temp_title || "Temperature & Dew Point (°F)", + yAxisLabel: (labels) => labels.degf || "°F", + }, + humidity: { + type: "line", + datasets: (data, labels) => [ + { + label: labels.hum_label || "Humidity (%)", + data: data.map((d) => d.humidity), + borderColor: "green", + backgroundColor: "rgba(0, 255, 0, 0.1)", + tension: 0.1, + pointRadius: 0, + }, + ], + title: (labels) => labels.hum_title || "Humidity (%)", + yAxisLabel: (labels) => labels.percent || "%", + yAxisOptions: { min: 0, max: 100 }, + }, + pressure: { + type: "line", + datasets: (data, labels) => [ + { + label: labels.prs_label || "Pressure (mb)", + data: data.map((d) => d.pressure), + borderColor: "purple", + backgroundColor: "rgba(128, 0, 128, 0.1)", + tension: 0.1, + pointRadius: 0, + }, + ], + title: (labels) => labels.prs_title || "Barometric Pressure (mb)", + yAxisLabel: (labels) => labels.mb || "mb", + }, + wind: { + type: "line", + datasets: (data, labels) => [ + { + label: labels.spd_label || "Wind Speed (mph)", + data: data.map((d) => d.wind_speed), + borderColor: "orange", + backgroundColor: "rgba(255, 165, 0, 0.1)", + tension: 0.1, + pointRadius: 0, + }, + { + label: labels.gst_label || "Wind Gust (mph)", + data: data.map((d) => d.wind_gust), + borderColor: "red", + backgroundColor: "rgba(255, 0, 0, 0.1)", + tension: 0.1, + pointRadius: 0, + }, + ], + title: (labels) => labels.wnd_title || "Wind Speed & Gust (mph)", + yAxisLabel: (labels) => labels.mph || "mph", + yAxisOptions: { min: 0 }, + }, + rain: { + type: "bar", + datasets: (data, labels) => [ + { + label: labels.h1_label || "Rain 1h (in)", + data: data.map((d) => d.rain_1h), + backgroundColor: "rgba(54, 162, 235, 0.8)", + borderColor: "rgba(54, 162, 235, 1)", + borderWidth: 1, + }, + { + label: labels.h24_label || "Rain 24h (in)", + data: data.map((d) => d.rain_24h), + backgroundColor: "rgba(153, 102, 255, 0.8)", + borderColor: "rgba(153, 102, 255, 1)", + borderWidth: 1, + }, + ], + title: (labels) => labels.rain_title || "Rainfall (inches)", + yAxisLabel: (labels) => labels.inches || "inches", + yAxisOptions: { min: 0 }, + }, + luminosity: { + type: "line", + datasets: (data, labels) => [ + { + label: labels.lum_label || "Luminosity (W/m²)", + data: data.map((d) => d.luminosity), + borderColor: "gold", + backgroundColor: "rgba(255, 215, 0, 0.1)", + 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 function createChartHook(configKey: string): Hook { - const config = chartConfigs[configKey]; - if (!config) { - throw new Error(`Unknown chart config: ${configKey}`); - } + const config = chartConfigs[configKey]; + if (!config) { + throw new Error(`Unknown chart config: ${configKey}`); + } - return { - mounted() { - const self = this as ChartHookContext; - registerChartInstance(self.el, self); - self.renderChart = () => { - if (self.chart) self.chart.destroy(); - - const data: WeatherHistoryDatum[] = parseWeatherHistory(self.el.dataset.weatherHistory); - 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); + return { + mounted() { + const self = this as ChartHookContext; + registerChartInstance(self.el, self); + self.renderChart = () => { + if (self.chart) self.chart.destroy(); + + const data: WeatherHistoryDatum[] = parseWeatherHistory( + self.el.dataset.weatherHistory, + ); + 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); + }, + }; } // Export weather chart hooks export const WeatherChartHooks: Record = { - ChartJSTempChart: createChartHook('temperature'), - ChartJSHumidityChart: createChartHook('humidity'), - ChartJSPressureChart: createChartHook('pressure'), - ChartJSWindChart: createChartHook('wind'), - ChartJSRainChart: createChartHook('rain'), - ChartJSLuminosityChart: createChartHook('luminosity') + ChartJSTempChart: createChartHook("temperature"), + ChartJSHumidityChart: createChartHook("humidity"), + ChartJSPressureChart: createChartHook("pressure"), + ChartJSWindChart: createChartHook("wind"), + ChartJSRainChart: createChartHook("rain"), + ChartJSLuminosityChart: createChartHook("luminosity"), }; -export default WeatherChartHooks; \ No newline at end of file +export default WeatherChartHooks; diff --git a/assets/js/map.ts b/assets/js/map.ts index 5d99e8d..7ee6cce 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -16,7 +16,10 @@ import type { LatLngBounds, Polyline, } from "leaflet"; -import type { LeafletTouchEvent, LeafletPopupEvent } from "./types/leaflet-events"; +import type { + LeafletTouchEvent, + LeafletPopupEvent, +} from "./types/leaflet-events"; import type { HeatLayer, MarkerClusterGroup, @@ -43,8 +46,13 @@ declare global { // Add plugin types to Leaflet declare module "leaflet" { - export function heatLayer(latlngs: HeatLatLng[], options?: HeatLayerOptions): HeatLayer; - export function markerClusterGroup(options?: MarkerClusterGroupOptions): MarkerClusterGroup; + export function heatLayer( + latlngs: HeatLatLng[], + options?: HeatLayerOptions, + ): HeatLayer; + export function markerClusterGroup( + options?: MarkerClusterGroupOptions, + ): MarkerClusterGroup; } // Import trail management functionality @@ -86,11 +94,13 @@ let MapAPRSMap = { setTimeout(() => self.attemptInitialization(), 1000); return; } else { - self.handleFatalError("Leaflet library failed to load after multiple attempts"); + self.handleFatalError( + "Leaflet library failed to load after multiple attempts", + ); return; } } - + // Create a local reference to Leaflet for this function const L = window.L; @@ -102,7 +112,8 @@ let MapAPRSMap = { const centerData = self.el.dataset.center; 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); initialZoom = parseInt(zoomData); @@ -117,7 +128,8 @@ let MapAPRSMap = { // Check if URL has explicit parameters (not default values) 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) { console.error("Error parsing map data attributes:", error); initialCenter = { lat: 39.8283, lng: -98.5795 }; @@ -154,17 +166,17 @@ let MapAPRSMap = { initializeMap(initialCenter: CenterData, initialZoom: number) { const self = this as unknown as LiveViewHookContext; - + // Check if Leaflet is still available if (typeof window.L === "undefined") { console.error("Leaflet library not loaded!"); self.handleFatalError("Leaflet library not available"); return; } - + // Create a local reference to Leaflet for this function const L = window.L; - + // Ensure the element and its parent exist if (!self.el || !self.el.parentNode) { console.warn("Map element or parent not found, retrying..."); @@ -218,9 +230,10 @@ let MapAPRSMap = { } // Detect if mobile device - const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( - navigator.userAgent, - ); + const isMobile = + /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ); self.map = L.map(self.el, { zoomControl: !isMobile, // Hide default zoom control on mobile, we'll add a better one @@ -251,14 +264,17 @@ let MapAPRSMap = { } catch (error) { console.error("Error initializing map:", error); 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!) { setTimeout(() => self.attemptInitialization(), 1000); return; } else { - self.handleFatalError("Map initialization failed after multiple attempts"); + self.handleFatalError( + "Map initialization failed after multiple attempts", + ); return; } } @@ -314,7 +330,8 @@ let MapAPRSMap = { // Exponential backoff setTimeout( () => { - error.tile.src = src + (src.includes("?") ? "&" : "?") + "_retry=" + Date.now(); + error.tile.src = + src + (src.includes("?") ? "&" : "?") + "_retry=" + Date.now(); }, Math.pow(2, count) * 500, ); @@ -326,7 +343,10 @@ let MapAPRSMap = { tileLayer.addTo(self.map); } 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 @@ -404,15 +424,19 @@ let MapAPRSMap = { } catch (error) { console.error("Error invalidating map size:", error); } - + // Helper function to send map ready events with retry 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", {}); if (self.map) { saveMapState(self.map, self.pushEvent.bind(self)); self.sendBoundsToServer(); - + // Send map state update after a delay setTimeout(() => { if (self.map && self.pushEvent && !self.isDestroyed) { @@ -422,7 +446,9 @@ let MapAPRSMap = { } } else if (retryCount < 3) { // 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); } }; @@ -432,10 +458,12 @@ let MapAPRSMap = { try { self.lastZoom = self.map!.getZoom(); self.sendMapReadyEvents(); - + // Process any pending markers that were queued before map was ready 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) => { try { self.addMarker(markerData); @@ -496,7 +524,9 @@ let MapAPRSMap = { if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { 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 if (self.oms) { @@ -506,9 +536,16 @@ let MapAPRSMap = { const markerState = self.markerStates.get(String(id)); // 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 - const shouldAddToOms = markerState?.is_most_recent_for_callsign === true || - (markerState?.is_most_recent_for_callsign == null && !marker._isHistorical); - if (marker && !marker._isClusterMarker && markerState && shouldAddToOms) { + const shouldAddToOms = + markerState?.is_most_recent_for_callsign === true || + (markerState?.is_most_recent_for_callsign == null && + !marker._isHistorical); + if ( + marker && + !marker._isClusterMarker && + markerState && + shouldAddToOms + ) { self.oms.addMarker(marker); } }); @@ -563,15 +600,15 @@ let MapAPRSMap = { // Queue to hold markers that arrive before map is ready self.pendingMarkers = []; - + // LiveView event handlers self.setupLiveViewHandlers(); - + // Process any pending markers once the map is ready self.map.whenReady(() => { if (self.pendingMarkers && self.pendingMarkers.length > 0) { console.log(`Processing ${self.pendingMarkers.length} pending markers`); - self.pendingMarkers.forEach(markerData => { + self.pendingMarkers.forEach((markerData) => { self.addMarker(markerData); }); self.pendingMarkers = []; @@ -589,10 +626,10 @@ let MapAPRSMap = { circleSpiralSwitchover: 15, legWeight: 2, legColors: { - usual: '#222', - highlighted: '#f00' + usual: "#222", + highlighted: "#f00", }, - spiderfyDistanceMultiplier: 3.5 + spiderfyDistanceMultiplier: 3.5, }); // Add click handler for spiderfied markers @@ -656,7 +693,9 @@ let MapAPRSMap = { if (e.touches.length !== 1) return; // Only handle single touch 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 let nearestMarker: APRSMarker | null = null; @@ -797,94 +836,97 @@ let MapAPRSMap = { }); // Zoom to location - self.handleEvent("zoom_to_location", (data: { lat: number; lng: number; zoom?: number }) => { - if (!self.map) { - console.error("Map not initialized, cannot zoom"); - return; - } - - 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); + self.handleEvent( + "zoom_to_location", + (data: { lat: number; lng: number; zoom?: number }) => { + if (!self.map) { + console.error("Map not initialized, cannot zoom"); return; } - if (isNaN(zoom) || zoom < 1 || zoom > 20) { - console.error("Invalid zoom level:", zoom); - return; - } + if (data.lat && data.lng) { + const lat = parseFloat(data.lat.toString()); + const lng = parseFloat(data.lng.toString()); + const zoom = parseInt(data.zoom?.toString() || "12"); - try { - // Check element dimensions before zoom - const beforeRect = self.el.getBoundingClientRect(); + // Validate coordinates + if (!isValidCoordinate(lat, lng)) { + console.error("Invalid coordinates for zoom:", lat, lng); + return; + } - // Force map size recalculation before zoom - self.map.invalidateSize(); + if (isNaN(zoom) || zoom < 1 || zoom > 20) { + console.error("Invalid zoom level:", zoom); + return; + } - // Use a slight delay to ensure map is ready - setTimeout(() => { - if (self.map) { - // Generate a unique ID for this programmatic move - const moveId = `move_${Date.now()}_${Math.random()}`; - self.programmaticMoveId = moveId; + try { + // Check element dimensions before zoom + const beforeRect = self.el.getBoundingClientRect(); - // Clear any existing timeout - if (self.programmaticMoveTimeout) { - clearTimeout(self.programmaticMoveTimeout); - } + // Force map size recalculation before zoom + self.map.invalidateSize(); - // Set a timeout to clear the programmatic move flag - // This ensures we don't block user interactions indefinitely - self.programmaticMoveTimeout = setTimeout(() => { - if (self.programmaticMoveId === moveId) { - self.programmaticMoveId = undefined; - } - }, 1500); + // Use a slight delay to ensure map is ready + setTimeout(() => { + if (self.map) { + // Generate a unique ID for this programmatic move + const moveId = `move_${Date.now()}_${Math.random()}`; + self.programmaticMoveId = moveId; - self.map.setView([lat, lng], zoom, { - animate: true, - duration: 1, - }); - - // Check element dimensions after zoom - const dimensionCheckTimeout = setTimeout(() => { - // Check if map still exists and not destroyed - if (!self.map || self.isDestroyed) { - return; + // Clear any existing timeout + if (self.programmaticMoveTimeout) { + clearTimeout(self.programmaticMoveTimeout); } - 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(); + // Set a timeout to clear the programmatic move flag + // This ensures we don't block user interactions indefinitely + self.programmaticMoveTimeout = setTimeout(() => { + if (self.programmaticMoveId === moveId) { + self.programmaticMoveId = undefined; } - } - }, 1000); + }, 1500); - // Store timeout for cleanup - if (!self.cleanupTimeouts) { - self.cleanupTimeouts = []; + self.map.setView([lat, lng], zoom, { + animate: true, + 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) { - console.error("Error during zoom operation:", error); + }, 100); + } 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 self.handleEvent("request_geolocation", () => { @@ -901,7 +943,9 @@ let MapAPRSMap = { ); } else { 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 - self.handleEvent("update_trail_duration", (data: { duration_hours: number }) => { - if (self.trailManager) { - self.trailManager.setTrailDuration(data.duration_hours); - } - }); + self.handleEvent( + "update_trail_duration", + (data: { duration_hours: number }) => { + if (self.trailManager) { + self.trailManager.setTrailDuration(data.duration_hours); + } + }, + ); // Handle new packets from LiveView self.handleEvent("new_packet", (data: MarkerData) => { try { // Skip if context is lost 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; } // Check if map exists and has the hasLayer method 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; } @@ -940,7 +991,8 @@ let MapAPRSMap = { } // 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) { // 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 markersToConvert.forEach((id) => { if (!self.markers || !self.markerStates) { - console.warn("markers or markerStates not available during conversion"); + console.warn( + "markers or markerStates not available during conversion", + ); return; } const existingMarker = self.markers.get(id); @@ -984,7 +1038,8 @@ let MapAPRSMap = { lat: existingState.lat, lng: existingState.lng, callsign: existingState.callsign || incomingCallsign, - callsign_group: existingState.callsign_group || incomingCallsign, + callsign_group: + existingState.callsign_group || incomingCallsign, symbol_table_id: existingState.symbol_table, symbol_code: existingState.symbol_code, historical: true, @@ -1008,7 +1063,8 @@ let MapAPRSMap = { ...data, historical: false, 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), openPopup: shouldOpenPopup, }); @@ -1023,7 +1079,10 @@ let MapAPRSMap = { if (!data.id || !self.markers || !self.markerStates) return; // 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); if (prevMarker && prevMarker.closePopup) prevMarker.closePopup(); } @@ -1051,43 +1110,47 @@ let MapAPRSMap = { }); // Handle bulk loading of historical packets - self.handleEvent("add_historical_packets", (data: { packets: 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(); + self.handleEvent( + "add_historical_packets", + (data: { packets: 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(); - data.packets.forEach((packet) => { - const callsign = packet.callsign_group || packet.callsign || packet.id; - 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); + data.packets.forEach((packet) => { + const callsign = + packet.callsign_group || packet.callsign || packet.id; + 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); + } + }); + }); + } + }, + ); // Handle progressive loading of historical packets (batch processing) self.handleEvent( @@ -1096,7 +1159,7 @@ let MapAPRSMap = { console.log("Received add_historical_packets_batch event:", { packetCount: data.packets?.length || 0, batch: data.batch, - is_final: data.is_final + is_final: data.is_final, }); try { if (data.packets && Array.isArray(data.packets)) { @@ -1104,7 +1167,8 @@ let MapAPRSMap = { const packetsByCallsign = new Map(); 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)) { packetsByCallsign.set(callsign, []); } @@ -1129,7 +1193,11 @@ let MapAPRSMap = { popup: packet.popup || self.buildPopupContent(packet), }); } 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) => { const markerState = self.markerStates!.get(String(id)); // 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)); } }); @@ -1192,11 +1263,15 @@ let MapAPRSMap = { station_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 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; } @@ -1212,8 +1287,17 @@ let MapAPRSMap = { data.path_stations.forEach((station, index) => { // Validate coordinates before drawing - if (!isFinite(prevLat) || !isFinite(prevLng) || !isFinite(station.lat) || !isFinite(station.lng)) { - console.warn("Invalid coordinates for RF path:", { prevLat, prevLng, station }); + if ( + !isFinite(prevLat) || + !isFinite(prevLng) || + !isFinite(station.lat) || + !isFinite(station.lng) + ) { + console.warn("Invalid coordinates for RF path:", { + prevLat, + prevLng, + station, + }); return; } @@ -1227,7 +1311,7 @@ let MapAPRSMap = { color: "#FF6B6B", weight: 3, 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 - self.handleEvent("filter_markers_by_bounds", (data: { bounds: BoundsData }) => { - if (data.bounds) { - // Create Leaflet bounds object from server data - const bounds = L.latLngBounds( - [data.bounds.south, data.bounds.west], - [data.bounds.north, data.bounds.east], - ); - self.removeMarkersOutsideBounds(bounds); - } - }); + self.handleEvent( + "filter_markers_by_bounds", + (data: { bounds: BoundsData }) => { + if (data.bounds) { + // Create Leaflet bounds object from server data + const bounds = L.latLngBounds( + [data.bounds.south, data.bounds.west], + [data.bounds.north, data.bounds.east], + ); + self.removeMarkersOutsideBounds(bounds); + } + }, + ); // Handle clearing all markers and reloading visible ones self.handleEvent("clear_and_reload_markers", () => { @@ -1373,7 +1460,12 @@ let MapAPRSMap = { sendBoundsToServer() { 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; try { @@ -1411,7 +1503,14 @@ let MapAPRSMap = { addMarker(data: MarkerData & { openPopup?: boolean }) { const self = this as unknown as LiveViewHookContext; 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); return; } @@ -1421,9 +1520,13 @@ let MapAPRSMap = { console.warn("Map data structures not initialized, skipping marker add"); return; } - + // 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); if (!self.pendingMarkers) { self.pendingMarkers = []; @@ -1438,7 +1541,14 @@ let MapAPRSMap = { // Validate coordinates 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; } @@ -1450,7 +1560,8 @@ let MapAPRSMap = { // Check if marker needs updating const currentPos = existingMarker.getLatLng(); 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 = existingState.symbol_table !== data.symbol_table_id || existingState.symbol_code !== data.symbol_code || @@ -1458,12 +1569,19 @@ let MapAPRSMap = { if (positionChanged && self.trailManager) { // 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); // Use callsign_group for proper trail grouping const trailId = getTrailId(data); - self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); + self.trailManager.addPosition( + trailId, + lat, + lng, + timestamp, + isHistoricalDot, + ); } if (!positionChanged && !dataChanged) { @@ -1491,7 +1609,11 @@ let MapAPRSMap = { // Handle popup close events - check if hook is still connected marker.on("popupclose", () => { // 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 { self.pushEvent("popup_closed", {}); } catch (e) { @@ -1522,7 +1644,10 @@ let MapAPRSMap = { // Find the highest z-index among all markers let maxZIndex = 1000; 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; }); @@ -1532,7 +1657,11 @@ let MapAPRSMap = { } // 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", { id: data.id, callsign: data.callsign, @@ -1547,7 +1676,11 @@ let MapAPRSMap = { if (data.path && data.path.trim() !== "" && !data.path.includes("TCPIP")) { marker.on("mouseover", () => { // 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 { self.pushEvent.call(self, "marker_hover_start", { id: data.id, @@ -1566,7 +1699,11 @@ let MapAPRSMap = { marker.on("mouseout", () => { // 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 { self.pushEvent.call(self, "marker_hover_end", { id: data.id, @@ -1609,12 +1746,19 @@ let MapAPRSMap = { // Initialize trail for new marker - always add to trail for line drawing 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); // Use callsign_group for proper trail grouping const trailId = getTrailId(data); - self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); + self.trailManager.addPosition( + trailId, + lat, + lng, + timestamp, + isHistoricalDot, + ); } // Open popup if requested @@ -1624,9 +1768,17 @@ let MapAPRSMap = { // 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 - const shouldAddToOms = data.is_most_recent_for_callsign === true || - (data.is_most_recent_for_callsign == null && !(marker as APRSMarker)._isHistorical); - if (self.oms && marker && self.map && !marker._isClusterMarker && shouldAddToOms) { + const shouldAddToOms = + data.is_most_recent_for_callsign === true || + (data.is_most_recent_for_callsign == null && + !(marker as APRSMarker)._isHistorical); + if ( + self.oms && + marker && + self.map && + !marker._isClusterMarker && + shouldAddToOms + ) { self.oms.addMarker(marker); } }, @@ -1642,17 +1794,26 @@ let MapAPRSMap = { if (marker) { try { // 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 - if (self.markerClusterGroup._map && self.markerClusterGroup._topClusterLevel) { + if ( + self.markerClusterGroup._map && + self.markerClusterGroup._topClusterLevel + ) { self.markerClusterGroup.removeLayer(marker); } 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)) { self.markerLayer.removeLayer(marker); } - + self.markers!.delete(markerId); self.markerStates!.delete(markerId); } catch (error) { @@ -1665,7 +1826,8 @@ let MapAPRSMap = { // Remove trail - use callsign_group for proper trail identification if (self.trailManager) { - const trailId = markerState?.callsign_group || markerState?.callsign || markerId; + const trailId = + markerState?.callsign_group || markerState?.callsign || markerId; self.trailManager.removeTrail(trailId); } @@ -1691,16 +1853,24 @@ let MapAPRSMap = { if (self.isValidCoordinate(lat, lng)) { const currentPos = existingMarker.getLatLng(); 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) { existingMarker.setLatLng([lat, lng]); 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); // Use callsign_group for proper trail grouping const trailId = getTrailId(data); - self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); + self.trailManager.addPosition( + trailId, + lat, + lng, + timestamp, + isHistoricalDot, + ); } } } @@ -1721,7 +1891,7 @@ let MapAPRSMap = { clearAllMarkers() { const self = this as unknown as LiveViewHookContext; - + // Clear any RF path lines first self.clearRfPathLines(); @@ -1733,8 +1903,10 @@ let MapAPRSMap = { self.markers!.forEach((marker, id) => { const markerState = self.markerStates!.get(String(id)); const isHistorical = - (marker as APRSMarker)._isHistorical || (markerState && markerState.historical); - const isMostRecent = markerState && markerState.is_most_recent_for_callsign; + (marker as APRSMarker)._isHistorical || + (markerState && markerState.historical); + const isMostRecent = + markerState && markerState.is_most_recent_for_callsign; // Keep historical markers and current position markers if (isHistorical || isMostRecent) { @@ -1781,8 +1953,10 @@ let MapAPRSMap = { // Check if this is a historical marker or the most recent marker for a callsign const markerState = self.markerStates!.get(String(id)); const isHistorical = - (marker as APRSMarker)._isHistorical || (markerState && markerState.historical); - const isMostRecent = markerState && markerState.is_most_recent_for_callsign; + (marker as APRSMarker)._isHistorical || + (markerState && markerState.historical); + const isMostRecent = + markerState && markerState.is_most_recent_for_callsign; // Always preserve historical markers and the most recent marker for a callsign if (isHistorical || isMostRecent) { @@ -1842,17 +2016,26 @@ let MapAPRSMap = { if (marker) { try { // 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 - if (self.markerClusterGroup._map && self.markerClusterGroup._topClusterLevel) { + if ( + self.markerClusterGroup._map && + self.markerClusterGroup._topClusterLevel + ) { self.markerClusterGroup.removeLayer(marker); } 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)) { self.markerLayer.removeLayer(marker); } - + // Always clean up the tracking maps self.markers!.delete(markerId); self.markerStates!.delete(markerId); @@ -2122,12 +2305,12 @@ function extractCoordinate(value: any): number { } // Handle numbers - if (typeof value === 'number') { + if (typeof value === "number") { return value; } // Handle strings - if (typeof value === 'string') { + if (typeof value === "string") { return parseFloat(value); } @@ -2136,15 +2319,27 @@ function extractCoordinate(value: any): number { // Helper to validate coordinates 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 -function createDivIcon(html: string, options: Partial<{ - className: string; - iconSize: [number, number]; - iconAnchor: [number, number]; -}> = {}) { +function createDivIcon( + html: string, + options: Partial<{ + className: string; + iconSize: [number, number]; + iconAnchor: [number, number]; + }> = {}, +) { return L.divIcon({ html, className: options.className || "",