aprs.me/assets/js/map.ts
Graham McIntire f005a1a119
fix: prevent incoming packets from closing user-opened map popup
Add a client-side userOpenedPopupMarkerId lock that's set instantly when
a user manually opens a popup (via click, OMS spiderfy click, or long-press)
and cleared when the popup closes. All programmatic openPopup paths
(highlight_packet, addMarker openPopup flag) now respect the lock.

Closes the network round-trip race where new packets arriving between
the user's click and the server's marker_clicked ack could steal the popup.
2026-04-27 09:46:45 -05:00

2694 lines
86 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 {
LeafletTouchEvent,
LeafletPopupEvent,
} from "./types/leaflet-events";
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";
// Leaflet and plugins are loaded globally from vendor bundle
// We'll access window.L dynamically when needed instead of storing it at module load time
// CSS files are bundled in vendor.js
// The OverlappingMarkerSpiderfier is attached to window by the UMD module
declare global {
interface Window {
OverlappingMarkerSpiderfier: any;
}
}
// Add plugin types to Leaflet
declare module "leaflet" {
export function heatLayer(
latlngs: HeatLatLng[],
options?: HeatLayerOptions,
): HeatLayer;
export function markerClusterGroup(
options?: MarkerClusterGroupOptions,
): MarkerClusterGroup;
}
// Import trail management functionality
import { TrailManager } from "./features/trail_manager";
// Import helper functions
import {
parseTimestamp,
getTrailId,
saveMapState,
safePushEvent,
getLiveSocket,
escapeHtml,
isValidCoordinate,
unwrapLongitudes,
} from "./map_helpers";
// APRS Map Hook - handles only basic map interaction
// All data logic handled by LiveView
// Zoom level at which marker clustering is disabled and individual markers are shown.
// Trails are only visible at this zoom level and above to avoid showing trails
// for markers hidden inside cluster bubbles.
const DISABLE_CLUSTERING_AT_ZOOM = 11;
let MapAPRSMap = {
mounted() {
const self = this as unknown as LiveViewHookContext;
console.log("APRSMap hook mounted() called on element:", this.el);
// 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 window.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;
}
}
// Create a local reference to Leaflet for this function
const L = window.L;
// 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;
}
self.initializeMap(initialCenter, initialZoom);
},
initializeMap(initialCenter: CenterData, initialZoom: number) {
const self = this as unknown as LiveViewHookContext;
// Check if Leaflet is still available
if (typeof window.L === "undefined") {
console.error("Leaflet library not loaded!");
self.handleFatalError("Leaflet library not available");
return;
}
// Create a local reference to Leaflet for this function
const L = window.L;
// Ensure the element and its parent exist
if (!self.el || !self.el.parentNode) {
console.warn("Map element or parent not found, retrying...");
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;
}
// Detect if mobile device
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
self.map = L.map(self.el, {
zoomControl: !isMobile, // Hide default zoom control on mobile, we'll add a better one
attributionControl: true,
closePopupOnClick: true,
tap: true, // Enable tap events for mobile
tapTolerance: 30, // Increase tap tolerance for mobile
touchZoom: true,
bounceAtZoomLimits: false, // Disable bouncing at zoom limits
worldCopyJump: true, // Better panning behavior
preferCanvas: isMobile, // Use canvas renderer on mobile for better performance
zoomAnimation: !isMobile, // Disable zoom animations on mobile for performance
fadeAnimation: !isMobile, // Disable fade animations on mobile
markerZoomAnimation: !isMobile, // Disable marker animations on mobile
}).setView([initialCenter.lat, initialCenter.lng], initialZoom);
// Add mobile-friendly zoom control if on mobile
if (isMobile) {
L.control
.zoom({
position: "bottomright",
})
.addTo(self.map);
// Add touch gesture handling
self.setupMobileGestures();
}
} 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 with improved settings
try {
// Tile provider options
const tileProviders = {
osm: {
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
subdomains: ["a", "b", "c"],
},
osmDE: {
url: "https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png",
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
subdomains: ["a", "b", "c"],
},
carto: {
url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png",
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a> | APRS.me',
subdomains: ["a", "b", "c", "d"],
},
};
// Create raster tile layer
const provider = tileProviders.osm;
const tileLayer = L.tileLayer(provider.url, {
attribution: provider.attribution,
maxZoom: 19,
subdomains: provider.subdomains,
tileSize: 256,
keepBuffer: 2,
updateWhenZooming: false,
updateInterval: 200,
// Remove errorTileUrl to let Leaflet handle tile errors gracefully
// errorTileUrl causes issues with some browsers when using data URLs
});
// Add event handler for tile errors with retry logic
let retryCount = new Map<string, number>();
tileLayer.on("tileerror", function (error: any) {
const src = error.tile.src;
const count = retryCount.get(src) || 0;
if (count < 3) {
console.warn(`Tile load error (attempt ${count + 1}/3):`, src);
retryCount.set(src, count + 1);
// Exponential backoff
setTimeout(
() => {
error.tile.src =
src + (src.includes("?") ? "&" : "?") + "_retry=" + Date.now();
},
Math.pow(2, count) * 500,
);
} else {
console.error("Tile failed after 3 attempts:", src);
retryCount.delete(src);
}
});
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: DISABLE_CLUSTERING_AT_ZOOM,
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 createDivIcon("<div><span>" + count + "</span></div>", {
className: "marker-cluster " + className,
iconSize: [40, 40],
iconAnchor: [20, 20],
});
},
});
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 with RF path hover callbacks
// Only add to map if zoom is high enough (trails hidden when markers are clustered)
const trailLayer = L.layerGroup();
if (self.map!.getZoom() >= DISABLE_CLUSTERING_AT_ZOOM) {
trailLayer.addTo(self.map!);
}
self.trailLayer = trailLayer;
self.trailManager = new TrailManager(
trailLayer,
60 * 60 * 1000,
// onTrailHover: send RF path visualization event to backend
(lat: number, lng: number, path: string) => {
if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
try {
self.pushEvent.call(self, "marker_hover_start", {
id: "trail_hover",
callsign: "",
path: path,
lat: lat,
lng: lng,
});
} catch (error) {
console.warn("Failed to send trail hover event:", error);
}
}
},
// onTrailHoverEnd: clear RF path lines
() => {
if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
try {
self.pushEvent.call(self, "marker_hover_end", {
id: "trail_hover",
});
} catch (error) {
console.warn("Failed to send trail hover end event:", error);
}
}
},
);
// Track marker states to prevent unnecessary operations
self.markerStates = new Map<string, MarkerState>();
// Reverse index: callsign_group → Set of marker IDs for O(1) lookup
self.callsignIndex = new Map<string, Set<string>>();
// 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);
}
// Helper function to send map ready events with retry
self.sendMapReadyEvents = (retryCount = 0) => {
if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
self.pushEvent("map_ready", {});
if (self.map) {
saveMapState(self.map, self.pushEvent.bind(self));
self.sendBoundsToServer();
// Send map state update after a delay
setTimeout(() => {
if (self.map && self.pushEvent && !self.isDestroyed) {
saveMapState(self.map, self.pushEvent.bind(self));
}
}, 500);
}
} else if (retryCount < 3) {
// Retry up to 3 times
console.warn(
`pushEvent not available, retrying... (attempt ${retryCount + 1})`,
);
setTimeout(() => self.sendMapReadyEvents(retryCount + 1), 200);
}
};
// Track when map is ready
self.map!.whenReady(() => {
if (self.isDestroyed) return;
try {
self.lastZoom = self.map!.getZoom();
self.sendMapReadyEvents();
// Process any pending markers that were queued before map was ready
if (self.pendingMarkers && self.pendingMarkers.length > 0) {
console.log(
`Processing ${self.pendingMarkers.length} pending markers`,
);
self.pendingMarkers.forEach((markerData: MarkerData) => {
try {
self.addMarker(markerData);
} catch (error) {
console.error("Error adding pending marker:", error, markerData);
}
});
self.pendingMarkers = [];
}
} catch (error) {
console.error("Error in map ready callback:", error);
}
});
// Start periodic cleanup of old trail positions (every 5 minutes)
self.cleanupInterval = setInterval(
() => {
if (!self.isDestroyed && self.trailManager) {
self.trailManager.cleanupOldPositions();
}
},
5 * 60 * 1000,
);
// 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 &&
self.pushEvent &&
typeof self.pushEvent === "function"
) {
saveMapState(self.map, self.pushEvent.bind(self));
}
}, 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;
// Only rebuild OMS when crossing the clustering threshold
const wasUnclustered = self.lastZoom !== undefined && self.lastZoom >= DISABLE_CLUSTERING_AT_ZOOM;
const isUnclustered = currentZoom >= DISABLE_CLUSTERING_AT_ZOOM;
if (self.oms && (wasUnclustered !== isUnclustered || self.lastZoom === undefined)) {
self.oms.clearMarkers();
self.markers.forEach((marker, id) => {
const markerState = self.markerStates.get(String(id));
const shouldAddToOms =
markerState?.is_most_recent_for_callsign === true ||
(markerState?.is_most_recent_for_callsign == null &&
!marker._isHistorical);
if (
marker &&
!marker._isClusterMarker &&
markerState &&
shouldAddToOms
) {
self.oms.addMarker(marker);
}
});
}
// Hide trails when markers are clustered, show when unclustered
if (self.trailLayer && self.map) {
if (currentZoom >= DISABLE_CLUSTERING_AT_ZOOM) {
if (!self.map.hasLayer(self.trailLayer)) {
self.trailLayer.addTo(self.map);
self.trailLayer.bringToFront();
}
} else {
if (self.map.hasLayer(self.trailLayer)) {
self.map.removeLayer(self.trailLayer);
}
}
}
// 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 &&
self.pushEvent &&
typeof self.pushEvent === "function"
) {
saveMapState(self.map, self.pushEvent.bind(self));
}
}, 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);
// Queue to hold markers that arrive before map is ready
self.pendingMarkers = [];
// LiveView event handlers
self.setupLiveViewHandlers();
// Process any pending markers once the map is ready
self.map.whenReady(() => {
if (self.pendingMarkers && self.pendingMarkers.length > 0) {
console.log(`Processing ${self.pendingMarkers.length} pending markers`);
self.pendingMarkers.forEach((markerData) => {
self.addMarker(markerData);
});
self.pendingMarkers = [];
}
});
// 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 window.OverlappingMarkerSpiderfier !== "undefined") {
self.oms = new window.OverlappingMarkerSpiderfier(self.map, {
keepSpiderfied: true,
nearbyDistance: 65,
circleSpiralSwitchover: 10,
circleFootSeparation: 80,
spiralFootSeparation: 50,
spiralLengthStart: 30,
spiralLengthFactor: 6,
legWeight: 2,
legColors: {
usual: "#222",
highlighted: "#f00",
},
spiderfyDistanceMultiplier: 6,
});
// Add click handler for spiderfied markers
self.oms.addListener("click", function (marker: Marker) {
const aprsMarker = marker as APRSMarker;
if (aprsMarker._markerId) {
self.userOpenedPopupMarkerId = aprsMarker._markerId;
}
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>
`;
}
},
setupMobileGestures() {
const self = this as unknown as LiveViewHookContext;
// Long press timer
let longPressTimer: any = null;
let startX = 0;
let startY = 0;
const longPressDuration = 750; // 750ms for long press
const movementThreshold = 10; // pixels
const handleLongPress = (e: TouchEvent) => {
if (e.touches.length !== 1) return; // Only handle single touch
const touch = e.touches[0];
const latlng = self.map!.containerPointToLatLng(
L.point(touch.clientX, touch.clientY),
);
// Find the nearest marker
let nearestMarker: APRSMarker | null = null;
let minDistance = Infinity;
self.markers.forEach((marker) => {
const markerLatLng = marker.getLatLng();
const distance = latlng.distanceTo(markerLatLng);
if (distance < minDistance && distance < 50) {
// Within 50 meters
minDistance = distance;
nearestMarker = marker;
}
});
if (nearestMarker) {
const aprsMarker = nearestMarker as APRSMarker;
if (aprsMarker._markerId) {
self.userOpenedPopupMarkerId = aprsMarker._markerId;
}
nearestMarker.openPopup();
}
};
self.map!.on("touchstart", (e: LeafletTouchEvent) => {
if (e.originalEvent.touches.length !== 1) return;
const touch = e.originalEvent.touches[0];
startX = touch.clientX;
startY = touch.clientY;
longPressTimer = setTimeout(() => {
if (!self.isDestroyed && self.map) {
handleLongPress(e.originalEvent);
}
}, longPressDuration);
});
self.map!.on("touchmove", (e: LeafletTouchEvent) => {
if (!longPressTimer) return;
const touch = e.originalEvent.touches[0];
const deltaX = Math.abs(touch.clientX - startX);
const deltaY = Math.abs(touch.clientY - startY);
// Cancel long press if moved too much
if (deltaX > movementThreshold || deltaY > movementThreshold) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
self.map!.on("touchend", () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
self.map!.on("touchcancel", () => {
if (longPressTimer) {
clearTimeout(longPressTimer);
longPressTimer = null;
}
});
},
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;
if (!(target instanceof Element)) {
return;
}
// Check if clicked element or its parent is a LiveView navigation link
const navLink = target.closest(".aprs-lv-link");
if (navLink instanceof HTMLAnchorElement && navLink.href) {
e.preventDefault();
e.stopPropagation();
// Use Phoenix LiveView's built-in navigation
const liveSocket = getLiveSocket();
if (liveSocket) {
try {
liveSocket.pushHistoryPatch(navLink.href, "push", navLink);
return;
} catch (error) {
console.warn("LiveView navigation failed, falling back to full navigation", error);
}
}
// Fallback to regular navigation if LiveView socket is not available or patching fails
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) => {
// Check if map is ready before adding markers
if (!self.map || !self.markerLayer) {
console.warn("Map not ready, queueing marker for later:", data.id);
// Queue the marker to be added when map is ready
if (!self.pendingMarkers) {
self.pendingMarkers = [];
}
self.pendingMarkers.push(data);
return;
}
self.addMarker(data);
});
// Add multiple markers at once
self.handleEvent("add_markers", (data: { markers: MarkerData[] }) => {
if (data.markers && Array.isArray(data.markers)) {
// Check if map is ready before adding markers
if (!self.map || !self.markerLayer) {
console.warn("Map not ready, queueing markers for later");
// Queue the markers to be added when map is ready
if (!self.pendingMarkers) {
self.pendingMarkers = [];
}
self.pendingMarkers.push(...data.markers);
return;
}
data.markers.forEach((marker) => self.addMarker(marker));
}
});
// Remove marker
self.handleEvent("remove_marker", (data: { id: string }) => {
self.removeMarker(data.id);
});
// Remove markers in batch
self.handleEvent("remove_markers_batch", (data: { ids: string[] }) => {
data.ids.forEach((id) => self.removeMarker(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 (!isValidCoordinate(lat, lng)) {
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
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);
}
}, 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) => {
if (
!self.pushEvent ||
typeof self.pushEvent !== "function" ||
self.isDestroyed
) {
return;
}
const { latitude, longitude } = position.coords;
self.pushEvent("set_location", { lat: latitude, lng: longitude });
},
(error) => {
if (
!self.pushEvent ||
typeof self.pushEvent !== "function" ||
self.isDestroyed
) {
return;
}
console.warn("Geolocation error:", error.message);
self.pushEvent("geolocation_error", { error: error.message });
},
);
} else {
if (
!self.pushEvent ||
typeof self.pushEvent !== "function" ||
self.isDestroyed
) {
return;
}
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 trail duration updates from LiveView
self.handleEvent(
"update_trail_duration",
(data: { duration_hours: number }) => {
if (self.trailManager) {
self.trailManager.setTrailDuration(data.duration_hours);
}
},
);
// Handle new packets from LiveView
self.handleEvent("new_packet", (data: MarkerData) => {
try {
// Skip if context is lost
if (!self || !self.map || self.isDestroyed) {
console.warn(
"Map context not ready or destroyed, skipping new packet",
);
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)) {
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 using reverse index for O(1) lookup
const markersToConvert: string[] = [];
// Ensure markerStates exists
if (!self.markerStates) {
console.warn("markerStates not initialized, skipping conversion");
return;
}
const existingIds = self.callsignIndex?.get(incomingCallsign);
if (existingIds) {
for (const id of existingIds) {
const state = self.markerStates.get(id);
if (
state &&
(!state.historical || state.is_most_recent_for_callsign) &&
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,
openPopup: shouldOpenPopup,
});
} catch (error) {
console.error("Error in new_packet handler:", error);
}
});
// Handle batched new packets from LiveView (performance optimization)
self.handleEvent(
"new_packets",
(payload: {
packets: MarkerData[];
convert_to_historical?: string[];
}) => {
try {
if (!self || !self.map || self.isDestroyed) return;
if (!self.map.hasLayer) return;
if (self.heatLayer && self.map.hasLayer(self.heatLayer)) return;
if (!self.markerStates || !self.markers) return;
const packets = payload.packets;
if (!packets || packets.length === 0) return;
// Phase 1: Find markers to convert to historical dots.
// If the server provides convert_to_historical callsign keys, use
// those for a targeted lookup instead of scanning all markerStates.
const serverConvertKeys = payload.convert_to_historical;
let markersToConvert: string[] = [];
// Single pass: build new IDs set and callsigns to convert
const allNewIds = new Set<string>();
const callsignsToConvert: Set<string> = new Set();
if (serverConvertKeys && serverConvertKeys.length > 0) {
for (const cs of serverConvertKeys) callsignsToConvert.add(cs);
for (const data of packets) {
if (data.id) allNewIds.add(String(data.id));
}
} else {
for (const data of packets) {
if (data.id) allNewIds.add(String(data.id));
const cs = data.callsign_group || data.callsign || data.id;
if (cs) callsignsToConvert.add(cs);
}
}
// Use callsignIndex for O(1) lookup per callsign instead of scanning all markerStates
if (self.callsignIndex) {
for (const cs of callsignsToConvert) {
const ids = self.callsignIndex.get(cs);
if (!ids) continue;
for (const id of ids) {
if (allNewIds.has(String(id))) continue;
const state = self.markerStates.get(id);
if (
state &&
(!state.historical || state.is_most_recent_for_callsign)
) {
markersToConvert.push(String(id));
}
}
}
}
// Phase 2: Convert all existing live markers to historical dots
for (const id of markersToConvert) {
const existingMarker = self.markers.get(id);
const existingState = self.markerStates.get(id);
if (existingMarker && existingState) {
existingState.historical = true;
existingState.is_most_recent_for_callsign = false;
const incomingCallsign =
existingState.callsign_group || existingState.callsign;
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,
};
existingMarker.setIcon(self.createMarkerIcon(historicalIconData));
(existingMarker as APRSMarker)._isHistorical = true;
}
}
// Phase 3: Add all new markers
for (const data of packets) {
const shouldOpenPopup = data.openPopup !== false;
const incomingCallsign =
data.callsign_group || data.callsign || data.id;
self.addMarker({
...data,
historical: false,
is_most_recent_for_callsign: true,
callsign_group: incomingCallsign,
popup: data.popup,
openPopup: shouldOpenPopup,
});
}
} catch (error) {
console.error("Error in new_packets handler:", error);
}
},
);
// Handle highlighting the latest packet (open its popup)
self.currentPopupMarkerId = null;
self.userOpenedPopupMarkerId = null;
self.handleEvent("highlight_packet", (data: { id: string }) => {
if (!data.id || !self.markers || !self.markerStates) return;
if (self.userOpenedPopupMarkerId) 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,
});
});
// Handle bulk loading of historical packets
// Server pre-sorts by callsign group with chronological order within each group
self.handleEvent(
"add_historical_packets",
(data: { packets: MarkerData[] }) => {
if (data.packets && Array.isArray(data.packets)) {
for (const packet of data.packets) {
try {
self.addMarker({
...packet,
historical: true,
popup: packet.popup,
});
} catch (error) {
console.error("Error adding historical packet:", error, packet);
}
}
}
},
);
// Handle progressive loading of historical packets (batch processing)
// Server pre-sorts by callsign group with chronological order within each group
self.handleEvent(
"add_historical_packets_batch",
(data: { packets: MarkerData[]; batch: number; is_final: boolean }) => {
console.log("Received add_historical_packets_batch event:", {
packetCount: data.packets?.length || 0,
batch: data.batch,
is_final: data.is_final,
});
try {
if (data.packets && Array.isArray(data.packets)) {
for (const packet of data.packets) {
try {
self.addMarker({
...packet,
historical: true,
popup: packet.popup,
});
} catch (error) {
console.error(
"Error adding historical packet:",
error,
packet,
);
}
}
}
} catch (error) {
console.error("Error processing historical packets batch:", error);
}
},
);
// 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", () => {
// 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;
// Validate initial station coordinates
if (!isFinite(data.station_lat) || !isFinite(data.station_lng)) {
console.warn("Invalid initial station coordinates for RF path:", {
lat: data.station_lat,
lng: data.station_lng,
});
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) => {
// Validate coordinates before drawing
if (
!isFinite(prevLat) ||
!isFinite(prevLng) ||
!isFinite(station.lat) ||
!isFinite(station.lng)
) {
console.warn("Invalid coordinates for RF path:", {
prevLat,
prevLng,
station,
});
return;
}
// 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 ? undefined : "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", () => {
self.clearRfPathLines();
});
// Resize map when slideover toggles (class changes handled by LiveView JS commands)
self.mapResizeHandler = () => {
setTimeout(() => {
if (self.map) {
self.map.invalidateSize();
}
}, 350);
};
self.el.addEventListener("phx:map-resize", self.mapResizeHandler);
// 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", () => {
// Clear trail line if present
if (self.trailLine) {
self.map?.removeLayer(self.trailLine);
self.trailLine = undefined;
}
// 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 {
if (!self.map || self.isDestroyed) {
console.warn("Map not ready or destroyed, skipping heat map update");
return;
}
// Clear trail line if present
if (self.trailLine) {
self.map.removeLayer(self.trailLine);
self.trailLine = undefined;
}
if (!L.heatLayer) {
console.error("Leaflet.heat plugin not loaded!");
return;
}
if (!self.heatLayer) {
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.
// Server pre-normalizes intensity to [0, 1] but validate defensively
// so malformed rows can't distort the whole heat layer.
const heatData = data.heat_points
.filter(
(point) =>
isValidCoordinate(point.lat, point.lng) &&
typeof point.intensity === "number" &&
isFinite(point.intensity) &&
point.intensity >= 0 &&
point.intensity <= 1,
)
.map(
(point) =>
[point.lat, point.lng, point.intensity] as [
number,
number,
number,
],
);
// Update heat layer data
self.heatLayer.setLatLngs(heatData);
// Show heat layer and hide marker layer
if (!self.map.hasLayer(self.heatLayer)) {
self.map.addLayer(self.heatLayer);
}
if (self.markerLayer && self.map.hasLayer(self.markerLayer)) {
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 {
if (!self.map || self.isDestroyed) {
console.warn("Map not ready or destroyed, skipping marker display");
return;
}
// Clear trail line if present
if (self.trailLine) {
self.map.removeLayer(self.trailLine);
self.trailLine = undefined;
}
// Hide heat layer and show marker layer
if (self.heatLayer && self.map.hasLayer(self.heatLayer)) {
self.map.removeLayer(self.heatLayer);
}
if (self.markerLayer && !self.map.hasLayer(self.markerLayer)) {
self.map.addLayer(self.markerLayer);
}
} catch (error) {
console.error("Error in show_markers handler:", error);
}
});
// Handle trail line display for tracked callsigns at low zoom
self.handleEvent("show_trail_line", (payload: BaseEventPayload) => {
const data = payload as { callsign: string; points: Array<{ lat: number; lng: number; timestamp: string }> };
if (!self.map) {
console.warn("Map not initialized, cannot show trail line");
return;
}
// Clear existing trail line if present
if (self.trailLine) {
self.map.removeLayer(self.trailLine);
self.trailLine = undefined;
}
// Clear heat map if present (switching from heat map to trail line)
if (self.heatLayer) {
self.map.removeLayer(self.heatLayer);
self.heatLayer = undefined;
}
// Filter out any invalid coordinates before building the polyline —
// NaN / Infinity / out-of-range values make Leaflet render junk.
const validPoints = data.points.filter((point) =>
isValidCoordinate(point.lat, point.lng),
);
// If we have less than 2 valid points, don't draw a line
if (validPoints.length < 2) {
console.debug(
`Not enough valid points for trail line: ${validPoints.length} of ${data.points.length}`,
);
return;
}
// Unwrap longitudes so a trail that crosses the antimeridian draws as a
// short hop instead of a polyline that spans the whole world.
const latlngs = unwrapLongitudes(
validPoints.map((p) => [p.lat, p.lng] as [number, number]),
);
// Access Leaflet from window
const L = (window as any).L;
// Create polyline
self.trailLine = L.polyline(latlngs, {
color: '#3B82F6', // blue-500
weight: 3,
opacity: 0.8,
lineCap: 'round',
lineJoin: 'round',
interactive: true
});
// Add to map
self.trailLine.addTo(self.map);
console.debug(`Trail line added for ${data.callsign} with ${validPoints.length} points`);
});
// Handle clearing trail line
self.handleEvent("clear_trail_line", (_payload: BaseEventPayload) => {
if (!self.map) {
return;
}
// Remove trail line if present
if (self.trailLine) {
self.map.removeLayer(self.trailLine);
self.trailLine = undefined;
console.debug("Trail line cleared");
}
});
},
sendBoundsToServer() {
const self = this as unknown as LiveViewHookContext;
console.log(
"sendBoundsToServer called, map:",
!!self.map,
"isDestroyed:",
self.isDestroyed,
);
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;
const L = window.L;
if (
!data ||
!data.id ||
!data.lat ||
!data.lng ||
typeof data.lat !== "number" ||
typeof data.lng !== "number"
) {
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;
}
// Additional check to ensure map is fully ready
if (
!self.map ||
!self.map.getContainer() ||
typeof self.map.getZoom !== "function"
) {
console.warn("Map not fully initialized, queueing marker:", data.id);
if (!self.pendingMarkers) {
self.pendingMarkers = [];
}
self.pendingMarkers.push(data);
return;
}
// Extract coordinates - handle both direct values and nested arrays
const lat = extractCoordinate(data.lat);
const lng = extractCoordinate(data.lng);
// Validate coordinates
if (!isValidCoordinate(lat, lng)) {
console.warn("Invalid coordinates for marker:", {
id: data.id,
lat,
lng,
callsign: data.callsign,
rawLat: data.lat,
rawLng: data.lng,
});
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,
data.path,
);
}
if (!positionChanged && !dataChanged) {
// No changes needed, skip update
// But if openPopup is requested and no user popup is locked, open it
if (data.openPopup && existingMarker.openPopup && !self.userOpenedPopupMarkerId) {
existingMarker.openPopup();
}
return;
}
}
// Remove existing marker if it exists (without affecting trails since we're just replacing it)
self.removeMarkerWithoutTrail(data.id);
// When adding a "most recent" marker, convert any existing "current" markers
// for the same callsign_group to historical dots. This prevents duplicate labels
// when multiple senders report the same object (e.g. two stations tracking one radiosonde).
if (data.is_most_recent_for_callsign && self.callsignIndex) {
const csGroup = data.callsign_group || data.callsign;
if (csGroup) {
const existingIds = self.callsignIndex.get(csGroup);
if (existingIds) {
for (const eid of existingIds) {
if (String(eid) === String(data.id)) continue;
const eState = self.markerStates.get(eid);
const eMarker = self.markers.get(eid);
if (
eState &&
eMarker &&
eState.is_most_recent_for_callsign
) {
eState.historical = true;
eState.is_most_recent_for_callsign = false;
eMarker.setIcon(
self.createMarkerIcon({
id: String(eid),
lat: eState.lat,
lng: eState.lng,
callsign: eState.callsign || csGroup,
callsign_group: eState.callsign_group || csGroup,
symbol_table_id: eState.symbol_table,
symbol_code: eState.symbol_code,
historical: true,
is_most_recent_for_callsign: false,
popup: eState.popup,
}),
);
(eMarker as APRSMarker)._isHistorical = true;
}
}
}
}
}
// Create marker - use simple dot for older historical positions, APRS icon for most recent
const marker = L.marker([lat, lng], {
icon: self.createMarkerIcon(data),
});
(marker as APRSMarker)._markerId = data.id;
// 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", () => {
if (self.userOpenedPopupMarkerId === data.id) {
self.userOpenedPopupMarkerId = null;
}
if (
!self.isDestroyed &&
self.pushEvent &&
typeof self.pushEvent === "function"
) {
try {
self.pushEvent("popup_closed", {});
} catch (e) {
console.debug("Unable to send popup_closed event:", e);
}
}
});
}
// Handle marker click
marker.on("click", (e: any) => {
// Don't open popup if the marker is managed by OMS and there are nearby markers
// The OMS will handle the click and open the popup after spiderfying
if (self.oms && marker._oms && marker._omsData) {
// Marker is currently spiderfied, allow normal popup
self.userOpenedPopupMarkerId = data.id;
if (marker.openPopup) marker.openPopup();
} else if (!self.oms || !marker._oms) {
// Normal marker not managed by OMS
self.userOpenedPopupMarkerId = data.id;
if (marker.openPopup) marker.openPopup();
}
// If marker._oms is true but no _omsData, let OMS handle it (will spiderfy)
// Bring the clicked marker to front using a counter instead of DOM scan
if (marker.getElement) {
const element = marker.getElement();
if (element) {
self.markerZIndexCounter = (self.markerZIndexCounter || 1000) + 1;
element.style.zIndex = self.markerZIndexCounter.toString();
}
}
// Use bound pushEvent function to preserve context
if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
safePushEvent(self.pushEvent.bind(self), "marker_clicked", {
id: data.id,
callsign: data.callsign,
lat: lat,
lng: lng,
});
}
});
// Handle marker hover for RF path visualization
if (data.path && data.path.trim() !== "") {
marker.on("mouseover", () => {
// Check if LiveView is still connected before sending event
if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
try {
self.pushEvent.call(self, "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", () => {
// Check if LiveView is still connected before sending event
if (
self.pushEvent &&
typeof self.pushEvent === "function" &&
!self.isDestroyed
) {
try {
self.pushEvent.call(self, "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,
});
// Update callsign reverse index
const csGroup = data.callsign_group || data.callsign;
if (csGroup && self.callsignIndex) {
let ids = self.callsignIndex.get(csGroup);
if (!ids) {
ids = new Set<string>();
self.callsignIndex.set(csGroup, ids);
}
ids.add(data.id);
}
// 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,
data.path,
);
}
// Open popup if requested and no user popup is locked
if (data.openPopup && marker.openPopup && !self.userOpenedPopupMarkerId) {
marker.openPopup();
}
// Add to OMS for overlapping marker handling (only most recent packets with icons)
// Fallback: if is_most_recent_for_callsign is undefined, exclude historical markers as before
const shouldAddToOms =
data.is_most_recent_for_callsign === true ||
(data.is_most_recent_for_callsign == null &&
!(marker as APRSMarker)._isHistorical);
if (
self.oms &&
marker &&
self.map &&
!marker._isClusterMarker &&
shouldAddToOms
) {
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) {
try {
// Remove marker from appropriate layer with safety checks
if (
self.markerClusterGroup &&
self.markerClusterGroup.hasLayer(marker)
) {
// Check if cluster group is ready before removing
if (
self.markerClusterGroup._map &&
self.markerClusterGroup._topClusterLevel
) {
self.markerClusterGroup.removeLayer(marker);
} else {
console.warn(
"Cluster group not ready, skipping marker removal:",
markerId,
);
}
} else if (self.markerLayer && self.markerLayer.hasLayer(marker)) {
self.markerLayer.removeLayer(marker);
}
self.markers!.delete(markerId);
self.markerStates!.delete(markerId);
} catch (error) {
console.error("Error removing marker:", markerId, error);
// Still clean up tracking maps even if layer removal failed
self.markers!.delete(markerId);
self.markerStates!.delete(markerId);
}
}
// Update callsign reverse index
if (markerState && self.callsignIndex) {
const csGroup = markerState.callsign_group || markerState.callsign;
if (csGroup) {
const ids = self.callsignIndex.get(csGroup);
if (ids) {
ids.delete(markerId);
if (ids.size === 0) self.callsignIndex.delete(csGroup);
}
}
}
// 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 (isValidCoordinate(lat, 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,
data.path,
);
}
}
}
}
// 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;
// Clear any RF path lines first
self.clearRfPathLines();
// 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();
if (self.callsignIndex) self.callsignIndex.clear();
markersToPreserve.forEach((marker, id) => {
self.markers!.set(id, marker);
});
statesToPreserve.forEach((state, id) => {
self.markerStates!.set(id, state);
// Rebuild callsign index from preserved markers
if (self.callsignIndex) {
const csGroup = state.callsign_group || state.callsign;
if (csGroup) {
let ids = self.callsignIndex.get(csGroup);
if (!ids) {
ids = new Set<string>();
self.callsignIndex.set(csGroup, ids);
}
ids.add(id);
}
}
});
// 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)));
},
clearRfPathLines() {
const self = this as unknown as LiveViewHookContext;
if (self.rfPathLines && self.rfPathLines.length > 0) {
self.rfPathLines.forEach((line) => {
try {
if (self.map && self.map.hasLayer(line)) {
self.map.removeLayer(line);
}
} catch (e) {
console.debug("Error removing RF path line:", e);
}
});
self.rfPathLines = [];
}
},
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) {
// Get state before deletion for index cleanup
const state = self.markerStates!.get(markerId);
try {
// Remove marker from appropriate layer with safety checks
if (
self.markerClusterGroup &&
self.markerClusterGroup.hasLayer(marker)
) {
// Check if cluster group is ready before removing
if (
self.markerClusterGroup._map &&
self.markerClusterGroup._topClusterLevel
) {
self.markerClusterGroup.removeLayer(marker);
} else {
console.warn(
"Cluster group not ready, skipping marker removal:",
markerId,
);
}
} else if (self.markerLayer && self.markerLayer.hasLayer(marker)) {
self.markerLayer.removeLayer(marker);
}
// Always clean up the tracking maps
self.markers!.delete(markerId);
self.markerStates!.delete(markerId);
// Note: We intentionally don't remove trails here
} catch (error) {
console.error("Error removing marker:", markerId, error);
// Still clean up tracking maps even if layer removal failed
self.markers!.delete(markerId);
self.markerStates!.delete(markerId);
}
// Update callsign reverse index
if (state && self.callsignIndex) {
const csGroup = state.callsign_group || state.callsign;
if (csGroup) {
const ids = self.callsignIndex.get(csGroup);
if (ids) {
ids.delete(markerId);
if (ids.size === 0) self.callsignIndex.delete(csGroup);
}
}
}
}
},
createMarkerIcon(data: MarkerData): L.DivIcon {
const self = this as unknown as LiveViewHookContext;
const L = window.L;
// 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
// Use callsignIndex for O(1) lookup instead of scanning all markers
if (self.markers && self.markerStates && self.callsignIndex && data.callsign_group) {
const ids = self.callsignIndex.get(data.callsign_group);
if (ids) {
for (const id of ids) {
const state = self.markerStates.get(String(id));
if (
state &&
state.historical &&
Math.abs(state.lat - data.lat) < 0.00001 &&
Math.abs(state.lng - data.lng) < 0.00001
) {
self.removeMarkerWithoutTrail(String(id));
}
}
}
}
// Use server-generated symbol HTML if available
if (data.symbol_html) {
return createDivIcon(data.symbol_html, {
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 ${escapeHtml(data.callsign || "")}"></div>`;
return createDivIcon(iconHtml, {
className: "historical-dot-marker",
iconSize: [12, 12],
iconAnchor: [6, 6],
});
}
// Fallback: Use server-generated symbol HTML if available
if (data.symbol_html) {
return createDivIcon(data.symbol_html);
}
// 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: ${escapeHtml(data.callsign || "")}"></div>`;
return createDivIcon(iconHtml, {
iconSize: [12, 12],
iconAnchor: [6, 6],
});
},
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;
}
// Clear initialization timeout
if (self.initializationTimeout !== undefined) {
clearTimeout(self.initializationTimeout);
self.initializationTimeout = undefined;
}
// Clear any dimension check timeouts
if (self.cleanupTimeouts !== undefined) {
self.cleanupTimeouts.forEach((timeout: number) => {
clearTimeout(timeout);
});
self.cleanupTimeouts = [];
}
// Clean up trail manager
if (self.trailManager) {
self.trailManager.destroy();
}
// 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);
self.boundsTimer = undefined;
}
if (self.resizeHandler !== undefined) {
window.removeEventListener("resize", self.resizeHandler);
self.resizeHandler = undefined;
}
if (self.mapResizeHandler !== undefined) {
self.el.removeEventListener("phx:map-resize", self.mapResizeHandler);
self.mapResizeHandler = 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();
}
// Clear any RF path lines
self.clearRfPathLines();
// 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.callsignIndex !== undefined) {
self.callsignIndex!.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 extract coordinate from various formats (number, string, or nested array)
function extractCoordinate(value: any): number {
// Handle null/undefined
if (value === null || value === undefined) {
return NaN;
}
// Handle arrays (sometimes coordinates come as nested arrays)
if (Array.isArray(value)) {
// Recursively extract from first element if it's an array
return extractCoordinate(value[0]);
}
// Handle numbers
if (typeof value === "number") {
return value;
}
// Handle strings
if (typeof value === "string") {
return parseFloat(value);
}
return NaN;
}
// Helper to create divIcon with common defaults
function createDivIcon(
html: string,
options: Partial<{
className: string;
iconSize: [number, number];
iconAnchor: [number, number];
}> = {},
) {
return L.divIcon({
html,
className: options.className || "",
iconSize: options.iconSize || [32, 32],
iconAnchor: options.iconAnchor || [16, 16],
});
}
// 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;