Critical: - Remove :protected from WeatherCache ETS (conflicted with :public) - Register Aprsme.ReplayRegistry in supervision tree - Route :continue_replay dead message to :start_replay handler - Add 5000ms timeout to unbounded :rpc.call calls - Fix falsy lat/lng check rejecting valid 0 coordinates Medium: - Fix live_reload pattern (temp_web -> aprsme_web) - Remove duplicate ecto_repos config - Make DB SSL configurable via DB_SSL env var - Track sizeCheckTimeout on self for cleanup in destroyed() - Wrap pushEvent calls in try/catch - Remove unused size variable in marker cluster icon - Capture touch coords at touchstart instead of stale TouchEvent - Demote console.log to console.debug in production code - Add catch-all to normalize_bounds preventing GenServer crash Style: - Add missing @impl true annotations in is.ex - Improve migration failure error message - Use textContent instead of innerHTML for error messages - Use proper type for longPressTimer instead of any
2703 lines
86 KiB
TypeScript
2703 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;
|
|
// 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:
|
|
'© <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:
|
|
'© <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:
|
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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 className = "marker-cluster-small";
|
|
|
|
if (count > 10) {
|
|
className = "marker-cluster-medium";
|
|
}
|
|
if (count > 50) {
|
|
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
|
|
) {
|
|
try {
|
|
self.pushEvent("map_ready", {});
|
|
} catch (e) {
|
|
console.debug("Failed to push map_ready event:", e);
|
|
}
|
|
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.debug(
|
|
`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) {
|
|
try {
|
|
self.pushEvent("refresh_markers", {});
|
|
} catch (e) {
|
|
console.debug("Failed to push refresh_markers event:", e);
|
|
}
|
|
}
|
|
|
|
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
|
|
self.sizeCheckTimeout = 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.debug(`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 id="map-error-message" style="margin: 0; font-size: 14px;"></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>
|
|
`;
|
|
const msgEl = self.el.querySelector('#map-error-message');
|
|
if (msgEl) msgEl.textContent = message;
|
|
}
|
|
},
|
|
|
|
setupMobileGestures() {
|
|
const self = this as unknown as LiveViewHookContext;
|
|
|
|
// Long press timer
|
|
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
const longPressDuration = 750; // 750ms for long press
|
|
const movementThreshold = 10; // pixels
|
|
|
|
const handleLongPress = (clientX: number, clientY: number) => {
|
|
const latlng = self.map!.containerPointToLatLng(
|
|
L.point(clientX, 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(touch.clientX, touch.clientY);
|
|
}
|
|
}, 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.debug("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.debug(
|
|
"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.debug("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 == null ||
|
|
data.lng == null ||
|
|
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 size check timeout
|
|
if (self.sizeCheckTimeout !== undefined) {
|
|
clearTimeout(self.sizeCheckTimeout);
|
|
self.sizeCheckTimeout = 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;
|