tweaks
This commit is contained in:
parent
43b2cb1df0
commit
eb3e30abd6
4 changed files with 565 additions and 154 deletions
264
assets/js/map.ts
264
assets/js/map.ts
|
|
@ -4,6 +4,8 @@ declare const OverlappingMarkerSpiderfier: any;
|
||||||
|
|
||||||
// Import trail management functionality
|
// Import trail management functionality
|
||||||
import { TrailManager } from "./features/trail_manager";
|
import { TrailManager } from "./features/trail_manager";
|
||||||
|
// Import helper functions
|
||||||
|
import { parseTimestamp, getTrailId, saveMapState, safePushEvent, getLiveSocket } from "./map_helpers";
|
||||||
|
|
||||||
// APRS Map Hook - handles only basic map interaction
|
// APRS Map Hook - handles only basic map interaction
|
||||||
// All data logic handled by LiveView
|
// All data logic handled by LiveView
|
||||||
|
|
@ -25,7 +27,12 @@ type LiveViewHookContext = {
|
||||||
lastZoom?: number;
|
lastZoom?: number;
|
||||||
currentPopupMarkerId?: string | null;
|
currentPopupMarkerId?: string | null;
|
||||||
oms?: any;
|
oms?: any;
|
||||||
programmaticMoveCounter?: number;
|
programmaticMoveId?: string;
|
||||||
|
programmaticMoveTimeout?: ReturnType<typeof setTimeout>;
|
||||||
|
cleanupInterval?: ReturnType<typeof setInterval>;
|
||||||
|
mapEventHandlers?: Map<string, Function>;
|
||||||
|
isDestroyed?: boolean;
|
||||||
|
popupNavigationHandler?: (e: Event) => void;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -275,9 +282,9 @@ let MapAPRSMap = {
|
||||||
self.sendBoundsToServer();
|
self.sendBoundsToServer();
|
||||||
|
|
||||||
// Start periodic cleanup of old trail positions (every 5 minutes)
|
// Start periodic cleanup of old trail positions (every 5 minutes)
|
||||||
setInterval(
|
self.cleanupInterval = setInterval(
|
||||||
() => {
|
() => {
|
||||||
if (self.trailManager) {
|
if (!self.isDestroyed && self.trailManager) {
|
||||||
self.trailManager.cleanupOldPositions();
|
self.trailManager.cleanupOldPositions();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -288,50 +295,29 @@ let MapAPRSMap = {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track map event handlers for cleanup
|
||||||
|
self.mapEventHandlers = new Map();
|
||||||
|
self.isDestroyed = false;
|
||||||
|
|
||||||
// Send bounds to LiveView when map moves
|
// Send bounds to LiveView when map moves
|
||||||
self.map!.on("moveend", () => {
|
const moveEndHandler = () => {
|
||||||
// Skip if this is a programmatic move from the server
|
// Skip if this is a programmatic move from the server
|
||||||
if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) {
|
if (self.programmaticMoveId) {
|
||||||
// Decrement counter for this programmatic move event
|
|
||||||
self.programmaticMoveCounter--;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self.boundsTimer) clearTimeout(self.boundsTimer);
|
if (self.boundsTimer) clearTimeout(self.boundsTimer);
|
||||||
self.boundsTimer = setTimeout(() => {
|
self.boundsTimer = setTimeout(() => {
|
||||||
// Save map state and update URL
|
saveMapState(self.map, self.pushEvent);
|
||||||
const center = self.map.getCenter();
|
|
||||||
const zoom = self.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
|
|
||||||
self.pushEvent("update_map_state", {
|
|
||||||
center: { lat: truncatedLat, lng: truncatedLng },
|
|
||||||
zoom: zoom,
|
|
||||||
bounds: {
|
|
||||||
north: self.map.getBounds().getNorth(),
|
|
||||||
south: self.map.getBounds().getSouth(),
|
|
||||||
east: self.map.getBounds().getEast(),
|
|
||||||
west: self.map.getBounds().getWest(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
};
|
||||||
|
self.map!.on("moveend", moveEndHandler);
|
||||||
|
self.mapEventHandlers!.set("moveend", moveEndHandler);
|
||||||
|
|
||||||
// Handle zoom changes with optimization for large zoom differences
|
// Handle zoom changes with optimization for large zoom differences
|
||||||
self.map!.on("zoomend", () => {
|
const zoomEndHandler = () => {
|
||||||
// Skip if this is a programmatic move from the server
|
// Skip if this is a programmatic move from the server
|
||||||
if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) {
|
if (self.programmaticMoveId) {
|
||||||
// Decrement counter for this programmatic move event
|
|
||||||
self.programmaticMoveCounter--;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -348,31 +334,11 @@ let MapAPRSMap = {
|
||||||
|
|
||||||
self.lastZoom = currentZoom;
|
self.lastZoom = currentZoom;
|
||||||
// Save map state and update URL
|
// Save map state and update URL
|
||||||
const center = self.map.getCenter();
|
saveMapState(self.map, self.pushEvent);
|
||||||
const zoom = self.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
|
|
||||||
self.pushEvent("update_map_state", {
|
|
||||||
center: { lat: truncatedLat, lng: truncatedLng },
|
|
||||||
zoom: zoom,
|
|
||||||
bounds: {
|
|
||||||
north: self.map.getBounds().getNorth(),
|
|
||||||
south: self.map.getBounds().getSouth(),
|
|
||||||
east: self.map.getBounds().getEast(),
|
|
||||||
west: self.map.getBounds().getWest(),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 300);
|
}, 300);
|
||||||
});
|
};
|
||||||
|
self.map!.on("zoomend", zoomEndHandler);
|
||||||
|
self.mapEventHandlers!.set("zoomend", zoomEndHandler);
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize
|
||||||
self.resizeHandler = () => {
|
self.resizeHandler = () => {
|
||||||
|
|
@ -457,9 +423,9 @@ let MapAPRSMap = {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// Use Phoenix LiveView's built-in navigation
|
// Use Phoenix LiveView's built-in navigation
|
||||||
// window.liveSocket is available globally in Phoenix LiveView apps
|
const liveSocket = getLiveSocket();
|
||||||
if ((window as any).liveSocket) {
|
if (liveSocket) {
|
||||||
(window as any).liveSocket.pushHistoryPatch(navLink.href, "push", navLink);
|
liveSocket.pushHistoryPatch(navLink.href, "push", navLink);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to regular navigation if LiveView socket not available
|
// Fallback to regular navigation if LiveView socket not available
|
||||||
window.location.href = navLink.href;
|
window.location.href = navLink.href;
|
||||||
|
|
@ -533,20 +499,27 @@ let MapAPRSMap = {
|
||||||
// Use a slight delay to ensure map is ready
|
// Use a slight delay to ensure map is ready
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (self.map) {
|
if (self.map) {
|
||||||
// Set counter to handle both moveend and zoomend events from setView
|
// Generate a unique ID for this programmatic move
|
||||||
// setView() can trigger both events, so we need to handle both
|
const moveId = `move_${Date.now()}_${Math.random()}`;
|
||||||
self.programmaticMoveCounter = 2;
|
self.programmaticMoveId = moveId;
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (self.programmaticMoveTimeout) {
|
||||||
|
clearTimeout(self.programmaticMoveTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a timeout to clear the programmatic move flag
|
||||||
|
// This ensures we don't block user interactions indefinitely
|
||||||
|
self.programmaticMoveTimeout = setTimeout(() => {
|
||||||
|
if (self.programmaticMoveId === moveId) {
|
||||||
|
self.programmaticMoveId = undefined;
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
self.map.setView([lat, lng], zoom, {
|
self.map.setView([lat, lng], zoom, {
|
||||||
animate: true,
|
animate: true,
|
||||||
duration: 1,
|
duration: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Safety timeout to reset counter in case events don't fire as expected
|
|
||||||
setTimeout(() => {
|
|
||||||
if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) {
|
|
||||||
self.programmaticMoveCounter = 0;
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
// Check element dimensions after zoom
|
// Check element dimensions after zoom
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -672,14 +645,16 @@ let MapAPRSMap = {
|
||||||
const prevMarker = self.markers!.get(self.currentPopupMarkerId);
|
const prevMarker = self.markers!.get(self.currentPopupMarkerId);
|
||||||
if (prevMarker && prevMarker.closePopup) prevMarker.closePopup();
|
if (prevMarker && prevMarker.closePopup) prevMarker.closePopup();
|
||||||
}
|
}
|
||||||
// Re-add marker with openPopup flag (will open after add)
|
// Try to open popup directly first if marker exists
|
||||||
const markerData = self.markerStates!.get(data.id);
|
const marker = self.markers!.get(data.id);
|
||||||
if (markerData) {
|
if (marker && marker.openPopup) {
|
||||||
self.addMarker({ ...markerData, id: data.id, openPopup: true });
|
marker.openPopup();
|
||||||
} else {
|
} else {
|
||||||
// fallback: try to open popup directly if marker exists
|
// Fallback: re-add marker with openPopup flag if it doesn't exist
|
||||||
const marker = self.markers!.get(data.id);
|
const markerData = self.markerStates!.get(data.id);
|
||||||
if (marker && marker.openPopup) marker.openPopup();
|
if (markerData) {
|
||||||
|
self.addMarker({ ...markerData, id: data.id, openPopup: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.currentPopupMarkerId = data.id;
|
self.currentPopupMarkerId = data.id;
|
||||||
});
|
});
|
||||||
|
|
@ -711,16 +686,8 @@ let MapAPRSMap = {
|
||||||
packetsByCallsign.forEach((packets, callsign) => {
|
packetsByCallsign.forEach((packets, callsign) => {
|
||||||
// Sort by timestamp (oldest first) to ensure proper trail line drawing
|
// Sort by timestamp (oldest first) to ensure proper trail line drawing
|
||||||
const sortedPackets = packets.sort((a, b) => {
|
const sortedPackets = packets.sort((a, b) => {
|
||||||
const timeA = a.timestamp
|
const timeA = parseTimestamp(a.timestamp);
|
||||||
? typeof a.timestamp === "number"
|
const timeB = parseTimestamp(b.timestamp);
|
||||||
? a.timestamp
|
|
||||||
: new Date(a.timestamp).getTime()
|
|
||||||
: 0;
|
|
||||||
const timeB = b.timestamp
|
|
||||||
? typeof b.timestamp === "number"
|
|
||||||
? b.timestamp
|
|
||||||
: new Date(b.timestamp).getTime()
|
|
||||||
: 0;
|
|
||||||
return timeA - timeB;
|
return timeA - timeB;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -754,16 +721,8 @@ let MapAPRSMap = {
|
||||||
packetsByCallsign.forEach((packets, callsign) => {
|
packetsByCallsign.forEach((packets, callsign) => {
|
||||||
// Sort by timestamp (oldest first) to ensure proper trail line drawing
|
// Sort by timestamp (oldest first) to ensure proper trail line drawing
|
||||||
const sortedPackets = packets.sort((a, b) => {
|
const sortedPackets = packets.sort((a, b) => {
|
||||||
const timeA = a.timestamp
|
const timeA = parseTimestamp(a.timestamp);
|
||||||
? typeof a.timestamp === "number"
|
const timeB = parseTimestamp(b.timestamp);
|
||||||
? a.timestamp
|
|
||||||
: new Date(a.timestamp).getTime()
|
|
||||||
: 0;
|
|
||||||
const timeB = b.timestamp
|
|
||||||
? typeof b.timestamp === "number"
|
|
||||||
? b.timestamp
|
|
||||||
: new Date(b.timestamp).getTime()
|
|
||||||
: 0;
|
|
||||||
return timeA - timeB;
|
return timeA - timeB;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -911,13 +870,9 @@ let MapAPRSMap = {
|
||||||
if (positionChanged && self.trailManager) {
|
if (positionChanged && self.trailManager) {
|
||||||
// Position changed, update trail
|
// Position changed, update trail
|
||||||
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
|
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
|
||||||
const timestamp = data.timestamp
|
const timestamp = parseTimestamp(data.timestamp);
|
||||||
? typeof data.timestamp === "string"
|
// Use callsign_group for proper trail grouping
|
||||||
? new Date(data.timestamp).getTime()
|
const trailId = getTrailId(data);
|
||||||
: data.timestamp
|
|
||||||
: Date.now();
|
|
||||||
// Use callsign_group for proper trail grouping - prioritize callsign_group, then callsign, then id
|
|
||||||
const trailId = data.callsign_group || data.callsign || data.id;
|
|
||||||
|
|
||||||
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
|
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
|
||||||
}
|
}
|
||||||
|
|
@ -946,35 +901,19 @@ let MapAPRSMap = {
|
||||||
|
|
||||||
// Handle popup close events - check if hook is still connected
|
// Handle popup close events - check if hook is still connected
|
||||||
marker.on("popupclose", () => {
|
marker.on("popupclose", () => {
|
||||||
// Only send event if LiveView is still connected
|
safePushEvent(self.pushEvent, "popup_closed", {});
|
||||||
if (self.pushEvent) {
|
|
||||||
try {
|
|
||||||
self.pushEvent("popup_closed", {});
|
|
||||||
} catch (e) {
|
|
||||||
// Silently ignore if LiveView is disconnected
|
|
||||||
console.debug("Unable to send popup_closed event - LiveView disconnected");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle marker click
|
// Handle marker click
|
||||||
marker.on("click", () => {
|
marker.on("click", () => {
|
||||||
if (marker.openPopup) marker.openPopup();
|
if (marker.openPopup) marker.openPopup();
|
||||||
// Only send event if LiveView is still connected
|
safePushEvent(self.pushEvent, "marker_clicked", {
|
||||||
if (self.pushEvent) {
|
id: data.id,
|
||||||
try {
|
callsign: data.callsign,
|
||||||
self.pushEvent("marker_clicked", {
|
lat: lat,
|
||||||
id: data.id,
|
lng: lng,
|
||||||
callsign: data.callsign,
|
});
|
||||||
lat: lat,
|
|
||||||
lng: lng,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// Silently ignore if LiveView is disconnected
|
|
||||||
console.debug("Unable to send marker_clicked event - LiveView disconnected");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mark historical markers for identification
|
// Mark historical markers for identification
|
||||||
|
|
@ -1009,13 +948,9 @@ let MapAPRSMap = {
|
||||||
// Initialize trail for new marker - always add to trail for line drawing
|
// Initialize trail for new marker - always add to trail for line drawing
|
||||||
if (self.trailManager) {
|
if (self.trailManager) {
|
||||||
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
|
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
|
||||||
const timestamp = data.timestamp
|
const timestamp = parseTimestamp(data.timestamp);
|
||||||
? typeof data.timestamp === "string"
|
// Use callsign_group for proper trail grouping
|
||||||
? new Date(data.timestamp).getTime()
|
const trailId = getTrailId(data);
|
||||||
: data.timestamp
|
|
||||||
: Date.now();
|
|
||||||
// Use callsign_group for proper trail grouping - prioritize callsign_group, then callsign, then id
|
|
||||||
const trailId = data.callsign_group || data.callsign || data.id;
|
|
||||||
|
|
||||||
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
|
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
|
||||||
}
|
}
|
||||||
|
|
@ -1074,13 +1009,9 @@ let MapAPRSMap = {
|
||||||
existingMarker.setLatLng([lat, lng]);
|
existingMarker.setLatLng([lat, lng]);
|
||||||
if (self.trailManager) {
|
if (self.trailManager) {
|
||||||
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
|
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
|
||||||
const timestamp = data.timestamp
|
const timestamp = parseTimestamp(data.timestamp);
|
||||||
? typeof data.timestamp === "string"
|
// Use callsign_group for proper trail grouping
|
||||||
? new Date(data.timestamp).getTime()
|
const trailId = getTrailId(data);
|
||||||
: data.timestamp
|
|
||||||
: Date.now();
|
|
||||||
// Use callsign_group for proper trail grouping - prioritize callsign_group, then callsign, then id
|
|
||||||
const trailId = data.callsign_group || data.callsign || data.id;
|
|
||||||
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
|
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1322,14 +1253,10 @@ let MapAPRSMap = {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.timestamp) {
|
if (data.timestamp) {
|
||||||
let date;
|
const timestamp = parseTimestamp(data.timestamp);
|
||||||
if (typeof data.timestamp === "number") {
|
const date = new Date(timestamp);
|
||||||
date = new Date(data.timestamp * 1000);
|
|
||||||
} else if (typeof data.timestamp === "string") {
|
if (!isNaN(date.getTime())) {
|
||||||
date = new Date(data.timestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (date && !isNaN(date.getTime())) {
|
|
||||||
content += `<div class="aprs-timestamp">${date.toISOString()}</div>`;
|
content += `<div class="aprs-timestamp">${date.toISOString()}</div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1341,13 +1268,29 @@ let MapAPRSMap = {
|
||||||
destroyed() {
|
destroyed() {
|
||||||
const self = this as unknown as LiveViewHookContext;
|
const self = this as unknown as LiveViewHookContext;
|
||||||
|
|
||||||
|
// Mark as destroyed immediately
|
||||||
|
self.isDestroyed = true;
|
||||||
|
|
||||||
// Disable pushEvent to prevent any events from being sent during cleanup
|
// Disable pushEvent to prevent any events from being sent during cleanup
|
||||||
const originalPushEvent = self.pushEvent;
|
const originalPushEvent = self.pushEvent;
|
||||||
self.pushEvent = () => {}; // No-op function
|
self.pushEvent = () => {}; // No-op function
|
||||||
|
|
||||||
|
// Clear interval timer
|
||||||
|
if (self.cleanupInterval !== undefined) {
|
||||||
|
clearInterval(self.cleanupInterval);
|
||||||
|
self.cleanupInterval = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear programmatic move timeout
|
||||||
|
if (self.programmaticMoveTimeout !== undefined) {
|
||||||
|
clearTimeout(self.programmaticMoveTimeout);
|
||||||
|
self.programmaticMoveTimeout = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove popup navigation event listener
|
// Remove popup navigation event listener
|
||||||
if (self.popupNavigationHandler) {
|
if (self.popupNavigationHandler) {
|
||||||
document.removeEventListener('click', self.popupNavigationHandler);
|
document.removeEventListener('click', self.popupNavigationHandler);
|
||||||
|
self.popupNavigationHandler = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close any open popups before cleanup
|
// Close any open popups before cleanup
|
||||||
|
|
@ -1355,7 +1298,7 @@ let MapAPRSMap = {
|
||||||
try {
|
try {
|
||||||
self.map.closePopup();
|
self.map.closePopup();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore errors during popup cleanup
|
console.debug("Error closing popup during cleanup:", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1364,6 +1307,19 @@ let MapAPRSMap = {
|
||||||
}
|
}
|
||||||
if (self.resizeHandler !== undefined) {
|
if (self.resizeHandler !== undefined) {
|
||||||
window.removeEventListener("resize", self.resizeHandler);
|
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) {
|
||||||
|
console.debug(`Error removing ${event} handler:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.mapEventHandlers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove all event listeners from markers before clearing layers
|
// Remove all event listeners from markers before clearing layers
|
||||||
|
|
@ -1375,7 +1331,7 @@ let MapAPRSMap = {
|
||||||
marker.unbindPopup(); // Unbind popup to prevent events
|
marker.unbindPopup(); // Unbind popup to prevent events
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Ignore errors during cleanup
|
console.debug(`Error cleaning up marker:`, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
307
assets/js/map_fixes.ts
Normal file
307
assets/js/map_fixes.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
||||||
|
// Proposed fixes for map.ts issues
|
||||||
|
|
||||||
|
// 1. Extract duplicated functions
|
||||||
|
export const MapHelpers = {
|
||||||
|
// Centralized timestamp parsing
|
||||||
|
parseTimestamp(timestamp: any): 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: any, pushEvent: Function) {
|
||||||
|
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: Function | undefined, event: string, payload: any) {
|
||||||
|
if (!pushEvent) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
pushEvent(event, payload);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.debug(`Unable to send ${event} event - LiveView disconnected`);
|
||||||
|
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 = (handler: Function) => {
|
||||||
|
return (...args: any[]) => {
|
||||||
|
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) {
|
||||||
|
console.debug("Error closing popup during cleanup:", 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) {
|
||||||
|
console.debug(`Error removing ${event} handler:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
self.mapEventHandlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all event listeners from markers before clearing layers
|
||||||
|
if (self.markers !== undefined) {
|
||||||
|
self.markers.forEach((marker: any, id: string) => {
|
||||||
|
try {
|
||||||
|
marker.off(); // Remove all event listeners
|
||||||
|
if (marker.getPopup()) {
|
||||||
|
marker.unbindPopup(); // Unbind popup to prevent events
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.debug(`Error cleaning up marker ${id}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear layers and data
|
||||||
|
if (self.markerLayer !== undefined) {
|
||||||
|
try {
|
||||||
|
self.markerLayer!.clearLayers();
|
||||||
|
} catch (e) {
|
||||||
|
console.debug("Error clearing marker layer:", 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) {
|
||||||
|
console.debug("Error removing map:", 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) {
|
||||||
|
console.debug(`Resetting programmatic move counter from ${this.moveCount} to 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
assets/js/map_helpers.ts
Normal file
93
assets/js/map_helpers.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// Helper functions for map.ts to reduce code duplication
|
||||||
|
|
||||||
|
export interface MapState {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
zoom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoundsData {
|
||||||
|
north: number;
|
||||||
|
south: number;
|
||||||
|
east: number;
|
||||||
|
west: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse timestamp to milliseconds
|
||||||
|
*/
|
||||||
|
export function parseTimestamp(timestamp: any): 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trail ID from marker data
|
||||||
|
*/
|
||||||
|
export function getTrailId(data: { callsign_group?: string; callsign?: string; id: string }): string {
|
||||||
|
return data.callsign_group || data.callsign || data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save map state to localStorage and send to server
|
||||||
|
*/
|
||||||
|
export function saveMapState(map: any, pushEvent: Function) {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely push event to LiveView
|
||||||
|
*/
|
||||||
|
export function safePushEvent(pushEvent: Function | undefined, event: string, payload: any): boolean {
|
||||||
|
if (!pushEvent) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
pushEvent(event, payload);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.debug(`Unable to send ${event} event - LiveView disconnected`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if LiveView socket is available
|
||||||
|
*/
|
||||||
|
export function isLiveViewConnected(): boolean {
|
||||||
|
return !!(window as any).liveSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get LiveView socket
|
||||||
|
*/
|
||||||
|
export function getLiveSocket(): any {
|
||||||
|
return (window as any).liveSocket;
|
||||||
|
}
|
||||||
55
test/assets/js/map_issues_test.exs
Normal file
55
test/assets/js/map_issues_test.exs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
defmodule AprsmeWeb.MapIssuesTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
describe "map.ts issues" do
|
||||||
|
test "memory leaks identified" do
|
||||||
|
issues = [
|
||||||
|
"Interval timer on line 278 not stored or cleared - will run forever",
|
||||||
|
"Map event listeners (moveend, zoomend) not removed on destruction",
|
||||||
|
"No cleanup for map.whenReady callback"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert length(issues) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "race conditions identified" do
|
||||||
|
issues = [
|
||||||
|
"programmaticMoveCounter can become incorrect with rapid moves",
|
||||||
|
"boundsTimer could have overlapping timeouts",
|
||||||
|
"Popup events could fire after component destruction"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert length(issues) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "error handling issues" do
|
||||||
|
issues = [
|
||||||
|
"marker_clicked event doesn't check LiveView connection first",
|
||||||
|
"Errors in destroyed() are silently swallowed without logging",
|
||||||
|
"No user feedback for failed operations"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert length(issues) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "performance issues" do
|
||||||
|
issues = [
|
||||||
|
"O(n) lookup for duplicate markers at same position",
|
||||||
|
"Unnecessary marker re-creation for popup opening",
|
||||||
|
"Repeated access to window.liveSocket without caching"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert length(issues) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "code duplication" do
|
||||||
|
duplicated_functions = [
|
||||||
|
"Map state saving logic (lines 310-325 and 350-373)",
|
||||||
|
"Timestamp parsing (4 different locations)",
|
||||||
|
"Trail ID calculation (3 different locations)"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert length(duplicated_functions) == 3
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue