refactor: map page efficiency, trail accuracy, and spidering fixes
Some checks are pending
Elixir CI / Build and test (push) Waiting to run
Elixir CI / Dialyzer (push) Waiting to run
Elixir CI / Build and Push Docker Image (push) Blocked by required conditions

- 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
This commit is contained in:
Graham McIntire 2026-08-02 14:04:26 -05:00
parent b218f21734
commit c8c42b7cc8
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
11 changed files with 309 additions and 605 deletions

View file

@ -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<typeof setTimeout>;
private lastHoveredPath?: string;
private isDestroyed: boolean = false;
private positionKeys: Map<string, Set<string>> = new Map(); // callsign -> Set<"lat,lng,ts">
private usedColors: Set<string> = 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<string>();
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<string>();
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) => {

View file

@ -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();

View file

@ -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<typeof setInterval>;
mapEventHandlers?: Map<string, Function>;
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 = <TArgs extends unknown[]>(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<string, Set<string>> = 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<string> {
const results = new Set<string>();
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<typeof setTimeout>;
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;
}
}
}

View file

@ -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 */

View file

@ -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

View file

@ -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 ->

View file

@ -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()}

View file

@ -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

View file

@ -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 --%>
<style>
.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 {
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;
}
</style>
<!-- Slideover Toggle Button -->
<button
class={["slideover-toggle", if(@slideover_open, do: "slideover-open", else: "slideover-closed")]}

View file

@ -86,12 +86,12 @@ defmodule AprsmeWeb.MapLive.PacketProcessor do
defp dispatch_visibility(_, _packet, _lat, _lon, _callsign_key, socket), do: socket
# A station's marker is moved only if we can compare the two coordinates AND
# the delta exceeds the 50-meter significance threshold. Any missing/invalid
# the delta exceeds the 20-meter significance threshold. Any missing/invalid
# existing coordinate means "not a significant move" (keep existing marker).
defp moved_significantly?(existing_packet, new_lat, new_lon) do
case CoordinateUtils.get_coordinates(existing_packet) do
{elat, elon, _} when is_number(elat) and is_number(elon) ->
GeoUtils.significant_movement?(elat, elon, new_lat, new_lon, 50)
GeoUtils.significant_movement?(elat, elon, new_lat, new_lon, 20)
_ ->
false

View file

@ -25,10 +25,10 @@ defmodule AprsmeWeb.MapLive.State do
page_title: "APRS Map",
station_popup_open: false,
map_bounds: %{
north: 49.0,
south: 24.0,
east: -66.0,
west: -125.0
north: 85.0,
south: -60.0,
east: 180.0,
west: -180.0
},
map_center: UrlParams.default_center(),
map_zoom: UrlParams.default_zoom(),