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/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..8e19eec --- /dev/null +++ b/assets/js/hooks/error_boundary.js @@ -0,0 +1,84 @@ +// 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 + // This is a simplified check - in production you might want more sophisticated logic + if (!error || !error.stack) return false; + + // Check if any element in the component has an error + const hasError = this.el.querySelector('[data-phx-error]'); + return !!hasError; + }, + + 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 ed25168..50f41ef 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -1,5 +1,16 @@ -// 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 @@ -10,85 +21,6 @@ import { parseTimestamp, getTrailId, saveMapState, safePushEvent, getLiveSocket // 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; - programmaticMoveId?: string; - programmaticMoveTimeout?: ReturnType; - cleanupInterval?: ReturnType; - mapEventHandlers?: Map; - isDestroyed?: boolean; - popupNavigationHandler?: (e: Event) => void; - [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; - symbol_html?: 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() { 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/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_web/live/components/error_boundary.ex b/lib/aprsme_web/live/components/error_boundary.ex new file mode 100644 index 0000000..4848706 --- /dev/null +++ b/lib/aprsme_web/live/components/error_boundary.ex @@ -0,0 +1,85 @@ +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 + + import AprsmeWeb.CoreComponents + + alias Phoenix.LiveView.Socket + + @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/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index e8857eb..7b6ee96 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -4,6 +4,7 @@ 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, push_patch: 2] @@ -408,6 +409,23 @@ defmodule AprsmeWeb.MapLive.Index do {: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 @@ -781,15 +799,17 @@ defmodule AprsmeWeb.MapLive.Index do } -
-
+ <.error_boundary id="map-error-boundary"> +
+
+