- Add comprehensive type definitions for Leaflet plugins (heatmap, marker clustering, overlapping marker spiderfier) - Create event payload interfaces for all LiveView events - Add marker extension types for APRS-specific properties - Define Chart.js dataset and configuration types - Replace 74 instances of 'any' type across all TypeScript files - Improve type safety for event handlers and state management - Add proper typing for external library integrations This refactoring enhances type safety throughout the codebase, making it easier to catch errors at compile time and improving IDE support for autocompletion and refactoring. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1756 lines
60 KiB
TypeScript
1756 lines
60 KiB
TypeScript
// Import type definitions
|
|
import type {
|
|
LiveViewHookContext,
|
|
MarkerData,
|
|
BoundsData,
|
|
CenterData,
|
|
MarkerState,
|
|
MapEventData
|
|
} from './types/map';
|
|
import type { Map as LeafletMap, Marker, TileLayer, LayerGroup, DivIcon, LatLngBounds, Polyline } from 'leaflet';
|
|
import type { HeatLayer, MarkerClusterGroup, OverlappingMarkerSpiderfier, MarkerClusterGroupOptions, HeatLayerOptions, MarkerCluster, HeatLatLng } from './types/leaflet-plugins';
|
|
import type { APRSMarker } from './types/marker-extensions';
|
|
import type { BaseEventPayload } from './types/events';
|
|
|
|
// Declare Leaflet as a global variable with proper typing
|
|
declare const L: typeof import('leaflet') & {
|
|
heatLayer?: (latlngs: HeatLatLng[], options?: HeatLayerOptions) => HeatLayer;
|
|
markerClusterGroup?: (options?: MarkerClusterGroupOptions) => MarkerClusterGroup;
|
|
};
|
|
|
|
// Import OverlappingMarkerSpiderfier constructor
|
|
import { OverlappingMarkerSpiderfier as OMSConstructor } from './types/leaflet-plugins';
|
|
declare const OverlappingMarkerSpiderfier: typeof OMSConstructor;
|
|
|
|
// Import trail management functionality
|
|
import { TrailManager } from "./features/trail_manager";
|
|
// Import helper functions
|
|
import { parseTimestamp, getTrailId, saveMapState, safePushEvent, getLiveSocket } from "./map_helpers";
|
|
|
|
// APRS Map Hook - handles only basic map interaction
|
|
// All data logic handled by LiveView
|
|
|
|
|
|
let MapAPRSMap = {
|
|
mounted() {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
// Initialize error tracking
|
|
self.errors = [];
|
|
self.initializationAttempts = 0;
|
|
self.maxInitializationAttempts = 3;
|
|
|
|
self.attemptInitialization();
|
|
},
|
|
|
|
attemptInitialization() {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
self.initializationAttempts!++;
|
|
|
|
// Check if Leaflet is available
|
|
if (typeof L === "undefined") {
|
|
console.error("Leaflet library not loaded!");
|
|
self.errors!.push("Leaflet library not available");
|
|
|
|
if (self.initializationAttempts! < self.maxInitializationAttempts!) {
|
|
setTimeout(() => self.attemptInitialization(), 1000);
|
|
return;
|
|
} else {
|
|
self.handleFatalError("Leaflet library failed to load after multiple attempts");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Initialize with URL parameters first (from data attributes)
|
|
let initialCenter: CenterData, initialZoom: number;
|
|
let useUrlParams = false;
|
|
|
|
try {
|
|
const centerData = self.el.dataset.center;
|
|
const zoomData = self.el.dataset.zoom;
|
|
|
|
if (!centerData || !zoomData) throw new Error("Missing map data attributes");
|
|
initialCenter = JSON.parse(centerData);
|
|
initialZoom = parseInt(zoomData);
|
|
|
|
|
|
if (
|
|
!initialCenter ||
|
|
typeof initialCenter.lat !== "number" ||
|
|
typeof initialCenter.lng !== "number"
|
|
)
|
|
throw new Error("Invalid center data");
|
|
if (isNaN(initialZoom) || initialZoom < 1 || initialZoom > 20)
|
|
throw new Error("Invalid zoom data");
|
|
|
|
// 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');
|
|
} catch (error) {
|
|
console.error("Error parsing map data attributes:", error);
|
|
initialCenter = { lat: 39.8283, lng: -98.5795 };
|
|
initialZoom = 5;
|
|
}
|
|
|
|
// Only use localStorage if no URL params are present
|
|
// Temporarily disabled - using server-side geolocation instead
|
|
/*
|
|
if (!useUrlParams) {
|
|
try {
|
|
const saved = localStorage.getItem("aprs_map_state");
|
|
if (saved) {
|
|
const { lat, lng, zoom } = JSON.parse(saved);
|
|
if (
|
|
typeof lat === "number" &&
|
|
typeof lng === "number" &&
|
|
typeof zoom === "number" &&
|
|
lat >= -90 &&
|
|
lat <= 90 &&
|
|
lng >= -180 &&
|
|
lng <= 180 &&
|
|
zoom >= 1 &&
|
|
zoom <= 20
|
|
) {
|
|
console.log("Using saved state from localStorage:", { lat, lng, zoom });
|
|
initialCenter = { lat, lng };
|
|
initialZoom = zoom;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.log("Could not load from localStorage:", e);
|
|
}
|
|
}
|
|
*/
|
|
|
|
self.initializeMap(initialCenter, initialZoom);
|
|
},
|
|
|
|
initializeMap(initialCenter: CenterData, initialZoom: number) {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
// Ensure the element and its parent exist
|
|
if (!self.el || !self.el.parentNode) {
|
|
console.warn("Map element or parent not found, retrying...");
|
|
if (self.initializationAttempts! < self.maxInitializationAttempts!) {
|
|
setTimeout(() => self.attemptInitialization(), 500);
|
|
return;
|
|
} else {
|
|
self.handleFatalError("Map element structure invalid");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Check element dimensions
|
|
const rect = self.el.getBoundingClientRect();
|
|
|
|
// Validate element has dimensions
|
|
if (rect.width === 0 || rect.height === 0) {
|
|
console.warn("Map element has no dimensions, retrying...");
|
|
self.errors!.push("Map element has zero dimensions");
|
|
|
|
// Try to force dimensions
|
|
self.el.style.width = "100%";
|
|
self.el.style.height = "100vh";
|
|
|
|
if (self.initializationAttempts! < self.maxInitializationAttempts!) {
|
|
setTimeout(() => self.attemptInitialization(), 500);
|
|
return;
|
|
} else {
|
|
self.handleFatalError("Map element never gained proper dimensions");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Initialize basic map
|
|
try {
|
|
// Check if map is already initialized
|
|
if (self.map) {
|
|
console.warn("Map already exists, reinitializing...");
|
|
self.map.remove();
|
|
self.map = null;
|
|
}
|
|
|
|
// Ensure element ID is unique and not already initialized
|
|
if (self.el._leaflet_id) {
|
|
console.warn("Map element already has Leaflet ID, cleaning up...");
|
|
const el = L.DomUtil.get(self.el.id) as HTMLElement | null;
|
|
if (el !== null) {
|
|
L.DomUtil.remove(el);
|
|
}
|
|
delete self.el._leaflet_id;
|
|
}
|
|
|
|
console.log("Creating map with center:", [initialCenter.lat, initialCenter.lng], "zoom:", initialZoom);
|
|
self.map = L.map(self.el, {
|
|
zoomControl: true,
|
|
attributionControl: true,
|
|
closePopupOnClick: true,
|
|
}).setView([initialCenter.lat, initialCenter.lng], initialZoom);
|
|
console.log("Map created and view set");
|
|
} catch (error) {
|
|
console.error("Error initializing map:", error);
|
|
self.errors!.push(
|
|
"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");
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Add OpenStreetMap tile layer
|
|
try {
|
|
const tileLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
|
attribution:
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
|
|
maxZoom: 19,
|
|
});
|
|
tileLayer.addTo(self.map);
|
|
} catch (error) {
|
|
self.errors!.push("Tile layer failed: " + (error instanceof Error ? error.message : error));
|
|
}
|
|
|
|
// Store markers for management
|
|
self.markers = new Map<string, APRSMarker>();
|
|
|
|
// Create marker cluster group for medium zoom levels (9-14)
|
|
if (L.markerClusterGroup) {
|
|
self.markerClusterGroup = L.markerClusterGroup({
|
|
showCoverageOnHover: false,
|
|
zoomToBoundsOnClick: true,
|
|
spiderfyOnMaxZoom: true,
|
|
removeOutsideVisibleBounds: true,
|
|
disableClusteringAtZoom: 11, // Show individual markers at zoom 11+
|
|
maxClusterRadius: 80,
|
|
iconCreateFunction: function(cluster: MarkerCluster) {
|
|
const count = cluster.getChildCount();
|
|
let size = 'small';
|
|
let className = 'marker-cluster-small';
|
|
|
|
if (count > 10) {
|
|
size = 'medium';
|
|
className = 'marker-cluster-medium';
|
|
}
|
|
if (count > 50) {
|
|
size = 'large';
|
|
className = 'marker-cluster-large';
|
|
}
|
|
|
|
return L.divIcon({
|
|
html: '<div><span>' + count + '</span></div>',
|
|
className: 'marker-cluster ' + className,
|
|
iconSize: [40, 40]
|
|
});
|
|
}
|
|
});
|
|
self.markerClusterGroup.addTo(self.map);
|
|
self.markerLayer = self.markerClusterGroup;
|
|
} else {
|
|
// Fallback to regular layer group if clustering not available
|
|
self.markerLayer = L.layerGroup().addTo(self.map);
|
|
}
|
|
|
|
// Create heat layer (hidden by default)
|
|
try {
|
|
if (L.heatLayer) {
|
|
self.heatLayer = L.heatLayer([], {
|
|
radius: 25,
|
|
blur: 15,
|
|
maxZoom: 8,
|
|
gradient: {
|
|
0.4: 'blue',
|
|
0.65: 'lime',
|
|
0.85: 'yellow',
|
|
1.0: 'red'
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("Error creating heat layer:", error);
|
|
}
|
|
|
|
// Create trail layer and manager
|
|
const trailLayer = L.layerGroup().addTo(self.map);
|
|
self.trailManager = new TrailManager(trailLayer);
|
|
|
|
// Track marker states to prevent unnecessary operations
|
|
self.markerStates = new Map<string, MarkerState>();
|
|
|
|
// Store RF path lines for hover visualization
|
|
self.rfPathLines = [];
|
|
|
|
// Force initial size calculation
|
|
try {
|
|
self.map.invalidateSize();
|
|
} catch (error) {
|
|
console.error("Error invalidating map size:", error);
|
|
}
|
|
|
|
// Track when map is ready
|
|
self.map!.whenReady(() => {
|
|
try {
|
|
self.lastZoom = self.map!.getZoom();
|
|
|
|
// Ensure we have a valid pushEvent function before using it
|
|
if (self.pushEvent && typeof self.pushEvent === 'function') {
|
|
console.log("Map ready - sending map_ready event");
|
|
self.pushEvent("map_ready", {});
|
|
// Send initial bounds to trigger historical loading
|
|
console.log("Sending initial bounds to server");
|
|
self.sendBoundsToServer();
|
|
|
|
// Also send update_map_state to ensure URL updates and bounds processing
|
|
// Increase delay to ensure LiveView is fully connected and ready
|
|
setTimeout(() => {
|
|
if (self.map && self.pushEvent && !self.isDestroyed) {
|
|
console.log("Sending initial update_map_state for historical loading");
|
|
saveMapState(self.map, (event: string, payload: BaseEventPayload) => self.pushEvent(event, payload));
|
|
}
|
|
}, 500);
|
|
} else {
|
|
console.warn("pushEvent not available in whenReady callback");
|
|
// Retry after a short delay
|
|
setTimeout(() => {
|
|
if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) {
|
|
self.pushEvent("map_ready", {});
|
|
self.sendBoundsToServer();
|
|
// Also trigger map state update after a delay
|
|
setTimeout(() => {
|
|
if (self.map && self.pushEvent && !self.isDestroyed) {
|
|
console.log("Sending initial update_map_state for historical loading (retry path)");
|
|
saveMapState(self.map, (event: string, payload: BaseEventPayload) => self.pushEvent(event, payload));
|
|
}
|
|
}, 500);
|
|
}
|
|
}, 200);
|
|
}
|
|
|
|
// Start periodic cleanup of old trail positions (every 5 minutes)
|
|
self.cleanupInterval = setInterval(
|
|
() => {
|
|
if (!self.isDestroyed && self.trailManager) {
|
|
self.trailManager.cleanupOldPositions();
|
|
}
|
|
},
|
|
5 * 60 * 1000,
|
|
);
|
|
} catch (error) {
|
|
console.error("Error in map ready callback:", error);
|
|
}
|
|
});
|
|
|
|
// Track map event handlers for cleanup
|
|
self.mapEventHandlers = new Map();
|
|
self.isDestroyed = false;
|
|
|
|
// Send bounds to LiveView when map moves
|
|
const moveEndHandler = () => {
|
|
// Skip if this is a programmatic move from the server
|
|
if (self.programmaticMoveId) {
|
|
return;
|
|
}
|
|
|
|
if (self.boundsTimer) clearTimeout(self.boundsTimer);
|
|
self.boundsTimer = setTimeout(() => {
|
|
if (self.map && !self.isDestroyed) {
|
|
saveMapState(self.map, (event: string, payload: any) => self.pushEvent(event, payload));
|
|
}
|
|
}, 300);
|
|
};
|
|
self.map!.on("moveend", moveEndHandler);
|
|
self.mapEventHandlers!.set("moveend", moveEndHandler);
|
|
|
|
// Handle zoom changes with optimization for large zoom differences
|
|
const zoomEndHandler = () => {
|
|
// Skip if this is a programmatic move from the server
|
|
if (self.programmaticMoveId) {
|
|
return;
|
|
}
|
|
|
|
if (self.boundsTimer) clearTimeout(self.boundsTimer);
|
|
self.boundsTimer = setTimeout(() => {
|
|
const currentZoom = self.map!.getZoom();
|
|
const zoomDifference = self.lastZoom ? Math.abs(currentZoom - self.lastZoom) : 0;
|
|
|
|
// Handle OMS markers when crossing zoom threshold
|
|
if (self.oms) {
|
|
if (currentZoom >= 11 && (!self.lastZoom || self.lastZoom < 11)) {
|
|
// We just zoomed in past clustering threshold, add all markers to OMS
|
|
self.markers.forEach((marker) => {
|
|
if (marker && !marker._isHistorical) {
|
|
self.oms.addMarker(marker);
|
|
}
|
|
});
|
|
} else if (currentZoom < 11 && self.lastZoom && self.lastZoom >= 11) {
|
|
// We just zoomed out below clustering threshold, remove all markers from OMS
|
|
self.oms.clearMarkers();
|
|
}
|
|
}
|
|
|
|
// If zoom changed significantly (more than 2 levels), request reload of normal markers
|
|
// but preserve historical ones and current position
|
|
if (zoomDifference > 2) {
|
|
self.pushEvent("refresh_markers", {});
|
|
}
|
|
|
|
self.lastZoom = currentZoom;
|
|
// Save map state and update URL
|
|
if (self.map && !self.isDestroyed) {
|
|
saveMapState(self.map, (event: string, payload: any) => self.pushEvent(event, payload));
|
|
}
|
|
}, 300);
|
|
};
|
|
self.map!.on("zoomend", zoomEndHandler);
|
|
self.mapEventHandlers!.set("zoomend", zoomEndHandler);
|
|
|
|
// Handle resize
|
|
self.resizeHandler = () => {
|
|
try {
|
|
if (self.map) {
|
|
self.map.invalidateSize();
|
|
}
|
|
} catch (error) {
|
|
console.error("Error invalidating map size on resize:", error);
|
|
}
|
|
};
|
|
window.addEventListener("resize", self.resizeHandler);
|
|
|
|
// Add a delayed size check
|
|
setTimeout(() => {
|
|
if (self.el && self.el.parentNode) {
|
|
const rect = self.el.getBoundingClientRect();
|
|
if (self.map && rect.width > 0 && rect.height > 0) {
|
|
try {
|
|
self.map.invalidateSize();
|
|
} catch (error) {
|
|
console.error("Error re-invalidating map size:", error);
|
|
}
|
|
}
|
|
}
|
|
}, 1000);
|
|
|
|
// LiveView event handlers
|
|
self.setupLiveViewHandlers();
|
|
|
|
// Set up event delegation for popup navigation links
|
|
self.setupPopupNavigation();
|
|
|
|
// Only initialize OMS if clustering is not available or at high zoom levels
|
|
if (typeof OverlappingMarkerSpiderfier !== "undefined") {
|
|
self.oms = new OverlappingMarkerSpiderfier(self.map, {
|
|
keepSpiderfied: true,
|
|
nearbyDistance: 40,
|
|
circleSpiralSwitchover: 9,
|
|
legWeight: 2
|
|
});
|
|
|
|
// Add click handler for spiderfied markers
|
|
self.oms.addListener('click', function(marker: Marker) {
|
|
if (marker.openPopup) marker.openPopup();
|
|
});
|
|
|
|
// Style the spider legs
|
|
self.oms.addListener('spiderfy', function(markers: Marker[]) {
|
|
self.map.closePopup();
|
|
});
|
|
|
|
self.oms.addListener('unspiderfy', function(markers: Marker[]) {
|
|
// Markers return to normal positions
|
|
});
|
|
}
|
|
},
|
|
|
|
handleFatalError(message: string) {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
console.error("Fatal map error:", message);
|
|
console.error("All errors:", self.errors);
|
|
|
|
// Display error message to user
|
|
if (self.el) {
|
|
self.el.innerHTML = `
|
|
<div style="
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100%;
|
|
background: #f8f9fa;
|
|
color: #721c24;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
text-align: center;
|
|
padding: 20px;
|
|
">
|
|
<div>
|
|
<h3 style="margin: 0 0 10px 0;">Map Loading Error</h3>
|
|
<p style="margin: 0; font-size: 14px;">${message}</p>
|
|
<p style="margin: 10px 0 0 0; font-size: 12px; color: #856404;">
|
|
Please refresh the page or check the browser console for details.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
},
|
|
|
|
setupPopupNavigation() {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
|
|
// Store the event handler so we can remove it later
|
|
self.popupNavigationHandler = (e: Event) => {
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Check if clicked element or its parent is a LiveView navigation link
|
|
const navLink = target.closest('.aprs-lv-link') as HTMLAnchorElement;
|
|
|
|
if (navLink && navLink.href) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Use Phoenix LiveView's built-in navigation
|
|
const liveSocket = getLiveSocket();
|
|
if (liveSocket) {
|
|
liveSocket.pushHistoryPatch(navLink.href, "push", navLink);
|
|
} else {
|
|
// Fallback to regular navigation if LiveView socket not available
|
|
window.location.href = navLink.href;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Use event delegation to handle clicks on popup navigation links
|
|
document.addEventListener('click', self.popupNavigationHandler);
|
|
},
|
|
|
|
setupLiveViewHandlers() {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
// Add single marker
|
|
self.handleEvent("add_marker", (data: MarkerData) => {
|
|
self.addMarker(data);
|
|
});
|
|
|
|
// Add multiple markers at once
|
|
self.handleEvent("add_markers", (data: { markers: MarkerData[] }) => {
|
|
if (data.markers && Array.isArray(data.markers)) {
|
|
data.markers.forEach((marker) => self.addMarker(marker));
|
|
}
|
|
});
|
|
|
|
// Remove marker
|
|
self.handleEvent("remove_marker", (data: { id: string }) => {
|
|
self.removeMarker(data.id);
|
|
});
|
|
|
|
// Clear all markers
|
|
self.handleEvent("clear_markers", () => {
|
|
self.clearAllMarkers();
|
|
});
|
|
|
|
// Update marker
|
|
self.handleEvent("update_marker", (data: MarkerData) => {
|
|
self.updateMarker(data);
|
|
});
|
|
|
|
// 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 (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
|
console.error("Invalid coordinates for zoom:", lat, lng);
|
|
return;
|
|
}
|
|
|
|
if (isNaN(zoom) || zoom < 1 || zoom > 20) {
|
|
console.error("Invalid zoom level:", zoom);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Check element dimensions before zoom
|
|
const beforeRect = self.el.getBoundingClientRect();
|
|
|
|
// Force map size recalculation before zoom
|
|
self.map.invalidateSize();
|
|
|
|
// 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;
|
|
|
|
// Clear any existing timeout
|
|
if (self.programmaticMoveTimeout) {
|
|
clearTimeout(self.programmaticMoveTimeout);
|
|
}
|
|
|
|
// 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);
|
|
|
|
self.map.setView([lat, lng], zoom, {
|
|
animate: true,
|
|
duration: 1,
|
|
});
|
|
|
|
// Check element dimensions after zoom
|
|
setTimeout(() => {
|
|
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";
|
|
self.map.invalidateSize();
|
|
}
|
|
}, 1000);
|
|
}
|
|
}, 100);
|
|
} catch (error) {
|
|
console.error("Error during zoom operation:", error);
|
|
}
|
|
} else {
|
|
console.warn("Missing lat/lng data for zoom operation:", data);
|
|
}
|
|
});
|
|
|
|
// Handle geolocation requests
|
|
self.handleEvent("request_geolocation", () => {
|
|
if ("geolocation" in navigator) {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
const { latitude, longitude } = position.coords;
|
|
self.pushEvent("set_location", { lat: latitude, lng: longitude });
|
|
},
|
|
(error) => {
|
|
console.warn("Geolocation error:", error.message);
|
|
self.pushEvent("geolocation_error", { error: error.message });
|
|
},
|
|
);
|
|
} else {
|
|
console.warn("Geolocation not available");
|
|
self.pushEvent("geolocation_error", { error: "Geolocation not supported" });
|
|
}
|
|
});
|
|
|
|
// Toggle trails visibility
|
|
self.handleEvent("toggle_trails", (data: { show: boolean }) => {
|
|
if (self.trailManager) {
|
|
self.trailManager.setShowTrails(data.show);
|
|
}
|
|
});
|
|
|
|
// 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");
|
|
return;
|
|
}
|
|
|
|
// Check if map exists and has the hasLayer method
|
|
if (!self.map.hasLayer) {
|
|
console.warn("Map hasLayer method not available, skipping new packet");
|
|
return;
|
|
}
|
|
|
|
// If heat layer is visible, we're in heat map mode - skip individual markers
|
|
if (self.heatLayer && self.map.hasLayer(self.heatLayer)) {
|
|
console.log("In heat map mode, skipping individual marker for new packet");
|
|
return;
|
|
}
|
|
|
|
// Check if there's already a marker for this callsign
|
|
const incomingCallsign = data.callsign_group || data.callsign || data.id;
|
|
|
|
if (incomingCallsign) {
|
|
// Find existing live markers for this callsign and convert them to historical dots
|
|
const markersToConvert: string[] = [];
|
|
|
|
// Ensure markerStates exists
|
|
if (!self.markerStates) {
|
|
console.warn("markerStates not initialized, skipping conversion");
|
|
return;
|
|
}
|
|
|
|
self.markerStates.forEach((state, id) => {
|
|
const stateCallsign = state.callsign_group || state.callsign;
|
|
// Only convert non-historical markers for the same callsign, but exclude the incoming packet's ID
|
|
if (
|
|
stateCallsign === incomingCallsign &&
|
|
!state.historical &&
|
|
String(id) !== String(data.id)
|
|
) {
|
|
markersToConvert.push(String(id));
|
|
}
|
|
});
|
|
|
|
// 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");
|
|
return;
|
|
}
|
|
const existingMarker = self.markers.get(id);
|
|
const existingState = self.markerStates.get(id);
|
|
|
|
if (existingMarker && existingState) {
|
|
// Update the state to historical
|
|
existingState.historical = true;
|
|
existingState.is_most_recent_for_callsign = false;
|
|
|
|
// Create new historical dot icon
|
|
const historicalIconData = {
|
|
id: String(id),
|
|
lat: existingState.lat,
|
|
lng: existingState.lng,
|
|
callsign: existingState.callsign || incomingCallsign,
|
|
callsign_group: existingState.callsign_group || incomingCallsign,
|
|
symbol_table_id: existingState.symbol_table,
|
|
symbol_code: existingState.symbol_code,
|
|
historical: true,
|
|
is_most_recent_for_callsign: false,
|
|
popup: existingState.popup,
|
|
};
|
|
|
|
// Update the marker's icon to show as a dot
|
|
existingMarker.setIcon(self.createMarkerIcon(historicalIconData));
|
|
|
|
// Mark as historical
|
|
(existingMarker as APRSMarker)._isHistorical = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Add the new marker as the most recent for this callsign
|
|
// Check if openPopup is explicitly set to false to prevent interrupting user
|
|
const shouldOpenPopup = data.openPopup !== false;
|
|
self.addMarker({
|
|
...data,
|
|
historical: false,
|
|
is_most_recent_for_callsign: true,
|
|
callsign_group: data.callsign_group || data.callsign || incomingCallsign,
|
|
popup: data.popup || self.buildPopupContent(data),
|
|
openPopup: shouldOpenPopup,
|
|
});
|
|
} catch (error) {
|
|
console.error("Error in new_packet handler:", error);
|
|
}
|
|
});
|
|
|
|
// Handle highlighting the latest packet (open its popup)
|
|
self.currentPopupMarkerId = null;
|
|
self.handleEvent("highlight_packet", (data: { id: string }) => {
|
|
if (!data.id || !self.markers || !self.markerStates) return;
|
|
|
|
// Close previous popup if open
|
|
if (self.currentPopupMarkerId && self.markers.has(self.currentPopupMarkerId)) {
|
|
const prevMarker = self.markers.get(self.currentPopupMarkerId);
|
|
if (prevMarker && prevMarker.closePopup) prevMarker.closePopup();
|
|
}
|
|
// Try to open popup directly first if marker exists
|
|
const marker = self.markers.get(data.id);
|
|
if (marker && marker.openPopup) {
|
|
marker.openPopup();
|
|
} else {
|
|
// Fallback: re-add marker with openPopup flag if it doesn't exist
|
|
const markerData = self.markerStates.get(data.id);
|
|
if (markerData) {
|
|
self.addMarker({ ...markerData, id: data.id, openPopup: true });
|
|
}
|
|
}
|
|
self.currentPopupMarkerId = data.id;
|
|
});
|
|
|
|
// Handle historical packets during replay
|
|
self.handleEvent("historical_packet", (data: MarkerData) => {
|
|
self.addMarker({
|
|
...data,
|
|
historical: true,
|
|
popup: data.popup || self.buildPopupContent(data),
|
|
});
|
|
});
|
|
|
|
// 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<string, MarkerData[]>();
|
|
|
|
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) => {
|
|
self.addMarker({
|
|
...packet,
|
|
historical: true,
|
|
popup: packet.popup || self.buildPopupContent(packet),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
// Handle progressive loading of historical packets (batch processing)
|
|
self.handleEvent("add_historical_packets_batch", (data: { packets: MarkerData[], batch: number, is_final: boolean }) => {
|
|
console.log("Received historical packet batch:", data.batch, "packet count:", data.packets?.length || 0);
|
|
try {
|
|
if (data.packets && Array.isArray(data.packets)) {
|
|
// Process all packets immediately for maximum speed
|
|
const packetsByCallsign = new Map<string, MarkerData[]>();
|
|
|
|
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) => {
|
|
self.addMarker({
|
|
...packet,
|
|
historical: true,
|
|
popup: packet.popup || self.buildPopupContent(packet),
|
|
});
|
|
});
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("Error processing historical packets batch:", error);
|
|
// Continue processing other batches even if one fails
|
|
}
|
|
});
|
|
|
|
// Handle refresh markers event
|
|
self.handleEvent("refresh_markers", () => {
|
|
// Remove markers that are outside current bounds
|
|
if (self.map) {
|
|
const bounds = self.map.getBounds();
|
|
self.removeMarkersOutsideBounds(bounds);
|
|
}
|
|
});
|
|
|
|
// Handle clearing historical packets
|
|
self.handleEvent("clear_historical_packets", () => {
|
|
console.log("Clearing historical packets");
|
|
// Remove only historical markers (preserve live markers and their trails)
|
|
const markersToRemove: string[] = [];
|
|
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)) {
|
|
markersToRemove.push(String(id));
|
|
}
|
|
});
|
|
|
|
// Remove historical markers without affecting trails of live markers
|
|
markersToRemove.forEach((id) => {
|
|
const marker = self.markers!.get(id);
|
|
const markerState = self.markerStates!.get(id);
|
|
|
|
if (marker) {
|
|
// Remove from map and storage
|
|
self.markerLayer!.removeLayer(marker);
|
|
self.markers!.delete(id);
|
|
self.markerStates!.delete(id);
|
|
|
|
// We now NEVER remove trails when clearing historical packets
|
|
// This preserves the historical trail visibility
|
|
}
|
|
});
|
|
|
|
// Make sure trail layer is visible and on top
|
|
if (self.trailLayer && self.trailLayer.bringToFront) {
|
|
self.trailLayer.bringToFront();
|
|
}
|
|
});
|
|
|
|
// Handle drawing RF path lines when hovering over a station
|
|
self.handleEvent("draw_rf_path", (data: {
|
|
station_lat: number,
|
|
station_lng: number,
|
|
path_stations: Array<{callsign: string, lat: number, lng: number}>
|
|
}) => {
|
|
if (!self.map || !data.path_stations || data.path_stations.length === 0) return;
|
|
|
|
// Clear any existing path lines
|
|
if (self.rfPathLines) {
|
|
self.rfPathLines.forEach(line => self.map!.removeLayer(line));
|
|
}
|
|
self.rfPathLines = [];
|
|
|
|
// Draw lines from station to each digipeater/igate in sequence
|
|
let prevLat = data.station_lat;
|
|
let prevLng = data.station_lng;
|
|
|
|
data.path_stations.forEach((station, index) => {
|
|
// Draw line from previous position to this station
|
|
const line = L.polyline(
|
|
[[prevLat, prevLng], [station.lat, station.lng]],
|
|
{
|
|
color: '#FF6B6B',
|
|
weight: 3,
|
|
opacity: 0.8,
|
|
dashArray: index === 0 ? null : '5, 10', // Solid line for first hop, dashed for subsequent
|
|
}
|
|
);
|
|
|
|
line.addTo(self.map!);
|
|
self.rfPathLines.push(line);
|
|
|
|
// Add a small circle marker at the digipeater/igate location
|
|
const circle = L.circleMarker([station.lat, station.lng], {
|
|
radius: 6,
|
|
fillColor: '#2563eb',
|
|
color: '#fff',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.8
|
|
});
|
|
|
|
circle.bindTooltip(station.callsign, {
|
|
permanent: false,
|
|
direction: 'top',
|
|
offset: [0, -10]
|
|
});
|
|
|
|
circle.addTo(self.map!);
|
|
self.rfPathLines.push(circle);
|
|
|
|
// Update prev position for next line
|
|
prevLat = station.lat;
|
|
prevLng = station.lng;
|
|
});
|
|
});
|
|
|
|
// Handle clearing RF path lines
|
|
self.handleEvent("clear_rf_path", () => {
|
|
if (self.rfPathLines) {
|
|
self.rfPathLines.forEach(line => {
|
|
if (self.map && self.map.hasLayer(line)) {
|
|
self.map.removeLayer(line);
|
|
}
|
|
});
|
|
self.rfPathLines = [];
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
|
|
// Handle clearing all markers and reloading visible ones
|
|
self.handleEvent("clear_and_reload_markers", () => {
|
|
// This event is just a trigger - the server will handle clearing and adding markers
|
|
});
|
|
|
|
// Handle clear_all_markers event
|
|
self.handleEvent("clear_all_markers", () => {
|
|
// Modified to preserve historical trails - only removes non-historical markers
|
|
self.clearAllMarkers();
|
|
|
|
// Force trail layer to front to ensure visibility
|
|
if (self.trailLayer && self.trailLayer.bringToFront) {
|
|
self.trailLayer.bringToFront();
|
|
}
|
|
});
|
|
|
|
// Handle heat map data for low zoom levels
|
|
self.handleEvent("show_heat_map", (data: { heat_points: HeatLatLng[] }) => {
|
|
try {
|
|
console.log("Received heat map data with", data.heat_points?.length || 0, "points");
|
|
|
|
if (!self.map || self.isDestroyed) {
|
|
console.warn("Map not ready or destroyed, skipping heat map update");
|
|
return;
|
|
}
|
|
|
|
if (!L.heatLayer) {
|
|
console.error("Leaflet.heat plugin not loaded!");
|
|
return;
|
|
}
|
|
|
|
if (!self.heatLayer) {
|
|
console.log("Creating heat layer for the first time");
|
|
try {
|
|
self.heatLayer = L.heatLayer([], {
|
|
radius: 25,
|
|
blur: 15,
|
|
maxZoom: 8,
|
|
gradient: {
|
|
0.4: 'blue',
|
|
0.65: 'lime',
|
|
0.85: 'yellow',
|
|
1.0: 'red'
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error("Failed to create heat layer:", error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Convert heat points to format expected by Leaflet.heat
|
|
const heatData = data.heat_points.map(point => [
|
|
point.lat,
|
|
point.lng,
|
|
Math.min(point.intensity / 50.0, 1.0) // Normalize intensity to 0-1 range, cap at 1
|
|
] as [number, number, number]);
|
|
|
|
console.log("Setting heat layer data:", heatData.length, "points");
|
|
|
|
// Update heat layer data
|
|
self.heatLayer.setLatLngs(heatData);
|
|
|
|
// Show heat layer and hide marker layer
|
|
if (!self.map.hasLayer(self.heatLayer)) {
|
|
console.log("Adding heat layer to map");
|
|
self.map.addLayer(self.heatLayer);
|
|
}
|
|
if (self.markerLayer && self.map.hasLayer(self.markerLayer)) {
|
|
console.log("Removing marker layer from map");
|
|
self.map.removeLayer(self.markerLayer);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in show_heat_map handler:", error);
|
|
}
|
|
});
|
|
|
|
// Handle switching back to markers
|
|
self.handleEvent("show_markers", () => {
|
|
try {
|
|
console.log("Switching from heat map to markers");
|
|
|
|
if (!self.map || self.isDestroyed) {
|
|
console.warn("Map not ready or destroyed, skipping marker display");
|
|
return;
|
|
}
|
|
|
|
// Hide heat layer and show marker layer
|
|
if (self.heatLayer && self.map.hasLayer(self.heatLayer)) {
|
|
console.log("Removing heat layer");
|
|
self.map.removeLayer(self.heatLayer);
|
|
}
|
|
if (self.markerLayer && !self.map.hasLayer(self.markerLayer)) {
|
|
console.log("Adding marker layer");
|
|
self.map.addLayer(self.markerLayer);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error in show_markers handler:", error);
|
|
}
|
|
});
|
|
},
|
|
|
|
sendBoundsToServer() {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
if (!self.map || self.isDestroyed) return;
|
|
|
|
try {
|
|
const bounds = self.map!.getBounds();
|
|
const center = self.map!.getCenter();
|
|
const zoom = self.map!.getZoom();
|
|
|
|
// We're no longer removing markers outside bounds during normal panning/zooming
|
|
// to preserve historical positions and the current marker
|
|
// self.removeMarkersOutsideBounds(bounds);
|
|
|
|
// Use direct pushEvent call with proper context
|
|
if (self.pushEvent && typeof self.pushEvent === 'function') {
|
|
const boundsData = {
|
|
bounds: {
|
|
north: bounds.getNorth(),
|
|
south: bounds.getSouth(),
|
|
east: bounds.getEast(),
|
|
west: bounds.getWest(),
|
|
},
|
|
center: {
|
|
lat: center.lat,
|
|
lng: center.lng,
|
|
},
|
|
zoom: zoom,
|
|
};
|
|
console.log("Sending bounds_changed event:", boundsData);
|
|
self.pushEvent("bounds_changed", boundsData);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error sending bounds to server:", error);
|
|
}
|
|
},
|
|
|
|
addMarker(data: MarkerData & { openPopup?: boolean }) {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
if (!data.id || !data.lat || !data.lng) {
|
|
console.warn("Invalid marker data:", data);
|
|
return;
|
|
}
|
|
|
|
// Ensure maps are initialized
|
|
if (!self.markers || !self.markerStates || !self.markerLayer) {
|
|
console.warn("Map data structures not initialized, skipping marker add");
|
|
return;
|
|
}
|
|
|
|
const lat = parseFloat(data.lat.toString());
|
|
const lng = parseFloat(data.lng.toString());
|
|
|
|
// Validate coordinates
|
|
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
|
console.warn("Invalid coordinates:", lat, lng);
|
|
return;
|
|
}
|
|
|
|
// Check if marker already exists with same position and data
|
|
const existingMarker = self.markers.get(data.id);
|
|
const existingState = self.markerStates.get(data.id);
|
|
|
|
if (existingMarker && existingState) {
|
|
// 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;
|
|
const dataChanged =
|
|
existingState.symbol_table !== data.symbol_table_id ||
|
|
existingState.symbol_code !== data.symbol_code ||
|
|
existingState.popup !== data.popup;
|
|
|
|
if (positionChanged && self.trailManager) {
|
|
// Position changed, update trail
|
|
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);
|
|
}
|
|
|
|
if (!positionChanged && !dataChanged) {
|
|
// No changes needed, skip update
|
|
// But if openPopup is requested, open it
|
|
if (data.openPopup && existingMarker.openPopup) {
|
|
existingMarker.openPopup();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Remove existing marker if it exists (without affecting trails since we're just replacing it)
|
|
self.removeMarkerWithoutTrail(data.id);
|
|
|
|
// Create marker - use simple dot for older historical positions, APRS icon for most recent
|
|
const marker = L.marker([lat, lng], {
|
|
icon: self.createMarkerIcon(data),
|
|
});
|
|
|
|
// Add popup if content provided
|
|
if (data.popup) {
|
|
marker.bindPopup(data.popup, { autoPan: false });
|
|
|
|
// 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') {
|
|
try {
|
|
self.pushEvent("popup_closed", {});
|
|
} catch (e) {
|
|
// Silently ignore errors if context is lost
|
|
console.debug("Unable to send popup_closed event:", e);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handle marker click
|
|
marker.on("click", () => {
|
|
if (marker.openPopup) marker.openPopup();
|
|
|
|
// Bring the clicked marker to front
|
|
if (marker.getElement) {
|
|
const element = marker.getElement();
|
|
if (element) {
|
|
// 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);
|
|
if (zIndex > maxZIndex) maxZIndex = zIndex;
|
|
});
|
|
|
|
// Set this marker's z-index higher than all others
|
|
element.style.zIndex = (maxZIndex + 1).toString();
|
|
}
|
|
}
|
|
|
|
safePushEvent(self.pushEvent, "marker_clicked", {
|
|
id: data.id,
|
|
callsign: data.callsign,
|
|
lat: lat,
|
|
lng: lng,
|
|
});
|
|
});
|
|
|
|
// Handle marker hover for RF path visualization
|
|
// Check for non-empty RF path (not TCPIP and not empty string)
|
|
if (data.path && data.path.trim() !== "" && !data.path.includes("TCPIP")) {
|
|
console.log("Adding hover handlers for RF packet:", data.callsign, "path:", data.path);
|
|
marker.on("mouseover", () => {
|
|
console.log("Marker hover start for:", data.callsign);
|
|
// Check if LiveView is still connected before sending event
|
|
if (self.pushEvent && !self.isDestroyed) {
|
|
try {
|
|
self.pushEvent("marker_hover_start", {
|
|
id: data.id,
|
|
callsign: data.callsign,
|
|
path: data.path,
|
|
lat: lat,
|
|
lng: lng,
|
|
});
|
|
} catch (error) {
|
|
console.warn("Failed to send marker_hover_start event:", error);
|
|
}
|
|
} else {
|
|
console.warn("LiveView not connected, cannot send hover event");
|
|
}
|
|
});
|
|
|
|
marker.on("mouseout", () => {
|
|
console.log("Marker hover end for:", data.callsign);
|
|
// Check if LiveView is still connected before sending event
|
|
if (self.pushEvent && !self.isDestroyed) {
|
|
try {
|
|
self.pushEvent("marker_hover_end", {
|
|
id: data.id,
|
|
});
|
|
} catch (error) {
|
|
console.warn("Failed to send marker_hover_end event:", error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// Mark historical markers for identification
|
|
if (data.historical) {
|
|
(marker as APRSMarker)._isHistorical = true;
|
|
}
|
|
|
|
// Add to map and store reference
|
|
marker.addTo(self.markerLayer);
|
|
self.markers.set(data.id, marker);
|
|
|
|
// Make sure historical markers and trails stay visible
|
|
if (data.historical || data.is_most_recent_for_callsign) {
|
|
if (self.trailLayer && self.trailLayer.bringToFront) {
|
|
setTimeout(() => self.trailLayer.bringToFront(), 50);
|
|
}
|
|
}
|
|
|
|
// Store marker state for optimization
|
|
self.markerStates.set(data.id, {
|
|
lat: lat,
|
|
lng: lng,
|
|
symbol_table: data.symbol_table_id || "/",
|
|
symbol_code: data.symbol_code || ">",
|
|
popup: data.popup,
|
|
historical: data.historical,
|
|
is_most_recent_for_callsign: data.is_most_recent_for_callsign,
|
|
callsign_group: data.callsign_group || data.callsign,
|
|
callsign: data.callsign,
|
|
});
|
|
|
|
// 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 timestamp = parseTimestamp(data.timestamp);
|
|
// Use callsign_group for proper trail grouping
|
|
const trailId = getTrailId(data);
|
|
|
|
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
|
|
}
|
|
|
|
// Open popup if requested
|
|
if (data.openPopup && marker.openPopup) {
|
|
marker.openPopup();
|
|
}
|
|
|
|
// Only add to OMS if we're at zoom level 11+ (when clustering is disabled)
|
|
if (self.oms && marker && self.map && self.map.getZoom() >= 11) {
|
|
self.oms.addMarker(marker);
|
|
}
|
|
},
|
|
|
|
removeMarker(id: string) {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
// Ensure id is a string
|
|
const markerId = typeof id === "string" ? id : String(id);
|
|
|
|
const marker = self.markers!.get(markerId);
|
|
const markerState = self.markerStates!.get(markerId);
|
|
|
|
if (marker) {
|
|
self.markerLayer!.removeLayer(marker);
|
|
self.markers!.delete(markerId);
|
|
self.markerStates!.delete(markerId);
|
|
}
|
|
|
|
// Remove trail - use callsign_group for proper trail identification
|
|
if (self.trailManager) {
|
|
const trailId = markerState?.callsign_group || markerState?.callsign || markerId;
|
|
self.trailManager.removeTrail(trailId);
|
|
}
|
|
|
|
if (self.oms && marker) {
|
|
try {
|
|
self.oms.removeMarker(marker);
|
|
} catch (e) {
|
|
console.debug("Error removing marker from OMS:", e);
|
|
}
|
|
}
|
|
},
|
|
|
|
updateMarker(data: MarkerData) {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
if (!data.id) return;
|
|
|
|
const existingMarker = self.markers!.get(data.id);
|
|
if (existingMarker) {
|
|
// Update position if provided
|
|
if (data.lat && data.lng) {
|
|
const lat = parseFloat(data.lat.toString());
|
|
const lng = parseFloat(data.lng.toString());
|
|
if (!isNaN(lat) && !isNaN(lng)) {
|
|
const currentPos = existingMarker.getLatLng();
|
|
const positionChanged =
|
|
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 timestamp = parseTimestamp(data.timestamp);
|
|
// Use callsign_group for proper trail grouping
|
|
const trailId = getTrailId(data);
|
|
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update popup if provided
|
|
if (data.popup) {
|
|
existingMarker.setPopupContent(data.popup);
|
|
// Force popup to refresh by unbinding and rebinding
|
|
existingMarker.unbindPopup();
|
|
existingMarker.bindPopup(data.popup, { autoPan: false });
|
|
}
|
|
} else {
|
|
// Marker doesn't exist, create it
|
|
self.addMarker(data);
|
|
}
|
|
},
|
|
|
|
clearAllMarkers() {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
|
|
// Instead of clearing all markers, only remove non-historical and non-current markers
|
|
const markersToPreserve = new Map();
|
|
const statesToPreserve = new Map();
|
|
|
|
// Identify historical markers and most recent markers to preserve
|
|
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;
|
|
|
|
// Keep historical markers and current position markers
|
|
if (isHistorical || isMostRecent) {
|
|
markersToPreserve.set(String(id), marker);
|
|
if (markerState) {
|
|
statesToPreserve.set(String(id), markerState);
|
|
}
|
|
} else {
|
|
// Remove only non-historical, non-current markers
|
|
self.markerLayer!.removeLayer(marker);
|
|
}
|
|
});
|
|
|
|
// Replace the markers and states with just the preserved ones
|
|
self.markers!.clear();
|
|
self.markerStates!.clear();
|
|
|
|
markersToPreserve.forEach((marker, id) => {
|
|
self.markers!.set(id, marker);
|
|
});
|
|
|
|
statesToPreserve.forEach((state, id) => {
|
|
self.markerStates!.set(id, state);
|
|
});
|
|
|
|
// Don't clear trails - keep them visible
|
|
// if (self.trailManager) {
|
|
// self.trailManager.clearAllTrails();
|
|
// }
|
|
|
|
// Make sure trail layer is on top
|
|
if (self.trailLayer && self.trailLayer.bringToFront) {
|
|
setTimeout(() => self.trailLayer.bringToFront(), 100);
|
|
}
|
|
},
|
|
|
|
removeMarkersOutsideBounds(bounds: L.LatLngBounds) {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
if (!bounds || !self.markers) return;
|
|
|
|
const markersToRemove: string[] = [];
|
|
|
|
self.markers!.forEach((marker: L.Marker, id: string) => {
|
|
// 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;
|
|
|
|
// Always preserve historical markers and the most recent marker for a callsign
|
|
if (isHistorical || isMostRecent) {
|
|
return; // Skip this marker, don't remove it
|
|
}
|
|
|
|
const position = marker.getLatLng();
|
|
const lat = position.lat;
|
|
const lng = position.lng;
|
|
|
|
// Check latitude bounds (straightforward)
|
|
const latOutOfBounds = lat < bounds.getSouth() || lat > bounds.getNorth();
|
|
|
|
// Check longitude bounds (handle potential wrapping)
|
|
let lngOutOfBounds: boolean;
|
|
if (bounds.getWest() <= bounds.getEast()) {
|
|
// Normal case: bounds don't cross antimeridian
|
|
lngOutOfBounds = lng < bounds.getWest() || lng > bounds.getEast();
|
|
} else {
|
|
// Bounds cross antimeridian (e.g., west=170, east=-170)
|
|
lngOutOfBounds = lng < bounds.getWest() && lng > bounds.getEast();
|
|
}
|
|
|
|
// Check if marker is outside the current bounds
|
|
if (latOutOfBounds || lngOutOfBounds) {
|
|
markersToRemove.push(id);
|
|
}
|
|
});
|
|
|
|
// Remove out-of-bounds markers without affecting trails
|
|
markersToRemove.forEach((id) => self.removeMarkerWithoutTrail(String(id)));
|
|
},
|
|
|
|
removeMarkerWithoutTrail(id: string) {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
// Ensure id is a string
|
|
const markerId = typeof id === "string" ? id : String(id);
|
|
|
|
const marker = self.markers!.get(markerId);
|
|
|
|
if (marker) {
|
|
// Remove marker from map but don't touch trails
|
|
self.markerLayer!.removeLayer(marker);
|
|
self.markers!.delete(markerId);
|
|
self.markerStates!.delete(markerId);
|
|
// Note: We intentionally don't remove trails here
|
|
}
|
|
},
|
|
|
|
createMarkerIcon(data: MarkerData): L.DivIcon {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
// If this is the most recent for the callsign, always show the APRS icon
|
|
if (data.is_most_recent_for_callsign) {
|
|
// Remove any historical dot at the same position for this callsign
|
|
if (self.markers && self.markerStates) {
|
|
self.markers.forEach((marker: APRSMarker, id: string) => {
|
|
const state = self.markerStates!.get(String(id));
|
|
if (
|
|
state &&
|
|
state.historical &&
|
|
state.callsign_group === data.callsign_group &&
|
|
Math.abs(state.lat - data.lat) < 0.00001 &&
|
|
Math.abs(state.lng - data.lng) < 0.00001
|
|
) {
|
|
self.removeMarkerWithoutTrail(id);
|
|
}
|
|
});
|
|
}
|
|
// Use server-generated symbol HTML if available
|
|
if (data.symbol_html) {
|
|
return L.divIcon({
|
|
html: data.symbol_html,
|
|
className: "",
|
|
iconSize: [120, 32], // Increased width to accommodate callsign label
|
|
iconAnchor: [16, 16],
|
|
});
|
|
}
|
|
}
|
|
|
|
// For historical packets that are not the most recent for their callsign,
|
|
// show a simple dot instead of the full APRS symbol
|
|
if (data.historical && !data.is_most_recent_for_callsign) {
|
|
// Always show a red dot for historical positions
|
|
const iconHtml = `<div style="
|
|
width: 8px;
|
|
height: 8px;
|
|
background-color: #FF6B6B;
|
|
border: 2px solid #FFFFFF;
|
|
border-radius: 50%;
|
|
opacity: 0.8;
|
|
box-shadow: 0 0 2px rgba(0,0,0,0.3);
|
|
" title="Historical position for ${data.callsign}"></div>`;
|
|
|
|
return L.divIcon({
|
|
html: iconHtml,
|
|
className: "historical-dot-marker",
|
|
iconSize: [12, 12],
|
|
iconAnchor: [6, 6],
|
|
});
|
|
}
|
|
|
|
// Fallback: Use server-generated symbol HTML if available
|
|
if (data.symbol_html) {
|
|
return L.divIcon({
|
|
html: data.symbol_html,
|
|
className: "",
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
});
|
|
}
|
|
|
|
// Final fallback: Simple dot
|
|
const iconHtml = `<div style="
|
|
width: 8px;
|
|
height: 8px;
|
|
background-color: #2563eb;
|
|
border: 2px solid #FFFFFF;
|
|
border-radius: 50%;
|
|
opacity: 0.8;
|
|
box-shadow: 0 0 2px rgba(0,0,0,0.3);
|
|
" title="APRS Station: ${data.callsign}"></div>`;
|
|
|
|
return L.divIcon({
|
|
html: iconHtml,
|
|
className: "",
|
|
iconSize: [12, 12],
|
|
iconAnchor: [6, 6],
|
|
});
|
|
},
|
|
|
|
buildPopupContent(data: MarkerData): string {
|
|
const callsign = data.callsign || data.id || "Unknown";
|
|
const comment = data.comment || "";
|
|
// const symbolTableId = data.symbol_table_id || "/";
|
|
// const symbolCode = data.symbol_code || ">";
|
|
// const symbolDesc = data.symbol_description || `Symbol: ${symbolTableId}${symbolCode}`;
|
|
|
|
let content = `<div class="aprs-popup">
|
|
<div class="aprs-callsign"><strong><a href="/${callsign}" class="aprs-lv-link">${callsign}</a></strong> <a href="/info/${callsign}" class="aprs-lv-link aprs-info-link">info</a></div>`;
|
|
// Removed symbol info from popup
|
|
// content += `<div class="aprs-symbol-info">${symbolDesc}</div>`;
|
|
|
|
if (comment) {
|
|
content += `<div class="aprs-comment">${comment}</div>`;
|
|
}
|
|
|
|
if (data.timestamp) {
|
|
const timestamp = parseTimestamp(data.timestamp);
|
|
const date = new Date(timestamp);
|
|
|
|
if (!isNaN(date.getTime())) {
|
|
content += `<div class="aprs-timestamp">${date.toISOString()}</div>`;
|
|
}
|
|
}
|
|
|
|
content += "</div>";
|
|
return content;
|
|
},
|
|
|
|
destroyed() {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
|
|
// Mark as destroyed immediately
|
|
self.isDestroyed = true;
|
|
|
|
// Disable pushEvent to prevent any events from being sent during cleanup
|
|
const originalPushEvent = self.pushEvent;
|
|
self.pushEvent = () => {}; // No-op function
|
|
|
|
// Clear interval timer
|
|
if (self.cleanupInterval !== undefined) {
|
|
clearInterval(self.cleanupInterval);
|
|
self.cleanupInterval = undefined;
|
|
}
|
|
|
|
// Clear programmatic move timeout
|
|
if (self.programmaticMoveTimeout !== undefined) {
|
|
clearTimeout(self.programmaticMoveTimeout);
|
|
self.programmaticMoveTimeout = undefined;
|
|
}
|
|
|
|
// Remove popup navigation event listener
|
|
if (self.popupNavigationHandler) {
|
|
document.removeEventListener('click', self.popupNavigationHandler);
|
|
self.popupNavigationHandler = undefined;
|
|
}
|
|
|
|
// Close any open popups before cleanup
|
|
if (self.map !== undefined) {
|
|
try {
|
|
self.map.closePopup();
|
|
} catch (e) {
|
|
console.debug("Error closing popup during cleanup:", e);
|
|
}
|
|
}
|
|
|
|
if (self.boundsTimer !== undefined) {
|
|
clearTimeout(self.boundsTimer);
|
|
}
|
|
if (self.resizeHandler !== undefined) {
|
|
window.removeEventListener("resize", self.resizeHandler);
|
|
self.resizeHandler = undefined;
|
|
}
|
|
|
|
// Remove map event handlers
|
|
if (self.map !== undefined && self.mapEventHandlers !== undefined) {
|
|
self.mapEventHandlers.forEach((handler, event) => {
|
|
try {
|
|
self.map.off(event, handler);
|
|
} catch (e) {
|
|
console.debug(`Error removing ${event} handler:`, e);
|
|
}
|
|
});
|
|
self.mapEventHandlers.clear();
|
|
}
|
|
|
|
// Remove all event listeners from markers before clearing layers
|
|
if (self.markers !== undefined) {
|
|
self.markers.forEach((marker: APRSMarker) => {
|
|
try {
|
|
marker.off(); // Remove all event listeners
|
|
if (marker.getPopup()) {
|
|
marker.unbindPopup(); // Unbind popup to prevent events
|
|
}
|
|
} catch (e) {
|
|
console.debug(`Error cleaning up marker:`, e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Clean up OMS (OverlappingMarkerSpiderfier)
|
|
if (self.oms !== undefined) {
|
|
try {
|
|
self.oms.clearMarkers();
|
|
self.oms.clearListeners('click');
|
|
self.oms.clearListeners('spiderfy');
|
|
self.oms.clearListeners('unspiderfy');
|
|
self.oms = undefined;
|
|
} catch (e) {
|
|
console.debug("Error cleaning up OMS:", e);
|
|
}
|
|
}
|
|
|
|
// Clean up heat layer
|
|
if (self.heatLayer !== undefined && self.map !== undefined) {
|
|
try {
|
|
if (self.map.hasLayer(self.heatLayer)) {
|
|
self.map.removeLayer(self.heatLayer);
|
|
}
|
|
self.heatLayer = undefined;
|
|
} catch (e) {
|
|
console.debug("Error removing heat layer:", e);
|
|
}
|
|
}
|
|
|
|
if (self.markerLayer !== undefined) {
|
|
self.markerLayer!.clearLayers();
|
|
// If it's a cluster group, remove it from the map
|
|
if (self.markerClusterGroup !== undefined && self.map !== undefined) {
|
|
try {
|
|
self.map.removeLayer(self.markerClusterGroup);
|
|
self.markerClusterGroup = undefined;
|
|
} catch (e) {
|
|
console.debug("Error removing cluster group:", e);
|
|
}
|
|
}
|
|
}
|
|
if (self.markers !== undefined) {
|
|
self.markers!.clear();
|
|
}
|
|
if (self.markerStates !== undefined) {
|
|
self.markerStates!.clear();
|
|
}
|
|
if (self.map !== undefined) {
|
|
self.map!.remove();
|
|
self.map = undefined;
|
|
}
|
|
|
|
// Restore original pushEvent (though it won't be used since we're destroyed)
|
|
self.pushEvent = originalPushEvent;
|
|
},
|
|
};
|
|
|
|
// Helper to validate and fallback symbol code per aprs.fi logic
|
|
function getValidSymbolCode(
|
|
symbolCode: string | undefined,
|
|
symbolTableId: string | undefined,
|
|
): string {
|
|
if (!symbolCode || symbolCode.length !== 1) {
|
|
return symbolTableId === "\\" ? "O" : ">";
|
|
}
|
|
const code = symbolCode.charCodeAt(0);
|
|
if (code < 33 || code > 126) {
|
|
return symbolTableId === "\\" ? "O" : ">";
|
|
}
|
|
return symbolCode;
|
|
}
|
|
|
|
export default MapAPRSMap;
|