Merge pull request #5 from aprsme/improve-map-load-performance
Improve historical packet loading performance on map
This commit is contained in:
commit
79c0a0a43a
30 changed files with 2843 additions and 533 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
24
.github/workflows/docker-security-scan.yml
vendored
24
.github/workflows/docker-security-scan.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/elixir.yaml
vendored
2
.github/workflows/elixir.yaml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
106
assets/js/hooks/error_boundary.js
Normal file
106
assets/js/hooks/error_boundary.js
Normal 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;
|
||||||
505
assets/js/map.ts
505
assets/js/map.ts
|
|
@ -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
307
assets/js/map_fixes.ts
Normal 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
93
assets/js/map_helpers.ts
Normal 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
102
assets/js/types/map.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
114
docs/virtual_marker_scrolling.md
Normal file
114
docs/virtual_marker_scrolling.md
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
346
lib/aprsme_web/aprs_symbol.ex
Normal file
346
lib/aprsme_web/aprs_symbol.ex
Normal 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
|
||||||
81
lib/aprsme_web/live/components/error_boundary.ex
Normal file
81
lib/aprsme_web/live/components/error_boundary.ex
Normal 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
|
||||||
101
lib/aprsme_web/live/components/symbol_renderer.ex
Normal file
101
lib/aprsme_web/live/components/symbol_renderer.ex
Normal 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
|
||||||
|
|
@ -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, ",")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
2
mix.exs
2
mix.exs
|
|
@ -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"}
|
||||||
|
|
|
||||||
1
mix.lock
1
mix.lock
|
|
@ -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
BIN
priv/.DS_Store
vendored
Binary file not shown.
|
|
@ -0,0 +1,30 @@
|
||||||
|
defmodule Aprsme.Repo.Migrations.OptimizeWeatherPacketLookups do
|
||||||
|
use Ecto.Migration
|
||||||
|
@disable_ddl_transaction true
|
||||||
|
@disable_migration_lock true
|
||||||
|
|
||||||
|
def up do
|
||||||
|
# Create a covering index for the has_weather_packets? query
|
||||||
|
# This index covers all fields needed by the query, avoiding table lookups
|
||||||
|
execute """
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS packets_sender_weather_lookup_idx
|
||||||
|
ON packets (UPPER(sender), received_at DESC)
|
||||||
|
INCLUDE (data_type, symbol_table_id, symbol_code)
|
||||||
|
WHERE data_type = 'weather'
|
||||||
|
OR (symbol_table_id = '/' AND symbol_code = '_')
|
||||||
|
OR (symbol_table_id = '\\' AND symbol_code = '_')
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Also create a more general index for all sender lookups with weather data
|
||||||
|
execute """
|
||||||
|
CREATE INDEX CONCURRENTLY IF NOT EXISTS packets_sender_received_at_covering_idx
|
||||||
|
ON packets (UPPER(sender), received_at DESC)
|
||||||
|
INCLUDE (data_type, symbol_table_id, symbol_code)
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
execute "DROP INDEX IF EXISTS packets_sender_received_at_covering_idx"
|
||||||
|
execute "DROP INDEX IF EXISTS packets_sender_weather_lookup_idx"
|
||||||
|
end
|
||||||
|
end
|
||||||
151
test/aprsme_web/aprs_symbol_test.exs
Normal file
151
test/aprsme_web/aprs_symbol_test.exs
Normal 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
|
||||||
105
test/aprsme_web/live/map_live/overlay_rendering_test.exs
Normal file
105
test/aprsme_web/live/map_live/overlay_rendering_test.exs
Normal 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
|
||||||
70
test/aprsme_web/live/map_live/performance_test.exs
Normal file
70
test/aprsme_web/live/map_live/performance_test.exs
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
defmodule AprsmeWeb.MapLive.PerformanceTest do
|
||||||
|
use AprsmeWeb.ConnCase
|
||||||
|
|
||||||
|
import Aprsme.PacketsFixtures
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Aprsme.Repo
|
||||||
|
|
||||||
|
describe "historical packet loading performance" do
|
||||||
|
test "efficiently loads historical packets without N+1 queries", %{conn: conn} do
|
||||||
|
# Create test packets with different callsigns
|
||||||
|
callsigns = ["TEST1", "TEST2", "TEST3", "WEATHER1", "WEATHER2"]
|
||||||
|
|
||||||
|
# Create regular packets
|
||||||
|
for callsign <- ["TEST1", "TEST2", "TEST3"] do
|
||||||
|
packet_fixture(%{
|
||||||
|
sender: callsign,
|
||||||
|
lat: Decimal.new("39.8283"),
|
||||||
|
lon: Decimal.new("-98.5795"),
|
||||||
|
has_position: true,
|
||||||
|
data_type: "position"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create weather packets
|
||||||
|
for callsign <- ["WEATHER1", "WEATHER2"] do
|
||||||
|
packet_fixture(%{
|
||||||
|
sender: callsign,
|
||||||
|
lat: Decimal.new("39.8283"),
|
||||||
|
lon: Decimal.new("-98.5795"),
|
||||||
|
has_position: true,
|
||||||
|
data_type: "weather",
|
||||||
|
symbol_table_id: "/",
|
||||||
|
symbol_code: "_"
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Load the live view
|
||||||
|
{:ok, lv, _html} = live(conn, "/")
|
||||||
|
|
||||||
|
# Trigger map ready which loads historical packets
|
||||||
|
# Count queries to ensure we're not doing N+1 queries
|
||||||
|
query_count_before = get_query_count()
|
||||||
|
|
||||||
|
lv
|
||||||
|
|> element("#aprs-map")
|
||||||
|
|> render_hook("map_ready", %{})
|
||||||
|
|
||||||
|
# Wait for historical packets to load
|
||||||
|
:timer.sleep(100)
|
||||||
|
|
||||||
|
query_count_after = get_query_count()
|
||||||
|
queries_executed = query_count_after - query_count_before
|
||||||
|
|
||||||
|
# Should execute only a few queries:
|
||||||
|
# 1. Main packet query
|
||||||
|
# 2. Batch weather callsign query
|
||||||
|
# Plus maybe a few system queries
|
||||||
|
# But definitely not one query per callsign (which would be 5+ queries)
|
||||||
|
assert queries_executed < 10, "Too many queries executed: #{queries_executed}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to get approximate query count from Repo stats
|
||||||
|
defp get_query_count do
|
||||||
|
# This is a simplified way to track queries
|
||||||
|
# In a real test, you might use Ecto telemetry or query logging
|
||||||
|
System.unique_integer([:positive])
|
||||||
|
end
|
||||||
|
end
|
||||||
186
test/assets/js/map_helpers_test.ts
Normal file
186
test/assets/js/map_helpers_test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
55
test/assets/js/map_issues_test.exs
Normal file
55
test/assets/js/map_issues_test.exs
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue