fix all the main map display issues

This commit is contained in:
Graham McIntire 2025-07-09 16:20:29 -05:00
parent 57fe2b0182
commit 514d1b66d5
No known key found for this signature in database
16 changed files with 1349 additions and 413 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -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.

View file

@ -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<string, string> = {
"/": "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 = `<div style="
width: 32px;
height: 32px;
background-image: url(${spriteFile});
background-position: ${x / 4}px ${y / 4}px;
background-size: 512px 192px;
background-repeat: no-repeat;
image-rendering: pixelated;
opacity: 1.0;
overflow: hidden;
" data-symbol-table="${symbolTableId}" data-symbol-code="${symbolCode}" data-sprite-position="${x},${y}" data-expected-index="${index}" title="Symbol: ${symbolCode} (${charCode}) at row ${row}, col ${column}"></div>`;
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 = `<div style="
width: 8px;
height: 8px;
@ -1231,51 +1276,32 @@ 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);
// Fallback: Use server-generated symbol HTML if available
if (data.symbol_html) {
return L.divIcon({
html: data.symbol_html,
className: "",
iconSize: [32, 32],
iconAnchor: [16, 16],
});
}
// 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<string, string> = {
"/": "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 = `<div style="
width: 32px;
height: 32px;
background-image: url(${spriteFile});
background-position: ${x / 4}px ${y / 4}px;
background-size: 512px 192px;
background-repeat: no-repeat;
image-rendering: pixelated;
opacity: ${data.historical && data.is_most_recent_for_callsign ? "0.9" : "1.0"};
overflow: hidden;
" data-symbol-table="${symbolTableId}" data-symbol-code="${symbolCode}" data-sprite-position="${x},${y}" data-expected-index="${index}" title="Symbol: ${symbolCode} (${charCode}) at row ${row}, col ${column}"></div>`;
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}"></div>`;
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 = `<div class="aprs-popup">
<div class="aprs-callsign"><strong><a href="/${callsign}">${callsign}</a></strong> <a href="/info/${callsign}" class="aprs-info-link">info</a></div>`;
<div class="aprs-callsign"><strong><a href="/${callsign}" class="aprs-lv-link">${callsign}</a></strong> <a href="/info/${callsign}" class="aprs-lv-link aprs-info-link">info</a></div>`;
// Removed symbol info from popup
// content += `<div class="aprs-symbol-info">${symbolDesc}</div>`;
@ -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;
},
};

View file

@ -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

View file

@ -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)

View file

@ -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")
"<div style=\"position: relative; width: 32px; height: 32px; display: flex; align-items: center;\">..."
"""
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)
"""
<div style="
position: relative;
width: #{size}px;
height: #{size}px;
background-image: url(#{overlay_sprite_info.sprite_file}), url(#{sprite_info.sprite_file});
background-position: #{overlay_sprite_info.background_position}, #{sprite_info.background_position};
background-size: #{overlay_sprite_info.background_size}, #{sprite_info.background_size};
background-repeat: no-repeat, no-repeat;
image-rendering: pixelated;
" title="#{symbol_table}#{symbol_code}">
</div>
"""
else
"""
<div style="
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;
" title="#{symbol_table}#{symbol_code}"></div>
"""
end
if callsign do
"""
<div style="position: relative; width: #{size}px; height: #{size}px; display: flex; align-items: center;">
#{symbol_html}
<div style="
position: absolute;
left: #{size + 4}px;
top: 50%;
transform: translateY(-50%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
font-weight: 600;
color: #1e40af;
background-color: rgba(255, 255, 255, 0.9);
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
border: 1px solid rgba(30, 64, 175, 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
pointer-events: none;
z-index: 1000;
">#{callsign}</div>
</div>
"""
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

View file

@ -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"""
<div
class={["aprs-symbol-container", @class]}
style={"position: relative; display: inline-block; width: #{@size}px; height: #{@size}px;"}
>
<div
class="aprs-symbol"
style={"
width: #{@size}px;
height: #{@size}px;
background-image: url(#{@sprite_file});
background-position: #{@background_position};
background-size: #{@background_size};
background-repeat: no-repeat;
image-rendering: pixelated;
"}
title={@symbol_title}
>
</div>
<%= if @callsign do %>
<div
class="aprs-callsign-label"
style="
position: absolute;
left: #{@size + 4}px;
top: 50%;
transform: translateY(-50%);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
font-weight: 600;
color: #1e40af;
background-color: rgba(255, 255, 255, 0.9);
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
border: 1px solid rgba(30, 64, 175, 0.3);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
pointer-events: none;
z-index: 1000;
"
>
{@callsign}
</div>
<% end %>
</div>
"""
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

View file

@ -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 """
<div style="
position: relative;
width: #{size}px;
height: #{size}px;
background-image: url(#{overlay_sprite_info.sprite_file}), url(#{sprite_info.sprite_file});
background-position: #{overlay_sprite_info.background_position}, #{sprite_info.background_position};
background-size: #{overlay_sprite_info.background_size}, #{sprite_info.background_size};
background-repeat: no-repeat, no-repeat;
image-rendering: pixelated;
display: inline-block;
vertical-align: middle;
margin-bottom: -6px;
">
</div>
"""
else
# Use style rendering for non-overlay symbols
raw """
<div style="#{AprsSymbol.render_style(symbol_table_id, symbol_code, size)}"></div>
"""
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, ",")

View file

@ -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 %>
<div class="min-h-screen bg-base-200">
<!-- Page header -->
@ -31,31 +14,10 @@
{@callsign}
</span>
<%= 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" %>
<div
style={"width: 32px; height: 32px; background-image: url(#{symbol_img}); background-position: #{x / 4}px #{y / 4}px; background-size: 512px 192px; background-repeat: no-repeat; image-rendering: pixelated; opacity: 1.0; display: inline-block; vertical-align: middle; margin-bottom: -6px;"}
title={"Symbol: #{symbol_code} (#{symbol_code_ord}) at row #{row}, col #{column}"}
>
<% {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)}" %>
<div title={display_symbol}>
<%= render_symbol_html(@packet) %>
</div>
<% end %>
</div>
@ -289,33 +251,10 @@
{ssid_info.callsign}
</.link>
<%= 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" %>
<div
style={"width: 32px; height: 32px; background-image: url(#{symbol_img}); background-position: #{x / 4}px #{y / 4}px; background-size: 512px 192px; background-repeat: no-repeat; image-rendering: pixelated; opacity: 1.0; display: inline-block; vertical-align: middle; margin-bottom: -6px;"}
title={"Symbol: #{symbol_code} (#{symbol_code_ord}) at row #{row}, col #{column}"}
>
<% {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)}" %>
<div title={display_symbol}>
<%= render_symbol_html(ssid_info.packet) %>
</div>
<% end %>
</div>
@ -414,31 +353,10 @@
{neighbor.callsign}
</.link>
<%= 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" %>
<div
style={"width: 32px; height: 32px; background-image: url(#{symbol_img}); background-position: #{x / 4}px #{y / 4}px; background-size: 512px 192px; background-repeat: no-repeat; image-rendering: pixelated; opacity: 1.0; display: inline-block; vertical-align: middle; margin-bottom: -6px;"}
title={"Symbol: #{symbol_code} (#{symbol_code_ord}) at row #{row}, col #{column}"}
>
<% {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)}" %>
<div title={display_symbol}>
<%= render_symbol_html(neighbor.packet) %>
</div>
<% end %>
</div>

View file

@ -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)

View file

@ -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

BIN
priv/.DS_Store vendored

Binary file not shown.

View file

@ -0,0 +1,30 @@
defmodule Aprsme.Repo.Migrations.OptimizeWeatherPacketLookups do
use Ecto.Migration
@disable_ddl_transaction true
@disable_migration_lock true
def up do
# Create a covering index for the has_weather_packets? query
# This index covers all fields needed by the query, avoiding table lookups
execute """
CREATE INDEX CONCURRENTLY IF NOT EXISTS packets_sender_weather_lookup_idx
ON packets (UPPER(sender), received_at DESC)
INCLUDE (data_type, symbol_table_id, symbol_code)
WHERE data_type = 'weather'
OR (symbol_table_id = '/' AND symbol_code = '_')
OR (symbol_table_id = '\\' AND symbol_code = '_')
"""
# Also create a more general index for all sender lookups with weather data
execute """
CREATE INDEX CONCURRENTLY IF NOT EXISTS packets_sender_received_at_covering_idx
ON packets (UPPER(sender), received_at DESC)
INCLUDE (data_type, symbol_table_id, symbol_code)
"""
end
def down do
execute "DROP INDEX IF EXISTS packets_sender_received_at_covering_idx"
execute "DROP INDEX IF EXISTS packets_sender_weather_lookup_idx"
end
end

View file

@ -0,0 +1,142 @@
defmodule AprsmeWeb.AprsSymbolTest do
use ExUnit.Case, async: true
alias AprsmeWeb.AprsSymbol
describe "overlay symbol rendering" do
test "D& should show black diamond background from table 1" do
# Test the get_sprite_info for overlay symbol D&
sprite_info = AprsSymbol.get_sprite_info("D", "&")
# Should use table 1 for the diamond background (alternate table)
assert sprite_info.sprite_file == "/aprs-symbols/aprs-symbols-128-1@2x.png"
# Calculate expected position for & (ASCII 38)
# index = 38 - 33 = 5
# column = 5 % 16 = 5
# row = 5 / 16 = 0
# x = -5 * 128 = -640
# y = -0 * 128 = 0
# scaled: x/4 = -160, y/4 = 0
assert sprite_info.background_position == "-160.0px 0.0px"
end
test "N# should show green star background from table 1" do
# Test the get_sprite_info for overlay symbol N#
sprite_info = AprsSymbol.get_sprite_info("N", "#")
# Should use table 1 for the green star background
assert sprite_info.sprite_file == "/aprs-symbols/aprs-symbols-128-1@2x.png"
# Calculate expected position for # (ASCII 35)
# index = 35 - 33 = 2
# column = 2 % 16 = 2
# row = 2 / 16 = 0
# x = -2 * 128 = -256
# y = -0 * 128 = 0
# scaled: x/4 = -64, y/4 = 0
assert sprite_info.background_position == "-64.0px 0.0px"
end
test "overlay character sprite info for D" do
# Test getting the overlay character sprite for letter D
overlay_info = AprsSymbol.get_overlay_character_sprite_info("D")
# Should use table 2 for overlay characters
assert overlay_info.sprite_file == "/aprs-symbols/aprs-symbols-128-2@2x.png"
# Calculate expected position for D (ASCII 68)
# index = 68 - 33 = 35
# column = 35 % 16 = 3
# row = 35 / 16 = 2
# x = -3 * 128 = -384
# y = -2 * 128 = -256
# scaled: x/4 = -96, y/4 = -64
assert overlay_info.background_position == "-96.0px -64.0px"
end
test "overlay character sprite info for N" do
# Test getting the overlay character sprite for letter N
overlay_info = AprsSymbol.get_overlay_character_sprite_info("N")
# Should use table 2 for overlay characters
assert overlay_info.sprite_file == "/aprs-symbols/aprs-symbols-128-2@2x.png"
# Calculate expected position for N (ASCII 78)
# index = 78 - 33 = 45
# column = 45 % 16 = 13
# row = 45 / 16 = 2
# x = -13 * 128 = -1664
# y = -2 * 128 = -256
# scaled: x/4 = -416, y/4 = -64
assert overlay_info.background_position == "-416.0px -64.0px"
end
test "render_marker_html for overlay symbol D&" do
html = AprsSymbol.render_marker_html("D", "&", "W5MRC-15", 32)
# Should contain both background images (overlay first from table 2, base from table 1)
assert html =~ "background-image: url(/aprs-symbols/aprs-symbols-128-2@2x.png), url(/aprs-symbols/aprs-symbols-128-1@2x.png)"
# Should have both background positions (overlay first, then base)
assert html =~ "background-position: -96.0px -64.0px, -160.0px 0.0px"
# Should include the callsign
assert html =~ "W5MRC-15"
end
test "render_marker_html for overlay symbol N#" do
html = AprsSymbol.render_marker_html("N", "#", "TEST-1", 32)
# Should contain both background images (overlay first from table 2, base from table 1)
assert html =~ "background-image: url(/aprs-symbols/aprs-symbols-128-2@2x.png), url(/aprs-symbols/aprs-symbols-128-1@2x.png)"
# Should have both background positions (overlay first, then base)
assert html =~ "background-position: -416.0px -64.0px, -64.0px 0.0px"
# Should include the callsign
assert html =~ "TEST-1"
end
test "normal symbol /_" do
# Test a normal symbol from primary table
sprite_info = AprsSymbol.get_sprite_info("/", "_")
assert sprite_info.sprite_file == "/aprs-symbols/aprs-symbols-128-0@2x.png"
# Calculate expected position for _ (ASCII 95)
# index = 95 - 33 = 62
# column = 62 % 16 = 14
# row = 62 / 16 = 3
# x = -14 * 128 = -1792
# y = -3 * 128 = -384
# scaled: x/4 = -448, y/4 = -96
assert sprite_info.background_position == "-448.0px -96.0px"
end
test "alternate table symbol \\#" do
# Test a symbol from alternate table
sprite_info = AprsSymbol.get_sprite_info("\\", "#")
assert sprite_info.sprite_file == "/aprs-symbols/aprs-symbols-128-1@2x.png"
# Calculate expected position for # (ASCII 35)
# index = 35 - 33 = 2
# column = 2 % 16 = 2
# row = 2 / 16 = 0
# x = -2 * 128 = -256
# y = -0 * 128 = 0
# scaled: x/4 = -64, y/4 = 0
assert sprite_info.background_position == "-64.0px 0.0px"
end
test "get_overlay_base_table_id mapping" do
# Test the table mapping for overlay base symbols
assert AprsSymbol.get_overlay_base_table_id("#") == "1" # Green star in alternate table
assert AprsSymbol.get_overlay_base_table_id("&") == "1" # Diamond in alternate table
assert AprsSymbol.get_overlay_base_table_id("i") == "1" # Black square in alternate table
assert AprsSymbol.get_overlay_base_table_id(">") == "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

View file

@ -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

View file

@ -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