diff --git a/assets/js/map.ts b/assets/js/map.ts index e7f58dc..ed25168 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -4,6 +4,8 @@ declare const OverlappingMarkerSpiderfier: any; // Import trail management functionality 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 // All data logic handled by LiveView @@ -25,7 +27,12 @@ type LiveViewHookContext = { lastZoom?: number; currentPopupMarkerId?: string | null; oms?: any; - programmaticMoveCounter?: number; + programmaticMoveId?: string; + programmaticMoveTimeout?: ReturnType; + cleanupInterval?: ReturnType; + mapEventHandlers?: Map; + isDestroyed?: boolean; + popupNavigationHandler?: (e: Event) => void; [key: string]: any; }; @@ -275,9 +282,9 @@ let MapAPRSMap = { self.sendBoundsToServer(); // 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(); } }, @@ -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 - self.map!.on("moveend", () => { + const moveEndHandler = () => { // Skip if this is a programmatic move from the server - if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) { - // Decrement counter for this programmatic move event - self.programmaticMoveCounter--; + if (self.programmaticMoveId) { return; } if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { - // Save map state and update URL - 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(), - } - }); + saveMapState(self.map, self.pushEvent); }, 300); - }); + }; + self.map!.on("moveend", moveEndHandler); + self.mapEventHandlers!.set("moveend", moveEndHandler); // 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 - if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) { - // Decrement counter for this programmatic move event - self.programmaticMoveCounter--; + if (self.programmaticMoveId) { return; } @@ -348,31 +334,11 @@ let MapAPRSMap = { self.lastZoom = currentZoom; // Save map state and update URL - 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(), - } - }); + saveMapState(self.map, self.pushEvent); }, 300); - }); + }; + self.map!.on("zoomend", zoomEndHandler); + self.mapEventHandlers!.set("zoomend", zoomEndHandler); // Handle resize self.resizeHandler = () => { @@ -457,9 +423,9 @@ let MapAPRSMap = { e.stopPropagation(); // Use Phoenix LiveView's built-in navigation - // window.liveSocket is available globally in Phoenix LiveView apps - if ((window as any).liveSocket) { - (window as any).liveSocket.pushHistoryPatch(navLink.href, "push", navLink); + const liveSocket = getLiveSocket(); + if (liveSocket) { + liveSocket.pushHistoryPatch(navLink.href, "push", navLink); } else { // Fallback to regular navigation if LiveView socket not available window.location.href = navLink.href; @@ -533,20 +499,27 @@ let MapAPRSMap = { // Use a slight delay to ensure map is ready setTimeout(() => { if (self.map) { - // Set counter to handle both moveend and zoomend events from setView - // setView() can trigger both events, so we need to handle both - self.programmaticMoveCounter = 2; + // Generate a unique ID for this programmatic move + const moveId = `move_${Date.now()}_${Math.random()}`; + self.programmaticMoveId = moveId; + + // Clear any existing timeout + if (self.programmaticMoveTimeout) { + clearTimeout(self.programmaticMoveTimeout); + } + + // Set a timeout to clear the programmatic move flag + // This ensures we don't block user interactions indefinitely + self.programmaticMoveTimeout = setTimeout(() => { + if (self.programmaticMoveId === moveId) { + self.programmaticMoveId = undefined; + } + }, 1500); + self.map.setView([lat, lng], zoom, { animate: true, 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 setTimeout(() => { @@ -672,14 +645,16 @@ let MapAPRSMap = { const prevMarker = self.markers!.get(self.currentPopupMarkerId); if (prevMarker && prevMarker.closePopup) prevMarker.closePopup(); } - // Re-add marker with openPopup flag (will open after add) - const markerData = self.markerStates!.get(data.id); - if (markerData) { - self.addMarker({ ...markerData, id: data.id, openPopup: true }); + // Try to open popup directly first if marker exists + const marker = self.markers!.get(data.id); + if (marker && marker.openPopup) { + marker.openPopup(); } else { - // fallback: try to open popup directly if marker exists - const marker = self.markers!.get(data.id); - if (marker && marker.openPopup) marker.openPopup(); + // Fallback: re-add marker with openPopup flag if it doesn't exist + const markerData = self.markerStates!.get(data.id); + if (markerData) { + self.addMarker({ ...markerData, id: data.id, openPopup: true }); + } } self.currentPopupMarkerId = data.id; }); @@ -711,16 +686,8 @@ let MapAPRSMap = { packetsByCallsign.forEach((packets, callsign) => { // Sort by timestamp (oldest first) to ensure proper trail line drawing const sortedPackets = packets.sort((a, b) => { - const timeA = a.timestamp - ? typeof a.timestamp === "number" - ? a.timestamp - : new Date(a.timestamp).getTime() - : 0; - const timeB = b.timestamp - ? typeof b.timestamp === "number" - ? b.timestamp - : new Date(b.timestamp).getTime() - : 0; + const timeA = parseTimestamp(a.timestamp); + const timeB = parseTimestamp(b.timestamp); return timeA - timeB; }); @@ -754,16 +721,8 @@ let MapAPRSMap = { packetsByCallsign.forEach((packets, callsign) => { // Sort by timestamp (oldest first) to ensure proper trail line drawing const sortedPackets = packets.sort((a, b) => { - const timeA = a.timestamp - ? typeof a.timestamp === "number" - ? a.timestamp - : new Date(a.timestamp).getTime() - : 0; - const timeB = b.timestamp - ? typeof b.timestamp === "number" - ? b.timestamp - : new Date(b.timestamp).getTime() - : 0; + const timeA = parseTimestamp(a.timestamp); + const timeB = parseTimestamp(b.timestamp); return timeA - timeB; }); @@ -911,13 +870,9 @@ let MapAPRSMap = { if (positionChanged && self.trailManager) { // Position changed, update trail const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; - const timestamp = data.timestamp - ? typeof data.timestamp === "string" - ? new Date(data.timestamp).getTime() - : 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; + const timestamp = parseTimestamp(data.timestamp); + // Use callsign_group for proper trail grouping + const trailId = getTrailId(data); self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); } @@ -946,35 +901,19 @@ let MapAPRSMap = { // Handle popup close events - check if hook is still connected marker.on("popupclose", () => { - // Only send event if LiveView is still connected - 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"); - } - } + safePushEvent(self.pushEvent, "popup_closed", {}); }); } // Handle marker click marker.on("click", () => { if (marker.openPopup) marker.openPopup(); - // Only send event if LiveView is still connected - if (self.pushEvent) { - try { - self.pushEvent("marker_clicked", { - id: data.id, - 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"); - } - } + safePushEvent(self.pushEvent, "marker_clicked", { + id: data.id, + callsign: data.callsign, + lat: lat, + lng: lng, + }); }); // Mark historical markers for identification @@ -1009,13 +948,9 @@ let MapAPRSMap = { // Initialize trail for new marker - always add to trail for line drawing if (self.trailManager) { const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; - const timestamp = data.timestamp - ? typeof data.timestamp === "string" - ? new Date(data.timestamp).getTime() - : 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; + const timestamp = parseTimestamp(data.timestamp); + // Use callsign_group for proper trail grouping + const trailId = getTrailId(data); self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); } @@ -1074,13 +1009,9 @@ let MapAPRSMap = { existingMarker.setLatLng([lat, lng]); if (self.trailManager) { const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; - const timestamp = data.timestamp - ? typeof data.timestamp === "string" - ? new Date(data.timestamp).getTime() - : 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; + const timestamp = parseTimestamp(data.timestamp); + // Use callsign_group for proper trail grouping + const trailId = getTrailId(data); self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); } } @@ -1322,14 +1253,10 @@ let MapAPRSMap = { } if (data.timestamp) { - let date; - if (typeof data.timestamp === "number") { - date = new Date(data.timestamp * 1000); - } else if (typeof data.timestamp === "string") { - date = new Date(data.timestamp); - } - - if (date && !isNaN(date.getTime())) { + const timestamp = parseTimestamp(data.timestamp); + const date = new Date(timestamp); + + if (!isNaN(date.getTime())) { content += `
${date.toISOString()}
`; } } @@ -1341,13 +1268,29 @@ let MapAPRSMap = { destroyed() { const self = this as unknown as LiveViewHookContext; + // Mark as destroyed immediately + self.isDestroyed = true; + // Disable pushEvent to prevent any events from being sent during cleanup const originalPushEvent = self.pushEvent; self.pushEvent = () => {}; // No-op function + // Clear interval timer + if (self.cleanupInterval !== undefined) { + clearInterval(self.cleanupInterval); + self.cleanupInterval = undefined; + } + + // Clear programmatic move timeout + if (self.programmaticMoveTimeout !== undefined) { + clearTimeout(self.programmaticMoveTimeout); + self.programmaticMoveTimeout = undefined; + } + // Remove popup navigation event listener if (self.popupNavigationHandler) { document.removeEventListener('click', self.popupNavigationHandler); + self.popupNavigationHandler = undefined; } // Close any open popups before cleanup @@ -1355,7 +1298,7 @@ let MapAPRSMap = { try { self.map.closePopup(); } 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) { 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 @@ -1375,7 +1331,7 @@ let MapAPRSMap = { marker.unbindPopup(); // Unbind popup to prevent events } } catch (e) { - // Ignore errors during cleanup + console.debug(`Error cleaning up marker:`, e); } }); } diff --git a/assets/js/map_fixes.ts b/assets/js/map_fixes.ts new file mode 100644 index 0000000..1d1bea6 --- /dev/null +++ b/assets/js/map_fixes.ts @@ -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; + mapEventHandlers?: Map; + isDestroyed?: boolean; +} + +// 3. Example of fixed initialization with proper cleanup tracking +export const setupMapWithCleanup = (self: ImprovedLiveViewHookContext) => { + // Track if component is destroyed + self.isDestroyed = false; + + // Store event handlers for cleanup + self.mapEventHandlers = new Map(); + + // Store interval for cleanup + self.cleanupInterval = setInterval(() => { + if (!self.isDestroyed && self.trailManager) { + self.trailManager.cleanupOldPositions(); + } + }, 5 * 60 * 1000); + + // Create wrapped event handlers that check if destroyed + const createSafeHandler = (handler: 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> = new Map(); + private cellSize: number = 0.0001; // ~11 meters at equator + + private getGridKey(lat: number, lng: number): string { + const latCell = Math.floor(lat / this.cellSize); + const lngCell = Math.floor(lng / this.cellSize); + return `${latCell},${lngCell}`; + } + + add(id: string, lat: number, lng: number) { + const key = this.getGridKey(lat, lng); + if (!this.grid.has(key)) { + this.grid.set(key, new Set()); + } + this.grid.get(key)!.add(id); + } + + remove(id: string, lat: number, lng: number) { + const key = this.getGridKey(lat, lng); + const cell = this.grid.get(key); + if (cell) { + cell.delete(id); + if (cell.size === 0) { + this.grid.delete(key); + } + } + } + + findNearby(lat: number, lng: number, radius: number = 0.00001): Set { + const results = new Set(); + const cellRadius = Math.ceil(radius / this.cellSize); + const centerLatCell = Math.floor(lat / this.cellSize); + const centerLngCell = Math.floor(lng / this.cellSize); + + for (let latOffset = -cellRadius; latOffset <= cellRadius; latOffset++) { + for (let lngOffset = -cellRadius; lngOffset <= cellRadius; lngOffset++) { + const key = `${centerLatCell + latOffset},${centerLngCell + lngOffset}`; + const cell = this.grid.get(key); + if (cell) { + cell.forEach(id => results.add(id)); + } + } + } + + return results; + } + + clear() { + this.grid.clear(); + } +} + +// 6. Improved programmatic move counter with proper state management +export class ProgrammaticMoveTracker { + private moveCount: number = 0; + private timeoutId?: ReturnType; + + expectMoves(count: number) { + this.moveCount += count; + + // Clear existing timeout + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + + // Set safety timeout + this.timeoutId = setTimeout(() => { + if (this.moveCount > 0) { + 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; + } + } +} \ No newline at end of file diff --git a/assets/js/map_helpers.ts b/assets/js/map_helpers.ts new file mode 100644 index 0000000..5226de8 --- /dev/null +++ b/assets/js/map_helpers.ts @@ -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; +} \ No newline at end of file diff --git a/test/assets/js/map_issues_test.exs b/test/assets/js/map_issues_test.exs new file mode 100644 index 0000000..1b54c56 --- /dev/null +++ b/test/assets/js/map_issues_test.exs @@ -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