Convert JS hooks to TypeScript and update esbuild to 0.25.4
Convert app.js, info_map.js, error_boundary.js, and time_ago_hook.js to TypeScript with proper typing for Phoenix hooks, Leaflet, DOM interactions, and timer management. Update esbuild hex package to ~> 0.10 and binary to 0.25.4. Entry point changed to app.ts.
This commit is contained in:
parent
b562c23589
commit
13bc58e650
10 changed files with 453 additions and 411 deletions
|
|
@ -1,51 +1,59 @@
|
|||
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
|
||||
// to get started and then uncomment the line below.
|
||||
// import "./user_socket.js"
|
||||
|
||||
// You can include dependencies in two ways.
|
||||
//
|
||||
// The simplest option is to put them in assets/vendor and
|
||||
// import them using relative paths:
|
||||
//
|
||||
// import "../vendor/some-package.js"
|
||||
//
|
||||
// Alternatively, you can `npm install some-package --prefix assets` and import
|
||||
// them using a path starting with the package name:
|
||||
//
|
||||
// import "some-package"
|
||||
//
|
||||
|
||||
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
|
||||
import "phoenix_html";
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import { Socket } from "phoenix";
|
||||
import { LiveSocket } from "phoenix_live_view";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
topbar: {
|
||||
config: (opts: { barColors: Record<number, string>; shadowColor: string }) => void;
|
||||
show: (delay?: number) => void;
|
||||
hide: () => void;
|
||||
};
|
||||
mapBundleLoaded?: boolean;
|
||||
chartBundleLoaded?: boolean;
|
||||
Chart?: unknown;
|
||||
VendorLoader?: {
|
||||
mapBundleUrl: string;
|
||||
loadCharts: () => void;
|
||||
};
|
||||
liveSocket: any;
|
||||
Hooks: Record<string, any>;
|
||||
}
|
||||
}
|
||||
|
||||
// topbar is loaded globally from vendor bundle
|
||||
const topbar = window.topbar;
|
||||
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";
|
||||
const csrfToken =
|
||||
document
|
||||
.querySelector("meta[name='csrf-token']")
|
||||
?.getAttribute("content") || "";
|
||||
if (!csrfToken) {
|
||||
console.error("CSRF token not found in meta tags");
|
||||
}
|
||||
|
||||
// Import minimal APRS map hook
|
||||
// Import hooks
|
||||
import MapAPRSMap from "./map";
|
||||
// Import error boundary hook
|
||||
import ErrorBoundary from "./hooks/error_boundary";
|
||||
// Import info map hook
|
||||
import { InfoMap } from "./hooks/info_map";
|
||||
// Import time ago hook
|
||||
import TimeAgoHook from "./hooks/time_ago_hook";
|
||||
|
||||
// APRS MapAPRSMap Hook
|
||||
let Hooks = {};
|
||||
interface HookDef {
|
||||
mounted?: () => void;
|
||||
updated?: () => void;
|
||||
destroyed?: () => void;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const Hooks: Record<string, HookDef> = {};
|
||||
|
||||
// Singleton map bundle loader — ensures the script is only appended once
|
||||
let mapBundleCallbacks = [];
|
||||
let mapBundleCallbacks: Array<() => void> = [];
|
||||
let mapBundleLoading = false;
|
||||
|
||||
function loadMapBundle(callback) {
|
||||
function loadMapBundle(callback: () => void) {
|
||||
if (window.mapBundleLoaded) {
|
||||
callback();
|
||||
return;
|
||||
|
|
@ -54,19 +62,19 @@ function loadMapBundle(callback) {
|
|||
mapBundleCallbacks.push(callback);
|
||||
|
||||
if (mapBundleLoading) {
|
||||
return; // Already loading, callback will fire when ready
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.VendorLoader) {
|
||||
mapBundleLoading = true;
|
||||
const script = document.createElement('script');
|
||||
const script = document.createElement("script");
|
||||
script.src = window.VendorLoader.mapBundleUrl;
|
||||
script.onload = () => {
|
||||
window.mapBundleLoaded = true;
|
||||
mapBundleLoading = false;
|
||||
const cbs = mapBundleCallbacks;
|
||||
mapBundleCallbacks = [];
|
||||
cbs.forEach(cb => cb());
|
||||
cbs.forEach((cb) => cb());
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error("Failed to load map bundle");
|
||||
|
|
@ -90,7 +98,7 @@ Hooks.APRSMap = {
|
|||
originalMapMounted.call(self);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.InfoMap = {
|
||||
|
|
@ -102,21 +110,19 @@ Hooks.InfoMap = {
|
|||
InfoMap.mounted.call(self);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Chart hooks - load chart bundle when needed
|
||||
import { WeatherChartHooks } from "./features/weather_charts";
|
||||
Object.keys(WeatherChartHooks).forEach(hookName => {
|
||||
const originalHook = WeatherChartHooks[hookName];
|
||||
Object.keys(WeatherChartHooks).forEach((hookName) => {
|
||||
const originalHook = (WeatherChartHooks as Record<string, HookDef>)[hookName];
|
||||
Hooks[hookName] = {
|
||||
...originalHook,
|
||||
mounted() {
|
||||
const self = this;
|
||||
if (window.VendorLoader && !window.chartBundleLoaded) {
|
||||
// Load chart bundle and wait for it to complete
|
||||
window.VendorLoader.loadCharts();
|
||||
// Wait a bit for charts to load, then call mounted
|
||||
const checkChartLoaded = () => {
|
||||
if (window.Chart) {
|
||||
if (originalHook.mounted) {
|
||||
|
|
@ -128,12 +134,11 @@ Object.keys(WeatherChartHooks).forEach(hookName => {
|
|||
};
|
||||
setTimeout(checkChartLoaded, 100);
|
||||
} else {
|
||||
// Chart bundle already loaded or loading, call mounted
|
||||
if (originalHook.mounted) {
|
||||
originalHook.mounted.call(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -142,12 +147,15 @@ Hooks.ErrorBoundary = ErrorBoundary;
|
|||
Hooks.TimeAgoHook = TimeAgoHook;
|
||||
|
||||
// Theme management
|
||||
const applyTheme = (theme) => {
|
||||
const applyTheme = (theme: string | null) => {
|
||||
const element = document.documentElement;
|
||||
if (!element) return;
|
||||
|
||||
if (theme === "auto") {
|
||||
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
|
||||
if (theme === "auto" || !theme) {
|
||||
if (
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
) {
|
||||
element.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
element.setAttribute("data-theme", "light");
|
||||
|
|
@ -161,52 +169,56 @@ const applyTheme = (theme) => {
|
|||
applyTheme(localStorage.getItem("theme") || "auto");
|
||||
|
||||
// Handle theme changes dispatched from LiveView via JS.dispatch
|
||||
window.addEventListener("phx:set-theme", (e) => {
|
||||
window.addEventListener("phx:set-theme", ((e: CustomEvent<{ theme: string }>) => {
|
||||
const theme = e.detail.theme;
|
||||
applyTheme(theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
window.dispatchEvent(new CustomEvent("themeChanged"));
|
||||
});
|
||||
}) as EventListener);
|
||||
|
||||
// Listen for system theme changes when auto is selected
|
||||
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
|
||||
if (localStorage.getItem("theme") === "auto") {
|
||||
applyTheme("auto");
|
||||
window.dispatchEvent(new CustomEvent("themeChanged"));
|
||||
}
|
||||
});
|
||||
window
|
||||
.matchMedia("(prefers-color-scheme: dark)")
|
||||
.addEventListener("change", () => {
|
||||
if (localStorage.getItem("theme") === "auto") {
|
||||
applyTheme("auto");
|
||||
window.dispatchEvent(new CustomEvent("themeChanged"));
|
||||
}
|
||||
});
|
||||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: { _csrf_token: csrfToken, viewport_width: window.innerWidth },
|
||||
hooks: Hooks,
|
||||
timeout: 60000, // 60 second timeout for slow initial loads
|
||||
timeout: 60000,
|
||||
});
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" });
|
||||
topbar.config({
|
||||
barColors: { 0: "#29d" },
|
||||
shadowColor: "rgba(0, 0, 0, .3)",
|
||||
});
|
||||
window.addEventListener("phx:page-loading-start", (_info) => topbar.show(100));
|
||||
window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide());
|
||||
|
||||
// Handle connection draining reconnect events
|
||||
window.addEventListener("phx:reconnect", (e) => {
|
||||
window.addEventListener("phx:reconnect", ((e: CustomEvent<{ delay?: number }>) => {
|
||||
const delay = e.detail.delay || 1000;
|
||||
setTimeout(() => {
|
||||
// Disconnect and reconnect to potentially land on a different server
|
||||
liveSocket.disconnect();
|
||||
setTimeout(() => {
|
||||
liveSocket.connect();
|
||||
}, 100);
|
||||
}, delay);
|
||||
});
|
||||
}) as EventListener);
|
||||
|
||||
// connect if there are any LiveViews on the page
|
||||
liveSocket.connect();
|
||||
|
||||
// Workaround for Phoenix LiveView bug where fallback timer isn't cleared
|
||||
// after successful WebSocket connection
|
||||
window.addEventListener("phx:live_socket:connect", (info) => {
|
||||
const socket = liveSocket.socket;
|
||||
window.addEventListener("phx:live_socket:connect", (_info) => {
|
||||
const socket = (liveSocket as any).socket;
|
||||
if (socket && socket.fallbackTimer) {
|
||||
clearTimeout(socket.fallbackTimer);
|
||||
socket.fallbackTimer = null;
|
||||
|
|
@ -215,7 +227,7 @@ window.addEventListener("phx:live_socket:connect", (info) => {
|
|||
|
||||
// Also check periodically in case the event doesn't fire
|
||||
setTimeout(() => {
|
||||
const socket = liveSocket.socket;
|
||||
const socket = (liveSocket as any).socket;
|
||||
if (socket && socket.isConnected() && socket.fallbackTimer) {
|
||||
clearTimeout(socket.fallbackTimer);
|
||||
socket.fallbackTimer = null;
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
// Error Boundary Hook for Phoenix LiveView
|
||||
// Catches JavaScript errors and displays fallback content
|
||||
|
||||
const ErrorBoundary = {
|
||||
mounted() {
|
||||
// Get content and fallback elements
|
||||
this.content = this.el.querySelector('.error-boundary-content');
|
||||
this.fallback = this.el.querySelector('.error-boundary-fallback');
|
||||
|
||||
// Create bound error handlers
|
||||
this.errorHandler = this.handleGlobalError.bind(this);
|
||||
this.unhandledRejectionHandler = this.handleUnhandledRejection.bind(this);
|
||||
|
||||
// Set up error handlers for this component
|
||||
this.setupErrorHandlers();
|
||||
},
|
||||
|
||||
setupErrorHandlers() {
|
||||
// Use event listeners instead of overriding global handlers
|
||||
window.addEventListener('error', this.errorHandler, true);
|
||||
window.addEventListener('unhandledrejection', this.unhandledRejectionHandler, true);
|
||||
},
|
||||
|
||||
handleGlobalError(event) {
|
||||
// Extract error from ErrorEvent
|
||||
const error = event.error || new Error(event.message);
|
||||
|
||||
if (this.isErrorInComponent(error)) {
|
||||
this.handleError(error);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
|
||||
handleUnhandledRejection(event) {
|
||||
if (this.isErrorInComponent(event.reason)) {
|
||||
this.handleError(event.reason);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
|
||||
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) {
|
||||
this.content.style.display = 'none';
|
||||
}
|
||||
if (this.fallback && this.fallback.classList) {
|
||||
this.fallback.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Log error to server if pushEvent is available
|
||||
if (this.pushEvent && typeof this.pushEvent === 'function') {
|
||||
try {
|
||||
this.pushEvent('error_boundary_triggered', {
|
||||
message: error?.message || 'Unknown error',
|
||||
stack: error?.stack || 'No stack trace',
|
||||
component_id: this.el?.id || 'unknown'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to send error to server:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
// Remove event listeners
|
||||
if (this.errorHandler) {
|
||||
window.removeEventListener('error', this.errorHandler, true);
|
||||
}
|
||||
if (this.unhandledRejectionHandler) {
|
||||
window.removeEventListener('unhandledrejection', this.unhandledRejectionHandler, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
129
assets/js/hooks/error_boundary.ts
Normal file
129
assets/js/hooks/error_boundary.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// Error Boundary Hook for Phoenix LiveView
|
||||
// Catches JavaScript errors and displays fallback content
|
||||
|
||||
interface ErrorBoundaryContext {
|
||||
el: HTMLElement;
|
||||
content: HTMLElement | null;
|
||||
fallback: HTMLElement | null;
|
||||
errorHandler: (event: ErrorEvent) => void;
|
||||
unhandledRejectionHandler: (event: PromiseRejectionEvent) => void;
|
||||
pushEvent?: (event: string, payload: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
interface ErrorLike {
|
||||
message?: string;
|
||||
stack?: string;
|
||||
target?: EventTarget | null;
|
||||
}
|
||||
|
||||
const ErrorBoundary = {
|
||||
mounted(this: ErrorBoundaryContext) {
|
||||
this.content = this.el.querySelector(".error-boundary-content");
|
||||
this.fallback = this.el.querySelector(".error-boundary-fallback");
|
||||
|
||||
this.errorHandler = handleGlobalError.bind(this);
|
||||
this.unhandledRejectionHandler = handleUnhandledRejection.bind(this);
|
||||
|
||||
window.addEventListener("error", this.errorHandler, true);
|
||||
window.addEventListener(
|
||||
"unhandledrejection",
|
||||
this.unhandledRejectionHandler,
|
||||
true,
|
||||
);
|
||||
},
|
||||
|
||||
destroyed(this: ErrorBoundaryContext) {
|
||||
if (this.errorHandler) {
|
||||
window.removeEventListener("error", this.errorHandler, true);
|
||||
}
|
||||
if (this.unhandledRejectionHandler) {
|
||||
window.removeEventListener(
|
||||
"unhandledrejection",
|
||||
this.unhandledRejectionHandler,
|
||||
true,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function handleGlobalError(this: ErrorBoundaryContext, event: ErrorEvent) {
|
||||
const error: ErrorLike = event.error || { message: event.message };
|
||||
|
||||
if (isErrorInComponent.call(this, error)) {
|
||||
handleError.call(this, error);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnhandledRejection(
|
||||
this: ErrorBoundaryContext,
|
||||
event: PromiseRejectionEvent,
|
||||
) {
|
||||
const error = event.reason as ErrorLike | undefined;
|
||||
if (isErrorInComponent.call(this, error)) {
|
||||
handleError.call(this, error);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function isErrorInComponent(
|
||||
this: ErrorBoundaryContext,
|
||||
error: ErrorLike | undefined,
|
||||
): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
if (
|
||||
error.target &&
|
||||
error.target instanceof Node &&
|
||||
this.el.contains(error.target)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error.stack) {
|
||||
const componentId = this.el.id;
|
||||
if (componentId && error.stack.includes(componentId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hooks = this.el.querySelectorAll("[phx-hook]");
|
||||
for (const hook of hooks) {
|
||||
const hookName = hook.getAttribute("phx-hook");
|
||||
if (hookName && error.stack.includes(hookName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleError(
|
||||
this: ErrorBoundaryContext,
|
||||
error: ErrorLike | undefined,
|
||||
) {
|
||||
console.error("Error caught by boundary:", error);
|
||||
|
||||
if (this.content) {
|
||||
this.content.style.display = "none";
|
||||
}
|
||||
if (this.fallback) {
|
||||
this.fallback.classList.remove("hidden");
|
||||
}
|
||||
|
||||
if (this.pushEvent && typeof this.pushEvent === "function") {
|
||||
try {
|
||||
this.pushEvent("error_boundary_triggered", {
|
||||
message: error?.message || "Unknown error",
|
||||
stack: error?.stack || "No stack trace",
|
||||
component_id: this.el?.id || "unknown",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to send error to server:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
// Simple map hook for displaying a single station on the info page
|
||||
export const InfoMap = {
|
||||
mounted() {
|
||||
this.initializeMap();
|
||||
},
|
||||
|
||||
updated() {
|
||||
// When the element updates, check if we need to update the marker
|
||||
const lat = parseFloat(this.el.dataset.lat);
|
||||
const lon = parseFloat(this.el.dataset.lon);
|
||||
const symbolHtml = this.el.dataset.symbolHtml;
|
||||
|
||||
// Validate coordinates
|
||||
const callsign = this.el.dataset.callsign;
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
console.warn(`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign} during update`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If map doesn't exist yet, initialize it
|
||||
if (!this.map || !this.map._container || !this.map._container.parentNode) {
|
||||
// Map was destroyed or removed from DOM, reinitialize
|
||||
this.initializeMap();
|
||||
return;
|
||||
}
|
||||
|
||||
// Update marker position if it changed
|
||||
if (this.marker) {
|
||||
const currentPos = this.marker.getLatLng();
|
||||
if (currentPos.lat !== lat || currentPos.lng !== lon) {
|
||||
// Animate the marker to the new position
|
||||
this.marker.setLatLng([lat, lon]);
|
||||
|
||||
// Update the popup content
|
||||
const callsign = this.el.dataset.callsign;
|
||||
this.marker.setPopupContent(`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`);
|
||||
|
||||
// Optionally pan the map to the new position with animation
|
||||
this.map.panTo([lat, lon], { animate: true, duration: 1 });
|
||||
}
|
||||
|
||||
// Update marker icon if symbol changed
|
||||
if (symbolHtml !== this.lastSymbolHtml) {
|
||||
const markerIcon = this.createMarkerIcon(symbolHtml);
|
||||
this.marker.setIcon(markerIcon);
|
||||
this.lastSymbolHtml = symbolHtml;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
initializeMap() {
|
||||
// Prevent multiple simultaneous initializations
|
||||
if (this.initializing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if Leaflet is available
|
||||
if (typeof L === "undefined") {
|
||||
console.error("Leaflet not loaded for InfoMap");
|
||||
return;
|
||||
}
|
||||
|
||||
this.initializing = true;
|
||||
|
||||
// Get data from element attributes
|
||||
const lat = parseFloat(this.el.dataset.lat);
|
||||
const lon = parseFloat(this.el.dataset.lon);
|
||||
const zoom = parseInt(this.el.dataset.zoom) || 13;
|
||||
const callsign = this.el.dataset.callsign;
|
||||
const symbolHtml = this.el.dataset.symbolHtml;
|
||||
|
||||
// Validate coordinates
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
console.warn(`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide loading spinner
|
||||
const loadingEl = this.el.querySelector(`#${this.el.id}-loading`);
|
||||
if (loadingEl) {
|
||||
loadingEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Initialize the map
|
||||
try {
|
||||
this.map = L.map(this.el, {
|
||||
center: [lat, lon],
|
||||
zoom: zoom,
|
||||
scrollWheelZoom: false, // Disable scroll zoom for embedded maps
|
||||
zoomControl: true,
|
||||
attributionControl: true
|
||||
});
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19
|
||||
}).addTo(this.map);
|
||||
|
||||
// Create marker icon
|
||||
const markerIcon = this.createMarkerIcon(symbolHtml);
|
||||
this.lastSymbolHtml = symbolHtml;
|
||||
|
||||
// Add marker for the station
|
||||
this.marker = L.marker([lat, lon], { icon: markerIcon })
|
||||
.addTo(this.map)
|
||||
.bindPopup(`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`);
|
||||
|
||||
// Invalidate size after a short delay to ensure proper rendering
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
if (this.map) {
|
||||
this.map.invalidateSize();
|
||||
}
|
||||
this.resizeTimer = null;
|
||||
}, 250);
|
||||
|
||||
// Mark initialization as complete
|
||||
this.initializing = false;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error initializing InfoMap:", error);
|
||||
this.initializing = false;
|
||||
}
|
||||
},
|
||||
|
||||
createMarkerIcon(symbolHtml) {
|
||||
if (symbolHtml) {
|
||||
// Use the APRS symbol if provided
|
||||
return L.divIcon({
|
||||
html: symbolHtml,
|
||||
className: 'aprs-info-marker',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
} else {
|
||||
// Default marker
|
||||
return L.divIcon({
|
||||
html: `<div style="
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #3b82f6;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
||||
"></div>`,
|
||||
className: '',
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12]
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
// Cancel pending resize timer
|
||||
if (this.resizeTimer) {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = null;
|
||||
}
|
||||
// Clean up map and reset state
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
this.map = null;
|
||||
}
|
||||
this.marker = null;
|
||||
this.lastSymbolHtml = null;
|
||||
this.initializing = false;
|
||||
}
|
||||
};
|
||||
174
assets/js/hooks/info_map.ts
Normal file
174
assets/js/hooks/info_map.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// Simple map hook for displaying a single station on the info page
|
||||
|
||||
declare const L: typeof import("leaflet");
|
||||
|
||||
interface InfoMapContext {
|
||||
el: HTMLElement;
|
||||
map: import("leaflet").Map | null;
|
||||
marker: import("leaflet").Marker | null;
|
||||
lastSymbolHtml: string | null;
|
||||
initializing: boolean;
|
||||
resizeTimer: ReturnType<typeof setTimeout> | null;
|
||||
}
|
||||
|
||||
export const InfoMap = {
|
||||
mounted(this: InfoMapContext) {
|
||||
this.map = null;
|
||||
this.marker = null;
|
||||
this.lastSymbolHtml = null;
|
||||
this.initializing = false;
|
||||
this.resizeTimer = null;
|
||||
initializeMap.call(this);
|
||||
},
|
||||
|
||||
updated(this: InfoMapContext) {
|
||||
const lat = parseFloat(this.el.dataset.lat || "");
|
||||
const lon = parseFloat(this.el.dataset.lon || "");
|
||||
const symbolHtml = this.el.dataset.symbolHtml || null;
|
||||
const callsign = this.el.dataset.callsign || "unknown";
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
console.warn(
|
||||
`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign} during update`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.map ||
|
||||
!(this.map as any)._container ||
|
||||
!(this.map as any)._container.parentNode
|
||||
) {
|
||||
initializeMap.call(this);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.marker) {
|
||||
const currentPos = this.marker.getLatLng();
|
||||
if (currentPos.lat !== lat || currentPos.lng !== lon) {
|
||||
this.marker.setLatLng([lat, lon]);
|
||||
|
||||
this.marker.setPopupContent(
|
||||
`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`,
|
||||
);
|
||||
|
||||
this.map!.panTo([lat, lon], { animate: true, duration: 1 });
|
||||
}
|
||||
|
||||
if (symbolHtml !== this.lastSymbolHtml) {
|
||||
const markerIcon = createMarkerIcon(symbolHtml);
|
||||
this.marker.setIcon(markerIcon);
|
||||
this.lastSymbolHtml = symbolHtml;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
destroyed(this: InfoMapContext) {
|
||||
if (this.resizeTimer) {
|
||||
clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = null;
|
||||
}
|
||||
if (this.map) {
|
||||
this.map.remove();
|
||||
this.map = null;
|
||||
}
|
||||
this.marker = null;
|
||||
this.lastSymbolHtml = null;
|
||||
this.initializing = false;
|
||||
},
|
||||
};
|
||||
|
||||
function initializeMap(this: InfoMapContext) {
|
||||
if (this.initializing) return;
|
||||
|
||||
if (typeof L === "undefined") {
|
||||
console.error("Leaflet not loaded for InfoMap");
|
||||
return;
|
||||
}
|
||||
|
||||
this.initializing = true;
|
||||
|
||||
const lat = parseFloat(this.el.dataset.lat || "");
|
||||
const lon = parseFloat(this.el.dataset.lon || "");
|
||||
const zoom = parseInt(this.el.dataset.zoom || "13") || 13;
|
||||
const callsign = this.el.dataset.callsign || "unknown";
|
||||
const symbolHtml = this.el.dataset.symbolHtml || null;
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
console.warn(
|
||||
`InfoMap: Invalid coordinates lat=${lat}, lon=${lon} for ${callsign}`,
|
||||
);
|
||||
this.initializing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingEl = this.el.querySelector(
|
||||
`#${this.el.id}-loading`,
|
||||
) as HTMLElement | null;
|
||||
if (loadingEl) {
|
||||
loadingEl.style.display = "none";
|
||||
}
|
||||
|
||||
try {
|
||||
this.map = L.map(this.el, {
|
||||
center: [lat, lon],
|
||||
zoom: zoom,
|
||||
scrollWheelZoom: false,
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
});
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(this.map);
|
||||
|
||||
const markerIcon = createMarkerIcon(symbolHtml);
|
||||
this.lastSymbolHtml = symbolHtml;
|
||||
|
||||
this.marker = L.marker([lat, lon], { icon: markerIcon })
|
||||
.addTo(this.map)
|
||||
.bindPopup(
|
||||
`<strong>${callsign}</strong><br/>Lat: ${lat.toFixed(6)}<br/>Lon: ${lon.toFixed(6)}`,
|
||||
);
|
||||
|
||||
const map = this.map;
|
||||
this.resizeTimer = setTimeout(() => {
|
||||
if (map) {
|
||||
map.invalidateSize();
|
||||
}
|
||||
this.resizeTimer = null;
|
||||
}, 250);
|
||||
|
||||
this.initializing = false;
|
||||
} catch (error) {
|
||||
console.error("Error initializing InfoMap:", error);
|
||||
this.initializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createMarkerIcon(symbolHtml: string | null): import("leaflet").DivIcon {
|
||||
if (symbolHtml) {
|
||||
return L.divIcon({
|
||||
html: symbolHtml,
|
||||
className: "aprs-info-marker",
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
});
|
||||
} else {
|
||||
return L.divIcon({
|
||||
html: `<div style="
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background-color: #3b82f6;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
||||
"></div>`,
|
||||
className: "",
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
// Hook to automatically update time ago displays
|
||||
export default {
|
||||
mounted() {
|
||||
this.startTimer();
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.stopTimer();
|
||||
},
|
||||
|
||||
updated() {
|
||||
// Restart timer when the element updates
|
||||
this.stopTimer();
|
||||
this.startTimer();
|
||||
},
|
||||
|
||||
startTimer() {
|
||||
const updateTimeAgo = () => {
|
||||
const timestampStr = this.el.dataset.timestamp;
|
||||
if (!timestampStr) return;
|
||||
|
||||
const timestamp = new Date(timestampStr);
|
||||
const now = new Date();
|
||||
const diffMs = now - timestamp;
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffSeconds < 3600) {
|
||||
const minutes = Math.floor(diffSeconds / 60);
|
||||
this.el.textContent = `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||
} else if (diffSeconds < 86400) {
|
||||
const hours = Math.floor(diffSeconds / 3600);
|
||||
this.el.textContent = `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||
} else {
|
||||
const days = Math.floor(diffSeconds / 86400);
|
||||
this.el.textContent = `${days} day${days !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
updateTimeAgo();
|
||||
|
||||
// Update every second for recent timestamps, less frequently for older ones
|
||||
const timestampStr = this.el.dataset.timestamp;
|
||||
if (timestampStr) {
|
||||
const timestamp = new Date(timestampStr);
|
||||
const age = Date.now() - timestamp;
|
||||
|
||||
let interval;
|
||||
if (age < 60000) { // Less than 1 minute old
|
||||
interval = 1000; // Update every second
|
||||
} else if (age < 3600000) { // Less than 1 hour old
|
||||
interval = 60000; // Update every minute
|
||||
} else {
|
||||
interval = 300000; // Update every 5 minutes
|
||||
}
|
||||
|
||||
this.timer = setInterval(updateTimeAgo, interval);
|
||||
}
|
||||
},
|
||||
|
||||
stopTimer() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
75
assets/js/hooks/time_ago_hook.ts
Normal file
75
assets/js/hooks/time_ago_hook.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
// Hook to automatically update time ago displays
|
||||
|
||||
interface TimeAgoHookContext {
|
||||
el: HTMLElement;
|
||||
timer: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
|
||||
const TimeAgoHook = {
|
||||
mounted(this: TimeAgoHookContext) {
|
||||
this.timer = null;
|
||||
startTimer.call(this);
|
||||
},
|
||||
|
||||
destroyed(this: TimeAgoHookContext) {
|
||||
stopTimer.call(this);
|
||||
},
|
||||
|
||||
updated(this: TimeAgoHookContext) {
|
||||
stopTimer.call(this);
|
||||
startTimer.call(this);
|
||||
},
|
||||
};
|
||||
|
||||
function startTimer(this: TimeAgoHookContext) {
|
||||
const updateTimeAgo = () => {
|
||||
const timestampStr = this.el.dataset.timestamp;
|
||||
if (!timestampStr) return;
|
||||
|
||||
const timestamp = new Date(timestampStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - timestamp.getTime();
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSeconds < 60) {
|
||||
this.el.textContent = `${diffSeconds} second${diffSeconds !== 1 ? "s" : ""} ago`;
|
||||
} else if (diffSeconds < 3600) {
|
||||
const minutes = Math.floor(diffSeconds / 60);
|
||||
this.el.textContent = `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;
|
||||
} else if (diffSeconds < 86400) {
|
||||
const hours = Math.floor(diffSeconds / 3600);
|
||||
this.el.textContent = `${hours} hour${hours !== 1 ? "s" : ""} ago`;
|
||||
} else {
|
||||
const days = Math.floor(diffSeconds / 86400);
|
||||
this.el.textContent = `${days} day${days !== 1 ? "s" : ""} ago`;
|
||||
}
|
||||
};
|
||||
|
||||
updateTimeAgo();
|
||||
|
||||
const timestampStr = this.el.dataset.timestamp;
|
||||
if (timestampStr) {
|
||||
const timestamp = new Date(timestampStr);
|
||||
const age = Date.now() - timestamp.getTime();
|
||||
|
||||
let interval: number;
|
||||
if (age < 60000) {
|
||||
interval = 1000;
|
||||
} else if (age < 3600000) {
|
||||
interval = 60000;
|
||||
} else {
|
||||
interval = 300000;
|
||||
}
|
||||
|
||||
this.timer = setInterval(updateTimeAgo, interval);
|
||||
}
|
||||
}
|
||||
|
||||
function stopTimer(this: TimeAgoHookContext) {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default TimeAgoHook;
|
||||
|
|
@ -81,10 +81,10 @@ config :error_tracker,
|
|||
|
||||
# Configure esbuild (the version is required)
|
||||
config :esbuild,
|
||||
version: "0.24.2",
|
||||
version: "0.25.4",
|
||||
default: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --loader:.css=css --loader:.png=file --loader:.svg=file),
|
||||
~w(js/app.ts --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/* --loader:.css=css --loader:.png=file --loader:.svg=file),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@ config :aprsme, AprsmeWeb.Endpoint,
|
|||
http: [ip: {0, 0, 0, 0}, port: 4000]
|
||||
|
||||
config :esbuild,
|
||||
version: "0.24.2",
|
||||
version: "0.25.4",
|
||||
default: [
|
||||
args:
|
||||
~w(js/app.js --bundle --target=es2020 --outdir=../priv/static/assets --loader:.ts=ts --loader:.css=css --loader:.png=file --loader:.svg=file --external:/fonts/* --external:/images/*),
|
||||
~w(js/app.ts --bundle --target=es2020 --outdir=../priv/static/assets --loader:.css=css --loader:.png=file --loader:.svg=file --external:/fonts/* --external:/images/*),
|
||||
cd: Path.expand("../assets", __DIR__),
|
||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||
]
|
||||
|
|
|
|||
2
mix.exs
2
mix.exs
|
|
@ -81,7 +81,7 @@ defmodule Aprsme.MixProject do
|
|||
{:telemetry_metrics, "~> 1.0"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
aprs_dep(),
|
||||
{:esbuild, "~> 0.9", runtime: Mix.env() == :dev},
|
||||
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
|
||||
{:tailwind, "~> 0.4.0", runtime: Mix.env() == :dev},
|
||||
{:heroicons,
|
||||
github: "tailwindlabs/heroicons", tag: "v2.1.1", sparse: "optimized", app: false, compile: false, depth: 1},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue