From 514d1b66d5a4d55f5d6593b5ef5bbbf0664dd285 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 9 Jul 2025 16:20:29 -0500 Subject: [PATCH] fix all the main map display issues --- .DS_Store | Bin 6148 -> 0 bytes CLAUDE.md | 7 + assets/js/map.ts | 287 ++++++----- lib/aprsme/packet.ex | 25 +- lib/aprsme/packets.ex | 8 +- lib/aprsme_web/aprs_symbol.ex | 332 +++++++++++++ .../live/components/symbol_renderer.ex | 102 ++++ lib/aprsme_web/live/info_live/show.ex | 55 +++ lib/aprsme_web/live/info_live/show.html.heex | 106 +--- lib/aprsme_web/live/map_live/index.ex | 465 +++++++++++------- lib/aprsme_web/live/map_live/packet_utils.ex | 31 +- priv/.DS_Store | Bin 6148 -> 0 bytes ...250109_optimize_weather_packet_lookups.exs | 30 ++ test/aprsme_web/aprs_symbol_test.exs | 142 ++++++ .../live/map_live/overlay_rendering_test.exs | 102 ++++ .../live/map_live/performance_test.exs | 70 +++ 16 files changed, 1349 insertions(+), 413 deletions(-) delete mode 100644 .DS_Store create mode 100644 lib/aprsme_web/aprs_symbol.ex create mode 100644 lib/aprsme_web/live/components/symbol_renderer.ex delete mode 100644 priv/.DS_Store create mode 100644 priv/repo/migrations/20250109_optimize_weather_packet_lookups.exs create mode 100644 test/aprsme_web/aprs_symbol_test.exs create mode 100644 test/aprsme_web/live/map_live/overlay_rendering_test.exs create mode 100644 test/aprsme_web/live/map_live/performance_test.exs diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index b1113db44ff0b36f296b468a1b98aa85a1d40c69..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK-AcnS6i!^VD?{jof_N41cH*2<5pPPJFJMJ4ROZTt7Hc!wZoL?TK7hWE590H9 zPLhhlc`M@1f#f^CN%KMT!x-cKc(Biy%NVOeL*%F|5OgmLt(as)j$?#HHVR`Ig8gP< ze;x4KEjD5)i`eAr_lKi6%JNq0ov+la)iv98?1p_8Jjx=-`}sKY`{@l@mr}-IrT4?D zXjt^zjWe0#{U{lxsvsH+A?5ZuN(Qp<rm7vD-Op&zfR- z(rz_H=iq2IbLyKrd#4w@r}#OMFPc&gj4N3;SivhOpDUUKX%fri0en?{l}AVn5Cg;j zF|e!*m=mGiSXKgP-NXPf@FN4bKL}`up21S1+B%@a>odkJL=@2RErBQudIn345CP%3 z6i}CP^Tgn~9Q?xMc?L_3x}0$}GmK+qt{yL3%?^H{(i!(OQcnyJ1M>{jwCUpce-6LQ z!bkpm30cGdG4RhA;8r*2`miW-wtib4p0xtnJv0=|D^URfeeMzf2JRzUDyZWEb;$D! WmKt#s^s90}x(Fyjs3Qh`fq@Ual}hIT diff --git a/CLAUDE.md b/CLAUDE.md index 9b6ff76..cd3ae25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,9 +90,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/map.ts b/assets/js/map.ts index 774c8da..e7f58dc 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -25,7 +25,7 @@ type LiveViewHookContext = { lastZoom?: number; currentPopupMarkerId?: string | null; oms?: any; - programmaticMove?: boolean; + programmaticMoveCounter?: number; [key: string]: any; }; @@ -44,6 +44,7 @@ interface MarkerData { timestamp?: number; is_most_recent_for_callsign?: boolean; callsign_group?: string; + symbol_html?: string; } interface BoundsData { @@ -81,6 +82,7 @@ interface MapEventData { markers?: MarkerData[]; } + let MapAPRSMap = { mounted() { const self = this as unknown as LiveViewHookContext; @@ -289,8 +291,9 @@ let MapAPRSMap = { // Send bounds to LiveView when map moves self.map!.on("moveend", () => { // Skip if this is a programmatic move from the server - if (self.programmaticMove) { - self.programmaticMove = false; + if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) { + // Decrement counter for this programmatic move event + self.programmaticMoveCounter--; return; } @@ -299,14 +302,19 @@ let MapAPRSMap = { // Save map state and update URL const center = self.map.getCenter(); const zoom = self.map.getZoom(); + + // Truncate lat/lng to 5 decimal places for URL + const truncatedLat = Math.round(center.lat * 100000) / 100000; + const truncatedLng = Math.round(center.lng * 100000) / 100000; + localStorage.setItem( "aprs_map_state", - JSON.stringify({ lat: center.lat, lng: center.lng, zoom }), + JSON.stringify({ lat: truncatedLat, lng: truncatedLng, zoom }), ); // Send combined map state update to server for URL and bounds updating self.pushEvent("update_map_state", { - center: { lat: center.lat, lng: center.lng }, + center: { lat: truncatedLat, lng: truncatedLng }, zoom: zoom, bounds: { north: self.map.getBounds().getNorth(), @@ -321,8 +329,9 @@ let MapAPRSMap = { // Handle zoom changes with optimization for large zoom differences self.map!.on("zoomend", () => { // Skip if this is a programmatic move from the server - if (self.programmaticMove) { - self.programmaticMove = false; + if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) { + // Decrement counter for this programmatic move event + self.programmaticMoveCounter--; return; } @@ -341,14 +350,19 @@ let MapAPRSMap = { // Save map state and update URL const center = self.map.getCenter(); const zoom = self.map.getZoom(); + + // Truncate lat/lng to 5 decimal places for URL + const truncatedLat = Math.round(center.lat * 100000) / 100000; + const truncatedLng = Math.round(center.lng * 100000) / 100000; + localStorage.setItem( "aprs_map_state", - JSON.stringify({ lat: center.lat, lng: center.lng, zoom }), + JSON.stringify({ lat: truncatedLat, lng: truncatedLng, zoom }), ); // Send combined map state update to server for URL and bounds updating self.pushEvent("update_map_state", { - center: { lat: center.lat, lng: center.lng }, + center: { lat: truncatedLat, lng: truncatedLng }, zoom: zoom, bounds: { north: self.map.getBounds().getNorth(), @@ -389,6 +403,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); } @@ -425,6 +442,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 + // window.liveSocket is available globally in Phoenix LiveView apps + if ((window as any).liveSocket) { + (window as any).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 @@ -487,12 +533,20 @@ let MapAPRSMap = { // Use a slight delay to ensure map is ready setTimeout(() => { if (self.map) { - // Set flag to prevent event loop - self.programmaticMove = true; + // Set counter to handle both moveend and zoomend events from setView + // setView() can trigger both events, so we need to handle both + self.programmaticMoveCounter = 2; self.map.setView([lat, lng], zoom, { animate: true, duration: 1, }); + + // Safety timeout to reset counter in case events don't fire as expected + setTimeout(() => { + if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) { + self.programmaticMoveCounter = 0; + } + }, 2000); // Check element dimensions after zoom setTimeout(() => { @@ -890,26 +944,37 @@ 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", {}); + // Only send event if LiveView is still connected + if (self.pushEvent) { + try { + self.pushEvent("popup_closed", {}); + } catch (e) { + // Silently ignore if LiveView is disconnected + console.debug("Unable to send popup_closed event - LiveView disconnected"); + } + } }); } // Handle marker click marker.on("click", () => { if (marker.openPopup) marker.openPopup(); - self.pushEvent("marker_clicked", { - id: data.id, - callsign: data.callsign, - lat: lat, - lng: lng, - }); - }); - - // Handle popup close events - marker.on("popupclose", () => { - self.pushEvent("popup_closed", {}); + // Only send event if LiveView is still connected + if (self.pushEvent) { + try { + self.pushEvent("marker_clicked", { + id: data.id, + callsign: data.callsign, + lat: lat, + lng: lng, + }); + } catch (e) { + // Silently ignore if LiveView is disconnected + console.debug("Unable to send marker_clicked event - LiveView disconnected"); + } + } }); // Mark historical markers for identification @@ -1162,57 +1227,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], }); }, @@ -1287,7 +1313,7 @@ let MapAPRSMap = { // const symbolDesc = data.symbol_description || `Symbol: ${symbolTableId}${symbolCode}`; let content = `
- `; + `; // Removed symbol info from popup // content += `
${symbolDesc}
`; @@ -1314,12 +1340,46 @@ let MapAPRSMap = { destroyed() { const self = this as unknown as LiveViewHookContext; + + // Disable pushEvent to prevent any events from being sent during cleanup + const originalPushEvent = self.pushEvent; + self.pushEvent = () => {}; // No-op function + + // Remove popup navigation event listener + if (self.popupNavigationHandler) { + document.removeEventListener('click', self.popupNavigationHandler); + } + + // Close any open popups before cleanup + if (self.map !== undefined) { + try { + self.map.closePopup(); + } catch (e) { + // Ignore errors during popup cleanup + } + } + if (self.boundsTimer !== undefined) { clearTimeout(self.boundsTimer); } if (self.resizeHandler !== undefined) { window.removeEventListener("resize", self.resizeHandler); } + + // 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) { + // Ignore errors during cleanup + } + }); + } + if (self.markerLayer !== undefined) { self.markerLayer!.clearLayers(); } @@ -1333,6 +1393,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/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 e7bd21c..c2a9491 100644 --- a/lib/aprsme/packets.ex +++ b/lib/aprsme/packets.ex @@ -400,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) @@ -415,8 +415,8 @@ 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) diff --git a/lib/aprsme_web/aprs_symbol.ex b/lib/aprsme_web/aprs_symbol.ex new file mode 100644 index 0000000..840f728 --- /dev/null +++ b/lib/aprsme_web/aprs_symbol.ex @@ -0,0 +1,332 @@ +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() + |> (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() + |> (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 + "#" -> "1" # Digipeater - green star background + "a" -> "1" # Diamond shape - APRS overlay symbol (alternate table) + "A" -> "1" # Square shape - APRS overlay symbol (alternate table) + "&" -> "1" # Diamond shape - alternate table + ">" -> "1" # Arrow symbols + "<" -> "1" + "^" -> "1" + "v" -> "1" + "i" -> "1" # Black square background - alternate table + # 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() + |> (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 + "/" -> "0" # Primary table + "\\" -> "1" # Alternate table + "]" -> "2" # Overlay table (A-Z, 0-9) + _ -> "0" # Default to primary table + 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 \ No newline at end of file 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..f5c21a4 --- /dev/null +++ b/lib/aprsme_web/live/components/symbol_renderer.ex @@ -0,0 +1,102 @@ +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..8aadc48 100644 --- a/lib/aprsme_web/live/info_live/show.ex +++ b/lib/aprsme_web/live/info_live/show.ex @@ -5,6 +5,7 @@ defmodule AprsmeWeb.InfoLive.Show do alias Aprsme.Packets alias AprsmeWeb.MapLive.PacketUtils + alias AprsmeWeb.AprsSymbol @neighbor_radius_km 10 @neighbor_limit 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..ce3bf9e 100644 --- a/lib/aprsme_web/live/info_live/show.html.heex +++ b/lib/aprsme_web/live/info_live/show.html.heex @@ -1,20 +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 +14,10 @@ {@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 +251,10 @@ {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 +353,10 @@ {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 148dd0f..e8857eb 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -10,6 +10,8 @@ defmodule AprsmeWeb.MapLive.Index do 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} @@ -75,13 +77,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) @@ -110,6 +117,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, @@ -218,8 +256,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 = @@ -346,22 +384,26 @@ defmodule AprsmeWeb.MapLive.Index do 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 + 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 @@ -1141,189 +1183,243 @@ defmodule AprsmeWeb.MapLive.Index do # Helper functions # Fetch historical packets from the database - defp process_historical_packets(socket, historical_packets) do - socket = push_event(socket, "clear_historical_packets", %{}) - packet_data_list = build_packet_data_list(historical_packets) + # Select the best packet to display for a callsign - prioritize position over weather + defp select_best_packet_for_display(packets) do + # Separate position and weather packets using the same logic as PacketUtils.weather_packet? + {position_packets, weather_packets} = + Enum.split_with(packets, fn packet -> + # A packet is a position packet if it's NOT a weather packet + not PacketUtils.weather_packet?(packet) + end) - if Enum.any?(packet_data_list) do - push_event(socket, "add_historical_packets", %{packets: packet_data_list}) - else - socket + # Prefer the most recent position packet, fall back to most recent weather packet + case position_packets do + [] -> + # No position packets, use most recent weather packet + hd(weather_packets) + + [single_position] -> + # Only one position packet, use it + single_position + + position_list -> + # Multiple position packets, use most recent one + Enum.max_by(position_list, & &1.received_at, DateTime) end end defp build_packet_data_list(historical_packets) do - historical_packets - |> Enum.group_by(&PacketUtils.generate_callsign/1) - |> Enum.flat_map(&process_callsign_packets/1) - end + # Group by callsign and identify most recent packet for each + grouped_packets = + Enum.group_by(historical_packets, fn packet -> + packet.sender || "unknown" + end) - defp process_callsign_packets({callsign, packets}) do - sorted_packets = sort_packets_by_inserted_at(packets) - unique_position_packets = filter_unique_positions(sorted_packets) + # Batch fetch weather information for all callsigns to avoid N+1 queries + callsigns = Map.keys(grouped_packets) + weather_callsigns = get_weather_callsigns_batch(callsigns) - unique_position_packets - |> Enum.with_index() - |> Enum.map(&build_packet_data_with_index(&1, callsign)) + # For each callsign group, find the most recent packet and mark it appropriately + grouped_packets + |> Enum.flat_map(fn {callsign, packets} -> + # Sort by received_at to find most recent + sorted_packets = Enum.sort_by(packets, & &1.received_at, {:desc, DateTime}) + + case sorted_packets do + [] -> + [] + + packets_list -> + # Find the best packet to display as "current" - prioritize position over weather + selected_packet = select_best_packet_for_display(packets_list) + historical = Enum.reject(packets_list, &(&1.id == selected_packet.id)) + + # Always include the selected packet + has_weather = MapSet.member?(weather_callsigns, String.upcase(callsign)) + most_recent_data = build_minimal_packet_data(selected_packet, true, has_weather) + + # Get coordinates of selected packet for distance filtering + {most_recent_lat, most_recent_lon, _} = MapHelpers.get_coordinates(selected_packet) + + # Filter historical packets that are too close to most recent position + filtered_historical = + if most_recent_lat && most_recent_lon do + Enum.filter(historical, fn packet -> + {lat, lon, _} = MapHelpers.get_coordinates(packet) + + if lat && lon do + distance_meters = calculate_distance_meters(most_recent_lat, most_recent_lon, lat, lon) + # Only show if 10+ meters away + distance_meters >= 10.0 + else + # Skip packets without coordinates + false + end + end) + else + # If most recent has no coordinates, include all historical + historical + end + + # Build data for remaining historical packets + historical_data = + filtered_historical + |> Enum.map(fn packet -> build_minimal_packet_data(packet, false, has_weather) end) + |> Enum.filter(& &1) + + # Combine most recent and filtered historical + Enum.filter([most_recent_data | historical_data], & &1) + end + end) |> Enum.filter(& &1) end - defp sort_packets_by_inserted_at(packets) do - Enum.sort_by( - packets, - fn packet -> - case packet.inserted_at do - %NaiveDateTime{} = naive_dt -> DateTime.from_naive!(naive_dt, "Etc/UTC") - %DateTime{} = dt -> dt - _other -> DateTime.utc_now() - end - end, - {:desc, DateTime} - ) - end - - defp build_packet_data_with_index({packet, index}, callsign) do - # The first packet (index 0) is the most recent for this callsign - # Only show as red dot if it's not the most recent position - is_most_recent = index == 0 - - # Note: We don't have access to locale here, so we'll use default "en" - packet_data = PacketUtils.build_packet_data(packet, is_most_recent, "en") - - if packet_data do - packet_data - |> Map.put(:callsign, callsign) - |> Map.put(:historical, true) - end - end - - defp filter_unique_positions(packets) do - packets - |> Enum.reduce([], fn packet, acc -> - add_if_unique_position(packet, acc) - end) - |> Enum.reverse() - end - - defp add_if_unique_position(packet, []), do: if_position_present(packet, []) - defp add_if_unique_position(packet, [last_packet | _] = acc), do: if_position_changed(packet, last_packet, acc) - - defp if_position_present(packet, acc) do - {lat, lon, _} = MapHelpers.get_coordinates(packet) - if lat && lon, do: [packet | acc], else: acc - end - - defp if_position_changed(packet, last_packet, acc) do + defp build_minimal_packet_data(packet, is_most_recent, has_weather) do + # Build minimal packet data without calling expensive PacketUtils.build_packet_data {lat, lon, _} = MapHelpers.get_coordinates(packet) if lat && lon do - if position_changed?(packet, last_packet), do: [packet | acc], else: acc - else - acc + # Use PacketUtils to get symbol information properly (includes data_extended fallback) + symbol_table_id = PacketUtils.get_packet_field(packet, :symbol_table_id, "/") + symbol_code = PacketUtils.get_packet_field(packet, :symbol_code, ">") + + # Generate symbol HTML using the SymbolRenderer + symbol_html = + AprsmeWeb.SymbolRenderer.render_marker_symbol( + symbol_table_id, + symbol_code, + packet.sender || "", + 32 + ) + + %{ + "id" => if(is_most_recent, do: "current_#{packet.id}", else: "hist_#{packet.id}"), + "lat" => lat, + "lng" => lon, + "callsign" => packet.sender || "", + "symbol_table_id" => symbol_table_id, + "symbol_code" => symbol_code, + "symbol_html" => symbol_html, + "comment" => packet.comment || "", + "timestamp" => DateTime.to_unix(packet.received_at || DateTime.utc_now(), :millisecond), + "historical" => !is_most_recent, + "is_most_recent_for_callsign" => is_most_recent, + "popup" => build_simple_popup(packet, has_weather) + } end end - # Check if position changed significantly between two packets (more than ~1 meter) - @spec position_changed?(struct(), struct()) :: boolean() - defp position_changed?(packet1, packet2) do - {lat1, lng1, _} = MapHelpers.get_coordinates(packet1) - {lat2, lng2, _} = MapHelpers.get_coordinates(packet2) + defp build_simple_popup(packet, has_weather) do + # Build popup HTML directly without database queries + callsign = packet.sender || "Unknown" + timestamp_dt = packet.received_at || DateTime.utc_now() + cache_buster = System.system_time(:millisecond) - abs(lat1 - lat2) > 0.0001 || abs(lng1 - lng2) > 0.0001 - end + # Check if this packet itself is a weather packet + is_weather = PacketUtils.weather_packet?(packet) - # Fetch historical packets from the database - @spec fetch_historical_packets(list(), DateTime.t(), DateTime.t()) :: [struct()] - defp fetch_historical_packets(bounds, start_time, end_time) do - effective_start_time = start_time - - # Use the Packets context to retrieve historical packets - packets_params = %{ - bounds: bounds, - start_time: effective_start_time, - end_time: end_time, - with_position: true, - # Reasonable limit to prevent overwhelming the client - limit: 1000 - } - - # Call the database through the Packets context - packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets) - packets = packets_module.get_packets_for_replay(packets_params) - - # Sort packets by received_at timestamp to ensure chronological replay - Enum.sort_by(packets, & &1.received_at) - end - - @spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t() - defp load_historical_packets_for_bounds(socket, map_bounds) do - now = DateTime.utc_now() - historical_hours = String.to_integer(socket.assigns.historical_hours) - start_time = DateTime.add(now, -historical_hours * 3600, :second) - - bounds = [ - map_bounds.west, - map_bounds.south, - map_bounds.east, - map_bounds.north - ] - - historical_packets = fetch_historical_packets(bounds, start_time, now) - - if Enum.empty?(historical_packets) do - assign(socket, historical_loaded: true) + if is_weather do + # Build weather popup + %{ + callsign: callsign, + comment: nil, + timestamp_dt: timestamp_dt, + cache_buster: cache_buster, + weather: true, + weather_link: true, + temperature: PacketUtils.get_weather_field(packet, :temperature), + temp_unit: "°F", + humidity: PacketUtils.get_weather_field(packet, :humidity), + wind_direction: PacketUtils.get_weather_field(packet, :wind_direction), + wind_speed: PacketUtils.get_weather_field(packet, :wind_speed), + wind_unit: "mph", + wind_gust: PacketUtils.get_weather_field(packet, :wind_gust), + gust_unit: "mph", + pressure: PacketUtils.get_weather_field(packet, :pressure), + rain_1h: PacketUtils.get_weather_field(packet, :rain_1h), + rain_24h: PacketUtils.get_weather_field(packet, :rain_24h), + rain_since_midnight: PacketUtils.get_weather_field(packet, :rain_since_midnight), + rain_1h_unit: "in", + rain_24h_unit: "in", + rain_since_midnight_unit: "in" + } + |> PopupComponent.popup() + |> Safe.to_iodata() + |> IO.iodata_to_binary() else - process_historical_packets(socket, historical_packets) + # Build standard popup + %{ + callsign: callsign, + comment: packet.comment || "", + timestamp_dt: timestamp_dt, + cache_buster: cache_buster, + weather: false, + # Use pre-fetched weather info + weather_link: has_weather + } + |> PopupComponent.popup() + |> Safe.to_iodata() + |> IO.iodata_to_binary() end end - @spec load_historical_packets_for_bounds_optimized(Socket.t(), map()) :: Socket.t() - defp load_historical_packets_for_bounds_optimized(socket, map_bounds) do - bounds = [ - map_bounds.west, - map_bounds.south, - map_bounds.east, - map_bounds.north - ] + # Batch fetch weather callsigns to avoid N+1 queries + defp get_weather_callsigns_batch(callsigns) when is_list(callsigns) do + import Ecto.Query - # Use the optimized query for initial load with smaller limit for faster loading - packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets) + # Normalize callsigns + normalized_callsigns = Enum.map(callsigns, &String.upcase/1) - historical_packets = - if packets_module == Aprsme.Packets do - # Use cached queries for better performance - # Include zoom level in cache key for better cache efficiency - zoom = socket.assigns.map_zoom || 5 + # Single query to find all callsigns that have weather packets + query = + from p in Aprsme.Packet, + where: fragment("UPPER(?)", p.sender) in ^normalized_callsigns, + where: p.data_type == "weather" or (p.symbol_table_id == "/" and p.symbol_code == "_"), + select: fragment("UPPER(?)", p.sender), + distinct: true - Aprsme.CachedQueries.get_recent_packets_cached(%{ - bounds: bounds, - # Reduced limit for faster initial load - limit: 200, - zoom: zoom - }) - else - # Fallback for testing - packets_module.get_recent_packets_optimized(%{ - bounds: bounds, - # Reduced limit for faster initial load - limit: 200 - }) - end + weather_callsigns = Aprsme.Repo.all(query) + MapSet.new(weather_callsigns) + rescue + _ -> MapSet.new() + end - if Enum.empty?(historical_packets) do - assign(socket, historical_loaded: true) - else - process_historical_packets(socket, historical_packets) - end + # Calculate distance between two lat/lon points in meters using Haversine formula + defp calculate_distance_meters(lat1, lon1, lat2, lon2) do + # Convert latitude and longitude from degrees to radians + lat1_rad = lat1 * :math.pi() / 180 + lon1_rad = lon1 * :math.pi() / 180 + lat2_rad = lat2 * :math.pi() / 180 + lon2_rad = lon2 * :math.pi() / 180 + + # Haversine formula + dlat = lat2_rad - lat1_rad + dlon = lon2_rad - lon1_rad + + a = + :math.sin(dlat / 2) * :math.sin(dlat / 2) + + :math.cos(lat1_rad) * :math.cos(lat2_rad) * + :math.sin(dlon / 2) * :math.sin(dlon / 2) + + c = 2 * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a)) + + # Earth's radius in meters + earth_radius_meters = 6_371_000 + + # Distance in meters + earth_radius_meters * c end # Progressive loading functions using LiveView's efficient update mechanisms @spec start_progressive_historical_loading(Socket.t()) :: Socket.t() defp start_progressive_historical_loading(socket) do + # Clear existing historical packets before loading new ones + socket = push_event(socket, "clear_historical_packets", %{}) + # For high zoom levels, load everything in one batch for maximum speed zoom = socket.assigns.map_zoom || 5 - + if zoom >= 10 do # High zoom - load everything at once for maximum speed socket @@ -1332,7 +1428,7 @@ defmodule AprsmeWeb.MapLive.Index do else # Low zoom - use progressive loading to prevent overwhelming total_batches = calculate_batch_count_for_zoom(zoom) - + # Start with first batch socket = socket @@ -1375,8 +1471,6 @@ defmodule AprsmeWeb.MapLive.Index do @spec load_historical_batch(Socket.t(), integer()) :: Socket.t() defp load_historical_batch(socket, batch_offset) do if socket.assigns.map_bounds do - require Logger - bounds = [ socket.assigns.map_bounds.west, socket.assigns.map_bounds.south, @@ -1389,8 +1483,6 @@ defmodule AprsmeWeb.MapLive.Index do batch_size = calculate_batch_size_for_zoom(zoom) offset = batch_offset * batch_size - # Debug logging removed for performance - packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets) historical_packets = @@ -1435,7 +1527,6 @@ defmodule AprsmeWeb.MapLive.Index do socket end else - # No more data in this batch socket end else @@ -1569,7 +1660,16 @@ defmodule AprsmeWeb.MapLive.Index do if compare_bounds(map_bounds, socket.assigns.map_bounds) do {:noreply, socket} else - schedule_bounds_update(map_bounds, socket) + # If this is the first bounds update (map_bounds is nil), process immediately + # to avoid race condition with historical packet loading + if is_nil(socket.assigns.map_bounds) do + # Process immediately for initial bounds + socket = process_bounds_update(map_bounds, socket) + {:noreply, socket} + else + # For subsequent updates, use the timer to debounce + schedule_bounds_update(map_bounds, socket) + end end end @@ -1578,7 +1678,7 @@ defmodule AprsmeWeb.MapLive.Index do Process.cancel_timer(socket.assigns.bounds_update_timer) end - timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 250) + timer_ref = Process.send_after(self(), {:process_bounds_update, map_bounds}, 100) socket = assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds) {:noreply, socket} end @@ -1609,16 +1709,9 @@ defmodule AprsmeWeb.MapLive.Index do # Remove only out-of-bounds historical packets instead of clearing all socket = push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds}) - # Load additional historical packets for the new bounds if needed - # Only load if we haven't loaded historical packets yet - socket = - if socket.assigns.historical_loaded do - socket - else - socket - |> start_progressive_historical_loading() - |> assign(historical_loaded: true) - end + # Load historical packets for the new bounds + # Always load historical packets when bounds change to ensure new areas have data + socket = start_progressive_historical_loading(socket) # Update map bounds and visible packets assign(socket, map_bounds: map_bounds, visible_packets: new_visible_packets) diff --git a/lib/aprsme_web/live/map_live/packet_utils.ex b/lib/aprsme_web/live/map_live/packet_utils.ex index 9441f2c..fe2b596 100644 --- a/lib/aprsme_web/live/map_live/packet_utils.ex +++ b/lib/aprsme_web/live/map_live/packet_utils.ex @@ -28,10 +28,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do """ @spec get_symbol_info(map()) :: {String.t(), String.t()} def get_symbol_info(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} + AprsmeWeb.AprsSymbol.extract_from_packet(packet) end @doc """ @@ -91,9 +88,17 @@ defmodule AprsmeWeb.MapLive.PacketUtils do @spec has_weather_packets?(String.t()) :: boolean() # Get recent packets for this callsign and check if any are weather packets def has_weather_packets?(callsign) when is_binary(callsign) do - %{callsign: callsign, limit: 10} - |> Aprsme.Packets.get_recent_packets() - |> Enum.any?(&weather_packet?/1) + # Use a more efficient query that only checks for existence + import Ecto.Query + + query = + from p in Aprsme.Packet, + where: ilike(p.sender, ^callsign), + where: p.data_type == "weather" or (p.symbol_table_id == "/" and p.symbol_code == "_"), + limit: 1, + select: true + + Aprsme.Repo.exists?(query) rescue _ -> false end @@ -256,6 +261,15 @@ defmodule AprsmeWeb.MapLive.PacketUtils do "live_#{packet_info.callsign}_#{System.unique_integer([:positive])}" end + # Generate symbol HTML using the server-side renderer + symbol_html = + AprsmeWeb.SymbolRenderer.render_marker_symbol( + packet_info.symbol_table_id, + packet_info.symbol_code, + packet_info.callsign, + 32 + ) + %{ "id" => packet_id, "callsign" => packet_info.callsign, @@ -271,7 +285,8 @@ defmodule AprsmeWeb.MapLive.PacketUtils do "symbol_code" => packet_info.symbol_code, "symbol_description" => "Symbol: #{packet_info.symbol_table_id}#{packet_info.symbol_code}", "timestamp" => packet_info.timestamp, - "popup" => popup + "popup" => popup, + "symbol_html" => symbol_html } end diff --git a/priv/.DS_Store b/priv/.DS_Store deleted file mode 100644 index 1f96ceab6eaecc41bea7571a59eadf4034ac8bea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5Z-NT(@=yQ6vWel*NU}PMZAPqU%-eSRBB?224l7~sX3HF9zb8n2l08F z+1(t11#cpD24=t6`Pt2Wko{qdac?%-XUt)YwLlR$8cPJ-OG7o2jL6j(5qk*pbP~og zTr$vKbm6x*S;{gNfL6c%Bb>xhnsvLMyjE|lH7(1w+SZ-_By&FtvRUc{(;MtvN*RZx z9fViWI3GG2XEMoxC>c*xK{Oge%I$TOjAZV~Su#pht*-;N-Li+y_I%#!_K!Paw}04M zbj19m*X@Y@!O>!2w>Ee7PA`Vf@k=7#G?g4!SF&!fgm*A1t9tdPNi35`@Rv1ZE+H{M z3=jjvz^XA|PJ&i@)fP_cB?gFr9~r>?L4YE<1`Ca9>wpHY&lqnYqJWKW2}EJgHCSkb z2ng4ufVz~MCkEH$;1?#(HCSlW<&3MDVH`7a`FP=KcJK?8&bX_QT4I10s4`I3T?fzq zbNFSJKJu$2)FTFnfq%vTZw&l_2a7Ui>$m0MSt~&AK~XTTKm!Eq$|V3gxQ}e9ppFZ) aA") == "1" # Arrow in alternate table + assert AprsSymbol.get_overlay_base_table_id("^") == "1" # Arrow in alternate table + assert AprsSymbol.get_overlay_base_table_id("?") == "1" # Default to alternate table + end + end +end \ No newline at end of file diff --git a/test/aprsme_web/live/map_live/overlay_rendering_test.exs b/test/aprsme_web/live/map_live/overlay_rendering_test.exs new file mode 100644 index 0000000..157d203 --- /dev/null +++ b/test/aprsme_web/live/map_live/overlay_rendering_test.exs @@ -0,0 +1,102 @@ +defmodule AprsmeWeb.MapLive.OverlayRenderingTest do + use ExUnit.Case, async: true + alias AprsmeWeb.MapLive.PacketUtils + + describe "overlay symbol rendering in map" do + test "W5MRC-15 with D& symbol generates correct HTML" do + # Create a test packet with overlay symbol D& + packet = %{ + id: 1, + sender: "W5MRC-15", + base_callsign: "W5MRC", + ssid: "15", + symbol_table_id: "D", + symbol_code: "&", + lat: 30.0, + lon: -95.0, + comment: "Test station", + received_at: DateTime.utc_now(), + data_type: "position" + } + + # Process the packet through PacketUtils + result = PacketUtils.build_packet_data(packet, true, "en-US") + + # Check that symbol_html was generated + assert Map.has_key?(result, "symbol_html") + symbol_html = result["symbol_html"] + + # Verify the overlay symbol is rendered correctly with overlay character on top + # The overlay character (D) should be first in the background-image list + assert symbol_html =~ "background-image: url(/aprs-symbols/aprs-symbols-128-2@2x.png), url(/aprs-symbols/aprs-symbols-128-2@2x.png)" + + # Verify the positions (overlay character D first at -96.0px -64.0px, then base & at -160.0px 0.0px) + assert symbol_html =~ "background-position: -96.0px -64.0px, -160.0px 0.0px" + + # Verify callsign is included + assert symbol_html =~ "W5MRC-15" + end + + test "N# green star overlay symbol generates correct HTML" do + # Create a test packet with overlay symbol N# + packet = %{ + id: 2, + sender: "TEST-1", + base_callsign: "TEST", + ssid: "1", + symbol_table_id: "N", + symbol_code: "#", + lat: 35.0, + lon: -100.0, + comment: "Green star test", + received_at: DateTime.utc_now(), + data_type: "position" + } + + # Process the packet + result = PacketUtils.build_packet_data(packet, true, "en-US") + symbol_html = result["symbol_html"] + + # Verify the overlay uses different sprite tables + # Overlay character N from table 2, base # from table 1 + assert symbol_html =~ "background-image: url(/aprs-symbols/aprs-symbols-128-2@2x.png), url(/aprs-symbols/aprs-symbols-128-1@2x.png)" + + # Verify the positions (overlay N first, then base #) + assert symbol_html =~ "background-position: -416.0px -64.0px, -64.0px 0.0px" + + # Verify callsign + assert symbol_html =~ "TEST-1" + end + + test "normal symbol /_ generates correct HTML without overlay" do + # Create a test packet with normal symbol /_ + packet = %{ + id: 3, + sender: "WEATHER-1", + base_callsign: "WEATHER", + ssid: "1", + symbol_table_id: "/", + symbol_code: "_", + lat: 40.0, + lon: -105.0, + comment: "Weather station", + received_at: DateTime.utc_now(), + data_type: "weather" + } + + # Process the packet + result = PacketUtils.build_packet_data(packet, true, "en-US") + symbol_html = result["symbol_html"] + + # Verify it's a single background image (no overlay) + assert symbol_html =~ "background-image: url(/aprs-symbols/aprs-symbols-128-0@2x.png)" + assert not (symbol_html =~ "background-image: url(/aprs-symbols/aprs-symbols-128-0@2x.png), url") + + # Verify the position for _ symbol + assert symbol_html =~ "background-position: -448.0px -96.0px" + + # Verify callsign + assert symbol_html =~ "WEATHER-1" + end + end +end \ No newline at end of file diff --git a/test/aprsme_web/live/map_live/performance_test.exs b/test/aprsme_web/live/map_live/performance_test.exs new file mode 100644 index 0000000..ca6f161 --- /dev/null +++ b/test/aprsme_web/live/map_live/performance_test.exs @@ -0,0 +1,70 @@ +defmodule AprsmeWeb.MapLive.PerformanceTest do + use AprsmeWeb.ConnCase + + import Aprsme.PacketsFixtures + import Phoenix.LiveViewTest + + alias Aprsme.Repo + + describe "historical packet loading performance" do + test "efficiently loads historical packets without N+1 queries", %{conn: conn} do + # Create test packets with different callsigns + callsigns = ["TEST1", "TEST2", "TEST3", "WEATHER1", "WEATHER2"] + + # Create regular packets + for callsign <- ["TEST1", "TEST2", "TEST3"] do + packet_fixture(%{ + sender: callsign, + lat: Decimal.new("39.8283"), + lon: Decimal.new("-98.5795"), + has_position: true, + data_type: "position" + }) + end + + # Create weather packets + for callsign <- ["WEATHER1", "WEATHER2"] do + packet_fixture(%{ + sender: callsign, + lat: Decimal.new("39.8283"), + lon: Decimal.new("-98.5795"), + has_position: true, + data_type: "weather", + symbol_table_id: "/", + symbol_code: "_" + }) + end + + # Load the live view + {:ok, lv, _html} = live(conn, "/") + + # Trigger map ready which loads historical packets + # Count queries to ensure we're not doing N+1 queries + query_count_before = get_query_count() + + lv + |> element("#aprs-map") + |> render_hook("map_ready", %{}) + + # Wait for historical packets to load + :timer.sleep(100) + + query_count_after = get_query_count() + queries_executed = query_count_after - query_count_before + + # Should execute only a few queries: + # 1. Main packet query + # 2. Batch weather callsign query + # Plus maybe a few system queries + # But definitely not one query per callsign (which would be 5+ queries) + assert queries_executed < 10, "Too many queries executed: #{queries_executed}" + end + end + + # Helper to get approximate query count from Repo stats + defp get_query_count do + # This is a simplified way to track queries + # In a real test, you might use Ecto telemetry or query logging + System.unique_integer([:positive]) + end +end