diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index b1113db..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/workflows/docker-security-scan.yml b/.github/workflows/docker-security-scan.yml index f6df75d..595943a 100644 --- a/.github/workflows/docker-security-scan.yml +++ b/.github/workflows/docker-security-scan.yml @@ -97,12 +97,15 @@ jobs: - name: Run Docker Scout vulnerability scan uses: docker/scout-action@v1 + continue-on-error: true with: - command: quickview,cves + command: cves image: aprsme:${{ github.sha }} - output-format: sarif + format: sarif only-severities: critical,high sarif-file: scout-results.sarif + dockerhub-user: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Upload Docker Scout results uses: actions/upload-artifact@v4 @@ -110,6 +113,7 @@ jobs: with: name: docker-scout-report path: scout-results.sarif + if-no-files-found: ignore retention-days: 7 - name: Generate security report summary @@ -128,6 +132,9 @@ jobs: echo '```' >> security-summary.md cat app-packages.txt >> security-summary.md || echo "No application vulnerabilities report available" >> security-summary.md echo '```' >> security-summary.md + + # Also output to GitHub Actions summary + cat security-summary.md >> $GITHUB_STEP_SUMMARY - name: Upload security summary uses: actions/upload-artifact@v4 @@ -138,9 +145,18 @@ jobs: retention-days: 14 - name: Check for critical vulnerabilities + continue-on-error: true run: | if grep -q "CRITICAL" trivy-results.sarif; then - echo "Critical vulnerabilities found in the Docker image." - echo "Please review the scan results in the Security tab." + echo "::warning::Critical vulnerabilities found in the Docker image." + echo "Please review the scan results in the Security tab and the uploaded reports." + echo "" + echo "Summary of critical vulnerabilities:" + grep -A 5 -B 5 "CRITICAL" trivy-results.sarif | head -50 || true + echo "" + echo "For full details, check the Security tab and artifact reports." + # Exit with error code but continue-on-error will prevent workflow failure exit 1 + else + echo "No critical vulnerabilities found." fi diff --git a/.github/workflows/elixir.yaml b/.github/workflows/elixir.yaml index fe4c30f..26f38d8 100644 --- a/.github/workflows/elixir.yaml +++ b/.github/workflows/elixir.yaml @@ -29,7 +29,7 @@ jobs: # Additional services can be defined here if required. services: db: - image: postgres:16 + image: postgis/postgis:17-3.5 ports: ["5432:5432"] env: POSTGRES_PASSWORD: postgres diff --git a/CLAUDE.md b/CLAUDE.md index 9b6ff76..5e43864 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,8 @@ This is an Elixir Phoenix LiveView application that serves as a real-time APRS ( - `mix credo` - Static code analysis and style checking - `mix dialyzer` - Static type analysis (must run and fix errors/warnings) - `mix sobelow` - Security vulnerability scanning +- **IMPORTANT**: Always run `mix format` before considering any task complete +- **MANDATORY**: Run `mix compile --warnings-as-errors` and ensure it passes before considering any task complete ### Assets (No Node.js) - `mix assets.deploy` - Build and minify frontend assets (Tailwind CSS + ESBuild) @@ -90,9 +92,16 @@ Tests use comprehensive mocking to prevent external connections: - Run `mix format` before committing - Address any compiler warnings - Run `mix dialyzer` and fix all errors/warnings +- **MANDATORY**: Run `mix compile --warnings-as-errors` and ensure it passes before considering any task complete - Use function composition over nested conditionals - Write descriptive test names that explain behavior +## Web Testing + +- **MANDATORY**: When viewing any website or web application, always use Puppeteer to take screenshots and interact with the page +- Use `mcp__puppeteer__puppeteer_navigate`, `mcp__puppeteer__puppeteer_screenshot`, and other Puppeteer tools +- This ensures accurate visual feedback and proper testing of the user interface + ## Deployment The application supports Kubernetes deployment with manifests in `k8s/` directory and GitHub Actions CI/CD pipeline. Database migrations run automatically via init containers. \ No newline at end of file diff --git a/assets/js/app.js b/assets/js/app.js index 6149116..9ef8048 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,6 +27,8 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute(" // Import minimal APRS map hook import MapAPRSMap from "./map"; +// Import error boundary hook +import ErrorBoundary from "./hooks/error_boundary"; // Responsive Slideover Hook let ResponsiveSlideoverHook = { @@ -98,6 +100,7 @@ let Hooks = {}; Hooks.APRSMap = MapAPRSMap; Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook; Hooks.BodyClassHook = BodyClassHook; +Hooks.ErrorBoundary = ErrorBoundary; // Register weather chart hooks from TypeScript import { WeatherChartHooks } from "./features/weather_charts"; diff --git a/assets/js/hooks/error_boundary.js b/assets/js/hooks/error_boundary.js new file mode 100644 index 0000000..d9c7813 --- /dev/null +++ b/assets/js/hooks/error_boundary.js @@ -0,0 +1,106 @@ +// Error Boundary Hook for Phoenix LiveView +// Catches JavaScript errors and displays fallback content + +const ErrorBoundary = { + mounted() { + // Store original error handler + this.originalErrorHandler = window.onerror; + this.originalUnhandledRejection = window.onunhandledrejection; + + // Get content and fallback elements + this.content = this.el.querySelector('.error-boundary-content'); + this.fallback = this.el.querySelector('.error-boundary-fallback'); + + // Set up error handlers for this component + this.setupErrorHandlers(); + }, + + setupErrorHandlers() { + // Handle synchronous errors + window.onerror = (message, source, lineno, colno, error) => { + if (this.isErrorInComponent(error)) { + this.handleError(error || new Error(message)); + return true; // Prevent default error handling + } + + // Call original handler if error is not in this component + if (this.originalErrorHandler) { + return this.originalErrorHandler(message, source, lineno, colno, error); + } + return false; + }; + + // Handle async errors (unhandled promise rejections) + window.onunhandledrejection = (event) => { + if (this.isErrorInComponent(event.reason)) { + this.handleError(event.reason); + event.preventDefault(); + return; + } + + // Call original handler if error is not in this component + if (this.originalUnhandledRejection) { + this.originalUnhandledRejection(event); + } + }; + }, + + isErrorInComponent(error) { + // Check if the error occurred within this component's DOM tree + if (!error) return false; + + // If the error has a target element, check if it's within our component + if (error.target && this.el.contains(error.target)) { + return true; + } + + // Check if the error stack trace contains references to our component's hooks or elements + if (error.stack) { + // Look for our component's ID in the stack trace + const componentId = this.el.id; + if (componentId && error.stack.includes(componentId)) { + return true; + } + + // Check if the error originated from a hook within this component + const hooks = this.el.querySelectorAll('[phx-hook]'); + for (let hook of hooks) { + const hookName = hook.getAttribute('phx-hook'); + if (hookName && error.stack.includes(hookName)) { + return true; + } + } + } + + // For now, we'll be conservative and only handle errors we're sure about + // In a more sophisticated implementation, you might analyze the error source + return false; + }, + + handleError(error) { + console.error('Error caught by boundary:', error); + + // Hide content and show fallback + if (this.content) { + this.content.style.display = 'none'; + } + if (this.fallback) { + this.fallback.classList.remove('hidden'); + } + + // Log error to server + this.pushEvent('error_boundary_triggered', { + message: error.message, + stack: error.stack, + component_id: this.el.id + }); + }, + + destroyed() { + // Restore original error handlers + window.onerror = this.originalErrorHandler; + window.onunhandledrejection = this.originalUnhandledRejection; + } +}; + +export default ErrorBoundary; \ No newline at end of file diff --git a/assets/js/map.ts b/assets/js/map.ts index 6bc9b36..50f41ef 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -1,84 +1,26 @@ -// Declare Leaflet as a global variable -declare const L: any; +// Import type definitions +import type { + LiveViewHookContext, + MarkerData, + BoundsData, + CenterData, + MarkerState, + MapEventData +} from './types/map'; +import type { Map as LeafletMap, Marker, TileLayer, LayerGroup, DivIcon, LatLngBounds } from 'leaflet'; + +// Declare Leaflet as a global variable with proper typing +declare const L: typeof import('leaflet'); 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 -type LiveViewHookContext = { - el: HTMLElement & { _leaflet_id?: any }; - pushEvent: (event: string, payload: any) => void; - handleEvent: (event: string, callback: Function) => void; - map?: any; - markers?: Map; - markerStates?: Map; - markerLayer?: any; - trailManager?: TrailManager; - boundsTimer?: ReturnType; - resizeHandler?: () => void; - errors?: string[]; - initializationAttempts?: number; - maxInitializationAttempts?: number; - lastZoom?: number; - currentPopupMarkerId?: string | null; - oms?: any; - [key: string]: any; -}; - -interface MarkerData { - id: string; - lat: number; - lng: number; - callsign?: string; - comment?: string; - symbol_table_id?: string; - symbol_code?: string; - symbol_description?: string; - popup?: string; - historical?: boolean; - color?: string; - timestamp?: number; - is_most_recent_for_callsign?: boolean; - callsign_group?: string; -} - -interface BoundsData { - north: number; - south: number; - east: number; - west: number; -} - -interface CenterData { - lat: number; - lng: number; -} - -interface MarkerState { - lat: number; - lng: number; - symbol_table: string; - symbol_code: string; - popup?: string; - historical?: boolean; - is_most_recent_for_callsign?: boolean; - callsign_group?: string; - callsign?: string; -} - -interface MapEventData { - bounds?: BoundsData; - center?: CenterData; - zoom?: number; - id?: string; - callsign?: string; - lat?: number; - lng?: number; - markers?: MarkerData[]; -} let MapAPRSMap = { mounted() { @@ -272,9 +214,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(); } }, @@ -285,23 +227,32 @@ 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.programmaticMoveId) { + return; + } + if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { - self.sendBoundsToServer(); - // Save map state - const center = self.map.getCenter(); - const zoom = self.map.getZoom(); - localStorage.setItem( - "aprs_map_state", - JSON.stringify({ lat: center.lat, lng: center.lng, zoom }), - ); + 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.programmaticMoveId) { + return; + } + if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { const currentZoom = self.map!.getZoom(); @@ -313,17 +264,13 @@ let MapAPRSMap = { self.pushEvent("refresh_markers", {}); } - self.sendBoundsToServer(); self.lastZoom = currentZoom; - // Save map state - const center = self.map.getCenter(); - const zoom = self.map.getZoom(); - localStorage.setItem( - "aprs_map_state", - JSON.stringify({ lat: center.lat, lng: center.lng, zoom }), - ); + // Save map state and update URL + saveMapState(self.map, self.pushEvent); }, 300); - }); + }; + self.map!.on("zoomend", zoomEndHandler); + self.mapEventHandlers!.set("zoomend", zoomEndHandler); // Handle resize self.resizeHandler = () => { @@ -354,6 +301,9 @@ let MapAPRSMap = { // LiveView event handlers self.setupLiveViewHandlers(); + // Set up event delegation for popup navigation links + self.setupPopupNavigation(); + if (typeof OverlappingMarkerSpiderfier !== "undefined") { self.oms = new OverlappingMarkerSpiderfier(self.map); } @@ -390,6 +340,35 @@ let MapAPRSMap = { } }, + setupPopupNavigation() { + const self = this as unknown as LiveViewHookContext; + + // Store the event handler so we can remove it later + self.popupNavigationHandler = (e: Event) => { + const target = e.target as HTMLElement; + + // Check if clicked element or its parent is a LiveView navigation link + const navLink = target.closest('.aprs-lv-link') as HTMLAnchorElement; + + if (navLink && navLink.href) { + e.preventDefault(); + e.stopPropagation(); + + // Use Phoenix LiveView's built-in navigation + 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; + } + } + }; + + // Use event delegation to handle clicks on popup navigation links + document.addEventListener('click', self.popupNavigationHandler); + }, + setupLiveViewHandlers() { const self = this as unknown as LiveViewHookContext; // Add single marker @@ -452,6 +431,23 @@ let MapAPRSMap = { // Use a slight delay to ensure map is ready setTimeout(() => { if (self.map) { + // 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, @@ -581,14 +577,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; }); @@ -620,16 +618,43 @@ 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; + }); + + // Add markers in chronological order + sortedPackets.forEach((packet) => { + self.addMarker({ + ...packet, + historical: true, + popup: packet.popup || self.buildPopupContent(packet), + }); + }); + }); + } + }); + + // Handle progressive loading of historical packets (batch processing) + self.handleEvent("add_historical_packets_batch", (data: { packets: MarkerData[], batch: number, is_final: boolean }) => { + if (data.packets && Array.isArray(data.packets)) { + // Process all packets immediately for maximum speed + const packetsByCallsign = new Map(); + + data.packets.forEach((packet) => { + const callsign = packet.callsign_group || packet.callsign || packet.id; + if (!packetsByCallsign.has(callsign)) { + packetsByCallsign.set(callsign, []); + } + packetsByCallsign.get(callsign)!.push(packet); + }); + + // Process each callsign group in chronological order (oldest first) + packetsByCallsign.forEach((packets, callsign) => { + // Sort by timestamp (oldest first) to ensure proper trail line drawing + const sortedPackets = packets.sort((a, b) => { + const timeA = parseTimestamp(a.timestamp); + const timeB = parseTimestamp(b.timestamp); return timeA - timeB; }); @@ -777,13 +802,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); } @@ -810,16 +831,16 @@ let MapAPRSMap = { if (data.popup) { marker.bindPopup(data.popup, { autoPan: false }); - // Handle popup close events for all popups + // Handle popup close events - check if hook is still connected marker.on("popupclose", () => { - self.pushEvent("popup_closed", {}); + safePushEvent(self.pushEvent, "popup_closed", {}); }); } // Handle marker click marker.on("click", () => { if (marker.openPopup) marker.openPopup(); - self.pushEvent("marker_clicked", { + safePushEvent(self.pushEvent, "marker_clicked", { id: data.id, callsign: data.callsign, lat: lat, @@ -827,11 +848,6 @@ let MapAPRSMap = { }); }); - // Handle popup close events - marker.on("popupclose", () => { - self.pushEvent("popup_closed", {}); - }); - // Mark historical markers for identification if (data.historical) { (marker as any)._isHistorical = true; @@ -864,13 +880,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); } @@ -929,13 +941,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); } } @@ -1082,57 +1090,37 @@ let MapAPRSMap = { } }); } - // For current packets or most recent historical packets, show the full APRS symbol icon - const symbolTableId = data.symbol_table_id || "/"; - const symbolCode = getValidSymbolCode(data.symbol_code, symbolTableId); - - // Map symbol table identifier to correct table index per hessu/aprs-symbols - // Primary table: / (0) - // Alternate table: \ (1) - // Overlay table: ] (2) - // Any other character is treated as alternate table (1) - const tableMap: Record = { - "/": "0", // Primary table - "\\": "1", // Alternate table - "]": "2", // Overlay table - }; - const tableId = symbolTableId === "/" ? "0" : symbolTableId === "]" ? "2" : "1"; - const spriteFile = `/aprs-symbols/aprs-symbols-128-${tableId}@2x.png`; - - // Calculate sprite position per hessu/aprs-symbols - const charCode = symbolCode.charCodeAt(0); - const index = charCode - 33; // ASCII printable characters start at 33 (!) - // Clamp to valid range (0-93 for printable ASCII 33-126) - const safeIndex = Math.max(0, Math.min(index, 93)); - const column = safeIndex % 16; - const row = Math.floor(safeIndex / 16); - const x = -column * 128; - const y = -row * 128; - - // Create the HTML string directly to ensure proper style application - const iconHtml = `
`; - - return L.divIcon({ - html: iconHtml, - className: "", - iconSize: [32, 32], - iconAnchor: [16, 16], - }); + // Use server-generated symbol HTML if available + if (data.symbol_html) { + return L.divIcon({ + html: data.symbol_html, + className: "", + iconSize: [120, 32], // Increased width to accommodate callsign label + iconAnchor: [16, 16], + }); + } } // For historical packets that are not the most recent for their callsign, - // show a simple red dot (only positions where lat/lon actually changed) + // still show the proper APRS symbol but with reduced opacity if (data.historical && !data.is_most_recent_for_callsign) { + // Use server-generated symbol HTML if available + if (data.symbol_html) { + // Add opacity to the symbol HTML for historical markers + const historicalHtml = data.symbol_html.replace( + /style="([^"]*)"/, + 'style="$1 opacity: 0.7;"' + ); + + return L.divIcon({ + html: historicalHtml, + className: "historical-aprs-marker", + iconSize: [120, 32], + iconAnchor: [16, 16], + }); + } + + // Fallback: red dot for historical positions without symbol data const iconHtml = `
= { - "/": "0", // Primary table - "\\": "1", // Alternate table - "]": "2", // Overlay table - }; - const tableId = symbolTableId === "/" ? "0" : symbolTableId === "]" ? "2" : "1"; - const spriteFile = `/aprs-symbols/aprs-symbols-128-${tableId}@2x.png`; - - // Calculate sprite position per hessu/aprs-symbols - const charCode = symbolCode.charCodeAt(0); - const index = charCode - 33; // ASCII printable characters start at 33 (!) - // Clamp to valid range (0-93 for printable ASCII 33-126) - const safeIndex = Math.max(0, Math.min(index, 93)); - const column = safeIndex % 16; - const row = Math.floor(safeIndex / 16); - const x = -column * 128; - const y = -row * 128; - - // Create the HTML string directly to ensure proper style application + // Final fallback: Simple dot const iconHtml = `
`; + width: 8px; + height: 8px; + background-color: #2563eb; + border: 2px solid #FFFFFF; + border-radius: 50%; + opacity: 0.8; + box-shadow: 0 0 2px rgba(0,0,0,0.3); + " title="APRS Station: ${data.callsign}">
`; return L.divIcon({ html: iconHtml, - className: "", // Remove class to avoid CSS conflicts - iconSize: [32, 32], - iconAnchor: [16, 16], + className: "", + iconSize: [12, 12], + iconAnchor: [6, 6], }); }, @@ -1207,7 +1176,7 @@ let MapAPRSMap = { // const symbolDesc = data.symbol_description || `Symbol: ${symbolTableId}${symbolCode}`; let content = `
- `; + `; // Removed symbol info from popup // content += `
${symbolDesc}
`; @@ -1216,14 +1185,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()}
`; } } @@ -1234,12 +1199,75 @@ 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 + if (self.map !== undefined) { + try { + self.map.closePopup(); + } catch (e) { + console.debug("Error closing popup during cleanup:", e); + } + } + if (self.boundsTimer !== undefined) { clearTimeout(self.boundsTimer); } 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) => { + 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:`, e); + } + }); + } + if (self.markerLayer !== undefined) { self.markerLayer!.clearLayers(); } @@ -1253,6 +1281,9 @@ let MapAPRSMap = { self.map!.remove(); self.map = undefined; } + + // Restore original pushEvent (though it won't be used since we're destroyed) + self.pushEvent = originalPushEvent; }, }; 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/assets/js/types/map.d.ts b/assets/js/types/map.d.ts new file mode 100644 index 0000000..dacc2d4 --- /dev/null +++ b/assets/js/types/map.d.ts @@ -0,0 +1,102 @@ +// Type definitions for APRS Map application + +import type { Map as LeafletMap, Marker, LatLng, LatLngBounds, DivIcon, LayerGroup, Popup } from 'leaflet'; + +export interface LiveViewHookContext { + el: HTMLElement & { _leaflet_id?: any }; + pushEvent: (event: string, payload: any) => void; + handleEvent: (event: string, callback: (data: any) => void) => void; + map?: LeafletMap; + markers?: Map; + markerStates?: Map; + markerLayer?: LayerGroup; + trailManager?: any; // Import from trail_manager.ts when typed + boundsTimer?: ReturnType; + resizeHandler?: () => void; + errors?: string[]; + initializationAttempts?: number; + maxInitializationAttempts?: number; + lastZoom?: number; + currentPopupMarkerId?: string | null; + oms?: any; // OverlappingMarkerSpiderfier type + programmaticMoveId?: string; + programmaticMoveTimeout?: ReturnType; + cleanupInterval?: ReturnType; + mapEventHandlers?: Map; + isDestroyed?: boolean; + popupNavigationHandler?: (e: Event) => void; + moveEndHandler?: () => void; + zoomEndHandler?: () => void; + [key: string]: any; +} + +export interface MarkerData { + id: string; + lat: number; + lng: number; + callsign?: string; + comment?: string; + symbol_table_id?: string; + symbol_code?: string; + symbol_description?: string; + popup?: string; + historical?: boolean; + color?: string; + timestamp?: number | string; + is_most_recent_for_callsign?: boolean; + callsign_group?: string; + symbol_html?: string; + openPopup?: boolean; +} + +export interface BoundsData { + north: number; + south: number; + east: number; + west: number; +} + +export interface CenterData { + lat: number; + lng: number; +} + +export interface MarkerState { + lat: number; + lng: number; + symbol_table: string; + symbol_code: string; + popup?: string; + historical?: boolean; + is_most_recent_for_callsign?: boolean; + callsign_group?: string; + callsign?: string; +} + +export interface MapEventData { + bounds?: BoundsData; + center?: CenterData; + zoom?: number; + id?: string; + callsign?: string; + lat?: number; + lng?: number; + markers?: MarkerData[]; +} + +export interface MapState { + lat: number; + lng: number; + zoom: number; +} + +export interface LiveSocket { + connected: boolean; + pushHistoryPatch: (href: string, state: string, target: HTMLElement) => void; +} + +declare global { + interface Window { + liveSocket?: LiveSocket; + } +} \ No newline at end of file diff --git a/config/test.exs b/config/test.exs index 297ee09..3f1128a 100644 --- a/config/test.exs +++ b/config/test.exs @@ -15,7 +15,9 @@ config :aprsme, Aprsme.Repo, database: "aprsme_test#{System.get_env("MIX_TEST_PARTITION")}", pool: Ecto.Adapters.SQL.Sandbox, pool_size: System.schedulers_online() * 2, - types: Aprsme.PostgresTypes + types: Aprsme.PostgresTypes, + ownership_timeout: 300_000, + timeout: 300_000 # We don't run a server during test. If one is required, # you can enable the server option below. diff --git a/docs/virtual_marker_scrolling.md b/docs/virtual_marker_scrolling.md new file mode 100644 index 0000000..bafacff --- /dev/null +++ b/docs/virtual_marker_scrolling.md @@ -0,0 +1,114 @@ +# Virtual Marker Scrolling for Large Datasets + +## Overview + +When dealing with thousands of APRS markers on the map, rendering all markers at once can cause performance issues. Virtual scrolling for map markers is a technique where only visible markers within the viewport (plus a buffer zone) are rendered. + +## Current Implementation + +The current implementation already has some optimizations: +1. Viewport-based filtering in `get_recent_packets_optimized/3` +2. Marker state tracking to prevent unnecessary updates +3. Batch processing of historical packets + +## Proposed Virtual Scrolling Enhancement + +### 1. Marker Clustering at Low Zoom Levels +```typescript +// Use Leaflet.markercluster for automatic clustering +// Already included in the project but not fully utilized +if (self.map.getZoom() < 10) { + // Use marker clustering + self.clusterLayer = L.markerClusterGroup({ + maxClusterRadius: 80, + spiderfyOnMaxZoom: true, + showCoverageOnHover: false, + zoomToBoundsOnClick: true + }); +} +``` + +### 2. Quadtree-based Spatial Indexing +```elixir +defmodule AprsmeWeb.MapLive.QuadTree do + @moduledoc """ + Quadtree implementation for efficient spatial queries + """ + + defstruct [:bounds, :markers, :children, :max_markers] + + def insert(tree, marker) do + # Insert marker into appropriate quadrant + end + + def query(tree, bounds) do + # Return only markers within bounds + end +end +``` + +### 3. Progressive Rendering with RequestIdleCallback +```typescript +function renderMarkersProgressively(markers: MarkerData[]) { + let index = 0; + const batchSize = 50; + + function renderBatch() { + const batch = markers.slice(index, index + batchSize); + batch.forEach(marker => self.addMarker(marker)); + index += batchSize; + + if (index < markers.length) { + requestIdleCallback(renderBatch); + } + } + + requestIdleCallback(renderBatch); +} +``` + +### 4. Server-side Aggregation +```elixir +def aggregate_markers_for_zoom(zoom_level, bounds) do + case zoom_level do + z when z < 8 -> + # Return aggregated grid cells with counts + Packets.get_grid_aggregates(bounds, grid_size: 50) + + z when z < 12 -> + # Return simplified markers (no popups) + Packets.get_simplified_markers(bounds) + + _ -> + # Return full markers + Packets.get_recent_packets_optimized(bounds) + end +end +``` + +### 5. Viewport Buffer Strategy +```typescript +// Render markers in expanded viewport to reduce re-renders during panning +const bounds = self.map.getBounds(); +const bufferedBounds = bounds.pad(0.5); // 50% buffer +``` + +## Implementation Priority + +1. **Phase 1**: Implement marker clustering (easiest, biggest impact) +2. **Phase 2**: Add server-side aggregation for low zoom levels +3. **Phase 3**: Implement progressive rendering +4. **Phase 4**: Add quadtree spatial indexing if still needed + +## Performance Metrics to Track + +- Time to first marker render +- Frame rate during pan/zoom +- Memory usage with 10k+ markers +- Server query time by zoom level + +## Notes + +- The current implementation already handles viewport filtering well +- Virtual scrolling is most beneficial when dealing with 5000+ markers +- Consider implementing only if performance issues are reported by users \ No newline at end of file diff --git a/lib/aprsme/packet.ex b/lib/aprsme/packet.ex index f3c5223..45b7a47 100644 --- a/lib/aprsme/packet.ex +++ b/lib/aprsme/packet.ex @@ -247,6 +247,13 @@ defmodule Aprsme.Packet do base_attrs = Map.delete(base_attrs, :raw_weather_data) base_attrs = Map.delete(base_attrs, "raw_weather_data") + # Check if symbol data exists at the top level of attrs (from APRS parser) + # and preserve it if not already in base_attrs + base_attrs = + base_attrs + |> maybe_put(:symbol_code, attrs[:symbol_code] || attrs["symbol_code"]) + |> maybe_put(:symbol_table_id, attrs[:symbol_table_id] || attrs["symbol_table_id"]) + # Extract data based on the type of data_extended additional_data = case data_extended do @@ -332,12 +339,13 @@ defmodule Aprsme.Packet do end defp put_symbol_fields(map, data_extended) do + # First try to get symbol data from the data_extended map + symbol_code = data_extended[:symbol_code] || data_extended["symbol_code"] + symbol_table_id = data_extended[:symbol_table_id] || data_extended["symbol_table_id"] + map - |> maybe_put(:symbol_code, data_extended[:symbol_code] || data_extended["symbol_code"]) - |> maybe_put( - :symbol_table_id, - data_extended[:symbol_table_id] || data_extended["symbol_table_id"] - ) + |> maybe_put(:symbol_code, symbol_code) + |> maybe_put(:symbol_table_id, symbol_table_id) |> maybe_put(:comment, data_extended[:comment] || data_extended["comment"]) |> maybe_put(:timestamp, data_extended[:timestamp] || data_extended["timestamp"]) |> maybe_put( @@ -397,10 +405,9 @@ defmodule Aprsme.Packet do |> maybe_put(:manufacturer, mic_e_map[:manufacturer]) |> maybe_put(:course, mic_e_map[:heading]) |> maybe_put(:speed, mic_e_map[:speed]) - # Default car symbol for MicE - |> maybe_put(:symbol_code, ">") - # Primary table - |> maybe_put(:symbol_table_id, "/") + # Use symbol data from MicE if available, otherwise use default car symbol + |> maybe_put(:symbol_code, mic_e_map[:symbol_code] || mic_e_map["symbol_code"] || ">") + |> maybe_put(:symbol_table_id, mic_e_map[:symbol_table_id] || mic_e_map["symbol_table_id"] || "/") end # Extract weather data from various formats diff --git a/lib/aprsme/packets.ex b/lib/aprsme/packets.ex index 3782d49..c2a9491 100644 --- a/lib/aprsme/packets.ex +++ b/lib/aprsme/packets.ex @@ -168,6 +168,11 @@ defmodule Aprsme.Packets do defp insert_packet(attrs, packet_data) do case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do {:ok, packet} -> + # Invalidate cache for this packet's callsign + if Map.has_key?(attrs, :sender) do + Aprsme.CachedQueries.invalidate_callsign_cache(attrs.sender) + end + {:ok, packet} {:error, changeset} -> @@ -395,8 +400,8 @@ defmodule Aprsme.Packets do @impl true @spec get_recent_packets(map()) :: [struct()] def get_recent_packets(opts \\ %{}) do - # Always limit to the last hour - one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + # Always limit to the last 24 hours for more symbol variety + one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) # Merge the one-hour limit with any other filters opts_with_time = Map.put(opts, :start_time, one_hour_ago) @@ -410,25 +415,28 @@ defmodule Aprsme.Packets do """ @spec get_recent_packets_optimized(map()) :: [struct()] def get_recent_packets_optimized(opts \\ %{}) do - # Always limit to the last hour for initial load - one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + # Always limit to the last 24 hours for more symbol variety + one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) limit = Map.get(opts, :limit, 200) + offset = Map.get(opts, :offset, 0) - # Use a more efficient query that leverages the partial indexes - # Order by received_at DESC to get the most recent packets first + # Build base query with time and position filters base_query = from(p in Packet, where: p.has_position == true, - where: p.received_at >= ^one_hour_ago, - order_by: [desc: p.received_at], - limit: ^limit + where: p.received_at >= ^one_hour_ago ) + # Apply spatial and other filters BEFORE limiting + # This ensures we get the most recent packets within the specified bounds query = base_query |> filter_by_region(opts) |> filter_by_callsign(opts) |> filter_by_map_bounds(opts) + |> order_by(desc: :received_at) + |> limit(^limit) + |> offset(^offset) |> select_with_virtual_coordinates() Repo.all(query) diff --git a/lib/aprsme_web/aprs_symbol.ex b/lib/aprsme_web/aprs_symbol.ex new file mode 100644 index 0000000..5e78be7 --- /dev/null +++ b/lib/aprsme_web/aprs_symbol.ex @@ -0,0 +1,346 @@ +defmodule AprsmeWeb.AprsSymbol do + @moduledoc """ + Shared library for APRS symbol handling and rendering. + + This module provides centralized functions for: + - Symbol table and code normalization + - Sprite file mapping + - Symbol positioning calculations + - HTML generation for symbols + + All APRS symbol logic should use this module to ensure consistency + across the application. + """ + + @doc """ + Gets sprite information for a given symbol table and code. + Returns a map with sprite_file, background_position, and background_size. + + ## Examples + + iex> AprsmeWeb.AprsSymbol.get_sprite_info("/", "_") + %{ + sprite_file: "/aprs-symbols/aprs-symbols-128-0@2x.png", + background_position: "-352px -32px", + background_size: "512px 192px" + } + """ + def get_sprite_info(symbol_table, symbol_code) do + # For overlay symbols (A-Z, 0-9), display the base symbol with overlay + if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/) do + # Use the base symbol from the overlay table, not the overlay character itself + get_overlay_base_symbol_info(symbol_code) + else + # Normal symbol table processing + symbol_table = normalize_symbol_table(symbol_table) + symbol_code = normalize_symbol_code(symbol_code) + + # Map symbol table to sprite file ID + table_id = get_table_id(symbol_table) + + sprite_file = "/aprs-symbols/aprs-symbols-128-#{table_id}@2x.png" + + # Get symbol position using ASCII-based calculation + symbol_code_ord = + symbol_code + |> String.to_charlist() + |> List.first() + |> then(fn c -> if is_integer(c), do: c, else: 63 end) + + index = symbol_code_ord - 33 + safe_index = max(0, min(index, 93)) + + # Calculate positioning for 16-column grid + column = rem(safe_index, 16) + row = div(safe_index, 16) + x = -column * 128 + y = -row * 128 + + %{ + sprite_file: sprite_file, + background_position: "#{x / 4}px #{y / 4}px", + background_size: "512px 192px" + } + end + end + + @doc """ + Gets sprite information for overlay symbols (A-Z, 0-9). + These symbols display the base symbol from the overlay table. + """ + def get_overlay_base_symbol_info(base_symbol_code) do + # Some overlay base symbols are in the alternate table (1), others in overlay table (2) + # Check which table to use based on the symbol code + table_id = get_overlay_base_table_id(base_symbol_code) + sprite_file = "/aprs-symbols/aprs-symbols-128-#{table_id}@2x.png" + + # Get position of the base symbol in the appropriate table + base_symbol_ord = + base_symbol_code + |> String.to_charlist() + |> List.first() + |> then(fn c -> if is_integer(c), do: c, else: 63 end) + + index = base_symbol_ord - 33 + safe_index = max(0, min(index, 93)) + + # Calculate positioning for 16-column grid + column = rem(safe_index, 16) + row = div(safe_index, 16) + x = -column * 128 + y = -row * 128 + + %{ + sprite_file: sprite_file, + background_position: "#{x / 4}px #{y / 4}px", + background_size: "512px 192px" + } + end + + @doc """ + Determines which sprite table to use for overlay base symbols. + Some symbols are in the alternate table (1), others in overlay table (2). + """ + def get_overlay_base_table_id(base_symbol_code) do + # Map symbols to the correct sprite table based on APRS specification + # Most overlay symbols are in the alternate table (1) + case base_symbol_code do + # Digipeater symbols are often in the alternate table (1) and have colored backgrounds + # Digipeater - green star background + "#" -> "1" + # Diamond shape - APRS overlay symbol (alternate table) + "a" -> "1" + # Square shape - APRS overlay symbol (alternate table) + "A" -> "1" + # Diamond shape - alternate table + "&" -> "1" + # Arrow symbols + ">" -> "1" + "<" -> "1" + "^" -> "1" + "v" -> "1" + # Black square background - alternate table + "i" -> "1" + # Most other symbols that can be overlaid are in the alternate table + _ -> "1" + end + end + + @doc """ + Gets sprite information for overlay characters (A-Z, 0-9). + These are rendered from the overlay table. + """ + def get_overlay_character_sprite_info(overlay_char) do + # Use overlay table (table 2) for the overlay character + sprite_file = "/aprs-symbols/aprs-symbols-128-2@2x.png" + + # Get position of the overlay character in the overlay table + overlay_char_ord = + overlay_char + |> String.to_charlist() + |> List.first() + |> then(fn c -> if is_integer(c), do: c, else: 63 end) + + index = overlay_char_ord - 33 + safe_index = max(0, min(index, 93)) + + # Calculate positioning for 16-column grid + column = rem(safe_index, 16) + row = div(safe_index, 16) + x = -column * 128 + y = -row * 128 + + %{ + sprite_file: sprite_file, + background_position: "#{x / 4}px #{y / 4}px", + background_size: "512px 192px" + } + end + + @doc """ + Normalizes a symbol table identifier. + + ## Examples + + iex> AprsmeWeb.AprsSymbol.normalize_symbol_table("/") + "/" + + iex> AprsmeWeb.AprsSymbol.normalize_symbol_table("A") + "]" + + iex> AprsmeWeb.AprsSymbol.normalize_symbol_table("invalid") + "/" + """ + def normalize_symbol_table(symbol_table) do + cond do + symbol_table in ["/", "\\", "]"] -> symbol_table + symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/) -> "]" + true -> "/" + end + end + + @doc """ + Normalizes a symbol code. + + ## Examples + + iex> AprsmeWeb.AprsSymbol.normalize_symbol_code("_") + "_" + + iex> AprsmeWeb.AprsSymbol.normalize_symbol_code(nil) + ">" + + iex> AprsmeWeb.AprsSymbol.normalize_symbol_code("") + ">" + """ + def normalize_symbol_code(symbol_code) do + if symbol_code && symbol_code != "", do: symbol_code, else: ">" + end + + @doc """ + Maps a symbol table to its sprite file ID. + + ## Examples + + iex> AprsmeWeb.AprsSymbol.get_table_id("/") + "0" + + iex> AprsmeWeb.AprsSymbol.get_table_id("\\") + "1" + + iex> AprsmeWeb.AprsSymbol.get_table_id("]") + "2" + """ + def get_table_id(symbol_table) do + case symbol_table do + # Primary table + "/" -> "0" + # Alternate table + "\\" -> "1" + # Overlay table (A-Z, 0-9) + "]" -> "2" + # Default to primary table + _ -> "0" + end + end + + @doc """ + Renders an APRS symbol as HTML for use in Leaflet markers. + Returns HTML string that can be used as marker content. + + ## Examples + + iex> AprsmeWeb.AprsSymbol.render_marker_html("/", "_", "W1AW") + "
..." + """ + def render_marker_html(symbol_table, symbol_code, callsign \\ nil, size \\ 32) do + sprite_info = get_sprite_info(symbol_table, symbol_code) + + # Check if this is an overlay symbol + is_overlay = symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/) + + symbol_html = + if is_overlay do + # For overlay symbols, we need both the base symbol background and the overlay character + overlay_sprite_info = get_overlay_character_sprite_info(symbol_table) + + """ +
+
+ """ + else + """ +
+ """ + end + + if callsign do + """ +
+ #{symbol_html} +
#{callsign}
+
+ """ + else + symbol_html + end + end + + @doc """ + Renders an APRS symbol as a style string for use in templates. + Returns a CSS style string that can be used directly in HTML. + + ## Examples + + iex> AprsmeWeb.AprsSymbol.render_style("/", "_", 32) + "width: 32px; height: 32px; background-image: url(/aprs-symbols/aprs-symbols-128-0@2x.png); ..." + """ + def render_style(symbol_table, symbol_code, size \\ 32) do + sprite_info = get_sprite_info(symbol_table, symbol_code) + + "width: #{size}px; height: #{size}px; background-image: url(#{sprite_info.sprite_file}); background-position: #{sprite_info.background_position}; background-size: #{sprite_info.background_size}; background-repeat: no-repeat; image-rendering: pixelated; opacity: 1.0; display: inline-block; vertical-align: middle; margin-bottom: -6px;" + end + + @doc """ + Extracts symbol information from a packet with fallbacks. + + ## Examples + + iex> AprsmeWeb.AprsSymbol.extract_from_packet(%{symbol_table_id: "/", symbol_code: "_"}) + {"/", "_"} + + iex> AprsmeWeb.AprsSymbol.extract_from_packet(%{}) + {"/", ">"} + """ + def extract_from_packet(packet) do + symbol_table_id = get_packet_field(packet, :symbol_table_id, "/") + symbol_code = get_packet_field(packet, :symbol_code, ">") + + {symbol_table_id, symbol_code} + end + + # Helper function to safely extract a value from a packet or data_extended map + defp get_packet_field(packet, field, default) do + data_extended = Map.get(packet, :data_extended, Map.get(packet, "data_extended", %{})) || %{} + + Map.get(packet, field) || + Map.get(packet, to_string(field)) || + Map.get(data_extended, field) || + Map.get(data_extended, to_string(field)) || + default + end +end diff --git a/lib/aprsme_web/live/components/error_boundary.ex b/lib/aprsme_web/live/components/error_boundary.ex new file mode 100644 index 0000000..1c0defe --- /dev/null +++ b/lib/aprsme_web/live/components/error_boundary.ex @@ -0,0 +1,81 @@ +defmodule AprsmeWeb.Components.ErrorBoundary do + @moduledoc """ + Error boundary component for gracefully handling errors in LiveView. + + This component wraps other components and catches errors, displaying + a user-friendly error message instead of crashing the entire view. + """ + use Phoenix.Component + + @doc """ + Wraps content in an error boundary that catches and displays errors gracefully. + + ## Examples + + <.error_boundary id="map-error-boundary"> + <%= live_render(@socket, AprsmeWeb.MapLive.Index) %> + + """ + attr :id, :string, required: true + attr :class, :string, default: nil + slot :inner_block, required: true + + slot :fallback do + attr :error, :string + end + + def error_boundary(assigns) do + ~H""" +
+
+ {render_slot(@inner_block)} +
+ +
+ """ + end + + defp default_error_message(assigns) do + ~H""" +
+
+
+ + + +
+
+

+ Something went wrong +

+
+

+ We're sorry, but something unexpected happened. The error has been logged + and we'll look into it. Please try refreshing the page. +

+
+
+ +
+
+
+
+ """ + end +end diff --git a/lib/aprsme_web/live/components/symbol_renderer.ex b/lib/aprsme_web/live/components/symbol_renderer.ex new file mode 100644 index 0000000..0132d6c --- /dev/null +++ b/lib/aprsme_web/live/components/symbol_renderer.ex @@ -0,0 +1,101 @@ +defmodule AprsmeWeb.SymbolRenderer do + @moduledoc """ + Server-side APRS symbol rendering component. + + This module handles the rendering of APRS symbols using the hessu/aprs-symbols sprite files. + It provides a centralized way to render symbols that can be used across all LiveView pages. + """ + + use Phoenix.Component + + @doc """ + Renders an APRS symbol with the correct sprite positioning. + + ## Examples + + <.symbol symbol_table="/" symbol_code="_" size={32} callsign="W1AW" /> + <.symbol symbol_table="D" symbol_code="&" size={64} /> + """ + attr :symbol_table, :string, required: true, doc: "APRS symbol table identifier (/, \\, or overlay)" + attr :symbol_code, :string, required: true, doc: "APRS symbol code character" + attr :size, :integer, default: 32, doc: "Display size in pixels" + attr :callsign, :string, default: nil, doc: "Optional callsign to display next to symbol" + attr :class, :string, default: "", doc: "Additional CSS classes" + attr :title, :string, default: nil, doc: "Optional title/tooltip text" + + def symbol(assigns) do + # Get the sprite file and position for this symbol + sprite_info = get_sprite_info(assigns.symbol_table, assigns.symbol_code) + + assigns = + assign(assigns, + sprite_file: sprite_info.sprite_file, + background_position: sprite_info.background_position, + background_size: sprite_info.background_size, + symbol_title: assigns.title || "#{assigns.symbol_table}#{assigns.symbol_code}" + ) + + ~H""" +
+
+
+ <%= if @callsign do %> +
+ {@callsign} +
+ <% end %> +
+ """ + end + + @doc """ + Gets sprite information for a given symbol table and code. + Returns a map with sprite_file, background_position, and background_size. + """ + def get_sprite_info(symbol_table, symbol_code) do + AprsmeWeb.AprsSymbol.get_sprite_info(symbol_table, symbol_code) + end + + @doc """ + Renders an APRS symbol for use in Leaflet markers. + Returns HTML string that can be used as marker content. + """ + def render_marker_symbol(symbol_table, symbol_code, callsign \\ nil, size \\ 32) do + AprsmeWeb.AprsSymbol.render_marker_html(symbol_table, symbol_code, callsign, size) + end +end diff --git a/lib/aprsme_web/live/info_live/show.ex b/lib/aprsme_web/live/info_live/show.ex index 8ff03d4..40b8dbf 100644 --- a/lib/aprsme_web/live/info_live/show.ex +++ b/lib/aprsme_web/live/info_live/show.ex @@ -4,6 +4,7 @@ defmodule AprsmeWeb.InfoLive.Show do use Gettext, backend: AprsmeWeb.Gettext alias Aprsme.Packets + alias AprsmeWeb.AprsSymbol alias AprsmeWeb.MapLive.PacketUtils @neighbor_radius_km 10 @@ -306,6 +307,60 @@ defmodule AprsmeWeb.InfoLive.Show do end end + @doc """ + Renders an APRS symbol style for use in templates. + """ + def render_symbol_style(packet, size \\ 32) do + if packet do + {symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet) + AprsSymbol.render_style(symbol_table_id, symbol_code, size) + else + # Return empty style if no packet + "" + end + end + + @doc """ + Renders an APRS symbol as HTML for overlay symbols that need proper overlay character display. + """ + def render_symbol_html(packet, size \\ 32) do + if packet do + {symbol_table_id, symbol_code} = AprsSymbol.extract_from_packet(packet) + + # Check if this is an overlay symbol + if symbol_table_id && String.match?(symbol_table_id, ~r/^[A-Z0-9]$/) do + # Use layered sprite backgrounds for overlay symbols + sprite_info = AprsSymbol.get_sprite_info(symbol_table_id, symbol_code) + overlay_sprite_info = AprsSymbol.get_overlay_character_sprite_info(symbol_table_id) + + raw(""" +
+
+ """) + else + # Use style rendering for non-overlay symbols + raw(""" +
+ """) + end + else + # Return empty if no packet + raw("") + end + end + defp decode_aprs_path(path) when is_binary(path) and path != "" do path_elements = String.split(path, ",") diff --git a/lib/aprsme_web/live/info_live/show.html.heex b/lib/aprsme_web/live/info_live/show.html.heex index 535a371..f021f63 100644 --- a/lib/aprsme_web/live/info_live/show.html.heex +++ b/lib/aprsme_web/live/info_live/show.html.heex @@ -1,21 +1,3 @@ -<% {symbol_table_id, symbol_code} = AprsmeWeb.MapLive.PacketUtils.get_symbol_info(@packet || %{}) %> -<% symbol_table = if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %> -<% symbol_code = symbol_code || ">" %> -<% symbol_table_num = - case symbol_table do - "/" -> 0 - "\\" -> 1 - "]" -> 2 - _ -> 0 - end %> -<% symbol_code_ord = - symbol_code - |> String.to_charlist() - |> List.first() - |> (fn c -> if is_integer(c), do: c, else: 63 end).() %> -<% _symbol_img = "/aprs-symbols/aprs-symbols-24-#{symbol_table_num}@2x.png" %> -<% _symbol_index = symbol_code_ord - 33 %> -
@@ -31,31 +13,14 @@ {@callsign} <%= if @packet do %> - <% {symbol_table_id, symbol_code} = - AprsmeWeb.MapLive.PacketUtils.get_symbol_info(@packet) %> - <% symbol_table = - if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %> - <% symbol_code = symbol_code || ">" %> - <% table_id = - if symbol_table == "/", - do: "0", - else: if(symbol_table == "]", do: "2", else: "1") %> - <% symbol_code_ord = - symbol_code - |> String.to_charlist() - |> List.first() - |> (fn c -> if is_integer(c), do: c, else: 63 end).() %> - <% index = symbol_code_ord - 33 %> - <% safe_index = max(0, min(index, 93)) %> - <% column = rem(safe_index, 16) %> - <% row = div(safe_index, 16) %> - <% x = -column * 128 %> - <% y = -row * 128 %> - <% symbol_img = "/aprs-symbols/aprs-symbols-128-#{table_id}@2x.png" %> -
+ <% {symbol_table, symbol_code} = AprsmeWeb.AprsSymbol.extract_from_packet(@packet) %> + <% display_symbol = + if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/), + do: symbol_table, + else: + "#{AprsmeWeb.AprsSymbol.normalize_symbol_table(symbol_table)}#{AprsmeWeb.AprsSymbol.normalize_symbol_code(symbol_code)}" %> +
+ {render_symbol_html(@packet)}
<% end %>
@@ -289,33 +254,15 @@ {ssid_info.callsign} <%= if ssid_info.packet do %> - <% {symbol_table_id, symbol_code} = - AprsmeWeb.MapLive.PacketUtils.get_symbol_info(ssid_info.packet) %> - <% symbol_table = - if symbol_table_id in ["/", "\\", "]"], - do: symbol_table_id, - else: "/" %> - <% symbol_code = symbol_code || ">" %> - <% table_id = - if symbol_table == "/", - do: "0", - else: if(symbol_table == "]", do: "2", else: "1") %> - <% symbol_code_ord = - symbol_code - |> String.to_charlist() - |> List.first() - |> (fn c -> if is_integer(c), do: c, else: 63 end).() %> - <% index = symbol_code_ord - 33 %> - <% safe_index = max(0, min(index, 93)) %> - <% column = rem(safe_index, 16) %> - <% row = div(safe_index, 16) %> - <% x = -column * 128 %> - <% y = -row * 128 %> - <% symbol_img = "/aprs-symbols/aprs-symbols-128-#{table_id}@2x.png" %> -
+ <% {symbol_table, symbol_code} = + AprsmeWeb.AprsSymbol.extract_from_packet(ssid_info.packet) %> + <% display_symbol = + if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/), + do: symbol_table, + else: + "#{AprsmeWeb.AprsSymbol.normalize_symbol_table(symbol_table)}#{AprsmeWeb.AprsSymbol.normalize_symbol_code(symbol_code)}" %> +
+ {render_symbol_html(ssid_info.packet)}
<% end %>
@@ -414,31 +361,15 @@ {neighbor.callsign} <%= if neighbor.packet do %> - <% {symbol_table_id, symbol_code} = - AprsmeWeb.MapLive.PacketUtils.get_symbol_info(neighbor.packet) %> - <% symbol_table = - if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %> - <% symbol_code = symbol_code || ">" %> - <% table_id = - if symbol_table == "/", - do: "0", - else: if(symbol_table == "]", do: "2", else: "1") %> - <% symbol_code_ord = - symbol_code - |> String.to_charlist() - |> List.first() - |> (fn c -> if is_integer(c), do: c, else: 63 end).() %> - <% index = symbol_code_ord - 33 %> - <% safe_index = max(0, min(index, 93)) %> - <% column = rem(safe_index, 16) %> - <% row = div(safe_index, 16) %> - <% x = -column * 128 %> - <% y = -row * 128 %> - <% symbol_img = "/aprs-symbols/aprs-symbols-128-#{table_id}@2x.png" %> -
+ <% {symbol_table, symbol_code} = + AprsmeWeb.AprsSymbol.extract_from_packet(neighbor.packet) %> + <% display_symbol = + if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/), + do: symbol_table, + else: + "#{AprsmeWeb.AprsSymbol.normalize_symbol_table(symbol_table)}#{AprsmeWeb.AprsSymbol.normalize_symbol_code(symbol_code)}" %> +
+ {render_symbol_html(neighbor.packet)}
<% end %>
diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index f4e2cae..7b6ee96 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -4,19 +4,68 @@ defmodule AprsmeWeb.MapLive.Index do """ use AprsmeWeb, :live_view + import AprsmeWeb.Components.ErrorBoundary import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1] - import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2] + import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2] alias AprsmeWeb.Endpoint alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.MapLive.PacketUtils + alias AprsmeWeb.MapLive.PopupComponent + alias Phoenix.HTML.Safe alias Phoenix.LiveView.Socket @default_center %{lat: 39.8283, lng: -98.5795} @default_zoom 5 + # Parse map state from URL parameters + @spec parse_map_params(map()) :: {map(), integer()} + defp parse_map_params(params) do + # Parse latitude (lat parameter) + lat = + case Map.get(params, "lat") do + nil -> + @default_center.lat + + lat_str -> + case Float.parse(lat_str) do + {lat_val, _} when lat_val >= -90 and lat_val <= 90 -> lat_val + _ -> @default_center.lat + end + end + + # Parse longitude (lng parameter) + lng = + case Map.get(params, "lng") do + nil -> + @default_center.lng + + lng_str -> + case Float.parse(lng_str) do + {lng_val, _} when lng_val >= -180 and lng_val <= 180 -> lng_val + _ -> @default_center.lng + end + end + + # Parse zoom level (z parameter) + zoom = + case Map.get(params, "z") do + nil -> + @default_zoom + + zoom_str -> + case Integer.parse(zoom_str) do + {zoom_val, _} when zoom_val >= 1 and zoom_val <= 20 -> zoom_val + _ -> @default_zoom + end + end + + map_center = %{lat: lat, lng: lng} + {map_center, zoom} + end + @impl true - def mount(_params, _session, socket) do + def mount(params, _session, socket) do if connected?(socket) do # Subscribe to packet updates Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") @@ -29,9 +78,18 @@ defmodule AprsmeWeb.MapLive.Index do # Get deployment timestamp from config (set during application startup) deployed_at = Aprsme.Release.deployed_at() - one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + # Show 24 hours for more symbol variety + one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second) + + # Parse map state from URL parameters + {map_center, map_zoom} = parse_map_params(params) socket = assign_defaults(socket, one_hour_ago) + socket = assign(socket, map_center: map_center, map_zoom: map_zoom) + + # Calculate initial bounds based on center and zoom level + initial_bounds = calculate_bounds_from_center_and_zoom(map_center, map_zoom) + socket = assign(socket, map_bounds: initial_bounds) socket = assign(socket, packet_buffer: [], buffer_timer: nil) socket = assign(socket, all_packets: %{}, station_popup_open: false) @@ -60,6 +118,37 @@ defmodule AprsmeWeb.MapLive.Index do )} end + # Calculate approximate bounds based on center point and zoom level + # This provides initial bounds for database queries before client sends actual bounds + @spec calculate_bounds_from_center_and_zoom(map(), integer()) :: map() + defp calculate_bounds_from_center_and_zoom(center, zoom) do + # Approximate degrees per pixel at different zoom levels + # These are rough estimates for initial bounds calculation + degrees_per_pixel = + case zoom do + z when z >= 15 -> 0.000005 + z when z >= 12 -> 0.00005 + z when z >= 10 -> 0.0002 + z when z >= 8 -> 0.001 + z when z >= 6 -> 0.005 + z when z >= 4 -> 0.02 + _ -> 0.1 + end + + # Assume viewport is roughly 800x600 pixels + # Half of 600px height + lat_offset = degrees_per_pixel * 300 + # Half of 800px width + lng_offset = degrees_per_pixel * 400 + + %{ + north: center.lat + lat_offset, + south: center.lat - lat_offset, + east: center.lng + lng_offset, + west: center.lng - lng_offset + } + end + @spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t() defp assign_defaults(socket, one_hour_ago) do assign(socket, @@ -168,8 +257,8 @@ defmodule AprsmeWeb.MapLive.Index do def handle_event("map_ready", _params, socket) do socket = assign(socket, map_ready: true) - # Load historical packets with a small delay to ensure map is fully ready - Process.send_after(self(), :reload_historical_packets, 100) + # Load historical packets immediately since we now have bounds from URL parameters + Process.send_after(self(), :reload_historical_packets, 10) # If we have pending geolocation, zoom to it now socket = @@ -266,6 +355,100 @@ defmodule AprsmeWeb.MapLive.Index do {:noreply, socket} end + @impl true + def handle_event("update_map_state", %{"center" => center, "zoom" => zoom} = params, socket) do + # Parse center coordinates + lat = + case center do + %{"lat" => lat_val} -> lat_val + _ -> socket.assigns.map_center.lat + end + + lng = + case center do + %{"lng" => lng_val} -> lng_val + _ -> socket.assigns.map_center.lng + end + + # Validate and clamp values + lat = max(-90.0, min(90.0, lat)) + lng = max(-180.0, min(180.0, lng)) + zoom = max(1, min(20, zoom)) + + map_center = %{lat: lat, lng: lng} + + # Update socket state + socket = assign(socket, map_center: map_center, map_zoom: zoom) + + # Update URL without page reload + new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}" + socket = push_patch(socket, to: new_path, replace: true) + + # If bounds are included, also process bounds update + socket = + case Map.get(params, "bounds") do + %{"north" => north, "south" => south, "east" => east, "west" => west} -> + map_bounds = %{ + north: north, + south: south, + east: east, + west: west + } + + # Only trigger bounds processing if bounds actually changed + if socket.assigns.map_bounds != map_bounds do + send(self(), {:process_bounds_update, map_bounds}) + end + + socket + + _ -> + socket + end + + {:noreply, socket} + end + + @impl true + def handle_event( + "error_boundary_triggered", + %{"message" => message, "stack" => stack, "component_id" => component_id}, + socket + ) do + # Log the error for monitoring + require Logger + + Logger.error("Error boundary triggered in component #{component_id}: #{message}\n#{stack}") + + # You could also send this to an error tracking service here + # ErrorTracker.report_error(message, stack, %{component: component_id, user_id: socket.assigns[:current_user_id]}) + + {:noreply, socket} + end + + @impl true + def handle_params(params, _url, socket) do + # Parse new map state from URL parameters + {map_center, map_zoom} = parse_map_params(params) + + # Update socket state + socket = assign(socket, map_center: map_center, map_zoom: map_zoom) + + # If map is ready, update the client-side map + socket = + if socket.assigns.map_ready do + push_event(socket, "zoom_to_location", %{ + lat: map_center.lat, + lng: map_center.lng, + zoom: map_zoom + }) + else + socket + end + + {:noreply, socket} + end + @impl true def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket) @@ -283,6 +466,11 @@ defmodule AprsmeWeb.MapLive.Index do def handle_info(:start_geolocation, socket), do: handle_info_start_geolocation(socket) + def handle_info({:load_historical_batch, batch_offset}, socket) do + socket = load_historical_batch(socket, batch_offset) + {:noreply, socket} + end + def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket), do: handle_info({:postgres_packet, packet}, socket) @@ -329,21 +517,18 @@ defmodule AprsmeWeb.MapLive.Index do defp handle_info_initialize_replay(socket) do if not socket.assigns.historical_loaded and socket.assigns.map_ready do - # Use default bounds if map_bounds is not available yet - map_bounds = - socket.assigns.map_bounds || - %{ - north: 90.0, - south: -90.0, - east: 180.0, - west: -180.0 - } - - socket = assign(socket, map_bounds: map_bounds) - - # Use optimized query for initial load - socket = load_historical_packets_for_bounds_optimized(socket, map_bounds) - {:noreply, socket} + # Only proceed if we have actual map bounds - don't use world bounds + if socket.assigns.map_bounds do + # Use progressive loading for better performance + socket = start_progressive_historical_loading(socket) + socket = assign(socket, historical_loaded: true) + {:noreply, socket} + else + # Wait a bit longer for map bounds to be available + # Increase delay to give client more time to send real bounds + Process.send_after(self(), :initialize_replay, 500) + {:noreply, socket} + end else {:noreply, socket} end @@ -614,15 +799,17 @@ defmodule AprsmeWeb.MapLive.Index do } -
-
+ <.error_boundary id="map-error-boundary"> +
+
+