This commit is contained in:
Graham McIntire 2025-07-09 16:37:30 -05:00
parent 43b2cb1df0
commit eb3e30abd6
No known key found for this signature in database
4 changed files with 565 additions and 154 deletions

View file

@ -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<typeof setTimeout>;
cleanupInterval?: ReturnType<typeof setInterval>;
mapEventHandlers?: Map<string, Function>;
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 += `<div class="aprs-timestamp">${date.toISOString()}</div>`;
}
}
@ -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);
}
});
}

307
assets/js/map_fixes.ts Normal file
View 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
View 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;
}

View 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