typescript

This commit is contained in:
Graham McIntire 2025-06-18 12:26:13 -05:00
parent 31159d2e10
commit 369ce05f1a
No known key found for this signature in database
17 changed files with 419 additions and 200 deletions

View file

@ -25,7 +25,7 @@ import topbar from "../vendor/topbar";
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content"); let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
// Import minimal APRS map hook // Import minimal APRS map hook
import MinimalAPRSMap from "./minimal_map.js"; import MinimalAPRSMap from "./minimal_map";
// APRS Map Hook // APRS Map Hook
let Hooks = {}; let Hooks = {};

View file

@ -1,38 +1,106 @@
// Declare Leaflet as a global variable
declare const L: any;
// Minimal APRS Map Hook - handles only basic map interaction // Minimal 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;
boundsTimer?: ReturnType<typeof setTimeout>;
resizeHandler?: () => void;
errors?: string[];
initializationAttempts?: number;
maxInitializationAttempts?: number;
lastZoom?: number;
[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;
}
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;
}
interface MapEventData {
bounds?: BoundsData;
center?: CenterData;
zoom?: number;
id?: string;
callsign?: string;
lat?: number;
lng?: number;
markers?: MarkerData[];
}
let MinimalAPRSMap = { let MinimalAPRSMap = {
mounted() { mounted() {
const self = this as unknown as LiveViewHookContext;
// Initialize error tracking // Initialize error tracking
this.errors = []; self.errors = [];
this.initializationAttempts = 0; self.initializationAttempts = 0;
this.maxInitializationAttempts = 3; self.maxInitializationAttempts = 3;
this.attemptInitialization(); self.attemptInitialization();
}, },
attemptInitialization() { attemptInitialization() {
this.initializationAttempts++; const self = this as unknown as LiveViewHookContext;
self.initializationAttempts!++;
// Check if Leaflet is available // Check if Leaflet is available
if (typeof L === "undefined") { if (typeof L === "undefined") {
console.error("Leaflet library not loaded!"); console.error("Leaflet library not loaded!");
this.errors.push("Leaflet library not available"); self.errors!.push("Leaflet library not available");
if (this.initializationAttempts < this.maxInitializationAttempts) { if (self.initializationAttempts! < self.maxInitializationAttempts!) {
setTimeout(() => this.attemptInitialization(), 1000); setTimeout(() => self.attemptInitialization(), 1000);
return; return;
} else { } else {
this.handleFatalError("Leaflet library failed to load after multiple attempts"); self.handleFatalError("Leaflet library failed to load after multiple attempts");
return; return;
} }
} }
// Get initial center and zoom from server-provided data attributes // Get initial center and zoom from server-provided data attributes
let initialCenter, initialZoom; let initialCenter: CenterData, initialZoom: number;
try { try {
const centerData = this.el.dataset.center; const centerData = self.el.dataset.center;
const zoomData = this.el.dataset.zoom; const zoomData = self.el.dataset.zoom;
if (!centerData || !zoomData) { if (!centerData || !zoomData) {
throw new Error("Missing map data attributes"); throw new Error("Missing map data attributes");
@ -61,39 +129,40 @@ let MinimalAPRSMap = {
initialZoom = 5; initialZoom = 5;
} }
this.initializeMap(initialCenter, initialZoom); self.initializeMap(initialCenter, initialZoom);
}, },
initializeMap(initialCenter, initialZoom) { initializeMap(initialCenter: CenterData, initialZoom: number) {
const self = this as unknown as LiveViewHookContext;
// Ensure the element and its parent exist // Ensure the element and its parent exist
if (!this.el || !this.el.parentNode) { if (!self.el || !self.el.parentNode) {
console.warn("Map element or parent not found, retrying..."); console.warn("Map element or parent not found, retrying...");
if (this.initializationAttempts < this.maxInitializationAttempts) { if (self.initializationAttempts! < self.maxInitializationAttempts!) {
setTimeout(() => this.attemptInitialization(), 500); setTimeout(() => self.attemptInitialization(), 500);
return; return;
} else { } else {
this.handleFatalError("Map element structure invalid"); self.handleFatalError("Map element structure invalid");
return; return;
} }
} }
// Check element dimensions // Check element dimensions
const rect = this.el.getBoundingClientRect(); const rect = self.el.getBoundingClientRect();
// Validate element has dimensions // Validate element has dimensions
if (rect.width === 0 || rect.height === 0) { if (rect.width === 0 || rect.height === 0) {
console.warn("Map element has no dimensions, retrying..."); console.warn("Map element has no dimensions, retrying...");
this.errors.push("Map element has zero dimensions"); self.errors!.push("Map element has zero dimensions");
// Try to force dimensions // Try to force dimensions
this.el.style.width = "100%"; self.el.style.width = "100%";
this.el.style.height = "100vh"; self.el.style.height = "100vh";
if (this.initializationAttempts < this.maxInitializationAttempts) { if (self.initializationAttempts! < self.maxInitializationAttempts!) {
setTimeout(() => this.attemptInitialization(), 500); setTimeout(() => self.attemptInitialization(), 500);
return; return;
} else { } else {
this.handleFatalError("Map element never gained proper dimensions"); self.handleFatalError("Map element never gained proper dimensions");
return; return;
} }
} }
@ -101,36 +170,36 @@ let MinimalAPRSMap = {
// Initialize basic map // Initialize basic map
try { try {
// Check if map is already initialized // Check if map is already initialized
if (this.map) { if (self.map) {
console.warn("Map already exists, reinitializing..."); console.warn("Map already exists, reinitializing...");
this.map.remove(); self.map.remove();
this.map = null; self.map = null;
} }
// Ensure element ID is unique and not already initialized // Ensure element ID is unique and not already initialized
if (this.el._leaflet_id) { if (self.el._leaflet_id) {
console.warn("Map element already has Leaflet ID, cleaning up..."); console.warn("Map element already has Leaflet ID, cleaning up...");
// Remove any existing Leaflet instance const el = L.DomUtil.get(self.el.id) as HTMLElement | null;
if (L.DomUtil.get(this.el.id)) { if (el !== null) {
L.DomUtil.remove(L.DomUtil.get(this.el.id)); L.DomUtil.remove(el);
} }
delete this.el._leaflet_id; delete self.el._leaflet_id;
} }
this.map = L.map(this.el, { self.map = L.map(self.el, {
zoomControl: true, zoomControl: true,
attributionControl: true, attributionControl: true,
closePopupOnClick: true, closePopupOnClick: true,
}).setView([initialCenter.lat, initialCenter.lng], initialZoom); }).setView([initialCenter.lat, initialCenter.lng], initialZoom);
} catch (error) { } catch (error) {
console.error("Error initializing map:", error); console.error("Error initializing map:", error);
this.errors.push("Map initialization failed: " + error.message); self.errors!.push("Map initialization failed: " + (error instanceof Error ? error.message : error));
if (this.initializationAttempts < this.maxInitializationAttempts) { if (self.initializationAttempts! < self.maxInitializationAttempts!) {
setTimeout(() => this.attemptInitialization(), 1000); setTimeout(() => self.attemptInitialization(), 1000);
return; return;
} else { } else {
this.handleFatalError("Map initialization failed after multiple attempts"); self.handleFatalError("Map initialization failed after multiple attempts");
return; return;
} }
} }
@ -142,80 +211,80 @@ let MinimalAPRSMap = {
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me', '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors | APRS.me',
maxZoom: 19, maxZoom: 19,
}); });
tileLayer.addTo(this.map); tileLayer.addTo(self.map);
} catch (error) { } catch (error) {
this.errors.push("Tile layer failed: " + error.message); self.errors!.push("Tile layer failed: " + (error instanceof Error ? error.message : error));
} }
// Store markers for management // Store markers for management
this.markers = new Map(); self.markers = new Map<string, any>();
this.markerLayer = L.layerGroup().addTo(this.map); self.markerLayer = L.layerGroup().addTo(self.map);
// Track marker states to prevent unnecessary operations // Track marker states to prevent unnecessary operations
this.markerStates = new Map(); self.markerStates = new Map<string, MarkerState>();
// Force initial size calculation // Force initial size calculation
try { try {
this.map.invalidateSize(); self.map.invalidateSize();
} catch (error) { } catch (error) {
console.error("Error invalidating map size:", error); console.error("Error invalidating map size:", error);
} }
// Track when map is ready // Track when map is ready
this.map.whenReady(() => { self.map!.whenReady(() => {
try { try {
this.lastZoom = this.map.getZoom(); self.lastZoom = self.map!.getZoom();
this.pushEvent("map_ready", {}); self.pushEvent("map_ready", {});
this.sendBoundsToServer(); self.sendBoundsToServer();
} catch (error) { } catch (error) {
console.error("Error in map ready callback:", error); console.error("Error in map ready callback:", error);
} }
}); });
// Send bounds to LiveView when map moves // Send bounds to LiveView when map moves
this.map.on("moveend", () => { self.map!.on("moveend", () => {
if (this.boundsTimer) clearTimeout(this.boundsTimer); if (self.boundsTimer) clearTimeout(self.boundsTimer);
this.boundsTimer = setTimeout(() => { self.boundsTimer = setTimeout(() => {
this.sendBoundsToServer(); self.sendBoundsToServer();
}, 300); }, 300);
}); });
// Handle zoom changes with optimization for large zoom differences // Handle zoom changes with optimization for large zoom differences
this.map.on("zoomend", () => { self.map!.on("zoomend", () => {
if (this.boundsTimer) clearTimeout(this.boundsTimer); if (self.boundsTimer) clearTimeout(self.boundsTimer);
this.boundsTimer = setTimeout(() => { self.boundsTimer = setTimeout(() => {
const currentZoom = this.map.getZoom(); const currentZoom = self.map!.getZoom();
const zoomDifference = this.lastZoom ? Math.abs(currentZoom - this.lastZoom) : 0; const zoomDifference = self.lastZoom ? Math.abs(currentZoom - self.lastZoom) : 0;
// If zoom changed significantly (more than 2 levels), clear and reload // If zoom changed significantly (more than 2 levels), clear and reload
if (zoomDifference > 2) { if (zoomDifference > 2) {
this.pushEvent("clear_and_reload_markers", {}); self.pushEvent("clear_and_reload_markers", {});
} }
this.sendBoundsToServer(); self.sendBoundsToServer();
this.lastZoom = currentZoom; self.lastZoom = currentZoom;
}, 300); }, 300);
}); });
// Handle resize // Handle resize
this.resizeHandler = () => { self.resizeHandler = () => {
try { try {
if (this.map) { if (self.map) {
this.map.invalidateSize(); self.map.invalidateSize();
} }
} catch (error) { } catch (error) {
console.error("Error invalidating map size on resize:", error); console.error("Error invalidating map size on resize:", error);
} }
}; };
window.addEventListener("resize", this.resizeHandler); window.addEventListener("resize", self.resizeHandler);
// Add a delayed size check // Add a delayed size check
setTimeout(() => { setTimeout(() => {
if (this.el && this.el.parentNode) { if (self.el && self.el.parentNode) {
const rect = this.el.getBoundingClientRect(); const rect = self.el.getBoundingClientRect();
if (this.map && rect.width > 0 && rect.height > 0) { if (self.map && rect.width > 0 && rect.height > 0) {
try { try {
this.map.invalidateSize(); self.map.invalidateSize();
} catch (error) { } catch (error) {
console.error("Error re-invalidating map size:", error); console.error("Error re-invalidating map size:", error);
} }
@ -224,16 +293,17 @@ let MinimalAPRSMap = {
}, 1000); }, 1000);
// LiveView event handlers // LiveView event handlers
this.setupLiveViewHandlers(); self.setupLiveViewHandlers();
}, },
handleFatalError(message) { handleFatalError(message: string) {
const self = this as unknown as LiveViewHookContext;
console.error("Fatal map error:", message); console.error("Fatal map error:", message);
console.error("All errors:", this.errors); console.error("All errors:", self.errors);
// Display error message to user // Display error message to user
if (this.el) { if (self.el) {
this.el.innerHTML = ` self.el.innerHTML = `
<div style=" <div style="
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -258,44 +328,45 @@ let MinimalAPRSMap = {
}, },
setupLiveViewHandlers() { setupLiveViewHandlers() {
const self = this as unknown as LiveViewHookContext;
// Add single marker // Add single marker
this.handleEvent("add_marker", (data) => { self.handleEvent("add_marker", (data: MarkerData) => {
this.addMarker(data); self.addMarker(data);
}); });
// Add multiple markers at once // Add multiple markers at once
this.handleEvent("add_markers", (data) => { self.handleEvent("add_markers", (data: { markers: MarkerData[] }) => {
if (data.markers && Array.isArray(data.markers)) { if (data.markers && Array.isArray(data.markers)) {
data.markers.forEach((marker) => this.addMarker(marker)); data.markers.forEach((marker) => self.addMarker(marker));
} }
}); });
// Remove marker // Remove marker
this.handleEvent("remove_marker", (data) => { self.handleEvent("remove_marker", (data: { id: string }) => {
this.removeMarker(data.id); self.removeMarker(data.id);
}); });
// Clear all markers // Clear all markers
this.handleEvent("clear_markers", () => { self.handleEvent("clear_markers", () => {
this.clearAllMarkers(); self.clearAllMarkers();
}); });
// Update marker // Update marker
this.handleEvent("update_marker", (data) => { self.handleEvent("update_marker", (data: MarkerData) => {
this.updateMarker(data); self.updateMarker(data);
}); });
// Zoom to location // Zoom to location
this.handleEvent("zoom_to_location", (data) => { self.handleEvent("zoom_to_location", (data: { lat: number; lng: number; zoom?: number }) => {
if (!this.map) { if (!self.map) {
console.error("Map not initialized, cannot zoom"); console.error("Map not initialized, cannot zoom");
return; return;
} }
if (data.lat && data.lng) { if (data.lat && data.lng) {
const lat = parseFloat(data.lat); const lat = parseFloat(data.lat.toString());
const lng = parseFloat(data.lng); const lng = parseFloat(data.lng.toString());
const zoom = parseInt(data.zoom || 12); const zoom = parseInt(data.zoom?.toString() || "12");
// Validate coordinates // Validate coordinates
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
@ -310,29 +381,29 @@ let MinimalAPRSMap = {
try { try {
// Check element dimensions before zoom // Check element dimensions before zoom
const beforeRect = this.el.getBoundingClientRect(); const beforeRect = self.el.getBoundingClientRect();
// Force map size recalculation before zoom // Force map size recalculation before zoom
this.map.invalidateSize(); self.map.invalidateSize();
// Use a slight delay to ensure map is ready // Use a slight delay to ensure map is ready
setTimeout(() => { setTimeout(() => {
if (this.map) { if (self.map) {
this.map.setView([lat, lng], zoom, { self.map.setView([lat, lng], zoom, {
animate: true, animate: true,
duration: 1, duration: 1,
}); });
// Check element dimensions after zoom // Check element dimensions after zoom
setTimeout(() => { setTimeout(() => {
const afterRect = this.el.getBoundingClientRect(); const afterRect = self.el.getBoundingClientRect();
if (afterRect.width === 0 || afterRect.height === 0) { if (afterRect.width === 0 || afterRect.height === 0) {
console.error("Map element lost dimensions after zoom!"); console.error("Map element lost dimensions after zoom!");
// Try to restore dimensions // Try to restore dimensions
this.el.style.width = "100vw"; self.el.style.width = "100vw";
this.el.style.height = "100vh"; self.el.style.height = "100vh";
this.map.invalidateSize(); self.map.invalidateSize();
} }
}, 1000); }, 1000);
} }
@ -346,92 +417,93 @@ let MinimalAPRSMap = {
}); });
// Handle geolocation requests // Handle geolocation requests
this.handleEvent("request_geolocation", () => { self.handleEvent("request_geolocation", () => {
if ("geolocation" in navigator) { if ("geolocation" in navigator) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { (position) => {
const { latitude, longitude } = position.coords; const { latitude, longitude } = position.coords;
this.pushEvent("set_location", { lat: latitude, lng: longitude }); self.pushEvent("set_location", { lat: latitude, lng: longitude });
}, },
(error) => { (error) => {
console.warn("Geolocation error:", error.message); console.warn("Geolocation error:", error.message);
this.pushEvent("geolocation_error", { error: error.message }); self.pushEvent("geolocation_error", { error: error.message });
}, },
); );
} else { } else {
console.warn("Geolocation not available"); console.warn("Geolocation not available");
this.pushEvent("geolocation_error", { error: "Geolocation not supported" }); self.pushEvent("geolocation_error", { error: "Geolocation not supported" });
} }
}); });
// Handle new packets from LiveView // Handle new packets from LiveView
this.handleEvent("new_packet", (data) => { self.handleEvent("new_packet", (data: MarkerData) => {
this.addMarker({ self.addMarker({
...data, ...data,
historical: false, historical: false,
popup: this.buildPopupContent(data), popup: self.buildPopupContent(data),
}); });
}); });
// Handle historical packets during replay // Handle historical packets during replay
this.handleEvent("historical_packet", (data) => { self.handleEvent("historical_packet", (data: MarkerData) => {
this.addMarker({ self.addMarker({
...data, ...data,
historical: true, historical: true,
popup: this.buildPopupContent(data), popup: self.buildPopupContent(data),
}); });
}); });
// Handle refresh markers event // Handle refresh markers event
this.handleEvent("refresh_markers", () => { self.handleEvent("refresh_markers", () => {
// Remove markers that are outside current bounds // Remove markers that are outside current bounds
if (this.map) { if (self.map) {
const bounds = this.map.getBounds(); const bounds = self.map.getBounds();
this.removeMarkersOutsideBounds(bounds); self.removeMarkersOutsideBounds(bounds);
} }
}); });
// Handle clearing historical packets // Handle clearing historical packets
this.handleEvent("clear_historical_packets", () => { self.handleEvent("clear_historical_packets", () => {
// Remove all historical markers // Remove all historical markers
const markersToRemove = []; const markersToRemove: string[] = [];
this.markers.forEach((marker, id) => { self.markers!.forEach((marker: any, id: any) => {
if (marker._isHistorical) { if ((marker as any)._isHistorical) {
markersToRemove.push(id); markersToRemove.push(id);
} }
}); });
markersToRemove.forEach((id) => this.removeMarker(id)); markersToRemove.forEach((id) => self.removeMarker(id));
}); });
// Handle bounds-based marker filtering // Handle bounds-based marker filtering
this.handleEvent("filter_markers_by_bounds", (data) => { self.handleEvent("filter_markers_by_bounds", (data: { bounds: BoundsData }) => {
if (data.bounds) { if (data.bounds) {
// Create Leaflet bounds object from server data // Create Leaflet bounds object from server data
const bounds = L.latLngBounds( const bounds = L.latLngBounds(
[data.bounds.south, data.bounds.west], [data.bounds.south, data.bounds.west],
[data.bounds.north, data.bounds.east], [data.bounds.north, data.bounds.east],
); );
this.removeMarkersOutsideBounds(bounds); self.removeMarkersOutsideBounds(bounds);
} }
}); });
// Handle clearing all markers and reloading visible ones // Handle clearing all markers and reloading visible ones
this.handleEvent("clear_and_reload_markers", () => { self.handleEvent("clear_and_reload_markers", () => {
// This event is just a trigger - the server will handle clearing and adding markers // This event is just a trigger - the server will handle clearing and adding markers
}); });
}, },
sendBoundsToServer() { sendBoundsToServer() {
if (!this.map) return; const self = this as unknown as LiveViewHookContext;
if (!self.map) return;
const bounds = this.map.getBounds(); const bounds = self.map!.getBounds();
const center = this.map.getCenter(); const center = self.map!.getCenter();
const zoom = this.map.getZoom(); const zoom = self.map!.getZoom();
// Remove markers that are now outside the visible bounds // Remove markers that are now outside the visible bounds
this.removeMarkersOutsideBounds(bounds); self.removeMarkersOutsideBounds(bounds);
this.pushEvent("bounds_changed", { self.pushEvent("bounds_changed", {
bounds: { bounds: {
north: bounds.getNorth(), north: bounds.getNorth(),
south: bounds.getSouth(), south: bounds.getSouth(),
@ -446,14 +518,15 @@ let MinimalAPRSMap = {
}); });
}, },
addMarker(data) { addMarker(data: MarkerData) {
const self = this as unknown as LiveViewHookContext;
if (!data.id || !data.lat || !data.lng) { if (!data.id || !data.lat || !data.lng) {
console.warn("Invalid marker data:", data); console.warn("Invalid marker data:", data);
return; return;
} }
const lat = parseFloat(data.lat); const lat = parseFloat(data.lat.toString());
const lng = parseFloat(data.lng); const lng = parseFloat(data.lng.toString());
// Validate coordinates // Validate coordinates
if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) { if (isNaN(lat) || isNaN(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
@ -462,8 +535,8 @@ let MinimalAPRSMap = {
} }
// Check if marker already exists with same position and data // Check if marker already exists with same position and data
const existingMarker = this.markers.get(data.id); const existingMarker = self.markers!.get(data.id);
const existingState = this.markerStates.get(data.id); const existingState = self.markerStates!.get(data.id);
if (existingMarker && existingState) { if (existingMarker && existingState) {
// Check if marker needs updating // Check if marker needs updating
@ -471,7 +544,7 @@ let MinimalAPRSMap = {
const positionChanged = const positionChanged =
Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001; Math.abs(currentPos.lat - lat) > 0.0001 || Math.abs(currentPos.lng - lng) > 0.0001;
const dataChanged = const dataChanged =
existingState.symbol_table !== data.symbol_table || existingState.symbol_table !== data.symbol_table_id ||
existingState.symbol_code !== data.symbol_code || existingState.symbol_code !== data.symbol_code ||
existingState.popup !== data.popup; existingState.popup !== data.popup;
@ -482,10 +555,10 @@ let MinimalAPRSMap = {
} }
// Remove existing marker if it exists // Remove existing marker if it exists
this.removeMarker(data.id); self.removeMarker(data.id);
// Create marker icon // Create marker icon
const icon = this.createMarkerIcon(data); const icon = self.createMarkerIcon(data);
// Create marker // Create marker
const marker = L.marker([lat, lng], { icon: icon }); const marker = L.marker([lat, lng], { icon: icon });
@ -497,7 +570,7 @@ let MinimalAPRSMap = {
// Handle marker click // Handle marker click
marker.on("click", () => { marker.on("click", () => {
this.pushEvent("marker_clicked", { self.pushEvent("marker_clicked", {
id: data.id, id: data.id,
callsign: data.callsign, callsign: data.callsign,
lat: lat, lat: lat,
@ -507,42 +580,44 @@ let MinimalAPRSMap = {
// Mark historical markers for identification // Mark historical markers for identification
if (data.historical) { if (data.historical) {
marker._isHistorical = true; (marker as any)._isHistorical = true;
} }
// Add to map and store reference // Add to map and store reference
marker.addTo(this.markerLayer); marker.addTo(self.markerLayer!);
this.markers.set(data.id, marker); self.markers!.set(data.id, marker);
// Store marker state for optimization // Store marker state for optimization
this.markerStates.set(data.id, { self.markerStates!.set(data.id, {
lat: lat, lat: lat,
lng: lng, lng: lng,
symbol_table: data.symbol_table, symbol_table: data.symbol_table_id || "/",
symbol_code: data.symbol_code, symbol_code: data.symbol_code || ">",
popup: data.popup, popup: data.popup,
historical: data.historical, historical: data.historical,
}); });
}, },
removeMarker(id) { removeMarker(id: string) {
const marker = this.markers.get(id); const self = this as unknown as LiveViewHookContext;
const marker = self.markers!.get(id);
if (marker) { if (marker) {
this.markerLayer.removeLayer(marker); self.markerLayer!.removeLayer(marker);
this.markers.delete(id); self.markers!.delete(id);
this.markerStates.delete(id); self.markerStates!.delete(id);
} }
}, },
updateMarker(data) { updateMarker(data: MarkerData) {
const self = this as unknown as LiveViewHookContext;
if (!data.id) return; if (!data.id) return;
const existingMarker = this.markers.get(data.id); const existingMarker = self.markers!.get(data.id);
if (existingMarker) { if (existingMarker) {
// Update position if provided // Update position if provided
if (data.lat && data.lng) { if (data.lat && data.lng) {
const lat = parseFloat(data.lat); const lat = parseFloat(data.lat.toString());
const lng = parseFloat(data.lng); const lng = parseFloat(data.lng.toString());
if (!isNaN(lat) && !isNaN(lng)) { if (!isNaN(lat) && !isNaN(lng)) {
existingMarker.setLatLng([lat, lng]); existingMarker.setLatLng([lat, lng]);
} }
@ -554,28 +629,30 @@ let MinimalAPRSMap = {
} }
// Update icon if data changed // Update icon if data changed
if (data.symbol_table || data.symbol_code || data.color) { if (data.symbol_table_id || data.symbol_code || data.color) {
const newIcon = this.createMarkerIcon(data); const newIcon = self.createMarkerIcon(data);
existingMarker.setIcon(newIcon); existingMarker.setIcon(newIcon);
} }
} else { } else {
// Marker doesn't exist, create it // Marker doesn't exist, create it
this.addMarker(data); self.addMarker(data);
} }
}, },
clearAllMarkers() { clearAllMarkers() {
this.markerLayer.clearLayers(); const self = this as unknown as LiveViewHookContext;
this.markers.clear(); self.markerLayer!.clearLayers();
this.markerStates.clear(); self.markers!.clear();
self.markerStates!.clear();
}, },
removeMarkersOutsideBounds(bounds) { removeMarkersOutsideBounds(bounds: L.LatLngBounds) {
if (!bounds || !this.markers) return; const self = this as unknown as LiveViewHookContext;
if (!bounds || !self.markers) return;
const markersToRemove = []; const markersToRemove: string[] = [];
this.markers.forEach((marker, id) => { self.markers!.forEach((marker: L.Marker, id: string) => {
const position = marker.getLatLng(); const position = marker.getLatLng();
const lat = position.lat; const lat = position.lat;
const lng = position.lng; const lng = position.lng;
@ -584,7 +661,7 @@ let MinimalAPRSMap = {
const latOutOfBounds = lat < bounds.getSouth() || lat > bounds.getNorth(); const latOutOfBounds = lat < bounds.getSouth() || lat > bounds.getNorth();
// Check longitude bounds (handle potential wrapping) // Check longitude bounds (handle potential wrapping)
let lngOutOfBounds; let lngOutOfBounds: boolean;
if (bounds.getWest() <= bounds.getEast()) { if (bounds.getWest() <= bounds.getEast()) {
// Normal case: bounds don't cross antimeridian // Normal case: bounds don't cross antimeridian
lngOutOfBounds = lng < bounds.getWest() || lng > bounds.getEast(); lngOutOfBounds = lng < bounds.getWest() || lng > bounds.getEast();
@ -600,10 +677,10 @@ let MinimalAPRSMap = {
}); });
// Remove out-of-bounds markers // Remove out-of-bounds markers
markersToRemove.forEach((id) => this.removeMarker(id)); markersToRemove.forEach((id) => self.removeMarker(id));
}, },
createMarkerIcon(data) { createMarkerIcon(data: MarkerData): L.DivIcon {
const symbolTableId = data.symbol_table_id || "/"; const symbolTableId = data.symbol_table_id || "/";
const symbolCode = data.symbol_code || ">"; const symbolCode = data.symbol_code || ">";
@ -667,7 +744,7 @@ let MinimalAPRSMap = {
}); });
}, },
buildPopupContent(data) { buildPopupContent(data: MarkerData): string {
const callsign = data.callsign || data.id || "Unknown"; const callsign = data.callsign || data.id || "Unknown";
const comment = data.comment || ""; const comment = data.comment || "";
const symbolTableId = data.symbol_table_id || "/"; const symbolTableId = data.symbol_table_id || "/";
@ -693,33 +770,25 @@ let MinimalAPRSMap = {
}, },
destroyed() { destroyed() {
// Clean up timers const self = this as unknown as LiveViewHookContext;
if (this.boundsTimer) { if (self.boundsTimer !== undefined) {
clearTimeout(this.boundsTimer); clearTimeout(self.boundsTimer);
} }
if (self.resizeHandler !== undefined) {
// Clean up event listeners window.removeEventListener("resize", self.resizeHandler);
if (this.resizeHandler) {
window.removeEventListener("resize", this.resizeHandler);
} }
if (self.markerLayer !== undefined) {
// Clean up markers self.markerLayer!.clearLayers();
if (this.markerLayer) {
this.markerLayer.clearLayers();
} }
if (self.markers !== undefined) {
if (this.markers) { self.markers!.clear();
this.markers.clear();
} }
if (self.markerStates !== undefined) {
if (this.markerStates) { self.markerStates!.clear();
this.markerStates.clear();
} }
if (self.map !== undefined) {
// Clean up map self.map!.remove();
if (this.map) { self.map = undefined;
this.map.remove();
this.map = null;
} }
}, },
}; };

96
assets/js/types/leaflet.d.ts vendored Normal file
View file

@ -0,0 +1,96 @@
declare namespace L {
class Map {
constructor(element: string | HTMLElement, options?: MapOptions);
setView(center: LatLngExpression, zoom: number, options?: ZoomOptions): this;
getCenter(): LatLng;
getZoom(): number;
getBounds(): LatLngBounds;
remove(): void;
invalidateSize(options?: { animate?: boolean; pan?: boolean }): this;
whenReady(callback: () => void): this;
on(event: string, handler: (e: any) => void): this;
}
interface MapOptions {
zoomControl?: boolean;
attributionControl?: boolean;
closePopupOnClick?: boolean;
}
interface ZoomOptions {
animate?: boolean;
duration?: number;
}
class LatLng {
lat: number;
lng: number;
constructor(lat: number, lng: number);
}
type LatLngExpression = LatLng | [number, number];
class LatLngBounds {
constructor(southWest: LatLngExpression, northEast: LatLngExpression);
getNorth(): number;
getSouth(): number;
getEast(): number;
getWest(): number;
}
class Marker {
constructor(latLng: LatLngExpression, options?: MarkerOptions);
setLatLng(latLng: LatLngExpression): this;
bindPopup(content: string | HTMLElement): this;
setPopupContent(content: string | HTMLElement): this;
setIcon(icon: DivIcon): this;
getLatLng(): LatLng;
}
interface MarkerOptions {
icon?: DivIcon;
}
class DivIcon {
constructor(options: DivIconOptions);
}
interface DivIconOptions {
html?: string | HTMLElement;
className?: string;
iconSize?: [number, number];
iconAnchor?: [number, number];
}
class LayerGroup {
addTo(map: Map): this;
clearLayers(): this;
removeLayer(layer: Layer): this;
}
interface Layer {
_leaflet_id?: number;
}
class TileLayer {
constructor(urlTemplate: string, options?: TileLayerOptions);
addTo(map: Map): this;
}
interface TileLayerOptions {
attribution?: string;
maxZoom?: number;
}
function map(element: string | HTMLElement, options?: MapOptions): Map;
function marker(latLng: LatLngExpression, options?: MarkerOptions): Marker;
function tileLayer(urlTemplate: string, options?: TileLayerOptions): TileLayer;
function latLngBounds(southWest: LatLngExpression, northEast: LatLngExpression): LatLngBounds;
function divIcon(options: DivIconOptions): DivIcon;
function layerGroup(): LayerGroup;
function latLng(lat: number, lng: number): LatLng;
function DomUtil(): {
get(id: string): HTMLElement | null;
remove(el: HTMLElement): void;
};
}

1
assets/vendor/leaflet/leaflet.css vendored Normal file

File diff suppressed because one or more lines are too long

1
assets/vendor/leaflet/leaflet.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -55,7 +55,8 @@ config :aprs,
config :esbuild, config :esbuild,
version: "0.24.2", version: "0.24.2",
default: [ default: [
args: ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), args:
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --define:global.L=window.L),
cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
] ]

View file

@ -26,7 +26,12 @@ config :aprs, AprsWeb.Endpoint,
debug_errors: true, debug_errors: true,
secret_key_base: "Vv8Uh9w7tSoac1wBo7A8MyTSE9J+RzNeAzPTGcsW2InUBHpPBgt1dACJrIZorfdH", secret_key_base: "Vv8Uh9w7tSoac1wBo7A8MyTSE9J+RzNeAzPTGcsW2InUBHpPBgt1dACJrIZorfdH",
watchers: [ watchers: [
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, esbuild:
{Esbuild, :install_and_run,
[
:default,
~w(--sourcemap=inline --watch --loader:.ts=ts)
]},
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
] ]
@ -37,12 +42,8 @@ config :aprs, AprsWeb.Endpoint,
~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$", ~r"priv/gettext/.*(po)$",
~r"lib/aprs_web/(live|views)/.*(ex)$", ~r"lib/aprs_web/(live|views)/.*(ex)$",
# ## SSL Support ~r"lib/aprs_web/templates/.*(eex)$",
# ~r"assets/vendor/.*(js|css)$"
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
~r"lib/aprs_web/templates/.*(eex)$"
] ]
] ]

View file

@ -19,3 +19,11 @@ config :logger, level: :info
# Configures Swoosh API Client # Configures Swoosh API Client
config :swoosh, :api_client, Aprs.Finch config :swoosh, :api_client, Aprs.Finch
config :esbuild,
version: "0.17.11",
default: [
args: ~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --loader:.ts=ts),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]

View file

@ -8,8 +8,9 @@
{assigns[:page_title] || "Aprs"} {assigns[:page_title] || "Aprs"}
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> <link phx-track-static rel="stylesheet" href={~p"/assets/vendor/leaflet/leaflet.css"} />
</script> <script defer phx-track-static type="text/javascript" src={~p"/assets/vendor/leaflet/leaflet.js"} data-source-map="false"></script>
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}></script>
</head> </head>
<body class={body_class(assigns)}> <body class={body_class(assigns)}>
{@inner_content} {@inner_content}

View file

@ -64,7 +64,7 @@ defmodule Aprs.MixProject do
{:swoosh, "~> 1.3"}, {:swoosh, "~> 1.3"},
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:esbuild, "~> 0.5", runtime: Mix.env() == :dev}, {:esbuild, "~> 0.5", runtime: Mix.env() == :dev, config: "assets/esbuild.config.js"},
{:tailwind, "~> 0.3.1", runtime: Mix.env() == :dev}, {:tailwind, "~> 0.3.1", runtime: Mix.env() == :dev},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dialyxir, "~> 1.0", only: :dev, runtime: false}, {:dialyxir, "~> 1.0", only: :dev, runtime: false},

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

41
tsconfig.json Normal file
View file

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"allowJs": true,
"checkJs": false,
"noEmit": true,
"isolatedModules": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"*": [
"assets/*"
]
},
"typeRoots": [
"./assets/js/types"
]
},
"include": [
"assets/js/**/*.ts",
"assets/js/**/*.js",
"assets/js/types/**/*.d.ts"
],
"exclude": [
"node_modules",
"deps",
"_build",
"assets/node_modules"
]
}