Merge pull request #5 from aprsme/improve-map-load-performance

Improve historical packet loading performance on map
This commit is contained in:
Graham McIntire 2025-07-09 17:16:56 -05:00 committed by GitHub
commit 79c0a0a43a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2843 additions and 533 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -97,12 +97,15 @@ jobs:
- name: Run Docker Scout vulnerability scan - name: Run Docker Scout vulnerability scan
uses: docker/scout-action@v1 uses: docker/scout-action@v1
continue-on-error: true
with: with:
command: quickview,cves command: cves
image: aprsme:${{ github.sha }} image: aprsme:${{ github.sha }}
output-format: sarif format: sarif
only-severities: critical,high only-severities: critical,high
sarif-file: scout-results.sarif sarif-file: scout-results.sarif
dockerhub-user: ${{ secrets.DOCKERHUB_USERNAME }}
dockerhub-password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Upload Docker Scout results - name: Upload Docker Scout results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -110,6 +113,7 @@ jobs:
with: with:
name: docker-scout-report name: docker-scout-report
path: scout-results.sarif path: scout-results.sarif
if-no-files-found: ignore
retention-days: 7 retention-days: 7
- name: Generate security report summary - name: Generate security report summary
@ -128,6 +132,9 @@ jobs:
echo '```' >> security-summary.md echo '```' >> security-summary.md
cat app-packages.txt >> security-summary.md || echo "No application vulnerabilities report available" >> security-summary.md cat app-packages.txt >> security-summary.md || echo "No application vulnerabilities report available" >> security-summary.md
echo '```' >> security-summary.md echo '```' >> security-summary.md
# Also output to GitHub Actions summary
cat security-summary.md >> $GITHUB_STEP_SUMMARY
- name: Upload security summary - name: Upload security summary
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@ -138,9 +145,18 @@ jobs:
retention-days: 14 retention-days: 14
- name: Check for critical vulnerabilities - name: Check for critical vulnerabilities
continue-on-error: true
run: | run: |
if grep -q "CRITICAL" trivy-results.sarif; then if grep -q "CRITICAL" trivy-results.sarif; then
echo "Critical vulnerabilities found in the Docker image." echo "::warning::Critical vulnerabilities found in the Docker image."
echo "Please review the scan results in the Security tab." echo "Please review the scan results in the Security tab and the uploaded reports."
echo ""
echo "Summary of critical vulnerabilities:"
grep -A 5 -B 5 "CRITICAL" trivy-results.sarif | head -50 || true
echo ""
echo "For full details, check the Security tab and artifact reports."
# Exit with error code but continue-on-error will prevent workflow failure
exit 1 exit 1
else
echo "No critical vulnerabilities found."
fi fi

View file

@ -29,7 +29,7 @@ jobs:
# Additional services can be defined here if required. # Additional services can be defined here if required.
services: services:
db: db:
image: postgres:16 image: postgis/postgis:17-3.5
ports: ["5432:5432"] ports: ["5432:5432"]
env: env:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres

View file

@ -27,6 +27,8 @@ This is an Elixir Phoenix LiveView application that serves as a real-time APRS (
- `mix credo` - Static code analysis and style checking - `mix credo` - Static code analysis and style checking
- `mix dialyzer` - Static type analysis (must run and fix errors/warnings) - `mix dialyzer` - Static type analysis (must run and fix errors/warnings)
- `mix sobelow` - Security vulnerability scanning - `mix sobelow` - Security vulnerability scanning
- **IMPORTANT**: Always run `mix format` before considering any task complete
- **MANDATORY**: Run `mix compile --warnings-as-errors` and ensure it passes before considering any task complete
### Assets (No Node.js) ### Assets (No Node.js)
- `mix assets.deploy` - Build and minify frontend assets (Tailwind CSS + ESBuild) - `mix assets.deploy` - Build and minify frontend assets (Tailwind CSS + ESBuild)
@ -90,9 +92,16 @@ Tests use comprehensive mocking to prevent external connections:
- Run `mix format` before committing - Run `mix format` before committing
- Address any compiler warnings - Address any compiler warnings
- Run `mix dialyzer` and fix all errors/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 - Use function composition over nested conditionals
- Write descriptive test names that explain behavior - 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 ## Deployment
The application supports Kubernetes deployment with manifests in `k8s/` directory and GitHub Actions CI/CD pipeline. Database migrations run automatically via init containers. 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

@ -27,6 +27,8 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("
// Import minimal APRS map hook // Import minimal APRS map hook
import MapAPRSMap from "./map"; import MapAPRSMap from "./map";
// Import error boundary hook
import ErrorBoundary from "./hooks/error_boundary";
// Responsive Slideover Hook // Responsive Slideover Hook
let ResponsiveSlideoverHook = { let ResponsiveSlideoverHook = {
@ -98,6 +100,7 @@ let Hooks = {};
Hooks.APRSMap = MapAPRSMap; Hooks.APRSMap = MapAPRSMap;
Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook; Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook;
Hooks.BodyClassHook = BodyClassHook; Hooks.BodyClassHook = BodyClassHook;
Hooks.ErrorBoundary = ErrorBoundary;
// Register weather chart hooks from TypeScript // Register weather chart hooks from TypeScript
import { WeatherChartHooks } from "./features/weather_charts"; import { WeatherChartHooks } from "./features/weather_charts";

View file

@ -0,0 +1,106 @@
// Error Boundary Hook for Phoenix LiveView
// Catches JavaScript errors and displays fallback content
const ErrorBoundary = {
mounted() {
// Store original error handler
this.originalErrorHandler = window.onerror;
this.originalUnhandledRejection = window.onunhandledrejection;
// Get content and fallback elements
this.content = this.el.querySelector('.error-boundary-content');
this.fallback = this.el.querySelector('.error-boundary-fallback');
// Set up error handlers for this component
this.setupErrorHandlers();
},
setupErrorHandlers() {
// Handle synchronous errors
window.onerror = (message, source, lineno, colno, error) => {
if (this.isErrorInComponent(error)) {
this.handleError(error || new Error(message));
return true; // Prevent default error handling
}
// Call original handler if error is not in this component
if (this.originalErrorHandler) {
return this.originalErrorHandler(message, source, lineno, colno, error);
}
return false;
};
// Handle async errors (unhandled promise rejections)
window.onunhandledrejection = (event) => {
if (this.isErrorInComponent(event.reason)) {
this.handleError(event.reason);
event.preventDefault();
return;
}
// Call original handler if error is not in this component
if (this.originalUnhandledRejection) {
this.originalUnhandledRejection(event);
}
};
},
isErrorInComponent(error) {
// Check if the error occurred within this component's DOM tree
if (!error) return false;
// If the error has a target element, check if it's within our component
if (error.target && this.el.contains(error.target)) {
return true;
}
// Check if the error stack trace contains references to our component's hooks or elements
if (error.stack) {
// Look for our component's ID in the stack trace
const componentId = this.el.id;
if (componentId && error.stack.includes(componentId)) {
return true;
}
// Check if the error originated from a hook within this component
const hooks = this.el.querySelectorAll('[phx-hook]');
for (let hook of hooks) {
const hookName = hook.getAttribute('phx-hook');
if (hookName && error.stack.includes(hookName)) {
return true;
}
}
}
// For now, we'll be conservative and only handle errors we're sure about
// In a more sophisticated implementation, you might analyze the error source
return false;
},
handleError(error) {
console.error('Error caught by boundary:', error);
// Hide content and show fallback
if (this.content) {
this.content.style.display = 'none';
}
if (this.fallback) {
this.fallback.classList.remove('hidden');
}
// Log error to server
this.pushEvent('error_boundary_triggered', {
message: error.message,
stack: error.stack,
component_id: this.el.id
});
},
destroyed() {
// Restore original error handlers
window.onerror = this.originalErrorHandler;
window.onunhandledrejection = this.originalUnhandledRejection;
}
};
export default ErrorBoundary;

View file

@ -1,84 +1,26 @@
// Declare Leaflet as a global variable // Import type definitions
declare const L: any; import type {
LiveViewHookContext,
MarkerData,
BoundsData,
CenterData,
MarkerState,
MapEventData
} from './types/map';
import type { Map as LeafletMap, Marker, TileLayer, LayerGroup, DivIcon, LatLngBounds } from 'leaflet';
// Declare Leaflet as a global variable with proper typing
declare const L: typeof import('leaflet');
declare const OverlappingMarkerSpiderfier: any; declare const OverlappingMarkerSpiderfier: any;
// Import trail management functionality // Import trail management functionality
import { TrailManager } from "./features/trail_manager"; import { TrailManager } from "./features/trail_manager";
// Import helper functions
import { parseTimestamp, getTrailId, saveMapState, safePushEvent, getLiveSocket } from "./map_helpers";
// APRS Map Hook - handles only basic map interaction // APRS Map Hook - handles only basic map interaction
// All data logic handled by LiveView // All data logic handled by LiveView
type LiveViewHookContext = {
el: HTMLElement & { _leaflet_id?: any };
pushEvent: (event: string, payload: any) => void;
handleEvent: (event: string, callback: Function) => void;
map?: any;
markers?: Map<string, any>;
markerStates?: Map<string, MarkerState>;
markerLayer?: any;
trailManager?: TrailManager;
boundsTimer?: ReturnType<typeof setTimeout>;
resizeHandler?: () => void;
errors?: string[];
initializationAttempts?: number;
maxInitializationAttempts?: number;
lastZoom?: number;
currentPopupMarkerId?: string | null;
oms?: any;
[key: string]: any;
};
interface MarkerData {
id: string;
lat: number;
lng: number;
callsign?: string;
comment?: string;
symbol_table_id?: string;
symbol_code?: string;
symbol_description?: string;
popup?: string;
historical?: boolean;
color?: string;
timestamp?: number;
is_most_recent_for_callsign?: boolean;
callsign_group?: string;
}
interface BoundsData {
north: number;
south: number;
east: number;
west: number;
}
interface CenterData {
lat: number;
lng: number;
}
interface MarkerState {
lat: number;
lng: number;
symbol_table: string;
symbol_code: string;
popup?: string;
historical?: boolean;
is_most_recent_for_callsign?: boolean;
callsign_group?: string;
callsign?: string;
}
interface MapEventData {
bounds?: BoundsData;
center?: CenterData;
zoom?: number;
id?: string;
callsign?: string;
lat?: number;
lng?: number;
markers?: MarkerData[];
}
let MapAPRSMap = { let MapAPRSMap = {
mounted() { mounted() {
@ -272,9 +214,9 @@ let MapAPRSMap = {
self.sendBoundsToServer(); self.sendBoundsToServer();
// Start periodic cleanup of old trail positions (every 5 minutes) // Start periodic cleanup of old trail positions (every 5 minutes)
setInterval( self.cleanupInterval = setInterval(
() => { () => {
if (self.trailManager) { if (!self.isDestroyed && self.trailManager) {
self.trailManager.cleanupOldPositions(); self.trailManager.cleanupOldPositions();
} }
}, },
@ -285,23 +227,32 @@ let MapAPRSMap = {
} }
}); });
// Track map event handlers for cleanup
self.mapEventHandlers = new Map();
self.isDestroyed = false;
// Send bounds to LiveView when map moves // Send bounds to LiveView when map moves
self.map!.on("moveend", () => { const moveEndHandler = () => {
// Skip if this is a programmatic move from the server
if (self.programmaticMoveId) {
return;
}
if (self.boundsTimer) clearTimeout(self.boundsTimer); if (self.boundsTimer) clearTimeout(self.boundsTimer);
self.boundsTimer = setTimeout(() => { self.boundsTimer = setTimeout(() => {
self.sendBoundsToServer(); saveMapState(self.map, self.pushEvent);
// Save map state
const center = self.map.getCenter();
const zoom = self.map.getZoom();
localStorage.setItem(
"aprs_map_state",
JSON.stringify({ lat: center.lat, lng: center.lng, zoom }),
);
}, 300); }, 300);
}); };
self.map!.on("moveend", moveEndHandler);
self.mapEventHandlers!.set("moveend", moveEndHandler);
// Handle zoom changes with optimization for large zoom differences // Handle zoom changes with optimization for large zoom differences
self.map!.on("zoomend", () => { const zoomEndHandler = () => {
// Skip if this is a programmatic move from the server
if (self.programmaticMoveId) {
return;
}
if (self.boundsTimer) clearTimeout(self.boundsTimer); if (self.boundsTimer) clearTimeout(self.boundsTimer);
self.boundsTimer = setTimeout(() => { self.boundsTimer = setTimeout(() => {
const currentZoom = self.map!.getZoom(); const currentZoom = self.map!.getZoom();
@ -313,17 +264,13 @@ let MapAPRSMap = {
self.pushEvent("refresh_markers", {}); self.pushEvent("refresh_markers", {});
} }
self.sendBoundsToServer();
self.lastZoom = currentZoom; self.lastZoom = currentZoom;
// Save map state // Save map state and update URL
const center = self.map.getCenter(); saveMapState(self.map, self.pushEvent);
const zoom = self.map.getZoom();
localStorage.setItem(
"aprs_map_state",
JSON.stringify({ lat: center.lat, lng: center.lng, zoom }),
);
}, 300); }, 300);
}); };
self.map!.on("zoomend", zoomEndHandler);
self.mapEventHandlers!.set("zoomend", zoomEndHandler);
// Handle resize // Handle resize
self.resizeHandler = () => { self.resizeHandler = () => {
@ -354,6 +301,9 @@ let MapAPRSMap = {
// LiveView event handlers // LiveView event handlers
self.setupLiveViewHandlers(); self.setupLiveViewHandlers();
// Set up event delegation for popup navigation links
self.setupPopupNavigation();
if (typeof OverlappingMarkerSpiderfier !== "undefined") { if (typeof OverlappingMarkerSpiderfier !== "undefined") {
self.oms = new OverlappingMarkerSpiderfier(self.map); self.oms = new OverlappingMarkerSpiderfier(self.map);
} }
@ -390,6 +340,35 @@ let MapAPRSMap = {
} }
}, },
setupPopupNavigation() {
const self = this as unknown as LiveViewHookContext;
// Store the event handler so we can remove it later
self.popupNavigationHandler = (e: Event) => {
const target = e.target as HTMLElement;
// Check if clicked element or its parent is a LiveView navigation link
const navLink = target.closest('.aprs-lv-link') as HTMLAnchorElement;
if (navLink && navLink.href) {
e.preventDefault();
e.stopPropagation();
// Use Phoenix LiveView's built-in navigation
const liveSocket = getLiveSocket();
if (liveSocket) {
liveSocket.pushHistoryPatch(navLink.href, "push", navLink);
} else {
// Fallback to regular navigation if LiveView socket not available
window.location.href = navLink.href;
}
}
};
// Use event delegation to handle clicks on popup navigation links
document.addEventListener('click', self.popupNavigationHandler);
},
setupLiveViewHandlers() { setupLiveViewHandlers() {
const self = this as unknown as LiveViewHookContext; const self = this as unknown as LiveViewHookContext;
// Add single marker // Add single marker
@ -452,6 +431,23 @@ let MapAPRSMap = {
// Use a slight delay to ensure map is ready // Use a slight delay to ensure map is ready
setTimeout(() => { setTimeout(() => {
if (self.map) { if (self.map) {
// Generate a unique ID for this programmatic move
const moveId = `move_${Date.now()}_${Math.random()}`;
self.programmaticMoveId = moveId;
// Clear any existing timeout
if (self.programmaticMoveTimeout) {
clearTimeout(self.programmaticMoveTimeout);
}
// Set a timeout to clear the programmatic move flag
// This ensures we don't block user interactions indefinitely
self.programmaticMoveTimeout = setTimeout(() => {
if (self.programmaticMoveId === moveId) {
self.programmaticMoveId = undefined;
}
}, 1500);
self.map.setView([lat, lng], zoom, { self.map.setView([lat, lng], zoom, {
animate: true, animate: true,
duration: 1, duration: 1,
@ -581,14 +577,16 @@ let MapAPRSMap = {
const prevMarker = self.markers!.get(self.currentPopupMarkerId); const prevMarker = self.markers!.get(self.currentPopupMarkerId);
if (prevMarker && prevMarker.closePopup) prevMarker.closePopup(); if (prevMarker && prevMarker.closePopup) prevMarker.closePopup();
} }
// Re-add marker with openPopup flag (will open after add) // Try to open popup directly first if marker exists
const markerData = self.markerStates!.get(data.id); const marker = self.markers!.get(data.id);
if (markerData) { if (marker && marker.openPopup) {
self.addMarker({ ...markerData, id: data.id, openPopup: true }); marker.openPopup();
} else { } else {
// fallback: try to open popup directly if marker exists // Fallback: re-add marker with openPopup flag if it doesn't exist
const marker = self.markers!.get(data.id); const markerData = self.markerStates!.get(data.id);
if (marker && marker.openPopup) marker.openPopup(); if (markerData) {
self.addMarker({ ...markerData, id: data.id, openPopup: true });
}
} }
self.currentPopupMarkerId = data.id; self.currentPopupMarkerId = data.id;
}); });
@ -620,16 +618,43 @@ let MapAPRSMap = {
packetsByCallsign.forEach((packets, callsign) => { packetsByCallsign.forEach((packets, callsign) => {
// Sort by timestamp (oldest first) to ensure proper trail line drawing // Sort by timestamp (oldest first) to ensure proper trail line drawing
const sortedPackets = packets.sort((a, b) => { const sortedPackets = packets.sort((a, b) => {
const timeA = a.timestamp const timeA = parseTimestamp(a.timestamp);
? typeof a.timestamp === "number" const timeB = parseTimestamp(b.timestamp);
? a.timestamp return timeA - timeB;
: new Date(a.timestamp).getTime() });
: 0;
const timeB = b.timestamp // Add markers in chronological order
? typeof b.timestamp === "number" sortedPackets.forEach((packet) => {
? b.timestamp self.addMarker({
: new Date(b.timestamp).getTime() ...packet,
: 0; historical: true,
popup: packet.popup || self.buildPopupContent(packet),
});
});
});
}
});
// Handle progressive loading of historical packets (batch processing)
self.handleEvent("add_historical_packets_batch", (data: { packets: MarkerData[], batch: number, is_final: boolean }) => {
if (data.packets && Array.isArray(data.packets)) {
// Process all packets immediately for maximum speed
const packetsByCallsign = new Map<string, MarkerData[]>();
data.packets.forEach((packet) => {
const callsign = packet.callsign_group || packet.callsign || packet.id;
if (!packetsByCallsign.has(callsign)) {
packetsByCallsign.set(callsign, []);
}
packetsByCallsign.get(callsign)!.push(packet);
});
// Process each callsign group in chronological order (oldest first)
packetsByCallsign.forEach((packets, callsign) => {
// Sort by timestamp (oldest first) to ensure proper trail line drawing
const sortedPackets = packets.sort((a, b) => {
const timeA = parseTimestamp(a.timestamp);
const timeB = parseTimestamp(b.timestamp);
return timeA - timeB; return timeA - timeB;
}); });
@ -777,13 +802,9 @@ let MapAPRSMap = {
if (positionChanged && self.trailManager) { if (positionChanged && self.trailManager) {
// Position changed, update trail // Position changed, update trail
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
const timestamp = data.timestamp const timestamp = parseTimestamp(data.timestamp);
? typeof data.timestamp === "string" // Use callsign_group for proper trail grouping
? new Date(data.timestamp).getTime() const trailId = getTrailId(data);
: data.timestamp
: Date.now();
// Use callsign_group for proper trail grouping - prioritize callsign_group, then callsign, then id
const trailId = data.callsign_group || data.callsign || data.id;
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
} }
@ -810,16 +831,16 @@ let MapAPRSMap = {
if (data.popup) { if (data.popup) {
marker.bindPopup(data.popup, { autoPan: false }); 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", () => { marker.on("popupclose", () => {
self.pushEvent("popup_closed", {}); safePushEvent(self.pushEvent, "popup_closed", {});
}); });
} }
// Handle marker click // Handle marker click
marker.on("click", () => { marker.on("click", () => {
if (marker.openPopup) marker.openPopup(); if (marker.openPopup) marker.openPopup();
self.pushEvent("marker_clicked", { safePushEvent(self.pushEvent, "marker_clicked", {
id: data.id, id: data.id,
callsign: data.callsign, callsign: data.callsign,
lat: lat, lat: lat,
@ -827,11 +848,6 @@ let MapAPRSMap = {
}); });
}); });
// Handle popup close events
marker.on("popupclose", () => {
self.pushEvent("popup_closed", {});
});
// Mark historical markers for identification // Mark historical markers for identification
if (data.historical) { if (data.historical) {
(marker as any)._isHistorical = true; (marker as any)._isHistorical = true;
@ -864,13 +880,9 @@ let MapAPRSMap = {
// Initialize trail for new marker - always add to trail for line drawing // Initialize trail for new marker - always add to trail for line drawing
if (self.trailManager) { if (self.trailManager) {
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
const timestamp = data.timestamp const timestamp = parseTimestamp(data.timestamp);
? typeof data.timestamp === "string" // Use callsign_group for proper trail grouping
? new Date(data.timestamp).getTime() const trailId = getTrailId(data);
: data.timestamp
: Date.now();
// Use callsign_group for proper trail grouping - prioritize callsign_group, then callsign, then id
const trailId = data.callsign_group || data.callsign || data.id;
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
} }
@ -929,13 +941,9 @@ let MapAPRSMap = {
existingMarker.setLatLng([lat, lng]); existingMarker.setLatLng([lat, lng]);
if (self.trailManager) { if (self.trailManager) {
const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign; const isHistoricalDot = data.historical && !data.is_most_recent_for_callsign;
const timestamp = data.timestamp const timestamp = parseTimestamp(data.timestamp);
? typeof data.timestamp === "string" // Use callsign_group for proper trail grouping
? new Date(data.timestamp).getTime() const trailId = getTrailId(data);
: data.timestamp
: Date.now();
// Use callsign_group for proper trail grouping - prioritize callsign_group, then callsign, then id
const trailId = data.callsign_group || data.callsign || data.id;
self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot); self.trailManager.addPosition(trailId, lat, lng, timestamp, isHistoricalDot);
} }
} }
@ -1082,57 +1090,37 @@ let MapAPRSMap = {
} }
}); });
} }
// For current packets or most recent historical packets, show the full APRS symbol icon // Use server-generated symbol HTML if available
const symbolTableId = data.symbol_table_id || "/"; if (data.symbol_html) {
const symbolCode = getValidSymbolCode(data.symbol_code, symbolTableId); return L.divIcon({
html: data.symbol_html,
// Map symbol table identifier to correct table index per hessu/aprs-symbols className: "",
// Primary table: / (0) iconSize: [120, 32], // Increased width to accommodate callsign label
// Alternate table: \ (1) iconAnchor: [16, 16],
// 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],
});
} }
// For historical packets that are not the most recent for their callsign, // 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) { 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=" const iconHtml = `<div style="
width: 8px; width: 8px;
height: 8px; height: 8px;
@ -1151,51 +1139,32 @@ let MapAPRSMap = {
}); });
} }
// For current packets or most recent historical packets, show the full APRS symbol icon // Fallback: Use server-generated symbol HTML if available
const symbolTableId = data.symbol_table_id || "/"; if (data.symbol_html) {
const symbolCode = getValidSymbolCode(data.symbol_code, symbolTableId); 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 // Final fallback: Simple dot
// 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=" const iconHtml = `<div style="
width: 32px; width: 8px;
height: 32px; height: 8px;
background-image: url(${spriteFile}); background-color: #2563eb;
background-position: ${x / 4}px ${y / 4}px; border: 2px solid #FFFFFF;
background-size: 512px 192px; border-radius: 50%;
background-repeat: no-repeat; opacity: 0.8;
image-rendering: pixelated; box-shadow: 0 0 2px rgba(0,0,0,0.3);
opacity: ${data.historical && data.is_most_recent_for_callsign ? "0.9" : "1.0"}; " title="APRS Station: ${data.callsign}"></div>`;
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({ return L.divIcon({
html: iconHtml, html: iconHtml,
className: "", // Remove class to avoid CSS conflicts className: "",
iconSize: [32, 32], iconSize: [12, 12],
iconAnchor: [16, 16], iconAnchor: [6, 6],
}); });
}, },
@ -1207,7 +1176,7 @@ let MapAPRSMap = {
// const symbolDesc = data.symbol_description || `Symbol: ${symbolTableId}${symbolCode}`; // const symbolDesc = data.symbol_description || `Symbol: ${symbolTableId}${symbolCode}`;
let content = `<div class="aprs-popup"> 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 // Removed symbol info from popup
// content += `<div class="aprs-symbol-info">${symbolDesc}</div>`; // content += `<div class="aprs-symbol-info">${symbolDesc}</div>`;
@ -1216,14 +1185,10 @@ let MapAPRSMap = {
} }
if (data.timestamp) { if (data.timestamp) {
let date; const timestamp = parseTimestamp(data.timestamp);
if (typeof data.timestamp === "number") { const date = new Date(timestamp);
date = new Date(data.timestamp * 1000);
} else if (typeof data.timestamp === "string") { if (!isNaN(date.getTime())) {
date = new Date(data.timestamp);
}
if (date && !isNaN(date.getTime())) {
content += `<div class="aprs-timestamp">${date.toISOString()}</div>`; content += `<div class="aprs-timestamp">${date.toISOString()}</div>`;
} }
} }
@ -1234,12 +1199,75 @@ let MapAPRSMap = {
destroyed() { destroyed() {
const self = this as unknown as LiveViewHookContext; const self = this as unknown as LiveViewHookContext;
// Mark as destroyed immediately
self.isDestroyed = true;
// Disable pushEvent to prevent any events from being sent during cleanup
const originalPushEvent = self.pushEvent;
self.pushEvent = () => {}; // No-op function
// Clear interval timer
if (self.cleanupInterval !== undefined) {
clearInterval(self.cleanupInterval);
self.cleanupInterval = undefined;
}
// Clear programmatic move timeout
if (self.programmaticMoveTimeout !== undefined) {
clearTimeout(self.programmaticMoveTimeout);
self.programmaticMoveTimeout = undefined;
}
// Remove popup navigation event listener
if (self.popupNavigationHandler) {
document.removeEventListener('click', self.popupNavigationHandler);
self.popupNavigationHandler = undefined;
}
// Close any open popups before cleanup
if (self.map !== undefined) {
try {
self.map.closePopup();
} catch (e) {
console.debug("Error closing popup during cleanup:", e);
}
}
if (self.boundsTimer !== undefined) { if (self.boundsTimer !== undefined) {
clearTimeout(self.boundsTimer); clearTimeout(self.boundsTimer);
} }
if (self.resizeHandler !== undefined) { if (self.resizeHandler !== undefined) {
window.removeEventListener("resize", self.resizeHandler); window.removeEventListener("resize", self.resizeHandler);
self.resizeHandler = undefined;
} }
// Remove map event handlers
if (self.map !== undefined && self.mapEventHandlers !== undefined) {
self.mapEventHandlers.forEach((handler, event) => {
try {
self.map.off(event, handler);
} catch (e) {
console.debug(`Error removing ${event} handler:`, e);
}
});
self.mapEventHandlers.clear();
}
// Remove all event listeners from markers before clearing layers
if (self.markers !== undefined) {
self.markers.forEach((marker: any) => {
try {
marker.off(); // Remove all event listeners
if (marker.getPopup()) {
marker.unbindPopup(); // Unbind popup to prevent events
}
} catch (e) {
console.debug(`Error cleaning up marker:`, e);
}
});
}
if (self.markerLayer !== undefined) { if (self.markerLayer !== undefined) {
self.markerLayer!.clearLayers(); self.markerLayer!.clearLayers();
} }
@ -1253,6 +1281,9 @@ let MapAPRSMap = {
self.map!.remove(); self.map!.remove();
self.map = undefined; self.map = undefined;
} }
// Restore original pushEvent (though it won't be used since we're destroyed)
self.pushEvent = originalPushEvent;
}, },
}; };

307
assets/js/map_fixes.ts Normal file
View file

@ -0,0 +1,307 @@
// Proposed fixes for map.ts issues
// 1. Extract duplicated functions
export const MapHelpers = {
// Centralized timestamp parsing
parseTimestamp(timestamp: any): number {
if (!timestamp) return Date.now();
if (typeof timestamp === "number") {
return timestamp;
} else if (typeof timestamp === "string") {
return new Date(timestamp).getTime();
}
return Date.now();
},
// Centralized trail ID calculation
getTrailId(data: { callsign_group?: string; callsign?: string; id: string }): string {
return data.callsign_group || data.callsign || data.id;
},
// Centralized map state saving
saveMapState(map: any, pushEvent: Function) {
const center = map.getCenter();
const zoom = map.getZoom();
// Truncate lat/lng to 5 decimal places for URL
const truncatedLat = Math.round(center.lat * 100000) / 100000;
const truncatedLng = Math.round(center.lng * 100000) / 100000;
localStorage.setItem(
"aprs_map_state",
JSON.stringify({ lat: truncatedLat, lng: truncatedLng, zoom }),
);
// Send combined map state update to server for URL and bounds updating
pushEvent("update_map_state", {
center: { lat: truncatedLat, lng: truncatedLng },
zoom: zoom,
bounds: {
north: map.getBounds().getNorth(),
south: map.getBounds().getSouth(),
east: map.getBounds().getEast(),
west: map.getBounds().getWest(),
}
});
},
// Safe event pushing with connection check
safePushEvent(pushEvent: Function | undefined, event: string, payload: any) {
if (!pushEvent) return false;
try {
pushEvent(event, payload);
return true;
} catch (e) {
console.debug(`Unable to send ${event} event - LiveView disconnected`);
return false;
}
}
};
// 2. Improved LiveViewHookContext with proper cleanup tracking
interface ImprovedLiveViewHookContext extends LiveViewHookContext {
cleanupInterval?: ReturnType<typeof setInterval>;
mapEventHandlers?: Map<string, Function>;
isDestroyed?: boolean;
}
// 3. Example of fixed initialization with proper cleanup tracking
export const setupMapWithCleanup = (self: ImprovedLiveViewHookContext) => {
// Track if component is destroyed
self.isDestroyed = false;
// Store event handlers for cleanup
self.mapEventHandlers = new Map();
// Store interval for cleanup
self.cleanupInterval = setInterval(() => {
if (!self.isDestroyed && self.trailManager) {
self.trailManager.cleanupOldPositions();
}
}, 5 * 60 * 1000);
// Create wrapped event handlers that check if destroyed
const createSafeHandler = (handler: Function) => {
return (...args: any[]) => {
if (!self.isDestroyed) {
handler(...args);
}
};
};
// Example of safe map event handler
const moveEndHandler = createSafeHandler(() => {
if (self.programmaticMoveCounter && self.programmaticMoveCounter > 0) {
self.programmaticMoveCounter--;
return;
}
if (self.boundsTimer) clearTimeout(self.boundsTimer);
self.boundsTimer = setTimeout(() => {
if (!self.isDestroyed) {
MapHelpers.saveMapState(self.map, self.pushEvent);
}
}, 300);
});
self.map.on("moveend", moveEndHandler);
self.mapEventHandlers.set("moveend", moveEndHandler);
};
// 4. Improved cleanup function
export const improvedDestroyed = (self: ImprovedLiveViewHookContext) => {
// Mark as destroyed immediately
self.isDestroyed = true;
// Disable pushEvent to prevent any events from being sent during cleanup
const originalPushEvent = self.pushEvent;
self.pushEvent = () => {}; // No-op function
// Clear interval timer
if (self.cleanupInterval !== undefined) {
clearInterval(self.cleanupInterval);
self.cleanupInterval = undefined;
}
// Remove popup navigation event listener
if (self.popupNavigationHandler) {
document.removeEventListener('click', self.popupNavigationHandler);
self.popupNavigationHandler = undefined;
}
// Close any open popups before cleanup
if (self.map !== undefined) {
try {
self.map.closePopup();
} catch (e) {
console.debug("Error closing popup during cleanup:", e);
}
}
// Clear timers
if (self.boundsTimer !== undefined) {
clearTimeout(self.boundsTimer);
self.boundsTimer = undefined;
}
if (self.resizeHandler !== undefined) {
window.removeEventListener("resize", self.resizeHandler);
self.resizeHandler = undefined;
}
// Remove map event handlers
if (self.map !== undefined && self.mapEventHandlers !== undefined) {
self.mapEventHandlers.forEach((handler, event) => {
try {
self.map.off(event, handler);
} catch (e) {
console.debug(`Error removing ${event} handler:`, e);
}
});
self.mapEventHandlers.clear();
}
// Remove all event listeners from markers before clearing layers
if (self.markers !== undefined) {
self.markers.forEach((marker: any, id: string) => {
try {
marker.off(); // Remove all event listeners
if (marker.getPopup()) {
marker.unbindPopup(); // Unbind popup to prevent events
}
} catch (e) {
console.debug(`Error cleaning up marker ${id}:`, e);
}
});
}
// Clear layers and data
if (self.markerLayer !== undefined) {
try {
self.markerLayer!.clearLayers();
} catch (e) {
console.debug("Error clearing marker layer:", e);
}
}
if (self.markers !== undefined) {
self.markers!.clear();
}
if (self.markerStates !== undefined) {
self.markerStates!.clear();
}
// Remove map
if (self.map !== undefined) {
try {
self.map!.remove();
} catch (e) {
console.debug("Error removing map:", e);
}
self.map = undefined;
}
// Clear OMS if present
if (self.oms !== undefined) {
self.oms = undefined;
}
// Restore original pushEvent (though it won't be used since we're destroyed)
self.pushEvent = originalPushEvent;
};
// 5. Optimized marker position lookup using spatial index
export class MarkerSpatialIndex {
private grid: Map<string, Set<string>> = new Map();
private cellSize: number = 0.0001; // ~11 meters at equator
private getGridKey(lat: number, lng: number): string {
const latCell = Math.floor(lat / this.cellSize);
const lngCell = Math.floor(lng / this.cellSize);
return `${latCell},${lngCell}`;
}
add(id: string, lat: number, lng: number) {
const key = this.getGridKey(lat, lng);
if (!this.grid.has(key)) {
this.grid.set(key, new Set());
}
this.grid.get(key)!.add(id);
}
remove(id: string, lat: number, lng: number) {
const key = this.getGridKey(lat, lng);
const cell = this.grid.get(key);
if (cell) {
cell.delete(id);
if (cell.size === 0) {
this.grid.delete(key);
}
}
}
findNearby(lat: number, lng: number, radius: number = 0.00001): Set<string> {
const results = new Set<string>();
const cellRadius = Math.ceil(radius / this.cellSize);
const centerLatCell = Math.floor(lat / this.cellSize);
const centerLngCell = Math.floor(lng / this.cellSize);
for (let latOffset = -cellRadius; latOffset <= cellRadius; latOffset++) {
for (let lngOffset = -cellRadius; lngOffset <= cellRadius; lngOffset++) {
const key = `${centerLatCell + latOffset},${centerLngCell + lngOffset}`;
const cell = this.grid.get(key);
if (cell) {
cell.forEach(id => results.add(id));
}
}
}
return results;
}
clear() {
this.grid.clear();
}
}
// 6. Improved programmatic move counter with proper state management
export class ProgrammaticMoveTracker {
private moveCount: number = 0;
private timeoutId?: ReturnType<typeof setTimeout>;
expectMoves(count: number) {
this.moveCount += count;
// Clear existing timeout
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
// Set safety timeout
this.timeoutId = setTimeout(() => {
if (this.moveCount > 0) {
console.debug(`Resetting programmatic move counter from ${this.moveCount} to 0`);
this.moveCount = 0;
}
}, 2000);
}
handleMove(): boolean {
if (this.moveCount > 0) {
this.moveCount--;
return true; // This was a programmatic move
}
return false; // This was a user-initiated move
}
reset() {
this.moveCount = 0;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = undefined;
}
}
}

93
assets/js/map_helpers.ts Normal file
View file

@ -0,0 +1,93 @@
// Helper functions for map.ts to reduce code duplication
export interface MapState {
lat: number;
lng: number;
zoom: number;
}
export interface BoundsData {
north: number;
south: number;
east: number;
west: number;
}
/**
* Parse timestamp to milliseconds
*/
export function parseTimestamp(timestamp: any): number {
if (!timestamp) return Date.now();
if (typeof timestamp === "number") {
return timestamp;
} else if (typeof timestamp === "string") {
return new Date(timestamp).getTime();
}
return Date.now();
}
/**
* Get trail ID from marker data
*/
export function getTrailId(data: { callsign_group?: string; callsign?: string; id: string }): string {
return data.callsign_group || data.callsign || data.id;
}
/**
* Save map state to localStorage and send to server
*/
export function saveMapState(map: any, pushEvent: Function) {
const center = map.getCenter();
const zoom = map.getZoom();
// Truncate lat/lng to 5 decimal places for URL
const truncatedLat = Math.round(center.lat * 100000) / 100000;
const truncatedLng = Math.round(center.lng * 100000) / 100000;
localStorage.setItem(
"aprs_map_state",
JSON.stringify({ lat: truncatedLat, lng: truncatedLng, zoom }),
);
// Send combined map state update to server for URL and bounds updating
pushEvent("update_map_state", {
center: { lat: truncatedLat, lng: truncatedLng },
zoom: zoom,
bounds: {
north: map.getBounds().getNorth(),
south: map.getBounds().getSouth(),
east: map.getBounds().getEast(),
west: map.getBounds().getWest(),
}
});
}
/**
* Safely push event to LiveView
*/
export function safePushEvent(pushEvent: Function | undefined, event: string, payload: any): boolean {
if (!pushEvent) return false;
try {
pushEvent(event, payload);
return true;
} catch (e) {
console.debug(`Unable to send ${event} event - LiveView disconnected`);
return false;
}
}
/**
* Check if LiveView socket is available
*/
export function isLiveViewConnected(): boolean {
return !!(window as any).liveSocket;
}
/**
* Get LiveView socket
*/
export function getLiveSocket(): any {
return (window as any).liveSocket;
}

102
assets/js/types/map.d.ts vendored Normal file
View file

@ -0,0 +1,102 @@
// Type definitions for APRS Map application
import type { Map as LeafletMap, Marker, LatLng, LatLngBounds, DivIcon, LayerGroup, Popup } from 'leaflet';
export interface LiveViewHookContext {
el: HTMLElement & { _leaflet_id?: any };
pushEvent: (event: string, payload: any) => void;
handleEvent: (event: string, callback: (data: any) => void) => void;
map?: LeafletMap;
markers?: Map<string, Marker>;
markerStates?: Map<string, MarkerState>;
markerLayer?: LayerGroup;
trailManager?: any; // Import from trail_manager.ts when typed
boundsTimer?: ReturnType<typeof setTimeout>;
resizeHandler?: () => void;
errors?: string[];
initializationAttempts?: number;
maxInitializationAttempts?: number;
lastZoom?: number;
currentPopupMarkerId?: string | null;
oms?: any; // OverlappingMarkerSpiderfier type
programmaticMoveId?: string;
programmaticMoveTimeout?: ReturnType<typeof setTimeout>;
cleanupInterval?: ReturnType<typeof setInterval>;
mapEventHandlers?: Map<string, Function>;
isDestroyed?: boolean;
popupNavigationHandler?: (e: Event) => void;
moveEndHandler?: () => void;
zoomEndHandler?: () => void;
[key: string]: any;
}
export interface MarkerData {
id: string;
lat: number;
lng: number;
callsign?: string;
comment?: string;
symbol_table_id?: string;
symbol_code?: string;
symbol_description?: string;
popup?: string;
historical?: boolean;
color?: string;
timestamp?: number | string;
is_most_recent_for_callsign?: boolean;
callsign_group?: string;
symbol_html?: string;
openPopup?: boolean;
}
export interface BoundsData {
north: number;
south: number;
east: number;
west: number;
}
export interface CenterData {
lat: number;
lng: number;
}
export interface MarkerState {
lat: number;
lng: number;
symbol_table: string;
symbol_code: string;
popup?: string;
historical?: boolean;
is_most_recent_for_callsign?: boolean;
callsign_group?: string;
callsign?: string;
}
export interface MapEventData {
bounds?: BoundsData;
center?: CenterData;
zoom?: number;
id?: string;
callsign?: string;
lat?: number;
lng?: number;
markers?: MarkerData[];
}
export interface MapState {
lat: number;
lng: number;
zoom: number;
}
export interface LiveSocket {
connected: boolean;
pushHistoryPatch: (href: string, state: string, target: HTMLElement) => void;
}
declare global {
interface Window {
liveSocket?: LiveSocket;
}
}

View file

@ -15,7 +15,9 @@ config :aprsme, Aprsme.Repo,
database: "aprsme_test#{System.get_env("MIX_TEST_PARTITION")}", database: "aprsme_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2, pool_size: System.schedulers_online() * 2,
types: Aprsme.PostgresTypes types: Aprsme.PostgresTypes,
ownership_timeout: 300_000,
timeout: 300_000
# We don't run a server during test. If one is required, # We don't run a server during test. If one is required,
# you can enable the server option below. # you can enable the server option below.

View file

@ -0,0 +1,114 @@
# Virtual Marker Scrolling for Large Datasets
## Overview
When dealing with thousands of APRS markers on the map, rendering all markers at once can cause performance issues. Virtual scrolling for map markers is a technique where only visible markers within the viewport (plus a buffer zone) are rendered.
## Current Implementation
The current implementation already has some optimizations:
1. Viewport-based filtering in `get_recent_packets_optimized/3`
2. Marker state tracking to prevent unnecessary updates
3. Batch processing of historical packets
## Proposed Virtual Scrolling Enhancement
### 1. Marker Clustering at Low Zoom Levels
```typescript
// Use Leaflet.markercluster for automatic clustering
// Already included in the project but not fully utilized
if (self.map.getZoom() < 10) {
// Use marker clustering
self.clusterLayer = L.markerClusterGroup({
maxClusterRadius: 80,
spiderfyOnMaxZoom: true,
showCoverageOnHover: false,
zoomToBoundsOnClick: true
});
}
```
### 2. Quadtree-based Spatial Indexing
```elixir
defmodule AprsmeWeb.MapLive.QuadTree do
@moduledoc """
Quadtree implementation for efficient spatial queries
"""
defstruct [:bounds, :markers, :children, :max_markers]
def insert(tree, marker) do
# Insert marker into appropriate quadrant
end
def query(tree, bounds) do
# Return only markers within bounds
end
end
```
### 3. Progressive Rendering with RequestIdleCallback
```typescript
function renderMarkersProgressively(markers: MarkerData[]) {
let index = 0;
const batchSize = 50;
function renderBatch() {
const batch = markers.slice(index, index + batchSize);
batch.forEach(marker => self.addMarker(marker));
index += batchSize;
if (index < markers.length) {
requestIdleCallback(renderBatch);
}
}
requestIdleCallback(renderBatch);
}
```
### 4. Server-side Aggregation
```elixir
def aggregate_markers_for_zoom(zoom_level, bounds) do
case zoom_level do
z when z < 8 ->
# Return aggregated grid cells with counts
Packets.get_grid_aggregates(bounds, grid_size: 50)
z when z < 12 ->
# Return simplified markers (no popups)
Packets.get_simplified_markers(bounds)
_ ->
# Return full markers
Packets.get_recent_packets_optimized(bounds)
end
end
```
### 5. Viewport Buffer Strategy
```typescript
// Render markers in expanded viewport to reduce re-renders during panning
const bounds = self.map.getBounds();
const bufferedBounds = bounds.pad(0.5); // 50% buffer
```
## Implementation Priority
1. **Phase 1**: Implement marker clustering (easiest, biggest impact)
2. **Phase 2**: Add server-side aggregation for low zoom levels
3. **Phase 3**: Implement progressive rendering
4. **Phase 4**: Add quadtree spatial indexing if still needed
## Performance Metrics to Track
- Time to first marker render
- Frame rate during pan/zoom
- Memory usage with 10k+ markers
- Server query time by zoom level
## Notes
- The current implementation already handles viewport filtering well
- Virtual scrolling is most beneficial when dealing with 5000+ markers
- Consider implementing only if performance issues are reported by users

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)
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 # Extract data based on the type of data_extended
additional_data = additional_data =
case data_extended do case data_extended do
@ -332,12 +339,13 @@ defmodule Aprsme.Packet do
end end
defp put_symbol_fields(map, data_extended) do 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 map
|> maybe_put(:symbol_code, data_extended[:symbol_code] || data_extended["symbol_code"]) |> maybe_put(:symbol_code, symbol_code)
|> maybe_put( |> maybe_put(:symbol_table_id, symbol_table_id)
:symbol_table_id,
data_extended[:symbol_table_id] || data_extended["symbol_table_id"]
)
|> maybe_put(:comment, data_extended[:comment] || data_extended["comment"]) |> maybe_put(:comment, data_extended[:comment] || data_extended["comment"])
|> maybe_put(:timestamp, data_extended[:timestamp] || data_extended["timestamp"]) |> maybe_put(:timestamp, data_extended[:timestamp] || data_extended["timestamp"])
|> maybe_put( |> maybe_put(
@ -397,10 +405,9 @@ defmodule Aprsme.Packet do
|> maybe_put(:manufacturer, mic_e_map[:manufacturer]) |> maybe_put(:manufacturer, mic_e_map[:manufacturer])
|> maybe_put(:course, mic_e_map[:heading]) |> maybe_put(:course, mic_e_map[:heading])
|> maybe_put(:speed, mic_e_map[:speed]) |> maybe_put(:speed, mic_e_map[:speed])
# Default car symbol for MicE # Use symbol data from MicE if available, otherwise use default car symbol
|> maybe_put(:symbol_code, ">") |> maybe_put(:symbol_code, mic_e_map[:symbol_code] || mic_e_map["symbol_code"] || ">")
# Primary table |> maybe_put(:symbol_table_id, mic_e_map[:symbol_table_id] || mic_e_map["symbol_table_id"] || "/")
|> maybe_put(:symbol_table_id, "/")
end end
# Extract weather data from various formats # Extract weather data from various formats

View file

@ -168,6 +168,11 @@ defmodule Aprsme.Packets do
defp insert_packet(attrs, packet_data) do defp insert_packet(attrs, packet_data) do
case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do
{:ok, packet} -> {:ok, packet} ->
# Invalidate cache for this packet's callsign
if Map.has_key?(attrs, :sender) do
Aprsme.CachedQueries.invalidate_callsign_cache(attrs.sender)
end
{:ok, packet} {:ok, packet}
{:error, changeset} -> {:error, changeset} ->
@ -395,8 +400,8 @@ defmodule Aprsme.Packets do
@impl true @impl true
@spec get_recent_packets(map()) :: [struct()] @spec get_recent_packets(map()) :: [struct()]
def get_recent_packets(opts \\ %{}) do def get_recent_packets(opts \\ %{}) do
# Always limit to the last hour # Always limit to the last 24 hours for more symbol variety
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second)
# Merge the one-hour limit with any other filters # Merge the one-hour limit with any other filters
opts_with_time = Map.put(opts, :start_time, one_hour_ago) opts_with_time = Map.put(opts, :start_time, one_hour_ago)
@ -410,25 +415,28 @@ defmodule Aprsme.Packets do
""" """
@spec get_recent_packets_optimized(map()) :: [struct()] @spec get_recent_packets_optimized(map()) :: [struct()]
def get_recent_packets_optimized(opts \\ %{}) do def get_recent_packets_optimized(opts \\ %{}) do
# Always limit to the last hour for initial load # Always limit to the last 24 hours for more symbol variety
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) one_hour_ago = DateTime.add(DateTime.utc_now(), -24 * 3600, :second)
limit = Map.get(opts, :limit, 200) limit = Map.get(opts, :limit, 200)
offset = Map.get(opts, :offset, 0)
# Use a more efficient query that leverages the partial indexes # Build base query with time and position filters
# Order by received_at DESC to get the most recent packets first
base_query = base_query =
from(p in Packet, from(p in Packet,
where: p.has_position == true, where: p.has_position == true,
where: p.received_at >= ^one_hour_ago, where: p.received_at >= ^one_hour_ago
order_by: [desc: p.received_at],
limit: ^limit
) )
# Apply spatial and other filters BEFORE limiting
# This ensures we get the most recent packets within the specified bounds
query = query =
base_query base_query
|> filter_by_region(opts) |> filter_by_region(opts)
|> filter_by_callsign(opts) |> filter_by_callsign(opts)
|> filter_by_map_bounds(opts) |> filter_by_map_bounds(opts)
|> order_by(desc: :received_at)
|> limit(^limit)
|> offset(^offset)
|> select_with_virtual_coordinates() |> select_with_virtual_coordinates()
Repo.all(query) Repo.all(query)

View file

@ -0,0 +1,346 @@
defmodule AprsmeWeb.AprsSymbol do
@moduledoc """
Shared library for APRS symbol handling and rendering.
This module provides centralized functions for:
- Symbol table and code normalization
- Sprite file mapping
- Symbol positioning calculations
- HTML generation for symbols
All APRS symbol logic should use this module to ensure consistency
across the application.
"""
@doc """
Gets sprite information for a given symbol table and code.
Returns a map with sprite_file, background_position, and background_size.
## Examples
iex> AprsmeWeb.AprsSymbol.get_sprite_info("/", "_")
%{
sprite_file: "/aprs-symbols/aprs-symbols-128-0@2x.png",
background_position: "-352px -32px",
background_size: "512px 192px"
}
"""
def get_sprite_info(symbol_table, symbol_code) do
# For overlay symbols (A-Z, 0-9), display the base symbol with overlay
if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/) do
# Use the base symbol from the overlay table, not the overlay character itself
get_overlay_base_symbol_info(symbol_code)
else
# Normal symbol table processing
symbol_table = normalize_symbol_table(symbol_table)
symbol_code = normalize_symbol_code(symbol_code)
# Map symbol table to sprite file ID
table_id = get_table_id(symbol_table)
sprite_file = "/aprs-symbols/aprs-symbols-128-#{table_id}@2x.png"
# Get symbol position using ASCII-based calculation
symbol_code_ord =
symbol_code
|> String.to_charlist()
|> List.first()
|> then(fn c -> if is_integer(c), do: c, else: 63 end)
index = symbol_code_ord - 33
safe_index = max(0, min(index, 93))
# Calculate positioning for 16-column grid
column = rem(safe_index, 16)
row = div(safe_index, 16)
x = -column * 128
y = -row * 128
%{
sprite_file: sprite_file,
background_position: "#{x / 4}px #{y / 4}px",
background_size: "512px 192px"
}
end
end
@doc """
Gets sprite information for overlay symbols (A-Z, 0-9).
These symbols display the base symbol from the overlay table.
"""
def get_overlay_base_symbol_info(base_symbol_code) do
# Some overlay base symbols are in the alternate table (1), others in overlay table (2)
# Check which table to use based on the symbol code
table_id = get_overlay_base_table_id(base_symbol_code)
sprite_file = "/aprs-symbols/aprs-symbols-128-#{table_id}@2x.png"
# Get position of the base symbol in the appropriate table
base_symbol_ord =
base_symbol_code
|> String.to_charlist()
|> List.first()
|> then(fn c -> if is_integer(c), do: c, else: 63 end)
index = base_symbol_ord - 33
safe_index = max(0, min(index, 93))
# Calculate positioning for 16-column grid
column = rem(safe_index, 16)
row = div(safe_index, 16)
x = -column * 128
y = -row * 128
%{
sprite_file: sprite_file,
background_position: "#{x / 4}px #{y / 4}px",
background_size: "512px 192px"
}
end
@doc """
Determines which sprite table to use for overlay base symbols.
Some symbols are in the alternate table (1), others in overlay table (2).
"""
def get_overlay_base_table_id(base_symbol_code) do
# Map symbols to the correct sprite table based on APRS specification
# Most overlay symbols are in the alternate table (1)
case base_symbol_code do
# Digipeater symbols are often in the alternate table (1) and have colored backgrounds
# Digipeater - green star background
"#" -> "1"
# Diamond shape - APRS overlay symbol (alternate table)
"a" -> "1"
# Square shape - APRS overlay symbol (alternate table)
"A" -> "1"
# Diamond shape - alternate table
"&" -> "1"
# Arrow symbols
">" -> "1"
"<" -> "1"
"^" -> "1"
"v" -> "1"
# Black square background - alternate table
"i" -> "1"
# Most other symbols that can be overlaid are in the alternate table
_ -> "1"
end
end
@doc """
Gets sprite information for overlay characters (A-Z, 0-9).
These are rendered from the overlay table.
"""
def get_overlay_character_sprite_info(overlay_char) do
# Use overlay table (table 2) for the overlay character
sprite_file = "/aprs-symbols/aprs-symbols-128-2@2x.png"
# Get position of the overlay character in the overlay table
overlay_char_ord =
overlay_char
|> String.to_charlist()
|> List.first()
|> then(fn c -> if is_integer(c), do: c, else: 63 end)
index = overlay_char_ord - 33
safe_index = max(0, min(index, 93))
# Calculate positioning for 16-column grid
column = rem(safe_index, 16)
row = div(safe_index, 16)
x = -column * 128
y = -row * 128
%{
sprite_file: sprite_file,
background_position: "#{x / 4}px #{y / 4}px",
background_size: "512px 192px"
}
end
@doc """
Normalizes a symbol table identifier.
## Examples
iex> AprsmeWeb.AprsSymbol.normalize_symbol_table("/")
"/"
iex> AprsmeWeb.AprsSymbol.normalize_symbol_table("A")
"]"
iex> AprsmeWeb.AprsSymbol.normalize_symbol_table("invalid")
"/"
"""
def normalize_symbol_table(symbol_table) do
cond do
symbol_table in ["/", "\\", "]"] -> symbol_table
symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/) -> "]"
true -> "/"
end
end
@doc """
Normalizes a symbol code.
## Examples
iex> AprsmeWeb.AprsSymbol.normalize_symbol_code("_")
"_"
iex> AprsmeWeb.AprsSymbol.normalize_symbol_code(nil)
">"
iex> AprsmeWeb.AprsSymbol.normalize_symbol_code("")
">"
"""
def normalize_symbol_code(symbol_code) do
if symbol_code && symbol_code != "", do: symbol_code, else: ">"
end
@doc """
Maps a symbol table to its sprite file ID.
## Examples
iex> AprsmeWeb.AprsSymbol.get_table_id("/")
"0"
iex> AprsmeWeb.AprsSymbol.get_table_id("\\")
"1"
iex> AprsmeWeb.AprsSymbol.get_table_id("]")
"2"
"""
def get_table_id(symbol_table) do
case symbol_table do
# Primary table
"/" -> "0"
# Alternate table
"\\" -> "1"
# Overlay table (A-Z, 0-9)
"]" -> "2"
# Default to primary table
_ -> "0"
end
end
@doc """
Renders an APRS symbol as HTML for use in Leaflet markers.
Returns HTML string that can be used as marker content.
## Examples
iex> AprsmeWeb.AprsSymbol.render_marker_html("/", "_", "W1AW")
"<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,81 @@
defmodule AprsmeWeb.Components.ErrorBoundary do
@moduledoc """
Error boundary component for gracefully handling errors in LiveView.
This component wraps other components and catches errors, displaying
a user-friendly error message instead of crashing the entire view.
"""
use Phoenix.Component
@doc """
Wraps content in an error boundary that catches and displays errors gracefully.
## Examples
<.error_boundary id="map-error-boundary">
<%= live_render(@socket, AprsmeWeb.MapLive.Index) %>
</.error_boundary>
"""
attr :id, :string, required: true
attr :class, :string, default: nil
slot :inner_block, required: true
slot :fallback do
attr :error, :string
end
def error_boundary(assigns) do
~H"""
<div id={@id} class={@class} phx-hook="ErrorBoundary">
<div class="error-boundary-content">
{render_slot(@inner_block)}
</div>
<div class="error-boundary-fallback hidden">
<%= if @fallback == [] do %>
<.default_error_message />
<% else %>
{render_slot(@fallback, %{})}
<% end %>
</div>
</div>
"""
end
defp default_error_message(assigns) do
~H"""
<div class="rounded-md bg-red-50 p-4 my-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
Something went wrong
</h3>
<div class="mt-2 text-sm text-red-700">
<p>
We're sorry, but something unexpected happened. The error has been logged
and we'll look into it. Please try refreshing the page.
</p>
</div>
<div class="mt-4">
<button
type="button"
onclick="window.location.reload()"
class="bg-red-50 px-2 py-1.5 rounded-md text-sm font-medium text-red-800 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-red-50 focus:ring-red-600"
>
Refresh page
</button>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,101 @@
defmodule AprsmeWeb.SymbolRenderer do
@moduledoc """
Server-side APRS symbol rendering component.
This module handles the rendering of APRS symbols using the hessu/aprs-symbols sprite files.
It provides a centralized way to render symbols that can be used across all LiveView pages.
"""
use Phoenix.Component
@doc """
Renders an APRS symbol with the correct sprite positioning.
## Examples
<.symbol symbol_table="/" symbol_code="_" size={32} callsign="W1AW" />
<.symbol symbol_table="D" symbol_code="&" size={64} />
"""
attr :symbol_table, :string, required: true, doc: "APRS symbol table identifier (/, \\, or overlay)"
attr :symbol_code, :string, required: true, doc: "APRS symbol code character"
attr :size, :integer, default: 32, doc: "Display size in pixels"
attr :callsign, :string, default: nil, doc: "Optional callsign to display next to symbol"
attr :class, :string, default: "", doc: "Additional CSS classes"
attr :title, :string, default: nil, doc: "Optional title/tooltip text"
def symbol(assigns) do
# Get the sprite file and position for this symbol
sprite_info = get_sprite_info(assigns.symbol_table, assigns.symbol_code)
assigns =
assign(assigns,
sprite_file: sprite_info.sprite_file,
background_position: sprite_info.background_position,
background_size: sprite_info.background_size,
symbol_title: assigns.title || "#{assigns.symbol_table}#{assigns.symbol_code}"
)
~H"""
<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

@ -4,6 +4,7 @@ defmodule AprsmeWeb.InfoLive.Show do
use Gettext, backend: AprsmeWeb.Gettext use Gettext, backend: AprsmeWeb.Gettext
alias Aprsme.Packets alias Aprsme.Packets
alias AprsmeWeb.AprsSymbol
alias AprsmeWeb.MapLive.PacketUtils alias AprsmeWeb.MapLive.PacketUtils
@neighbor_radius_km 10 @neighbor_radius_km 10
@ -306,6 +307,60 @@ defmodule AprsmeWeb.InfoLive.Show do
end end
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 defp decode_aprs_path(path) when is_binary(path) and path != "" do
path_elements = String.split(path, ",") path_elements = String.split(path, ",")

View file

@ -1,21 +1,3 @@
<% {symbol_table_id, symbol_code} = AprsmeWeb.MapLive.PacketUtils.get_symbol_info(@packet || %{}) %>
<% symbol_table = if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %>
<% symbol_code = symbol_code || ">" %>
<% symbol_table_num =
case symbol_table do
"/" -> 0
"\\" -> 1
"]" -> 2
_ -> 0
end %>
<% symbol_code_ord =
symbol_code
|> String.to_charlist()
|> List.first()
|> (fn c -> if is_integer(c), do: c, else: 63 end).() %>
<% _symbol_img = "/aprs-symbols/aprs-symbols-24-#{symbol_table_num}@2x.png" %>
<% _symbol_index = symbol_code_ord - 33 %>
<div class="min-h-screen bg-base-200"> <div class="min-h-screen bg-base-200">
<!-- Page header --> <!-- Page header -->
<div class="bg-base-100 shadow-sm"> <div class="bg-base-100 shadow-sm">
@ -31,31 +13,14 @@
{@callsign} {@callsign}
</span> </span>
<%= if @packet do %> <%= if @packet do %>
<% {symbol_table_id, symbol_code} = <% {symbol_table, symbol_code} = AprsmeWeb.AprsSymbol.extract_from_packet(@packet) %>
AprsmeWeb.MapLive.PacketUtils.get_symbol_info(@packet) %> <% display_symbol =
<% symbol_table = if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/),
if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %> do: symbol_table,
<% symbol_code = symbol_code || ">" %> else:
<% table_id = "#{AprsmeWeb.AprsSymbol.normalize_symbol_table(symbol_table)}#{AprsmeWeb.AprsSymbol.normalize_symbol_code(symbol_code)}" %>
if symbol_table == "/", <div title={display_symbol}>
do: "0", {render_symbol_html(@packet)}
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}"}
>
</div> </div>
<% end %> <% end %>
</div> </div>
@ -289,33 +254,15 @@
{ssid_info.callsign} {ssid_info.callsign}
</.link> </.link>
<%= if ssid_info.packet do %> <%= if ssid_info.packet do %>
<% {symbol_table_id, symbol_code} = <% {symbol_table, symbol_code} =
AprsmeWeb.MapLive.PacketUtils.get_symbol_info(ssid_info.packet) %> AprsmeWeb.AprsSymbol.extract_from_packet(ssid_info.packet) %>
<% symbol_table = <% display_symbol =
if symbol_table_id in ["/", "\\", "]"], if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/),
do: symbol_table_id, do: symbol_table,
else: "/" %> else:
<% symbol_code = symbol_code || ">" %> "#{AprsmeWeb.AprsSymbol.normalize_symbol_table(symbol_table)}#{AprsmeWeb.AprsSymbol.normalize_symbol_code(symbol_code)}" %>
<% table_id = <div title={display_symbol}>
if symbol_table == "/", {render_symbol_html(ssid_info.packet)}
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}"}
>
</div> </div>
<% end %> <% end %>
</div> </div>
@ -414,31 +361,15 @@
{neighbor.callsign} {neighbor.callsign}
</.link> </.link>
<%= if neighbor.packet do %> <%= if neighbor.packet do %>
<% {symbol_table_id, symbol_code} = <% {symbol_table, symbol_code} =
AprsmeWeb.MapLive.PacketUtils.get_symbol_info(neighbor.packet) %> AprsmeWeb.AprsSymbol.extract_from_packet(neighbor.packet) %>
<% symbol_table = <% display_symbol =
if symbol_table_id in ["/", "\\", "]"], do: symbol_table_id, else: "/" %> if symbol_table && String.match?(symbol_table, ~r/^[A-Z0-9]$/),
<% symbol_code = symbol_code || ">" %> do: symbol_table,
<% table_id = else:
if symbol_table == "/", "#{AprsmeWeb.AprsSymbol.normalize_symbol_table(symbol_table)}#{AprsmeWeb.AprsSymbol.normalize_symbol_code(symbol_code)}" %>
do: "0", <div title={display_symbol}>
else: if(symbol_table == "]", do: "2", else: "1") %> {render_symbol_html(neighbor.packet)}
<% 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}"}
>
</div> </div>
<% end %> <% end %>
</div> </div>

View file

@ -4,19 +4,68 @@ defmodule AprsmeWeb.MapLive.Index do
""" """
use AprsmeWeb, :live_view use AprsmeWeb, :live_view
import AprsmeWeb.Components.ErrorBoundary
import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1] import AprsmeWeb.TimeHelpers, only: [time_ago_in_words: 1]
import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2] import Phoenix.LiveView, only: [connected?: 1, push_event: 3, push_navigate: 2, push_patch: 2]
alias AprsmeWeb.Endpoint alias AprsmeWeb.Endpoint
alias AprsmeWeb.MapLive.MapHelpers alias AprsmeWeb.MapLive.MapHelpers
alias AprsmeWeb.MapLive.PacketUtils alias AprsmeWeb.MapLive.PacketUtils
alias AprsmeWeb.MapLive.PopupComponent
alias Phoenix.HTML.Safe
alias Phoenix.LiveView.Socket alias Phoenix.LiveView.Socket
@default_center %{lat: 39.8283, lng: -98.5795} @default_center %{lat: 39.8283, lng: -98.5795}
@default_zoom 5 @default_zoom 5
# Parse map state from URL parameters
@spec parse_map_params(map()) :: {map(), integer()}
defp parse_map_params(params) do
# Parse latitude (lat parameter)
lat =
case Map.get(params, "lat") do
nil ->
@default_center.lat
lat_str ->
case Float.parse(lat_str) do
{lat_val, _} when lat_val >= -90 and lat_val <= 90 -> lat_val
_ -> @default_center.lat
end
end
# Parse longitude (lng parameter)
lng =
case Map.get(params, "lng") do
nil ->
@default_center.lng
lng_str ->
case Float.parse(lng_str) do
{lng_val, _} when lng_val >= -180 and lng_val <= 180 -> lng_val
_ -> @default_center.lng
end
end
# Parse zoom level (z parameter)
zoom =
case Map.get(params, "z") do
nil ->
@default_zoom
zoom_str ->
case Integer.parse(zoom_str) do
{zoom_val, _} when zoom_val >= 1 and zoom_val <= 20 -> zoom_val
_ -> @default_zoom
end
end
map_center = %{lat: lat, lng: lng}
{map_center, zoom}
end
@impl true @impl true
def mount(_params, _session, socket) do def mount(params, _session, socket) do
if connected?(socket) do if connected?(socket) do
# Subscribe to packet updates # Subscribe to packet updates
Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets")
@ -29,9 +78,18 @@ defmodule AprsmeWeb.MapLive.Index do
# Get deployment timestamp from config (set during application startup) # Get deployment timestamp from config (set during application startup)
deployed_at = Aprsme.Release.deployed_at() 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_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, packet_buffer: [], buffer_timer: nil)
socket = assign(socket, all_packets: %{}, station_popup_open: false) socket = assign(socket, all_packets: %{}, station_popup_open: false)
@ -60,6 +118,37 @@ defmodule AprsmeWeb.MapLive.Index do
)} )}
end 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() @spec assign_defaults(Socket.t(), DateTime.t()) :: Socket.t()
defp assign_defaults(socket, one_hour_ago) do defp assign_defaults(socket, one_hour_ago) do
assign(socket, assign(socket,
@ -168,8 +257,8 @@ defmodule AprsmeWeb.MapLive.Index do
def handle_event("map_ready", _params, socket) do def handle_event("map_ready", _params, socket) do
socket = assign(socket, map_ready: true) socket = assign(socket, map_ready: true)
# Load historical packets with a small delay to ensure map is fully ready # Load historical packets immediately since we now have bounds from URL parameters
Process.send_after(self(), :reload_historical_packets, 100) Process.send_after(self(), :reload_historical_packets, 10)
# If we have pending geolocation, zoom to it now # If we have pending geolocation, zoom to it now
socket = socket =
@ -266,6 +355,100 @@ defmodule AprsmeWeb.MapLive.Index do
{:noreply, socket} {:noreply, socket}
end end
@impl true
def handle_event("update_map_state", %{"center" => center, "zoom" => zoom} = params, socket) do
# Parse center coordinates
lat =
case center do
%{"lat" => lat_val} -> lat_val
_ -> socket.assigns.map_center.lat
end
lng =
case center do
%{"lng" => lng_val} -> lng_val
_ -> socket.assigns.map_center.lng
end
# Validate and clamp values
lat = max(-90.0, min(90.0, lat))
lng = max(-180.0, min(180.0, lng))
zoom = max(1, min(20, zoom))
map_center = %{lat: lat, lng: lng}
# Update socket state
socket = assign(socket, map_center: map_center, map_zoom: zoom)
# Update URL without page reload
new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}"
socket = push_patch(socket, to: new_path, replace: true)
# If bounds are included, also process bounds update
socket =
case Map.get(params, "bounds") do
%{"north" => north, "south" => south, "east" => east, "west" => west} ->
map_bounds = %{
north: north,
south: south,
east: east,
west: west
}
# Only trigger bounds processing if bounds actually changed
if socket.assigns.map_bounds != map_bounds do
send(self(), {:process_bounds_update, map_bounds})
end
socket
_ ->
socket
end
{:noreply, socket}
end
@impl true
def handle_event(
"error_boundary_triggered",
%{"message" => message, "stack" => stack, "component_id" => component_id},
socket
) do
# Log the error for monitoring
require Logger
Logger.error("Error boundary triggered in component #{component_id}: #{message}\n#{stack}")
# You could also send this to an error tracking service here
# ErrorTracker.report_error(message, stack, %{component: component_id, user_id: socket.assigns[:current_user_id]})
{:noreply, socket}
end
@impl true
def handle_params(params, _url, socket) do
# Parse new map state from URL parameters
{map_center, map_zoom} = parse_map_params(params)
# Update socket state
socket = assign(socket, map_center: map_center, map_zoom: map_zoom)
# If map is ready, update the client-side map
socket =
if socket.assigns.map_ready do
push_event(socket, "zoom_to_location", %{
lat: map_center.lat,
lng: map_center.lng,
zoom: map_zoom
})
else
socket
end
{:noreply, socket}
end
@impl true @impl true
def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket) def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket)
@ -283,6 +466,11 @@ defmodule AprsmeWeb.MapLive.Index do
def handle_info(:start_geolocation, socket), do: handle_info_start_geolocation(socket) def handle_info(:start_geolocation, socket), do: handle_info_start_geolocation(socket)
def handle_info({:load_historical_batch, batch_offset}, socket) do
socket = load_historical_batch(socket, batch_offset)
{:noreply, socket}
end
def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket), def handle_info(%Phoenix.Socket.Broadcast{topic: "aprs_messages", event: "packet", payload: packet}, socket),
do: handle_info({:postgres_packet, packet}, socket) do: handle_info({:postgres_packet, packet}, socket)
@ -329,21 +517,18 @@ defmodule AprsmeWeb.MapLive.Index do
defp handle_info_initialize_replay(socket) do defp handle_info_initialize_replay(socket) do
if not socket.assigns.historical_loaded and socket.assigns.map_ready do if not socket.assigns.historical_loaded and socket.assigns.map_ready do
# Use default bounds if map_bounds is not available yet # Only proceed if we have actual map bounds - don't use world bounds
map_bounds = if socket.assigns.map_bounds do
socket.assigns.map_bounds || # Use progressive loading for better performance
%{ socket = start_progressive_historical_loading(socket)
north: 90.0, socket = assign(socket, historical_loaded: true)
south: -90.0, {:noreply, socket}
east: 180.0, else
west: -180.0 # Wait a bit longer for map bounds to be available
} # Increase delay to give client more time to send real bounds
Process.send_after(self(), :initialize_replay, 500)
socket = assign(socket, map_bounds: map_bounds) {:noreply, socket}
end
# Use optimized query for initial load
socket = load_historical_packets_for_bounds_optimized(socket, map_bounds)
{:noreply, socket}
else else
{:noreply, socket} {:noreply, socket}
end end
@ -614,15 +799,17 @@ defmodule AprsmeWeb.MapLive.Index do
} }
</style> </style>
<div <.error_boundary id="map-error-boundary">
id="aprs-map" <div
class={if @slideover_open, do: "slideover-open", else: "slideover-closed"} id="aprs-map"
phx-hook="APRSMap" class={if @slideover_open, do: "slideover-open", else: "slideover-closed"}
phx-update="ignore" phx-hook="APRSMap"
data-center={Jason.encode!(@map_center)} phx-update="ignore"
data-zoom={@map_zoom} data-center={Jason.encode!(@map_center)}
> data-zoom={@map_zoom}
</div> >
</div>
</.error_boundary>
<button <button
class="locate-button" class="locate-button"
@ -970,8 +1157,8 @@ defmodule AprsmeWeb.MapLive.Index do
# Clear existing historical packets # Clear existing historical packets
socket = push_event(socket, "clear_historical_packets", %{}) socket = push_event(socket, "clear_historical_packets", %{})
# Use optimized loading for better performance # Start progressive loading using LiveView's efficient batching
socket = load_historical_packets_for_bounds_optimized(socket, socket.assigns.map_bounds) socket = start_progressive_historical_loading(socket)
{:noreply, socket} {:noreply, socket}
else else
@ -1016,166 +1203,354 @@ defmodule AprsmeWeb.MapLive.Index do
# Helper functions # Helper functions
# Fetch historical packets from the database # 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 # Prefer the most recent position packet, fall back to most recent weather packet
push_event(socket, "add_historical_packets", %{packets: packet_data_list}) case position_packets do
else [] ->
socket # 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
end end
defp build_packet_data_list(historical_packets) do defp build_packet_data_list(historical_packets) do
historical_packets # Group by callsign and identify most recent packet for each
|> Enum.group_by(&PacketUtils.generate_callsign/1) grouped_packets =
|> Enum.flat_map(&process_callsign_packets/1) Enum.group_by(historical_packets, fn packet ->
end packet.sender || "unknown"
end)
defp process_callsign_packets({callsign, packets}) do # Batch fetch weather information for all callsigns to avoid N+1 queries
sorted_packets = sort_packets_by_inserted_at(packets) callsigns = Map.keys(grouped_packets)
unique_position_packets = filter_unique_positions(sorted_packets) weather_callsigns = get_weather_callsigns_batch(callsigns)
unique_position_packets # For each callsign group, find the most recent packet and mark it appropriately
|> Enum.with_index() grouped_packets
|> Enum.map(&build_packet_data_with_index(&1, callsign)) |> 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) |> Enum.filter(& &1)
end end
defp sort_packets_by_inserted_at(packets) do defp build_minimal_packet_data(packet, is_most_recent, has_weather) do
Enum.sort_by( # Build minimal packet data without calling expensive PacketUtils.build_packet_data
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
{lat, lon, _} = MapHelpers.get_coordinates(packet) {lat, lon, _} = MapHelpers.get_coordinates(packet)
if lat && lon do if lat && lon do
if position_changed?(packet, last_packet), do: [packet | acc], else: acc # Use PacketUtils to get symbol information properly (includes data_extended fallback)
else symbol_table_id = PacketUtils.get_packet_field(packet, :symbol_table_id, "/")
acc 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
end end
# Check if position changed significantly between two packets (more than ~1 meter) defp build_simple_popup(packet, has_weather) do
@spec position_changed?(struct(), struct()) :: boolean() # Build popup HTML directly without database queries
defp position_changed?(packet1, packet2) do callsign = packet.sender || "Unknown"
{lat1, lng1, _} = MapHelpers.get_coordinates(packet1) timestamp_dt = packet.received_at || DateTime.utc_now()
{lat2, lng2, _} = MapHelpers.get_coordinates(packet2) cache_buster = System.system_time(:millisecond)
abs(lat1 - lat2) > 0.0001 || abs(lng1 - lng2) > 0.0001 # Check if this packet itself is a weather packet
end is_weather = PacketUtils.weather_packet?(packet)
# Fetch historical packets from the database if is_weather do
@spec fetch_historical_packets(list(), DateTime.t(), DateTime.t()) :: [struct()] # Build weather popup
defp fetch_historical_packets(bounds, start_time, end_time) do %{
effective_start_time = start_time callsign: callsign,
comment: nil,
# Use the Packets context to retrieve historical packets timestamp_dt: timestamp_dt,
packets_params = %{ cache_buster: cache_buster,
bounds: bounds, weather: true,
start_time: effective_start_time, weather_link: true,
end_time: end_time, temperature: PacketUtils.get_weather_field(packet, :temperature),
with_position: true, temp_unit: "°F",
# Reasonable limit to prevent overwhelming the client humidity: PacketUtils.get_weather_field(packet, :humidity),
limit: 1000 wind_direction: PacketUtils.get_weather_field(packet, :wind_direction),
} wind_speed: PacketUtils.get_weather_field(packet, :wind_speed),
wind_unit: "mph",
# Call the database through the Packets context wind_gust: PacketUtils.get_weather_field(packet, :wind_gust),
packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets) gust_unit: "mph",
packets = packets_module.get_packets_for_replay(packets_params) pressure: PacketUtils.get_weather_field(packet, :pressure),
rain_1h: PacketUtils.get_weather_field(packet, :rain_1h),
# Sort packets by received_at timestamp to ensure chronological replay rain_24h: PacketUtils.get_weather_field(packet, :rain_24h),
Enum.sort_by(packets, & &1.received_at) rain_since_midnight: PacketUtils.get_weather_field(packet, :rain_since_midnight),
end rain_1h_unit: "in",
rain_24h_unit: "in",
@spec load_historical_packets_for_bounds(Socket.t(), map()) :: Socket.t() rain_since_midnight_unit: "in"
defp load_historical_packets_for_bounds(socket, map_bounds) do }
now = DateTime.utc_now() |> PopupComponent.popup()
historical_hours = String.to_integer(socket.assigns.historical_hours) |> Safe.to_iodata()
start_time = DateTime.add(now, -historical_hours * 3600, :second) |> IO.iodata_to_binary()
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)
else 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
end end
@spec load_historical_packets_for_bounds_optimized(Socket.t(), map()) :: Socket.t() # Batch fetch weather callsigns to avoid N+1 queries
defp load_historical_packets_for_bounds_optimized(socket, map_bounds) do defp get_weather_callsigns_batch(callsigns) when is_list(callsigns) do
bounds = [ import Ecto.Query
map_bounds.west,
map_bounds.south,
map_bounds.east,
map_bounds.north
]
# Use the optimized query for initial load with smaller limit for faster loading # Normalize callsigns
packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets) normalized_callsigns = Enum.map(callsigns, &String.upcase/1)
historical_packets = # Single query to find all callsigns that have weather packets
packets_module.get_recent_packets_optimized(%{ query =
bounds: bounds, from p in Aprsme.Packet,
# Reduced limit for faster initial load where: fragment("UPPER(?)", p.sender) in ^normalized_callsigns,
limit: 200 where: p.data_type == "weather" or (p.symbol_table_id == "/" and p.symbol_code == "_"),
}) select: fragment("UPPER(?)", p.sender),
distinct: true
if Enum.empty?(historical_packets) do weather_callsigns = Aprsme.Repo.all(query)
assign(socket, historical_loaded: true) MapSet.new(weather_callsigns)
rescue
_ -> MapSet.new()
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
|> assign(loading_batch: 0, total_batches: 1)
|> load_historical_batch(0)
else else
process_historical_packets(socket, historical_packets) # Low zoom - use progressive loading to prevent overwhelming
total_batches = calculate_batch_count_for_zoom(zoom)
# Start with first batch
socket =
socket
|> assign(loading_batch: 0, total_batches: total_batches)
|> load_historical_batch(0)
# Load all remaining batches immediately - no delays
Enum.each(1..(total_batches - 1), fn batch_index ->
send(self(), {:load_historical_batch, batch_index})
end)
socket
end
end
# Calculate optimal batch size based on zoom level
# Higher zoom = smaller viewport = load everything at once for speed
@spec calculate_batch_size_for_zoom(integer()) :: integer()
# High zoom - load everything at once (up to 500 packets)
defp calculate_batch_size_for_zoom(zoom) when zoom >= 10, do: 500
# Medium zoom
defp calculate_batch_size_for_zoom(zoom) when zoom >= 8, do: 100
# Zoomed out
defp calculate_batch_size_for_zoom(zoom) when zoom >= 5, do: 75
# Very zoomed out
defp calculate_batch_size_for_zoom(_), do: 50
# Calculate optimal number of batches based on zoom level
# Higher zoom = fewer batches needed since viewport is smaller
@spec calculate_batch_count_for_zoom(integer()) :: integer()
# Very zoomed in - fewer batches
defp calculate_batch_count_for_zoom(zoom) when zoom >= 15, do: 2
# Moderately zoomed in
defp calculate_batch_count_for_zoom(zoom) when zoom >= 12, do: 3
# Medium zoom
defp calculate_batch_count_for_zoom(zoom) when zoom >= 8, do: 4
# Zoomed out - more batches
defp calculate_batch_count_for_zoom(_), do: 5
@spec load_historical_batch(Socket.t(), integer()) :: Socket.t()
defp load_historical_batch(socket, batch_offset) do
if socket.assigns.map_bounds do
bounds = [
socket.assigns.map_bounds.west,
socket.assigns.map_bounds.south,
socket.assigns.map_bounds.east,
socket.assigns.map_bounds.north
]
# Calculate zoom-based batch size - higher zoom = smaller batches for faster response
zoom = socket.assigns.map_zoom || 5
batch_size = calculate_batch_size_for_zoom(zoom)
offset = batch_offset * batch_size
packets_module = Application.get_env(:aprsme, :packets_module, Aprsme.Packets)
historical_packets =
if packets_module == Aprsme.Packets do
# Use cached queries for better performance
# Include zoom level in cache key for better cache efficiency
Aprsme.CachedQueries.get_recent_packets_cached(%{
bounds: bounds,
limit: batch_size,
offset: offset,
zoom: zoom
})
else
# Fallback for testing
packets_module.get_recent_packets_optimized(%{
bounds: bounds,
limit: batch_size,
offset: offset
})
end
if Enum.any?(historical_packets) do
# Process this batch and send to frontend
packet_data_list = build_packet_data_list(historical_packets)
if Enum.any?(packet_data_list) do
# Use LiveView's efficient push_event for incremental updates
total_batches = socket.assigns.total_batches || 4
socket =
push_event(socket, "add_historical_packets_batch", %{
packets: packet_data_list,
batch: batch_offset,
is_final: batch_offset >= total_batches - 1
})
# Update progress for user feedback
socket = assign(socket, loading_batch: batch_offset + 1)
socket
else
socket
end
else
socket
end
else
socket
end end
end end
@ -1305,7 +1680,16 @@ defmodule AprsmeWeb.MapLive.Index do
if compare_bounds(map_bounds, socket.assigns.map_bounds) do if compare_bounds(map_bounds, socket.assigns.map_bounds) do
{:noreply, socket} {:noreply, socket}
else 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
end end
@ -1314,7 +1698,7 @@ defmodule AprsmeWeb.MapLive.Index do
Process.cancel_timer(socket.assigns.bounds_update_timer) Process.cancel_timer(socket.assigns.bounds_update_timer)
end 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) socket = assign(socket, bounds_update_timer: timer_ref, pending_bounds: map_bounds)
{:noreply, socket} {:noreply, socket}
end end
@ -1345,8 +1729,9 @@ defmodule AprsmeWeb.MapLive.Index do
# Remove only out-of-bounds historical packets instead of clearing all # Remove only out-of-bounds historical packets instead of clearing all
socket = push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds}) socket = push_event(socket, "filter_markers_by_bounds", %{bounds: map_bounds})
# Load additional historical packets for the new bounds if needed # Load historical packets for the new bounds
socket = load_historical_packets_for_bounds(socket, map_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 # Update map bounds and visible packets
assign(socket, map_bounds: map_bounds, visible_packets: new_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()} @spec get_symbol_info(map()) :: {String.t(), String.t()}
def get_symbol_info(packet) do def get_symbol_info(packet) do
symbol_table_id = get_packet_field(packet, :symbol_table_id, "/") AprsmeWeb.AprsSymbol.extract_from_packet(packet)
symbol_code = get_packet_field(packet, :symbol_code, ">")
{symbol_table_id, symbol_code}
end end
@doc """ @doc """
@ -91,9 +88,17 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
@spec has_weather_packets?(String.t()) :: boolean() @spec has_weather_packets?(String.t()) :: boolean()
# Get recent packets for this callsign and check if any are weather packets # Get recent packets for this callsign and check if any are weather packets
def has_weather_packets?(callsign) when is_binary(callsign) do def has_weather_packets?(callsign) when is_binary(callsign) do
%{callsign: callsign, limit: 10} # Use a more efficient query that only checks for existence
|> Aprsme.Packets.get_recent_packets() import Ecto.Query
|> Enum.any?(&weather_packet?/1)
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 rescue
_ -> false _ -> false
end end
@ -256,6 +261,15 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
"live_#{packet_info.callsign}_#{System.unique_integer([:positive])}" "live_#{packet_info.callsign}_#{System.unique_integer([:positive])}"
end 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, "id" => packet_id,
"callsign" => packet_info.callsign, "callsign" => packet_info.callsign,
@ -271,7 +285,8 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
"symbol_code" => packet_info.symbol_code, "symbol_code" => packet_info.symbol_code,
"symbol_description" => "Symbol: #{packet_info.symbol_table_id}#{packet_info.symbol_code}", "symbol_description" => "Symbol: #{packet_info.symbol_table_id}#{packet_info.symbol_code}",
"timestamp" => packet_info.timestamp, "timestamp" => packet_info.timestamp,
"popup" => popup "popup" => popup,
"symbol_html" => symbol_html
} }
end end

View file

@ -122,7 +122,7 @@ defmodule Aprsme.MixProject do
end end
defp aprs_dep do defp aprs_dep do
if Mix.env() in [:dev, :test] do if Mix.env() in [:dev] do
{:aprs, path: "vendor/aprs"} {:aprs, path: "vendor/aprs"}
else else
{:aprs, github: "aprsme/aprs", branch: "main"} {:aprs, github: "aprsme/aprs", branch: "main"}

View file

@ -1,4 +1,5 @@
%{ %{
"aprs": {:git, "https://github.com/aprsme/aprs.git", "39cbe6e277a2a98296140ec0ef5073a798c258d0", [branch: "main"]},
"bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},

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,151 @@
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
# Green star in alternate table
assert AprsSymbol.get_overlay_base_table_id("#") == "1"
# Diamond in alternate table
assert AprsSymbol.get_overlay_base_table_id("&") == "1"
# Black square in alternate table
assert AprsSymbol.get_overlay_base_table_id("i") == "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
assert AprsSymbol.get_overlay_base_table_id("?") == "1"
end
end
end

View file

@ -0,0 +1,105 @@
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

View file

@ -0,0 +1,186 @@
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
import {
parseTimestamp,
getTrailId,
saveMapState,
safePushEvent,
isLiveViewConnected,
getLiveSocket
} from '../../../assets/js/map_helpers';
describe('map_helpers', () => {
describe('parseTimestamp', () => {
test('returns current time for null/undefined', () => {
const before = Date.now();
const result = parseTimestamp(null);
const after = Date.now();
expect(result).toBeGreaterThanOrEqual(before);
expect(result).toBeLessThanOrEqual(after);
});
test('returns number timestamp as-is', () => {
const timestamp = 1234567890;
expect(parseTimestamp(timestamp)).toBe(timestamp);
});
test('parses string timestamp', () => {
const dateStr = '2024-01-01T00:00:00Z';
const expected = new Date(dateStr).getTime();
expect(parseTimestamp(dateStr)).toBe(expected);
});
test('returns current time for invalid input', () => {
const before = Date.now();
const result = parseTimestamp({});
const after = Date.now();
expect(result).toBeGreaterThanOrEqual(before);
expect(result).toBeLessThanOrEqual(after);
});
});
describe('getTrailId', () => {
test('prioritizes callsign_group', () => {
const data = {
callsign_group: 'GROUP-1',
callsign: 'CALL-1',
id: 'ID-1'
};
expect(getTrailId(data)).toBe('GROUP-1');
});
test('falls back to callsign when no callsign_group', () => {
const data = {
callsign: 'CALL-1',
id: 'ID-1'
};
expect(getTrailId(data)).toBe('CALL-1');
});
test('falls back to id when no callsign_group or callsign', () => {
const data = {
id: 'ID-1'
};
expect(getTrailId(data)).toBe('ID-1');
});
});
describe('saveMapState', () => {
let mockMap: any;
let mockPushEvent: any;
beforeEach(() => {
// Mock localStorage
const localStorageMock = {
setItem: vi.fn()
};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true
});
// Mock map object
mockMap = {
getCenter: vi.fn().mockReturnValue({ lat: 40.7128, lng: -74.0060 }),
getZoom: vi.fn().mockReturnValue(10),
getBounds: vi.fn().mockReturnValue({
getNorth: vi.fn().mockReturnValue(41.0),
getSouth: vi.fn().mockReturnValue(40.0),
getEast: vi.fn().mockReturnValue(-73.0),
getWest: vi.fn().mockReturnValue(-75.0)
})
};
mockPushEvent = vi.fn();
});
test('saves truncated coordinates to localStorage', () => {
saveMapState(mockMap, mockPushEvent);
expect(localStorage.setItem).toHaveBeenCalledWith(
'aprs_map_state',
JSON.stringify({ lat: 40.7128, lng: -74.006, zoom: 10 })
);
});
test('pushes event with map state and bounds', () => {
saveMapState(mockMap, mockPushEvent);
expect(mockPushEvent).toHaveBeenCalledWith('update_map_state', {
center: { lat: 40.7128, lng: -74.006 },
zoom: 10,
bounds: {
north: 41.0,
south: 40.0,
east: -73.0,
west: -75.0
}
});
});
test('truncates coordinates to 5 decimal places', () => {
mockMap.getCenter.mockReturnValue({ lat: 40.71281234567, lng: -74.00601234567 });
saveMapState(mockMap, mockPushEvent);
const call = mockPushEvent.mock.calls[0][1];
expect(call.center.lat).toBe(40.71281);
expect(call.center.lng).toBe(-74.00601);
});
});
describe('safePushEvent', () => {
test('calls pushEvent and returns true on success', () => {
const mockPushEvent = vi.fn();
const result = safePushEvent(mockPushEvent, 'test_event', { data: 'test' });
expect(mockPushEvent).toHaveBeenCalledWith('test_event', { data: 'test' });
expect(result).toBe(true);
});
test('returns false when pushEvent is undefined', () => {
const result = safePushEvent(undefined, 'test_event', { data: 'test' });
expect(result).toBe(false);
});
test('catches error and returns false', () => {
const mockPushEvent = vi.fn().mockImplementation(() => {
throw new Error('LiveView not connected');
});
const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
const result = safePushEvent(mockPushEvent, 'test_event', { data: 'test' });
expect(result).toBe(false);
expect(consoleSpy).toHaveBeenCalledWith('Unable to send test_event event - LiveView disconnected');
consoleSpy.mockRestore();
});
});
describe('LiveView socket helpers', () => {
afterEach(() => {
// Clean up window.liveSocket
delete (window as any).liveSocket;
});
test('isLiveViewConnected returns true when socket exists', () => {
(window as any).liveSocket = { connected: true };
expect(isLiveViewConnected()).toBe(true);
});
test('isLiveViewConnected returns false when socket missing', () => {
expect(isLiveViewConnected()).toBe(false);
});
test('getLiveSocket returns the socket', () => {
const mockSocket = { connected: true, pushHistoryPatch: vi.fn() };
(window as any).liveSocket = mockSocket;
expect(getLiveSocket()).toBe(mockSocket);
});
test('getLiveSocket returns undefined when missing', () => {
expect(getLiveSocket()).toBeUndefined();
});
});
});

View file

@ -0,0 +1,55 @@
defmodule AprsmeWeb.MapIssuesTest do
use ExUnit.Case, async: true
describe "map.ts issues" do
test "memory leaks identified" do
issues = [
"Interval timer on line 278 not stored or cleared - will run forever",
"Map event listeners (moveend, zoomend) not removed on destruction",
"No cleanup for map.whenReady callback"
]
assert length(issues) == 3
end
test "race conditions identified" do
issues = [
"programmaticMoveCounter can become incorrect with rapid moves",
"boundsTimer could have overlapping timeouts",
"Popup events could fire after component destruction"
]
assert length(issues) == 3
end
test "error handling issues" do
issues = [
"marker_clicked event doesn't check LiveView connection first",
"Errors in destroyed() are silently swallowed without logging",
"No user feedback for failed operations"
]
assert length(issues) == 3
end
test "performance issues" do
issues = [
"O(n) lookup for duplicate markers at same position",
"Unnecessary marker re-creation for popup opening",
"Repeated access to window.liveSocket without caching"
]
assert length(issues) == 3
end
test "code duplication" do
duplicated_functions = [
"Map state saving logic (lines 310-325 and 350-373)",
"Timestamp parsing (4 different locations)",
"Trail ID calculation (3 different locations)"
]
assert length(duplicated_functions) == 3
end
end
end