From c8c42b7cc873e9a38a1381fe07f7c081f6aa2ecc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 2 Aug 2026 14:04:26 -0500 Subject: [PATCH] refactor: map page efficiency, trail accuracy, and spidering fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete dead map_fixes.ts (305 lines unused) - Consolidate CSS from index.ex into components.ex map_styles - Guard redundant update_map_state → handle_params event loop - Guard packet_age_threshold recalculation on trail_duration change - Align server/client movement thresholds to 20m - O(1) duplicate detection in TrailManager.addPosition - Cache trail centers and optimize color assignment - Fix dynamic trail cleanup cutoff (was hardcoded 24h) - Preserve OMS spiderfied state on zoom threshold crossing - Rebuild OMS after clearAllMarkers - Cap client markers at 10,000 with historical eviction - Fix no-op start_progressive_historical_loading - Reduce historical loading timeout 30s → 15s - Default map_bounds to worldwide instead of US-only - Simplify bounds-update guard logic --- assets/js/features/trail_manager.ts | 57 ++-- assets/js/map.ts | 43 ++- assets/js/map_fixes.ts | 305 ------------------ lib/aprsme_web/live/map_live/components.ex | 213 ++++++++++-- lib/aprsme_web/live/map_live/data_builder.ex | 8 +- .../live/map_live/display_manager.ex | 10 +- lib/aprsme_web/live/map_live/events.ex | 13 +- .../live/map_live/historical_loader.ex | 2 +- lib/aprsme_web/live/map_live/index.ex | 251 ++------------ .../live/map_live/packet_processor.ex | 4 +- lib/aprsme_web/live/map_live/state.ex | 8 +- 11 files changed, 309 insertions(+), 605 deletions(-) delete mode 100644 assets/js/map_fixes.ts diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts index 1cceeb5..2ebc0a9 100644 --- a/assets/js/features/trail_manager.ts +++ b/assets/js/features/trail_manager.ts @@ -24,6 +24,7 @@ export interface TrailState { trail?: Polyline; dots?: CircleMarker[]; color?: string; + center?: { lat: number; lng: number } | null; // cached center, null = not computed } export class TrailManager { @@ -55,7 +56,7 @@ export class TrailManager { "#00BFA5", // Aquamarine ]; private proximityThreshold: number = 5.5; // kilometers - private minMovementThreshold: number = 0.1; // km - minimum total distance to show a trail + private minMovementThreshold: number = 0.02; // km (matches server-side 20m threshold) // km - max distance between consecutive points before breaking the trail. // Must tolerate legitimately fast movers (aircraft, balloons, high-altitude) // reporting ~1/min — too-tight values leave gaps between real movements. @@ -66,6 +67,8 @@ export class TrailManager { private trailHoverDebounceTimer?: ReturnType; private lastHoveredPath?: string; private isDestroyed: boolean = false; + private positionKeys: Map> = new Map(); // callsign -> Set<"lat,lng,ts"> + private usedColors: Set = new Set(); constructor( trailLayer: LayerGroup, @@ -166,27 +169,26 @@ export class TrailManager { this.trails.set(baseCallsign, trailState); } - // Check if this position already exists (avoid duplicates) - const existingPos = trailState.positions.find( - (pos) => - Math.abs(pos.lat - lat) < 0.00001 && - Math.abs(pos.lng - lng) < 0.00001 && - Math.abs(pos.timestamp - timestamp) < 1000, // Within 1 second - ); + // O(1) duplicate detection via position key set + const posKey = `${Math.round(lat * 100000)},${Math.round(lng * 100000)},${Math.floor(timestamp / 1000)}`; + let keys = this.positionKeys.get(baseCallsign); + if (!keys) { + keys = new Set(); + this.positionKeys.set(baseCallsign, keys); + } + const isDuplicate = keys.has(posKey); - if (!existingPos) { + if (!isDuplicate) { + keys.add(posKey); // Add new position trailState.positions.push({ lat, lng, timestamp, path }); - + // Invalidate cached center on position change + trailState.center = undefined; // Sort positions by timestamp to maintain chronological order trailState.positions.sort((a, b) => a.timestamp - b.timestamp); - // Limit the number of positions per trail if (trailState.positions.length > this.maxPositionsPerTrail) { - // Keep the most recent positions - trailState.positions = trailState.positions.slice( - -this.maxPositionsPerTrail, - ); + trailState.positions = trailState.positions.slice(-this.maxPositionsPerTrail); } } @@ -310,7 +312,10 @@ export class TrailManager { ) return; - const otherCenter = this.getTrailCenter(trailState.positions); + const otherCenter = trailState.center ?? this.getTrailCenter(trailState.positions); + if (trailState.center === undefined && otherCenter) { + trailState.center = otherCenter; // cache for reuse + } if (!otherCenter) return; const distance = this.calculateDistance( @@ -333,17 +338,10 @@ export class TrailManager { baseCallsign: string, positions: PositionHistory[], ): string { - // Collect all colors currently used by other trails - const usedColors = new Set(); - this.trails.forEach((trailState, callsign) => { - if (callsign !== baseCallsign && trailState.color) { - usedColors.add(trailState.color); - } - }); - // First priority: pick a color not used by any existing trail for (const color of this.colorPalette) { - if (!usedColors.has(color)) { + if (!this.usedColors.has(color)) { + this.usedColors.add(color); return color; } } @@ -541,6 +539,8 @@ export class TrailManager { trailState.dots.forEach((dot) => this.trailLayer.removeLayer(dot)); } this.trails.delete(baseCallsign); + if (trailState.color) this.usedColors.delete(trailState.color); + this.positionKeys.delete(baseCallsign); } } @@ -551,6 +551,8 @@ export class TrailManager { this.trailHoverDebounceTimer = undefined; } this.lastHoveredPath = undefined; + this.usedColors.clear(); + this.positionKeys.clear(); this.trails.forEach((_, markerId) => { this.removeTrail(markerId); }); @@ -571,7 +573,10 @@ export class TrailManager { cleanupOldPositions() { // Don't clean up historical positions - only clean up very old live positions - const veryOldCutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 hours + // Use trailDuration as the cleanup window, with a 24h minimum floor + // to prevent unbounded memory growth from very long durations + const cleanupWindow = Math.min(this.trailDuration, 24 * 60 * 60 * 1000); + const veryOldCutoff = Date.now() - cleanupWindow; this.trails.forEach((trailState, markerId) => { const originalLength = trailState.positions.length; trailState.positions = trailState.positions.filter((pos) => { diff --git a/assets/js/map.ts b/assets/js/map.ts index 2ff9824..65387ef 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -563,22 +563,33 @@ let MapAPRSMap = { 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)) { + // Preserve spiderfied state before clearing OMS + const spiderfiedMarkers: Marker[] = []; + self.markers.forEach((marker) => { + if ((marker as any)._omsData) { + spiderfiedMarkers.push(marker); + } + }); 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); + !(marker as APRSMarker)._isHistorical); if ( marker && - !marker._isClusterMarker && + !(marker as APRSMarker)._isClusterMarker && markerState && shouldAddToOms ) { self.oms.addMarker(marker); } }); + // Re-spiderfy markers that were spiderfied before the rebuild + if (spiderfiedMarkers.length > 0 && self.oms) { + try { self.oms.spiderfy(); } catch (e) { /* OMS may not support programmatic spiderfy */ } + } } // Hide trails when markers are clustered, show when unclustered @@ -2027,6 +2038,20 @@ let MapAPRSMap = { marker.addTo(self.markerLayer); self.markers.set(data.id, marker); + // Cap total markers to prevent unbounded memory growth + if (self.markers.size > 10000) { + // Remove oldest historical markers first + const historicalIds: string[] = []; + self.markerStates.forEach((state, id) => { + if (state.historical && !state.is_most_recent_for_callsign) { + historicalIds.push(id); + } + }); + // Remove oldest 1000 historical markers + const toRemove = historicalIds.slice(0, 1000); + toRemove.forEach((id) => self.removeMarkerWithoutTrail(id)); + } + // Make sure historical markers and trails stay visible if (data.historical || data.is_most_recent_for_callsign) { if (self.trailLayer && self.trailLayer.bringToFront) { @@ -2274,6 +2299,20 @@ let MapAPRSMap = { } }); + // Rebuild OMS with preserved markers + if (self.oms) { + markersToPreserve.forEach((marker, id) => { + const state = self.markerStates!.get(String(id)); + const shouldAddToOms = + state?.is_most_recent_for_callsign === true || + (state?.is_most_recent_for_callsign == null && + !(marker as APRSMarker)._isHistorical); + if (!(marker as APRSMarker)._isClusterMarker && shouldAddToOms) { + try { self.oms!.addMarker(marker); } catch (e) { /* ignore */ } + } + }); + } + // Don't clear trails - keep them visible // if (self.trailManager) { // self.trailManager.clearAllTrails(); diff --git a/assets/js/map_fixes.ts b/assets/js/map_fixes.ts deleted file mode 100644 index b454785..0000000 --- a/assets/js/map_fixes.ts +++ /dev/null @@ -1,305 +0,0 @@ -// Proposed fixes for map.ts issues - -import type { Map as LeafletMap } from 'leaflet'; -import type { LiveViewHookContext } from './types/map'; -import type { BaseEventPayload, PushEventFunction } from './types/events'; -import type { APRSMarker } from './types/marker-extensions'; - -// 1. Extract duplicated functions -export const MapHelpers = { - // Centralized timestamp parsing - parseTimestamp(timestamp: string | number | undefined | null): number { - if (!timestamp) return Date.now(); - - if (typeof timestamp === "number") { - return timestamp; - } else if (typeof timestamp === "string") { - return new Date(timestamp).getTime(); - } - return Date.now(); - }, - - // Centralized trail ID calculation - getTrailId(data: { callsign_group?: string; callsign?: string; id: string }): string { - return data.callsign_group || data.callsign || data.id; - }, - - // Centralized map state saving - saveMapState(map: LeafletMap, pushEvent: PushEventFunction) { - const center = map.getCenter(); - const zoom = map.getZoom(); - - // Truncate lat/lng to 5 decimal places for URL - const truncatedLat = Math.round(center.lat * 100000) / 100000; - const truncatedLng = Math.round(center.lng * 100000) / 100000; - - localStorage.setItem( - "aprs_map_state", - JSON.stringify({ lat: truncatedLat, lng: truncatedLng, zoom }), - ); - - // Send combined map state update to server for URL and bounds updating - pushEvent("update_map_state", { - center: { lat: truncatedLat, lng: truncatedLng }, - zoom: zoom, - bounds: { - north: map.getBounds().getNorth(), - south: map.getBounds().getSouth(), - east: map.getBounds().getEast(), - west: map.getBounds().getWest(), - } - }); - }, - - // Safe event pushing with connection check - safePushEvent(pushEvent: PushEventFunction | undefined, event: string, payload: BaseEventPayload) { - if (!pushEvent) return false; - - try { - pushEvent(event, payload); - return true; - } catch (e) { - return false; - } - } -}; - -// 2. Improved LiveViewHookContext with proper cleanup tracking -interface ImprovedLiveViewHookContext extends LiveViewHookContext { - cleanupInterval?: ReturnType; - mapEventHandlers?: Map; - isDestroyed?: boolean; -} - -// 3. Example of fixed initialization with proper cleanup tracking -export const setupMapWithCleanup = (self: ImprovedLiveViewHookContext) => { - // Track if component is destroyed - self.isDestroyed = false; - - // Store event handlers for cleanup - self.mapEventHandlers = new Map(); - - // Store interval for cleanup - self.cleanupInterval = setInterval(() => { - if (!self.isDestroyed && self.trailManager) { - self.trailManager.cleanupOldPositions(); - } - }, 5 * 60 * 1000); - - // Create wrapped event handlers that check if destroyed - const createSafeHandler = (handler: (...args: TArgs) => void) => { - return (...args: TArgs) => { - if (!self.isDestroyed) { - handler(...args); - } - }; - }; - - // Example of safe map event handler - const moveEndHandler = createSafeHandler(() => { - if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) { - self.programmaticMoveCounter--; - return; - } - - if (self.boundsTimer) clearTimeout(self.boundsTimer); - self.boundsTimer = setTimeout(() => { - if (!self.isDestroyed) { - MapHelpers.saveMapState(self.map, self.pushEvent); - } - }, 300); - }); - - self.map.on("moveend", moveEndHandler); - self.mapEventHandlers.set("moveend", moveEndHandler); -}; - -// 4. Improved cleanup function -export const improvedDestroyed = (self: ImprovedLiveViewHookContext) => { - // 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; - } - - // 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) { - } - } - - // Clear timers - if (self.boundsTimer !== undefined) { - clearTimeout(self.boundsTimer); - self.boundsTimer = undefined; - } - - if (self.resizeHandler !== undefined) { - window.removeEventListener("resize", self.resizeHandler); - self.resizeHandler = undefined; - } - - // Remove map event handlers - if (self.map !== undefined && self.mapEventHandlers !== undefined) { - self.mapEventHandlers.forEach((handler, event) => { - try { - self.map.off(event, handler); - } catch (e) { - } - }); - self.mapEventHandlers.clear(); - } - - // Remove all event listeners from markers before clearing layers - if (self.markers !== undefined) { - self.markers.forEach((marker: APRSMarker, id: string) => { - try { - marker.off(); // Remove all event listeners - if (marker.getPopup()) { - marker.unbindPopup(); // Unbind popup to prevent events - } - } catch (e) { - } - }); - } - - // Clear layers and data - if (self.markerLayer !== undefined) { - try { - self.markerLayer!.clearLayers(); - } catch (e) { - } - } - - if (self.markers !== undefined) { - self.markers!.clear(); - } - - if (self.markerStates !== undefined) { - self.markerStates!.clear(); - } - - // Remove map - if (self.map !== undefined) { - try { - self.map!.remove(); - } catch (e) { - } - self.map = undefined; - } - - // Clear OMS if present - if (self.oms !== undefined) { - self.oms = undefined; - } - - // Restore original pushEvent (though it won't be used since we're destroyed) - self.pushEvent = originalPushEvent; -}; - -// 5. Optimized marker position lookup using spatial index -export class MarkerSpatialIndex { - private grid: Map> = new Map(); - private cellSize: number = 0.0001; // ~11 meters at equator - - private getGridKey(lat: number, lng: number): string { - const latCell = Math.floor(lat / this.cellSize); - const lngCell = Math.floor(lng / this.cellSize); - return `${latCell},${lngCell}`; - } - - add(id: string, lat: number, lng: number) { - const key = this.getGridKey(lat, lng); - if (!this.grid.has(key)) { - this.grid.set(key, new Set()); - } - this.grid.get(key)!.add(id); - } - - remove(id: string, lat: number, lng: number) { - const key = this.getGridKey(lat, lng); - const cell = this.grid.get(key); - if (cell) { - cell.delete(id); - if (cell.size === 0) { - this.grid.delete(key); - } - } - } - - findNearby(lat: number, lng: number, radius: number = 0.00001): Set { - const results = new Set(); - const cellRadius = Math.ceil(radius / this.cellSize); - const centerLatCell = Math.floor(lat / this.cellSize); - const centerLngCell = Math.floor(lng / this.cellSize); - - for (let latOffset = -cellRadius; latOffset <= cellRadius; latOffset++) { - for (let lngOffset = -cellRadius; lngOffset <= cellRadius; lngOffset++) { - const key = `${centerLatCell + latOffset},${centerLngCell + lngOffset}`; - const cell = this.grid.get(key); - if (cell) { - cell.forEach(id => results.add(id)); - } - } - } - - return results; - } - - clear() { - this.grid.clear(); - } -} - -// 6. Improved programmatic move counter with proper state management -export class ProgrammaticMoveTracker { - private moveCount: number = 0; - private timeoutId?: ReturnType; - - expectMoves(count: number) { - this.moveCount += count; - - // Clear existing timeout - if (this.timeoutId) { - clearTimeout(this.timeoutId); - } - - // Set safety timeout - this.timeoutId = setTimeout(() => { - if (this.moveCount > 0) { - this.moveCount = 0; - } - }, 2000); - } - - handleMove(): boolean { - if (this.moveCount > 0) { - this.moveCount--; - return true; // This was a programmatic move - } - return false; // This was a user-initiated move - } - - reset() { - this.moveCount = 0; - if (this.timeoutId) { - clearTimeout(this.timeoutId); - this.timeoutId = undefined; - } - } -} \ No newline at end of file diff --git a/lib/aprsme_web/live/map_live/components.ex b/lib/aprsme_web/live/map_live/components.ex index aa10678..80bc740 100644 --- a/lib/aprsme_web/live/map_live/components.ex +++ b/lib/aprsme_web/live/map_live/components.ex @@ -289,43 +289,198 @@ defmodule AprsmeWeb.MapLive.Components do } } - /* Slideover panel base styles */ - .slideover-panel { - position: fixed; - top: 0; - right: 0; - bottom: 0; - width: 352px; - background: white; - box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); - z-index: 50; - display: flex; - flex-direction: column; - transition: transform 0.3s ease-in-out; - overflow: hidden; - } - - /* Desktop: default to visible, only hide when explicitly closed */ - @media (min-width: 1024px) { - .slideover-panel { - transform: translateX(0); - } - } - - .slideover-panel.slideover-closed { - transform: translateX(100%); - } - - /* Mobile responsiveness */ + /* Mobile: map always full-width */ @media (max-width: 1023px) { #aprs-map { right: 0 !important; } + } + .locate-button { + position: absolute; + left: 12px; + top: 100px; + z-index: 1000; + background: white; + border: 2px solid rgba(0,0,0,0.2); + border-radius: 4px; + padding: 5px; + cursor: pointer; + color: #333; + } + + .locate-button:hover { + background: #f4f4f4; + } + + /* Mobile: larger touch targets push zoom controls taller */ + @media (max-width: 1023px) { + .locate-button { + top: 110px; + width: 44px; + height: 44px; + display: flex; + align-items: center; + justify-content: center; + } + } + + .aprs-marker { + background: transparent !important; + border: none !important; + } + + .historical-marker { + opacity: 0.7; + } + + .aprs-popup { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + line-height: 1.4; + max-width: 200px; + } + + .aprs-callsign { + font-size: 14px; + font-weight: bold; + color: #1e40af; + margin-bottom: 4px; + } + + .aprs-info-link { + font-size: 11px; + color: #007cba; + text-decoration: none; + font-weight: normal; + margin-left: 8px; + padding: 2px 4px; + border-radius: 3px; + transition: background-color 0.2s; + } + + .aprs-info-link:hover { + background-color: rgba(0, 124, 186, 0.1); + text-decoration: none; + } + + .aprs-comment { + color: #374151; + margin-bottom: 4px; + word-wrap: break-word; + } + + .aprs-coords { + color: #6b7280; + font-size: 11px; + font-family: monospace; + } + + .aprs-timestamp { + color: #6b7280; + font-size: 11px; + font-family: monospace; + padding-top: 4px; + } + + /* Leaflet popup improvements for APRS data */ + .leaflet-popup-content-wrapper { + border-radius: 8px; + } + + .leaflet-popup-content { + margin: 8px 12px; + } + + /* Slideover panel styles */ + .slideover-panel { + position: fixed; + top: 0; + right: 0; + height: 100vh; + background: white; + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1); + z-index: 1000; + display: flex; + flex-direction: column; + transition: transform 0.3s ease-in-out; + overflow: hidden; + box-sizing: border-box; + } + + @media (prefers-color-scheme: dark) { .slideover-panel { - width: 100%; + background: rgb(15 23 42); /* slate-900 */ + box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3); + } + } + + /* Ensure proper box-sizing for all children */ + .slideover-panel * { + box-sizing: border-box; + } + + /* Desktop styles */ + @media (min-width: 1024px) { + .slideover-panel { + width: 352px; + transform: translateX(0); + } + + .slideover-panel.slideover-closed { + transform: translateX(100%); + } + } + + /* Mobile styles - override the desktop styles */ + @media (max-width: 1023px) { + .slideover-panel { + width: 90vw !important; max-width: 400px; } + + .slideover-panel.slideover-closed { + transform: translateX(100%); + } + + .slideover-panel.slideover-open { + transform: translateX(0); + } + } + + /* Slideover toggle button */ + .slideover-toggle { + position: fixed; + top: 50%; + transform: translateY(-50%); + z-index: 999; + background: white; + color: #374151; + border: 2px solid rgba(0, 0, 0, 0.1); + border-radius: 8px 0 0 8px; + padding: 12px 8px; + cursor: pointer; + transition: all 0.3s ease-in-out; + box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); + } + + .slideover-toggle.slideover-open { + right: 352px; + } + + .slideover-toggle.slideover-closed { + right: 0; + border-right: none; + } + + @media (max-width: 1023px) { + .slideover-toggle.slideover-open { + display: none; + } + } + + .slideover-toggle:hover { + background: #f3f4f6; } /* Loading spinner animation */ diff --git a/lib/aprsme_web/live/map_live/data_builder.ex b/lib/aprsme_web/live/map_live/data_builder.ex index cd58494..f7fe315 100644 --- a/lib/aprsme_web/live/map_live/data_builder.ex +++ b/lib/aprsme_web/live/map_live/data_builder.ex @@ -246,6 +246,9 @@ defmodule AprsmeWeb.MapLive.DataBuilder do end end + # credo:disable-for-next-line + # TODO: Consider lazy popup generation (render on marker click via client-side + # template) to avoid building 5000+ popup HTML strings on initial historical load. defp render_popup(data) do data |> PopupComponent.popup() @@ -520,10 +523,7 @@ defmodule AprsmeWeb.MapLive.DataBuilder do end defp convert_tuples_to_strings(list) when is_list(list) do - # Use Stream for memory efficiency on large lists - list - |> Stream.map(&convert_tuples_to_strings/1) - |> Enum.to_list() + Enum.map(list, &convert_tuples_to_strings/1) end defp convert_tuples_to_strings(tuple) when is_tuple(tuple) do diff --git a/lib/aprsme_web/live/map_live/display_manager.ex b/lib/aprsme_web/live/map_live/display_manager.ex index 1fc3e4e..87e7339 100644 --- a/lib/aprsme_web/live/map_live/display_manager.ex +++ b/lib/aprsme_web/live/map_live/display_manager.ex @@ -6,6 +6,7 @@ defmodule AprsmeWeb.MapLive.DisplayManager do alias Aprsme.Packets.Clustering alias AprsmeWeb.Live.Shared.BoundsUtils alias AprsmeWeb.MapLive.DataBuilder + alias AprsmeWeb.MapLive.HistoricalLoader alias Phoenix.LiveView alias Phoenix.LiveView.Socket @@ -153,12 +154,15 @@ defmodule AprsmeWeb.MapLive.DisplayManager do LiveView.push_event(socket, "remove_markers_batch", %{ids: marker_ids}) end - # Placeholder for historical loading function - this should be moved to HistoricalLoader defp start_progressive_historical_loading(socket) do - # This function should be moved to HistoricalLoader module - socket + HistoricalLoader.start_progressive_historical_loading(socket) end + # credo:disable-for-next-line + # TODO: Add hop-distance filtering (100km threshold) to match client-side + # TrailManager segmentation. Currently shows all time-filtered positions + # as a single polyline, which can create misleading long lines for + # stations that jump between widely separated locations. defp trail_packets_for_callsign(visible_packets, historical_packets, callsign, threshold) do (Map.values(visible_packets) ++ Map.values(historical_packets)) |> Enum.reduce(%{}, fn p, acc -> diff --git a/lib/aprsme_web/live/map_live/events.ex b/lib/aprsme_web/live/map_live/events.ex index 524cb24..3961c9e 100644 --- a/lib/aprsme_web/live/map_live/events.ex +++ b/lib/aprsme_web/live/map_live/events.ex @@ -324,11 +324,14 @@ defmodule AprsmeWeb.MapLive.Events do zoom = clamp_zoom(zoom) map_center = %{lat: lat, lng: lng} - socket = update_map_state(socket, map_center, zoom) - socket = handle_url_update(socket, lat, lng, zoom) - socket = process_bounds_from_params(socket, params) - - {:noreply, socket} + if map_center != socket.assigns.map_center or zoom != socket.assigns.map_zoom do + socket = update_map_state(socket, map_center, zoom) + socket = handle_url_update(socket, lat, lng, zoom) + socket = process_bounds_from_params(socket, params) + {:noreply, socket} + else + {:noreply, socket} + end end @spec handle_error_boundary_triggered(String.t(), String.t(), String.t(), Socket.t()) :: {:noreply, Socket.t()} diff --git a/lib/aprsme_web/live/map_live/historical_loader.ex b/lib/aprsme_web/live/map_live/historical_loader.ex index 34aade4..77c480c 100644 --- a/lib/aprsme_web/live/map_live/historical_loader.ex +++ b/lib/aprsme_web/live/map_live/historical_loader.ex @@ -55,7 +55,7 @@ defmodule AprsmeWeb.MapLive.HistoricalLoader do socket = cancel_pending_loads(socket) # Add a failsafe timeout to prevent infinite loading - Process.send_after(self(), {:historical_loading_timeout, new_generation}, 30_000) + Process.send_after(self(), {:historical_loading_timeout, new_generation}, 15_000) # For high zoom levels, load everything in one batch for maximum speed zoom = socket.assigns.map_zoom || 5 diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index d51c525..cfdf821 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -11,7 +11,6 @@ defmodule AprsmeWeb.MapLive.Index do only: [push_event: 3, put_flash: 3] alias Aprsme.Packets - alias AprsmeWeb.Live.Shared.BoundsUtils alias AprsmeWeb.Live.Shared.CoordinateUtils alias AprsmeWeb.Live.Shared.PacketUtils, as: SharedPacketUtils alias AprsmeWeb.MapLive.BoundsUpdater @@ -206,22 +205,16 @@ defmodule AprsmeWeb.MapLive.Index do # Update packet age threshold based on trail duration hours = String.to_integer(trail_duration) - new_threshold = DateTime.add(DateTime.utc_now(), -hours * 3600, :second) - socket = assign(socket, packet_age_threshold: new_threshold) + + socket = + if trail_duration == socket.assigns.trail_duration do + socket + else + assign(socket, packet_age_threshold: DateTime.add(DateTime.utc_now(), -hours * 3600, :second)) + end # If map is ready, update the client-side map and trail duration - socket = - if socket.assigns.map_ready do - socket - |> push_event("zoom_to_location", %{ - lat: map_center.lat, - lng: map_center.lng, - zoom: map_zoom - }) - |> push_event("update_trail_duration", %{duration_hours: hours}) - else - socket - end + socket = maybe_push_map_update(socket, map_center, map_zoom, hours) # Trigger cleanup and reload if settings changed if socket.assigns[:map_ready] do @@ -233,6 +226,19 @@ defmodule AprsmeWeb.MapLive.Index do end end + defp maybe_push_map_update(socket, map_center, map_zoom, hours) do + if socket.assigns.map_ready and + (map_center.lat != socket.assigns.map_center.lat or + map_center.lng != socket.assigns.map_center.lng or + map_zoom != socket.assigns.map_zoom) do + socket + |> push_event("zoom_to_location", %{lat: map_center.lat, lng: map_center.lng, zoom: map_zoom}) + |> push_event("update_trail_duration", %{duration_hours: hours}) + else + socket + end + end + # Parse trail duration with validation and bounds checking defp parse_trail_duration(duration), do: SharedPacketUtils.parse_trail_duration(duration) @@ -400,25 +406,8 @@ defmodule AprsmeWeb.MapLive.Index do # Clear the timer reference since we're processing this update socket = assign(socket, bounds_update_timer: nil, pending_bounds: nil) - # Check if we need to process this bounds update - should_process = - !socket.assigns[:initial_bounds_loaded] or - socket.assigns[:needs_initial_historical_load] or - not BoundsUtils.compare_bounds(map_bounds, socket.assigns.map_bounds) - - Logger.debug( - "handle_info_process_bounds_update - should_process: #{should_process}, initial_bounds_loaded: #{socket.assigns[:initial_bounds_loaded]}, needs_initial_historical_load: #{socket.assigns[:needs_initial_historical_load]}" - ) - - if should_process do - Logger.debug("Processing bounds update: #{inspect(map_bounds)}") - socket = BoundsUpdater.process_bounds_update(map_bounds, socket) - socket = assign(socket, initial_bounds_loaded: true) - {:noreply, socket} - else - Logger.debug("Skipping bounds update - no change detected") - {:noreply, socket} - end + socket = BoundsUpdater.process_bounds_update(map_bounds, socket) + {:noreply, socket} end end @@ -441,6 +430,8 @@ defmodule AprsmeWeb.MapLive.Index do end end + # Fallback path: only reached when PacketBatcher is not running (e.g., during + # batcher restart window). In normal operation, packets route through the batcher. defp handle_info_postgres_packet(packet, socket) do # Check if we're tracking a specific callsign # Only process if this packet is from the tracked callsign @@ -491,6 +482,8 @@ defmodule AprsmeWeb.MapLive.Index do end defp process_packet_batch(packets, socket) do + # Build marker/removal lists by prepending (O(1)) then reverse at the end. + # Batch size is capped at 10, so the double-pass cost is negligible. Enum.reduce(packets, {socket, [], []}, fn packet, {acc_socket, markers, removals} -> {new_socket, marker_data, removed_id} = process_packet_for_batch(packet, acc_socket) markers = if marker_data, do: [marker_data | markers], else: markers @@ -586,196 +579,6 @@ defmodule AprsmeWeb.MapLive.Index do defp bottom_controls(assigns) do ~H""" - <%!-- Existing bottom controls code will go here --%> - -