refactor: map page efficiency, trail accuracy, and spidering fixes
- 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:
parent
b218f21734
commit
c8c42b7cc8
11 changed files with 309 additions and 605 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue