fix all the main map display issues
This commit is contained in:
parent
57fe2b0182
commit
514d1b66d5
16 changed files with 1349 additions and 413 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
|
|
@ -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.
|
||||
287
assets/js/map.ts
287
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<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;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
332
lib/aprsme_web/aprs_symbol.ex
Normal file
332
lib/aprsme_web/aprs_symbol.ex
Normal 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
|
||||
102
lib/aprsme_web/live/components/symbol_renderer.ex
Normal file
102
lib/aprsme_web/live/components/symbol_renderer.ex
Normal 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
|
||||
|
|
@ -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, ",")
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
BIN
priv/.DS_Store
vendored
Binary file not shown.
|
|
@ -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
|
||||
142
test/aprsme_web/aprs_symbol_test.exs
Normal file
142
test/aprsme_web/aprs_symbol_test.exs
Normal 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
|
||||
102
test/aprsme_web/live/map_live/overlay_rendering_test.exs
Normal file
102
test/aprsme_web/live/map_live/overlay_rendering_test.exs
Normal 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
|
||||
70
test/aprsme_web/live/map_live/performance_test.exs
Normal file
70
test/aprsme_web/live/map_live/performance_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue