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:
parent
b2c25a152d
commit
ff06c13224
4 changed files with 873 additions and 575 deletions
17
.sobelow-skips
Normal file
17
.sobelow-skips
Normal 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
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
601
assets/js/map.ts
601
assets/js/map.ts
|
|
@ -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 || "",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue