diff --git a/assets/js/map.ts b/assets/js/map.ts index af70312..90591e5 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -1,17 +1,34 @@ // 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'; +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"; +// Vector grid types are included via the vendor bundle // Leaflet and plugins are loaded globally from vendor bundle const L = window.L; @@ -26,7 +43,7 @@ declare global { } // Add plugin types to Leaflet -declare module 'leaflet' { +declare module "leaflet" { export function heatLayer(latlngs: HeatLatLng[], options?: HeatLayerOptions): HeatLayer; export function markerClusterGroup(options?: MarkerClusterGroupOptions): MarkerClusterGroup; } @@ -34,12 +51,17 @@ declare module 'leaflet' { // Import trail management functionality import { TrailManager } from "./features/trail_manager"; // Import helper functions -import { parseTimestamp, getTrailId, saveMapState, safePushEvent, getLiveSocket } from "./map_helpers"; +import { + parseTimestamp, + getTrailId, + saveMapState, + safePushEvent, + getLiveSocket, +} from "./map_helpers"; // APRS Map Hook - handles only basic map interaction // All data logic handled by LiveView - let MapAPRSMap = { mounted() { const self = this as unknown as LiveViewHookContext; @@ -72,16 +94,15 @@ let MapAPRSMap = { // 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" || @@ -90,16 +111,16 @@ let MapAPRSMap = { 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'); + useUrlParams = urlParams.has("lat") || urlParams.has("lng") || urlParams.has("z"); } catch (error) { console.error("Error parsing map data attributes:", error); initialCenter = { lat: 39.8283, lng: -98.5795 }; initialZoom = 5; } - + // Only use localStorage if no URL params are present // Temporarily disabled - using server-side geolocation instead /* @@ -186,8 +207,10 @@ let MapAPRSMap = { } // Detect if mobile device - const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); - + const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( + navigator.userAgent, + ); + self.map = L.map(self.el, { zoomControl: !isMobile, // Hide default zoom control on mobile, we'll add a better one attributionControl: true, @@ -200,15 +223,17 @@ let MapAPRSMap = { 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 + 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); - + L.control + .zoom({ + position: "bottomright", + }) + .addTo(self.map); + // Add touch gesture handling self.setupMobileGestures(); } @@ -233,25 +258,160 @@ let MapAPRSMap = { const tileProviders = { osm: { url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - attribution: '© OpenStreetMap contributors | APRS.me', - subdomains: ['a', 'b', 'c'] + attribution: + '© OpenStreetMap contributors | APRS.me', + subdomains: ["a", "b", "c"], }, osmDE: { - url: "https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png", - attribution: '© OpenStreetMap contributors | APRS.me', - subdomains: ['a', 'b', 'c'] + url: "https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png", + attribution: + '© OpenStreetMap contributors | APRS.me', + subdomains: ["a", "b", "c"], }, carto: { url: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", - attribution: '© OpenStreetMap contributors © CARTO | APRS.me', - subdomains: ['a', 'b', 'c', 'd'] - } + attribution: + '© OpenStreetMap contributors © CARTO | APRS.me', + subdomains: ["a", "b", "c", "d"], + }, + osmVector: { + // Using official OpenStreetMap vector tiles + url: "https://vector.openstreetmap.org/shortbread_v1/{z}/{x}/{y}.mvt", + attribution: + '© OpenStreetMap contributors | APRS.me', + isVector: true, + maxZoom: 14, // OSM vector tiles only go to zoom 14 + }, }; + + // Try to use vector tiles if L.vectorGrid is available (loaded from CDN) + let tileLayer: L.Layer; - // Use OSM by default, but could be configured - const provider = tileProviders.osm; + // Check if L.vectorGrid is available + console.log('Checking for L.vectorGrid:', typeof (L as any).vectorGrid); + console.log('Checking for L.vectorGrid.protobuf:', typeof (L as any).vectorGrid?.protobuf); - const tileLayer = L.tileLayer(provider.url, { + if ((L as any).vectorGrid && (L as any).vectorGrid.protobuf) { + try { + console.log('L.vectorGrid.protobuf is available, attempting to use OpenStreetMap vector tiles'); + + const vectorProvider = tileProviders.osmVector; + + // Style configuration for OpenStreetMap Shortbread schema + const vectorTileStyles = { + // Water features + water_polygons: { + fill: true, + fillColor: '#a0c8f0', + fillOpacity: 1, + weight: 0 + }, + water_lines: { + weight: 2, + color: '#a0c8f0', + opacity: 1 + }, + water_areas: { + fill: true, + fillColor: '#a0c8f0', + fillOpacity: 1, + weight: 0 + }, + // Landuse and parks + landuse_areas: { + fill: true, + fillColor: '#f0f0f0', + fillOpacity: 0.6, + weight: 0 + }, + parks: { + fill: true, + fillColor: '#c8facc', + fillOpacity: 0.8, + weight: 0 + }, + // Buildings + buildings: { + fill: true, + fillColor: '#e0e0e0', + fillOpacity: 0.8, + weight: 1, + color: '#cccccc' + }, + // Transportation (roads) + transport_lines: function(properties: any) { + const kind = properties.kind || properties.highway; + if (kind === 'motorway' || kind === 'trunk') { + return { + weight: 4, + color: '#e9ac77', + opacity: 1 + }; + } else if (kind === 'primary' || kind === 'secondary') { + return { + weight: 3, + color: '#ffd380', + opacity: 1 + }; + } else { + return { + weight: 2, + color: '#ffffff', + opacity: 0.8 + }; + } + }, + // Places + places: function(properties: any, zoom: number) { + if (zoom < 10) return { weight: 0 }; // Hide at low zoom + return { + weight: 0, + fillOpacity: 0, + interactive: false + }; + }, + // Default style for unmatched layers + _default: { + weight: 1, + color: '#cccccc', + opacity: 0.5 + } + }; + + // Create vector grid layer using L.vectorGrid.protobuf + tileLayer = (L as any).vectorGrid.protobuf(vectorProvider.url, { + attribution: vectorProvider.attribution, + maxZoom: vectorProvider.maxZoom, + vectorTileLayerStyles: vectorTileStyles, + interactive: true, + getFeatureId: function(feature: any) { + return feature.properties.osm_id || feature.properties.id; + } + }); + + console.log('Leaflet VectorGrid layer created successfully'); + console.log('Vector tile layer:', tileLayer); + } catch (error) { + console.error('Failed to create vector tile layer with Leaflet.VectorGrid:', error); + // Fall back to raster tiles on error + const provider = tileProviders.osm; + tileLayer = L.tileLayer(provider.url, { + attribution: provider.attribution, + maxZoom: 19, + subdomains: provider.subdomains, + tileSize: 256, + keepBuffer: 2, + updateWhenZooming: false, + updateInterval: 200, + errorTileUrl: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", + }); + } + } else { + console.log('Leaflet.VectorGrid not available, falling back to raster tiles'); + // Fallback to raster tiles + const provider = tileProviders.osm; + tileLayer = L.tileLayer(provider.url, { attribution: provider.attribution, maxZoom: 19, subdomains: provider.subdomains, @@ -259,29 +419,34 @@ let MapAPRSMap = { keepBuffer: 2, updateWhenZooming: false, updateInterval: 200, - errorTileUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' + errorTileUrl: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=", }); - + // Add event handler for tile errors with retry logic let retryCount = new Map(); - tileLayer.on('tileerror', function(error: any) { + 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); + setTimeout( + () => { + error.tile.src = src + (src.includes("?") ? "&" : "?") + "_retry=" + Date.now(); + }, + Math.pow(2, count) * 500, + ); } else { - console.error('Tile failed after 3 attempts:', src); + 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)); @@ -289,7 +454,7 @@ let MapAPRSMap = { // Store markers for management self.markers = new Map(); - + // Create marker cluster group for medium zoom levels (9-14) if (L.markerClusterGroup) { self.markerClusterGroup = L.markerClusterGroup({ @@ -299,26 +464,26 @@ let MapAPRSMap = { removeOutsideVisibleBounds: true, disableClusteringAtZoom: 11, // Show individual markers at zoom 11+ maxClusterRadius: 80, - iconCreateFunction: function(cluster: MarkerCluster) { + iconCreateFunction: function (cluster: MarkerCluster) { const count = cluster.getChildCount(); - let size = 'small'; - let className = 'marker-cluster-small'; - + let size = "small"; + let className = "marker-cluster-small"; + if (count > 10) { - size = 'medium'; - className = 'marker-cluster-medium'; + size = "medium"; + className = "marker-cluster-medium"; } if (count > 50) { - size = 'large'; - className = 'marker-cluster-large'; + size = "large"; + className = "marker-cluster-large"; } - + return L.divIcon({ - html: '
' + count + '
', - className: 'marker-cluster ' + className, - iconSize: [40, 40] + html: "
" + count + "
", + className: "marker-cluster " + className, + iconSize: [40, 40], }); - } + }, }); self.markerClusterGroup.addTo(self.map); self.markerLayer = self.markerClusterGroup; @@ -335,11 +500,11 @@ let MapAPRSMap = { blur: 15, maxZoom: 8, gradient: { - 0.4: 'blue', - 0.65: 'lime', - 0.85: 'yellow', - 1.0: 'red' - } + 0.4: "blue", + 0.65: "lime", + 0.85: "yellow", + 1.0: "red", + }, }); } } catch (error) { @@ -352,7 +517,7 @@ let MapAPRSMap = { // Track marker states to prevent unnecessary operations self.markerStates = new Map(); - + // Store RF path lines for hover visualization self.rfPathLines = []; @@ -367,15 +532,15 @@ let MapAPRSMap = { self.map!.whenReady(() => { try { self.lastZoom = self.map!.getZoom(); - + // Ensure we have a valid pushEvent function before using it - if (self.pushEvent && typeof self.pushEvent === 'function') { + if (self.pushEvent && typeof self.pushEvent === "function") { self.pushEvent("map_ready", {}); // Send initial bounds to trigger historical loading - if (self.map && self.pushEvent && typeof self.pushEvent === 'function') { + if (self.map && self.pushEvent && typeof self.pushEvent === "function") { saveMapState(self.map, self.pushEvent.bind(self)); } - + // Also send update_map_state to ensure URL updates and bounds processing // Increase delay to ensure LiveView is fully connected and ready setTimeout(() => { @@ -387,7 +552,7 @@ let MapAPRSMap = { console.warn("pushEvent not available in whenReady callback"); // Retry after a short delay setTimeout(() => { - if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) { + if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) { self.pushEvent("map_ready", {}); if (self.map) { saveMapState(self.map, self.pushEvent.bind(self)); @@ -426,10 +591,15 @@ let MapAPRSMap = { if (self.programmaticMoveId) { return; } - + if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { - if (self.map && !self.isDestroyed && self.pushEvent && typeof self.pushEvent === 'function') { + if ( + self.map && + !self.isDestroyed && + self.pushEvent && + typeof self.pushEvent === "function" + ) { saveMapState(self.map, self.pushEvent.bind(self)); } }, 300); @@ -443,7 +613,7 @@ let MapAPRSMap = { if (self.programmaticMoveId) { return; } - + if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { const currentZoom = self.map!.getZoom(); @@ -468,7 +638,12 @@ let MapAPRSMap = { self.lastZoom = currentZoom; // Save map state and update URL - if (self.map && !self.isDestroyed && self.pushEvent && typeof self.pushEvent === 'function') { + if ( + self.map && + !self.isDestroyed && + self.pushEvent && + typeof self.pushEvent === "function" + ) { saveMapState(self.map, self.pushEvent.bind(self)); } }, 300); @@ -514,20 +689,20 @@ let MapAPRSMap = { keepSpiderfied: true, nearbyDistance: 40, circleSpiralSwitchover: 9, - legWeight: 2 + legWeight: 2, }); - + // Add click handler for spiderfied markers - self.oms.addListener('click', function(marker: Marker) { + self.oms.addListener("click", function (marker: Marker) { if (marker.openPopup) marker.openPopup(); }); - + // Style the spider legs - self.oms.addListener('spiderfy', function(markers: Marker[]) { + self.oms.addListener("spiderfy", function (markers: Marker[]) { self.map.closePopup(); }); - - self.oms.addListener('unspiderfy', function(markers: Marker[]) { + + self.oms.addListener("unspiderfy", function (markers: Marker[]) { // Markers return to normal positions }); } @@ -566,72 +741,73 @@ let MapAPRSMap = { 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 + if (distance < minDistance && distance < 50) { + // Within 50 meters minDistance = distance; nearestMarker = marker; } }); - + if (nearestMarker) { nearestMarker.openPopup(); } }; - - self.map!.on('touchstart', (e: LeafletTouchEvent) => { + + 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(() => { handleLongPress(e.originalEvent); }, longPressDuration); }); - - self.map!.on('touchmove', (e: LeafletTouchEvent) => { + + 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', () => { + + self.map!.on("touchend", () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }); - - self.map!.on('touchcancel', () => { + + self.map!.on("touchcancel", () => { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; @@ -641,18 +817,18 @@ let MapAPRSMap = { setupPopupNavigation() { const self = this as unknown as LiveViewHookContext; - + // Store the event handler so we can remove it later self.popupNavigationHandler = (e: Event) => { const target = e.target as HTMLElement; - + // Check if clicked element or its parent is a LiveView navigation link - const navLink = target.closest('.aprs-lv-link') as HTMLAnchorElement; - + const navLink = target.closest(".aprs-lv-link") as HTMLAnchorElement; + if (navLink && navLink.href) { e.preventDefault(); e.stopPropagation(); - + // Use Phoenix LiveView's built-in navigation const liveSocket = getLiveSocket(); if (liveSocket) { @@ -663,9 +839,9 @@ let MapAPRSMap = { } } }; - + // Use event delegation to handle clicks on popup navigation links - document.addEventListener('click', self.popupNavigationHandler); + document.addEventListener("click", self.popupNavigationHandler); }, setupLiveViewHandlers() { @@ -733,12 +909,12 @@ let MapAPRSMap = { // 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(() => { @@ -746,7 +922,7 @@ let MapAPRSMap = { self.programmaticMoveId = undefined; } }, 1500); - + self.map.setView([lat, lng], zoom, { animate: true, duration: 1, @@ -758,7 +934,7 @@ let MapAPRSMap = { if (!self.map || self.isDestroyed) { return; } - + const afterRect = self.el.getBoundingClientRect(); if (afterRect.width === 0 || afterRect.height === 0) { @@ -771,7 +947,7 @@ let MapAPRSMap = { } } }, 1000); - + // Store timeout for cleanup if (!self.cleanupTimeouts) { self.cleanupTimeouts = []; @@ -828,91 +1004,91 @@ let MapAPRSMap = { 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 and convert them to historical dots - const markersToConvert: string[] = []; + if (incomingCallsign) { + // Find existing live markers for this callsign and convert them to historical dots + const markersToConvert: string[] = []; - // Ensure markerStates exists - if (!self.markerStates) { - console.warn("markerStates not initialized, skipping conversion"); - return; - } - - self.markerStates.forEach((state, id) => { - const stateCallsign = state.callsign_group || state.callsign; - // Only convert non-historical markers for the same callsign, but exclude the incoming packet's ID - if ( - stateCallsign === incomingCallsign && - !state.historical && - String(id) !== String(data.id) - ) { - markersToConvert.push(String(id)); - } - }); - - // Convert existing live markers to historical dots by updating their icon - markersToConvert.forEach((id) => { - if (!self.markers || !self.markerStates) { - console.warn("markers or markerStates not available during conversion"); + // Ensure markerStates exists + if (!self.markerStates) { + console.warn("markerStates not initialized, skipping 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; + self.markerStates.forEach((state, id) => { + const stateCallsign = state.callsign_group || state.callsign; + // Only convert non-historical markers for the same callsign, but exclude the incoming packet's ID + if ( + stateCallsign === incomingCallsign && + !state.historical && + String(id) !== String(data.id) + ) { + markersToConvert.push(String(id)); + } + }); - // 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, - }; + // 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); - // Update the marker's icon to show as a dot - existingMarker.setIcon(self.createMarkerIcon(historicalIconData)); + if (existingMarker && existingState) { + // Update the state to historical + existingState.historical = true; + existingState.is_most_recent_for_callsign = false; - // Mark as historical - (existingMarker as APRSMarker)._isHistorical = true; - } + // Create new historical dot icon + const historicalIconData = { + id: String(id), + lat: existingState.lat, + lng: existingState.lng, + callsign: existingState.callsign || incomingCallsign, + callsign_group: existingState.callsign_group || incomingCallsign, + symbol_table_id: existingState.symbol_table, + symbol_code: existingState.symbol_code, + historical: true, + is_most_recent_for_callsign: false, + popup: existingState.popup, + }; + + // Update the marker's icon to show as a dot + existingMarker.setIcon(self.createMarkerIcon(historicalIconData)); + + // Mark as historical + (existingMarker as APRSMarker)._isHistorical = true; + } + }); + } + + // Add the new marker as the most recent for this callsign + // Check if openPopup is explicitly set to false to prevent interrupting user + const shouldOpenPopup = data.openPopup !== false; + self.addMarker({ + ...data, + historical: false, + is_most_recent_for_callsign: true, + callsign_group: data.callsign_group || data.callsign || incomingCallsign, + popup: data.popup || self.buildPopupContent(data), + openPopup: shouldOpenPopup, }); - } - - // Add the new marker as the most recent for this callsign - // Check if openPopup is explicitly set to false to prevent interrupting user - const shouldOpenPopup = data.openPopup !== false; - self.addMarker({ - ...data, - historical: false, - is_most_recent_for_callsign: true, - callsign_group: data.callsign_group || data.callsign || incomingCallsign, - popup: data.popup || self.buildPopupContent(data), - openPopup: shouldOpenPopup, - }); } catch (error) { console.error("Error in new_packet handler:", error); } @@ -922,7 +1098,7 @@ let MapAPRSMap = { self.currentPopupMarkerId = null; self.handleEvent("highlight_packet", (data: { id: string }) => { if (!data.id || !self.markers || !self.markerStates) return; - + // Close previous popup if open if (self.currentPopupMarkerId && self.markers.has(self.currentPopupMarkerId)) { const prevMarker = self.markers.get(self.currentPopupMarkerId); @@ -987,44 +1163,47 @@ let MapAPRSMap = { }); // Handle progressive loading of historical packets (batch processing) - self.handleEvent("add_historical_packets_batch", (data: { packets: MarkerData[], batch: number, is_final: boolean }) => { - try { - if (data.packets && Array.isArray(data.packets)) { - // Process all packets immediately for maximum speed - const packetsByCallsign = new Map(); + self.handleEvent( + "add_historical_packets_batch", + (data: { packets: MarkerData[]; batch: number; is_final: boolean }) => { + try { + if (data.packets && Array.isArray(data.packets)) { + // Process all packets immediately for maximum speed + const packetsByCallsign = new Map(); - data.packets.forEach((packet) => { - const callsign = packet.callsign_group || packet.callsign || packet.id; - if (!packetsByCallsign.has(callsign)) { - packetsByCallsign.set(callsign, []); - } - packetsByCallsign.get(callsign)!.push(packet); - }); - - // Process each callsign group in chronological order (oldest first) - packetsByCallsign.forEach((packets, callsign) => { - // Sort by timestamp (oldest first) to ensure proper trail line drawing - const sortedPackets = packets.sort((a, b) => { - const timeA = parseTimestamp(a.timestamp); - const timeB = parseTimestamp(b.timestamp); - return timeA - timeB; - }); - - // Add markers in chronological order - sortedPackets.forEach((packet) => { - self.addMarker({ - ...packet, - historical: true, - popup: packet.popup || self.buildPopupContent(packet), + data.packets.forEach((packet) => { + const callsign = packet.callsign_group || packet.callsign || packet.id; + if (!packetsByCallsign.has(callsign)) { + packetsByCallsign.set(callsign, []); + } + packetsByCallsign.get(callsign)!.push(packet); }); - }); - }); - } - } catch (error) { - console.error("Error processing historical packets batch:", error); - // Continue processing other batches even if one fails - } - }); + + // Process each callsign group in chronological order (oldest first) + packetsByCallsign.forEach((packets, callsign) => { + // Sort by timestamp (oldest first) to ensure proper trail line drawing + const sortedPackets = packets.sort((a, b) => { + const timeA = parseTimestamp(a.timestamp); + const timeB = parseTimestamp(b.timestamp); + return timeA - timeB; + }); + + // Add markers in chronological order + sortedPackets.forEach((packet) => { + self.addMarker({ + ...packet, + historical: true, + popup: packet.popup || self.buildPopupContent(packet), + }); + }); + }); + } + } catch (error) { + console.error("Error processing historical packets batch:", error); + // Continue processing other batches even if one fails + } + }, + ); // Handle refresh markers event self.handleEvent("refresh_markers", () => { @@ -1070,67 +1249,73 @@ let MapAPRSMap = { }); // 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; + self.handleEvent( + "draw_rf_path", + (data: { + station_lat: number; + station_lng: number; + path_stations: Array<{ callsign: string; lat: number; lng: number }>; + }) => { + if (!self.map || !data.path_stations || data.path_stations.length === 0) return; - // Clear any existing path lines - if (self.rfPathLines) { - self.rfPathLines.forEach(line => self.map!.removeLayer(line)); - } - self.rfPathLines = []; + // 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; + // Draw lines from station to each digipeater/igate in sequence + let prevLat = data.station_lat; + let prevLng = data.station_lng; - data.path_stations.forEach((station, index) => { - // Draw line from previous position to this station - const line = L.polyline( - [[prevLat, prevLng], [station.lat, station.lng]], - { - color: '#FF6B6B', - weight: 3, - opacity: 0.8, - dashArray: index === 0 ? null : '5, 10', // Solid line for first hop, dashed for subsequent - } - ); + data.path_stations.forEach((station, index) => { + // Draw line from previous position to this station + const line = L.polyline( + [ + [prevLat, prevLng], + [station.lat, station.lng], + ], + { + color: "#FF6B6B", + weight: 3, + opacity: 0.8, + dashArray: index === 0 ? null : "5, 10", // Solid line for first hop, dashed for subsequent + }, + ); - line.addTo(self.map!); - self.rfPathLines.push(line); + 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 + // 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; }); - - circle.bindTooltip(station.callsign, { - permanent: false, - direction: 'top', - offset: [0, -10] - }); - - circle.addTo(self.map!); - self.rfPathLines.push(circle); - - // Update prev position for next line - prevLat = station.lat; - prevLng = station.lng; - }); - }); + }, + ); // Handle clearing RF path lines self.handleEvent("clear_rf_path", () => { if (self.rfPathLines) { - self.rfPathLines.forEach(line => { + self.rfPathLines.forEach((line) => { if (self.map && self.map.hasLayer(line)) { self.map.removeLayer(line); } @@ -1170,17 +1355,16 @@ let MapAPRSMap = { // 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; } - + if (!L.heatLayer) { console.error("Leaflet.heat plugin not loaded!"); return; } - + if (!self.heatLayer) { try { self.heatLayer = L.heatLayer([], { @@ -1188,29 +1372,31 @@ let MapAPRSMap = { blur: 15, maxZoom: 8, gradient: { - 0.4: 'blue', - 0.65: 'lime', - 0.85: 'yellow', - 1.0: 'red' - } + 0.4: "blue", + 0.65: "lime", + 0.85: "yellow", + 1.0: "red", + }, }); } catch (error) { console.error("Failed to create heat layer:", error); return; } } - + // Convert heat points to format expected by Leaflet.heat - const heatData = data.heat_points.map(point => [ - point.lat, - point.lng, - Math.min(point.intensity / 50.0, 1.0) // Normalize intensity to 0-1 range, cap at 1 - ] as [number, number, number]); - - + const heatData = data.heat_points.map( + (point) => + [ + point.lat, + point.lng, + Math.min(point.intensity / 50.0, 1.0), // Normalize intensity to 0-1 range, cap at 1 + ] as [number, number, number], + ); + // 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); @@ -1226,12 +1412,11 @@ let MapAPRSMap = { // 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; } - + // Hide heat layer and show marker layer if (self.heatLayer && self.map.hasLayer(self.heatLayer)) { self.map.removeLayer(self.heatLayer); @@ -1259,7 +1444,7 @@ let MapAPRSMap = { // self.removeMarkersOutsideBounds(bounds); // Use direct pushEvent call with proper context - if (self.pushEvent && typeof self.pushEvent === 'function') { + if (self.pushEvent && typeof self.pushEvent === "function") { const boundsData = { bounds: { north: bounds.getNorth(), @@ -1347,11 +1532,11 @@ let MapAPRSMap = { // Add popup if content provided if (data.popup) { marker.bindPopup(data.popup, { autoPan: false }); - + // Handle popup close events - check if hook is still connected marker.on("popupclose", () => { // Only send event if not destroyed and pushEvent is still the original function - if (!self.isDestroyed && self.pushEvent && typeof self.pushEvent === 'function') { + if (!self.isDestroyed && self.pushEvent && typeof self.pushEvent === "function") { try { self.pushEvent("popup_closed", {}); } catch (e) { @@ -1374,25 +1559,25 @@ let MapAPRSMap = { 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 if (marker.getElement) { const element = marker.getElement(); if (element) { // Find the highest z-index among all markers let maxZIndex = 1000; - document.querySelectorAll('.leaflet-marker-icon').forEach((el) => { - const zIndex = parseInt((el as HTMLElement).style.zIndex || '0', 10); + document.querySelectorAll(".leaflet-marker-icon").forEach((el) => { + const zIndex = parseInt((el as HTMLElement).style.zIndex || "0", 10); if (zIndex > maxZIndex) maxZIndex = zIndex; }); - + // Set this marker's z-index higher than all others element.style.zIndex = (maxZIndex + 1).toString(); } } - + // Use bound pushEvent function to preserve context - if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) { + if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) { safePushEvent(self.pushEvent.bind(self), "marker_clicked", { id: data.id, callsign: data.callsign, @@ -1407,7 +1592,7 @@ let MapAPRSMap = { if (data.path && data.path.trim() !== "" && !data.path.includes("TCPIP")) { marker.on("mouseover", () => { // Check if LiveView is still connected before sending event - if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) { + if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) { try { self.pushEvent.call(self, "marker_hover_start", { id: data.id, @@ -1426,7 +1611,7 @@ let MapAPRSMap = { marker.on("mouseout", () => { // Check if LiveView is still connected before sending event - if (self.pushEvent && typeof self.pushEvent === 'function' && !self.isDestroyed) { + if (self.pushEvent && typeof self.pushEvent === "function" && !self.isDestroyed) { try { self.pushEvent.call(self, "marker_hover_end", { id: data.id, @@ -1437,7 +1622,6 @@ let MapAPRSMap = { } }); } - // Mark historical markers for identification if (data.historical) { @@ -1569,7 +1753,8 @@ let MapAPRSMap = { // 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 isHistorical = + (marker as APRSMarker)._isHistorical || (markerState && markerState.historical); const isMostRecent = markerState && markerState.is_most_recent_for_callsign; // Keep historical markers and current position markers @@ -1616,7 +1801,8 @@ let MapAPRSMap = { 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 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 @@ -1767,7 +1953,7 @@ let MapAPRSMap = { if (data.timestamp) { const timestamp = parseTimestamp(data.timestamp); const date = new Date(timestamp); - + if (!isNaN(date.getTime())) { content += `
${date.toISOString()}
`; } @@ -1779,26 +1965,26 @@ let MapAPRSMap = { 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 any dimension check timeouts if (self.cleanupTimeouts !== undefined) { self.cleanupTimeouts.forEach((timeout: number) => { @@ -1806,13 +1992,13 @@ let MapAPRSMap = { }); self.cleanupTimeouts = []; } - + // Remove popup navigation event listener if (self.popupNavigationHandler) { - document.removeEventListener('click', self.popupNavigationHandler); + document.removeEventListener("click", self.popupNavigationHandler); self.popupNavigationHandler = undefined; } - + // Close any open popups before cleanup if (self.map !== undefined) { try { @@ -1821,7 +2007,7 @@ let MapAPRSMap = { console.debug("Error closing popup during cleanup:", e); } } - + if (self.boundsTimer !== undefined) { clearTimeout(self.boundsTimer); } @@ -1829,7 +2015,7 @@ let MapAPRSMap = { window.removeEventListener("resize", self.resizeHandler); self.resizeHandler = undefined; } - + // Remove map event handlers if (self.map !== undefined && self.mapEventHandlers !== undefined) { self.mapEventHandlers.forEach((handler, event) => { @@ -1841,7 +2027,7 @@ let MapAPRSMap = { }); self.mapEventHandlers.clear(); } - + // Remove all event listeners from markers before clearing layers if (self.markers !== undefined) { self.markers.forEach((marker: APRSMarker) => { @@ -1855,20 +2041,20 @@ let MapAPRSMap = { } }); } - + // 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.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 { @@ -1880,7 +2066,7 @@ let MapAPRSMap = { 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 @@ -1903,87 +2089,10 @@ let MapAPRSMap = { self.map!.remove(); self.map = undefined; } - + // Restore original pushEvent (though it won't be used since we're destroyed) self.pushEvent = originalPushEvent; }, - - setupMobileGestures() { - const self = this as unknown as LiveViewHookContext; - if (!self.map) return; - - // Long press to show station info - let longPressTimer: NodeJS.Timeout | null = null; - let touchStartPos: { x: number; y: number } | null = null; - - self.map.on('touchstart', (e: LeafletTouchEvent) => { - touchStartPos = { x: e.originalEvent.touches[0].pageX, y: e.originalEvent.touches[0].pageY }; - - longPressTimer = setTimeout(() => { - // Get the closest marker to the touch point - const point = e.containerPoint; - let closestMarker: APRSMarker | null = null; - let closestDistance = Infinity; - - self.markers.forEach((marker) => { - const markerPoint = self.map.latLngToContainerPoint(marker.getLatLng()); - const distance = Math.sqrt( - Math.pow(markerPoint.x - point.x, 2) + - Math.pow(markerPoint.y - point.y, 2) - ); - - if (distance < closestDistance && distance < 50) { // 50px tolerance - closestDistance = distance; - closestMarker = marker; - } - }); - - if (closestMarker) { - closestMarker.openPopup(); - } - }, 600); // 600ms for long press - }); - - self.map.on('touchmove', (e: LeafletTouchEvent) => { - if (longPressTimer && touchStartPos) { - const moveThreshold = 10; // pixels - const currentPos = { x: e.originalEvent.touches[0].pageX, y: e.originalEvent.touches[0].pageY }; - const distance = Math.sqrt( - Math.pow(currentPos.x - touchStartPos.x, 2) + - Math.pow(currentPos.y - touchStartPos.y, 2) - ); - - if (distance > moveThreshold) { - clearTimeout(longPressTimer); - longPressTimer = null; - } - } - }); - - self.map.on('touchend touchcancel', () => { - if (longPressTimer) { - clearTimeout(longPressTimer); - longPressTimer = null; - } - touchStartPos = null; - }); - - // Improve popup behavior on mobile - self.map.on('popupopen', (e: LeafletPopupEvent) => { - // Ensure popup is visible on mobile - const popup = e.popup; - const px = self.map.project(popup.getLatLng()); - const popupHeight = popup.getElement()?.offsetHeight || 200; - const mapHeight = self.map.getContainer().offsetHeight; - - // If popup would be cut off at bottom, pan the map - const containerPoint = self.map.latLngToContainerPoint(popup.getLatLng()); - if (containerPoint.y + popupHeight > mapHeight - 50) { - px.y -= (popupHeight / 2); - self.map.panTo(self.map.unproject(px), { animate: true }); - } - }); - }, }; // Helper to validate and fallback symbol code per aprs.fi logic diff --git a/assets/js/types/leaflet-vectorgrid.d.ts b/assets/js/types/leaflet-vectorgrid.d.ts new file mode 100644 index 0000000..b118845 --- /dev/null +++ b/assets/js/types/leaflet-vectorgrid.d.ts @@ -0,0 +1,37 @@ +// Type definitions for leaflet.vectorgrid +import * as L from 'leaflet'; + +declare module 'leaflet' { + namespace vectorGrid { + interface VectorGridOptions extends L.GridLayerOptions { + rendererFactory?: L.Renderer; + vectorTileLayerStyles?: any; + interactive?: boolean; + getFeatureId?: (feature: any) => string | number; + } + + interface ProtobufOptions extends VectorGridOptions { + subdomains?: string | string[]; + key?: string; + token?: string; + maxNativeZoom?: number; + } + + class VectorGrid extends L.GridLayer { + constructor(options?: VectorGridOptions); + setFeatureStyle(id: string | number, style: L.PathOptions): void; + resetFeatureStyle(id: string | number): void; + clearHighlight(): void; + } + + class Protobuf extends VectorGrid { + constructor(url: string, options?: ProtobufOptions); + } + + function protobuf(url: string, options?: ProtobufOptions): Protobuf; + } + + function vectorGrid(options?: any): any; +} + +export {}; \ No newline at end of file diff --git a/assets/js/vendor.js b/assets/js/vendor.js index c126a45..6410268 100644 --- a/assets/js/vendor.js +++ b/assets/js/vendor.js @@ -1,28 +1,2 @@ -// Vendor bundle for all third-party dependencies -// This file bundles all npm packages to avoid resolution issues during Docker builds - -// Import CSS files first -import 'leaflet/dist/leaflet.css'; -import 'leaflet.markercluster/dist/MarkerCluster.css'; -import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; - -// Export Leaflet and plugins -import * as L from 'leaflet'; -import 'leaflet.heat'; -import 'leaflet.markercluster'; -import 'overlapping-marker-spiderfier-leaflet'; - -// Export Chart.js and adapter -import Chart from 'chart.js/auto'; -import 'chartjs-adapter-date-fns'; - -// Export topbar -import topbar from 'topbar'; - -// Make libraries available globally -window.L = L; -window.Chart = Chart; -window.topbar = topbar; - -// Export for ES modules -export { L, Chart, topbar }; \ No newline at end of file +// This file is now empty since all vendor libraries are loaded from CDN +// See root.html.heex for CDN script tags \ No newline at end of file diff --git a/assets/package.json b/assets/package.json index 2e992d6..1ca4219 100644 --- a/assets/package.json +++ b/assets/package.json @@ -1,13 +1,5 @@ { - "dependencies": { - "chart.js": "^4.5.0", - "chartjs-adapter-date-fns": "^3.0.0", - "leaflet": "^1.9.4", - "leaflet.heat": "^0.2.0", - "leaflet.markercluster": "^1.5.3", - "overlapping-marker-spiderfier-leaflet": "^0.2.7", - "topbar": "^3.0.0" - }, + "dependencies": {}, "name": "assets", "version": "1.0.0", "main": "index.js", diff --git a/config/runtime.exs b/config/runtime.exs index 7e21097..2ac5e19 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -109,7 +109,8 @@ if config_env() == :prod do "http://10.0.19.222:33897", "https://s.aprs.me", "https://js.sentry-cdn.com", - "https://*.sentry.io" + "https://*.sentry.io", + "https://*.openstreetmap.org" ] # Optional: Set the default "from" email address diff --git a/lib/aprsme_web/components/layouts/root.html.heex b/lib/aprsme_web/components/layouts/root.html.heex index 306f775..9b52e09 100644 --- a/lib/aprsme_web/components/layouts/root.html.heex +++ b/lib/aprsme_web/components/layouts/root.html.heex @@ -34,9 +34,29 @@ {assigns[:page_title] || "Aprs"} - - + + + + + + + + + + + + + + + + + + + + + + +