aprs.me/assets/js/map_fixes.ts
Graham McIntire af4ab270e2
Replace any types with specific TypeScript types
- Add comprehensive type definitions for Leaflet plugins (heatmap, marker clustering, overlapping marker spiderfier)
- Create event payload interfaces for all LiveView events
- Add marker extension types for APRS-specific properties
- Define Chart.js dataset and configuration types
- Replace 74 instances of 'any' type across all TypeScript files
- Improve type safety for event handlers and state management
- Add proper typing for external library integrations

This refactoring enhances type safety throughout the codebase, making it easier to catch errors at compile time and improving IDE support for autocompletion and refactoring.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-17 17:55:27 -05:00

312 lines
No EOL
8.8 KiB
TypeScript

// 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) {
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 = <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) {
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: APRSMarker, 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;
}
}
}