diff --git a/assets/js/app.js b/assets/js/app.js index e03aae5..1bbca15 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -22,7 +22,10 @@ import { Socket } from "phoenix"; import { LiveSocket } from "phoenix_live_view"; 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") || ""; +if (!csrfToken) { + console.error("CSRF token not found in meta tags"); +} // Import minimal APRS map hook import MapAPRSMap from "./map"; @@ -83,13 +86,15 @@ let BodyClassHook = { updateBodyClass() { // Get the map_page value from the element's data attribute - const mapPage = this.el.dataset.mapPage === 'true'; + const mapPage = this.el?.dataset?.mapPage === 'true'; // Update body class based on map_page value - if (mapPage) { - document.body.classList.add('map-page'); - } else { - document.body.classList.remove('map-page'); + if (document.body && document.body.classList) { + if (mapPage) { + document.body.classList.add('map-page'); + } else { + document.body.classList.remove('map-page'); + } } } }; @@ -125,9 +130,10 @@ const theme = (() => { const applyTheme = (theme) => { const element = document.documentElement; + if (!element) return; if (theme === 'auto') { - if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { element.setAttribute('data-theme', 'dark'); } else { element.setAttribute('data-theme', 'light'); diff --git a/assets/js/features/trail_manager.ts b/assets/js/features/trail_manager.ts index af847e5..612bd07 100644 --- a/assets/js/features/trail_manager.ts +++ b/assets/js/features/trail_manager.ts @@ -1,5 +1,10 @@ // Trail management module for APRS position history visualization +import type { LayerGroup, Polyline, CircleMarker, PolylineOptions } from 'leaflet'; + +// Declare Leaflet as a global +declare const L: typeof import('leaflet'); + export interface PositionHistory { lat: number; lng: number; @@ -8,17 +13,19 @@ export interface PositionHistory { export interface TrailState { positions: PositionHistory[]; - trail?: any; // L.Polyline - dots?: any[]; // L.CircleMarker[] + trail?: Polyline; + dots?: CircleMarker[]; } export class TrailManager { - private trailLayer: any; // L.LayerGroup + private trailLayer: LayerGroup; private trails: Map; private showTrails: boolean; private trailDuration: number; // in milliseconds + private maxTrails: number = 500; // Maximum number of trails to keep in memory + private maxPositionsPerTrail: number = 1000; // Maximum positions per trail - constructor(trailLayer: any, trailDuration: number = 60 * 60 * 1000) { + constructor(trailLayer: LayerGroup, trailDuration: number = 60 * 60 * 1000) { this.trailLayer = trailLayer; this.trails = new Map(); this.showTrails = true; @@ -56,6 +63,14 @@ export class TrailManager { let trailState = this.trails.get(baseCallsign); if (!trailState) { + // Check if we've reached the maximum number of trails + if (this.trails.size >= this.maxTrails) { + // Remove the oldest trail (first in the Map) + const oldestKey = this.trails.keys().next().value; + if (oldestKey) { + this.removeTrail(oldestKey); + } + } trailState = { positions: [] }; this.trails.set(baseCallsign, trailState); } @@ -74,6 +89,12 @@ export class TrailManager { // Sort positions by timestamp to maintain chronological order trailState.positions.sort((a, b) => a.timestamp - b.timestamp); + + // Limit the number of positions per trail + if (trailState.positions.length > this.maxPositionsPerTrail) { + // Keep the most recent positions + trailState.positions = trailState.positions.slice(-this.maxPositionsPerTrail); + } } // For historical dots, keep all positions. For live positions, filter by time. @@ -92,9 +113,9 @@ export class TrailManager { this.updateTrailVisualization(baseCallsign, trailState, isHistoricalDot); } - private extractBaseCallsign(markerId: string | any): string { + private extractBaseCallsign(markerId: string | number): string { // Ensure markerId is a string - const id = typeof markerId === "string" ? markerId : String(markerId); + const id = String(markerId); // For historical markers like "hist_CALLSIGN_123", extract the CALLSIGN part if (id.startsWith("hist_")) { @@ -124,12 +145,11 @@ export class TrailManager { // Create new trail if we have at least 2 positions if (trailState.positions.length >= 2) { - const L = (window as any).L; - const latLngs = trailState.positions.map((pos) => [pos.lat, pos.lng]); + const latLngs: [number, number][] = trailState.positions.map((pos) => [pos.lat, pos.lng]); // Create blue polyline connecting the historical position dots // For historical positions (immediate=true), use higher opacity for better visibility - trailState.trail = L.polyline(latLngs, { + const polylineOptions: PolylineOptions = { color: "#1E90FF", weight: 3, opacity: immediate ? 0.9 : 0.8, @@ -137,9 +157,9 @@ export class TrailManager { lineCap: "round", lineJoin: "round", className: "historical-trail-line", - // Ensure the trail renders on top of other map elements - zIndexOffset: immediate ? 1000 : 0, - }).addTo(this.trailLayer); + }; + + trailState.trail = L.polyline(latLngs, polylineOptions).addTo(this.trailLayer); // Don't create additional dots here since historical positions are now shown as markers trailState.dots = []; diff --git a/assets/js/features/weather_charts.ts b/assets/js/features/weather_charts.ts index d0c80b2..f82ea43 100644 --- a/assets/js/features/weather_charts.ts +++ b/assets/js/features/weather_charts.ts @@ -1,13 +1,29 @@ +// Import Chart.js types +import type { Chart, ChartConfiguration, ChartType } from 'chart.js'; + // Declare global Chart object from CDN declare global { interface Window { - Chart: any; - chartInstances?: Map; + Chart: typeof Chart; + chartInstances?: Map; } } // Type for LiveView hooks -type Hook = any; +interface Hook { + mounted?: () => void; + updated?: () => void; + destroyed?: () => void; + el: HTMLElement; + handleEvent: (event: string, handler: (payload: any) => void) => void; +} + +// Define chart hook context type +interface ChartHookContext extends Hook { + chart?: Chart; + themeChangeHandler?: () => void; + renderChart: () => void; +} // Type for weather history data interface WeatherHistoryDatum { @@ -30,6 +46,20 @@ interface UpdateWeatherChartsPayload { weather_history: string; } +// Helper function to safely parse weather history data +const parseWeatherHistory = (dataStr: string | undefined): WeatherHistoryDatum[] => { + if (!dataStr) { + console.warn("No weather history data provided"); + return []; + } + try { + return JSON.parse(dataStr); + } catch (error) { + console.error("Failed to parse weather history data:", error); + return []; + } +}; + // Helper function to get theme-aware colors const getThemeColors = () => { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; @@ -40,50 +70,31 @@ const getThemeColors = () => { }; }; -// Helper for 24-hour time formatting -function format24Hour(date: Date): string { - if (!(date instanceof Date) || isNaN(date.getTime())) return ''; - return new Intl.DateTimeFormat('en-GB', { - hour: '2-digit', - minute: '2-digit', - hour12: false - }).format(date); -} - -function toValidDate(raw: any): Date | null { - if (raw instanceof Date && !isNaN(raw.getTime())) return raw; - if (typeof raw === 'number') { - const d = new Date(raw); - if (!isNaN(d.getTime())) return d; - } - if (typeof raw === 'string') { - const d = new Date(raw); - if (!isNaN(d.getTime())) return d; - } - return null; -} - -declare global { - interface Window { - chartInstances?: Map; - } -} - -function registerChartInstance(el: HTMLElement, hook: any) { +// Register a chart instance +const registerChartInstance = (element: HTMLElement, instance: ChartHookContext) => { if (!window.chartInstances) { window.chartInstances = new Map(); } - window.chartInstances.set(el.id, hook); -} + const elementId = element.id || `chart-${Date.now()}`; + if (!element.id) element.id = elementId; + window.chartInstances.set(elementId, instance); +}; -function unregisterChartInstance(el: HTMLElement) { - if (window.chartInstances) { - window.chartInstances.delete(el.id); +// Unregister a chart instance +const unregisterChartInstance = (element: HTMLElement) => { + if (window.chartInstances && element.id) { + const instance = window.chartInstances.get(element.id); + if (instance?.chart) { + instance.chart.destroy(); + } + window.chartInstances.delete(element.id); } -} +}; -function getLabels(el: HTMLElement) { - const raw = el.getAttribute('data-chart-labels'); +// Get labels from the element +const getLabels = (el: HTMLElement | null): Record => { + if (!el || !el.dataset.labels) return {}; + const raw = el.dataset.labels; if (!raw) return {}; try { return JSON.parse(raw); @@ -92,359 +103,234 @@ function getLabels(el: HTMLElement) { } } -// All hooks are typed as 'any' for LiveView context compatibility -export const WeatherChartHooks: Record = { - ChartJSTempChart: { - mounted() { - const self = this as any; - registerChartInstance(self.el as HTMLElement, self); - self.renderChart(); - self.themeChangeHandler = () => self.renderChart(); - window.addEventListener('themeChanged', self.themeChangeHandler); - self.handleEvent("update_weather_charts", ({ weather_history }: UpdateWeatherChartsPayload) => { - self.el.dataset.weatherHistory = weather_history; - self.renderChart(); - }); - }, - updated() { (this as any).renderChart(); }, - destroyed() { - const self = this as any; - if (self.themeChangeHandler) window.removeEventListener('themeChanged', self.themeChangeHandler); - unregisterChartInstance(self.el as HTMLElement); - }, - renderChart() { - const self = this as any; - if (self.chart) self.chart.destroy(); - const data: WeatherHistoryDatum[] = JSON.parse(self.el.dataset.weatherHistory!); - const labels = getLabels(self.el); - const times = data.map(d => new Date(d.timestamp)); - const temps = data.map(d => d.temperature); - const dews = data.map(d => d.dew_point); - const colors = getThemeColors(); - const canvas = self.el.querySelector('canvas') as HTMLCanvasElement; - if (!canvas) return; - self.chart = new window.Chart(canvas, { - type: 'line', - data: { - labels: times, - datasets: [ - { label: labels.temp_label || 'Temperature (°F)', data: temps, borderColor: 'red', backgroundColor: 'rgba(255, 0, 0, 0.1)', tension: 0.1, pointRadius: 0 }, - { label: labels.dew_label || 'Dew Point (°F)', data: dews, borderColor: 'blue', backgroundColor: 'rgba(0, 0, 255, 0.1)', tension: 0.1, pointRadius: 0 } - ] - }, - options: { - adapters: { date: { locale: 'en-GB' } }, - responsive: true, - maintainAspectRatio: false, - plugins: { - title: { display: true, text: labels.temp_title || 'Temperature & Dew Point (°F)', color: colors.text }, - legend: { labels: { color: colors.text } } - }, - scales: { - x: { - type: 'time', - time: { - unit: 'minute', - tooltipFormat: 'HH:mm', - displayFormats: { minute: 'HH:mm', hour: 'HH:mm' }, - locale: 'en-GB' - }, - title: { display: true, text: labels.time || 'Time', color: colors.text }, - ticks: { color: colors.text, maxTicksLimit: 8 }, - grid: { color: colors.grid } - }, - y: { title: { display: true, text: labels.degf || '°F', color: colors.text }, ticks: { color: colors.text }, grid: { color: colors.grid } } - } - } - }); - } +// Chart configurations +interface ChartConfig { + type: ChartType; + datasets: (data: WeatherHistoryDatum[], labels: Record) => any[]; + title: (labels: Record) => string; + yAxisLabel?: (labels: Record) => string; + yAxisOptions?: any; +} + +const chartConfigs: Record = { + temperature: { + type: 'line', + datasets: (data, labels) => [ + { + label: labels.temp_label || 'Temperature (°F)', + data: data.map(d => d.temperature), + borderColor: 'red', + backgroundColor: 'rgba(255, 0, 0, 0.1)', + tension: 0.1, + pointRadius: 0 + }, + { + label: labels.dew_label || 'Dew Point (°F)', + data: data.map(d => d.dew_point), + borderColor: 'blue', + backgroundColor: 'rgba(0, 0, 255, 0.1)', + tension: 0.1, + pointRadius: 0 + } + ], + title: (labels) => labels.temp_title || 'Temperature & Dew Point (°F)', + yAxisLabel: (labels) => labels.degf || '°F' }, - ChartJSHumidityChart: { - mounted() { - const self = this as any; - registerChartInstance(self.el as HTMLElement, self); - self.renderChart(); - self.themeChangeHandler = () => self.renderChart(); - window.addEventListener('themeChanged', self.themeChangeHandler); - self.handleEvent("update_weather_charts", ({ weather_history }: UpdateWeatherChartsPayload) => { - self.el.dataset.weatherHistory = weather_history; - self.renderChart(); - }); - }, - updated() { (this as any).renderChart(); }, - destroyed() { - const self = this as any; - if (self.themeChangeHandler) window.removeEventListener('themeChanged', self.themeChangeHandler); - unregisterChartInstance(self.el as HTMLElement); - }, - renderChart() { - const self = this as any; - if (self.chart) self.chart.destroy(); - const data: WeatherHistoryDatum[] = JSON.parse(self.el.dataset.weatherHistory!); - const labels = getLabels(self.el); - const times = data.map(d => new Date(d.timestamp)); - const humidity = data.map(d => d.humidity); - const colors = getThemeColors(); - const canvas = self.el.querySelector('canvas') as HTMLCanvasElement; - if (!canvas) return; - self.chart = new window.Chart(canvas, { - type: 'line', - data: { labels: times, datasets: [{ label: labels.humidity_label || 'Humidity (%)', data: humidity, borderColor: 'green', backgroundColor: 'rgba(0, 255, 0, 0.1)', tension: 0.1, pointRadius: 0 }] }, - options: { - adapters: { date: { locale: 'en-GB' } }, - responsive: true, - maintainAspectRatio: false, - plugins: { title: { display: true, text: labels.humidity_title || 'Humidity (%)', color: colors.text }, legend: { labels: { color: colors.text } } }, - scales: { - x: { - type: 'time', - time: { - unit: 'minute', - tooltipFormat: 'HH:mm', - displayFormats: { minute: 'HH:mm', hour: 'HH:mm' }, - locale: 'en-GB' - }, - title: { display: true, text: labels.time || 'Time', color: colors.text }, - ticks: { color: colors.text, maxTicksLimit: 8 }, - grid: { color: colors.grid } - }, - y: { title: { display: true, text: labels.percent || '%', color: colors.text }, ticks: { color: colors.text }, grid: { color: colors.grid } } - } - } - }); - } + humidity: { + type: 'line', + datasets: (data, labels) => [{ + label: labels.hum_label || 'Humidity (%)', + data: data.map(d => d.humidity), + borderColor: 'green', + backgroundColor: 'rgba(0, 255, 0, 0.1)', + tension: 0.1, + pointRadius: 0 + }], + title: (labels) => labels.hum_title || 'Humidity (%)', + yAxisLabel: (labels) => labels.percent || '%', + yAxisOptions: { min: 0, max: 100 } }, - ChartJSPressureChart: { - mounted() { - const self = this as any; - registerChartInstance(self.el as HTMLElement, self); - self.renderChart(); - self.themeChangeHandler = () => self.renderChart(); - window.addEventListener('themeChanged', self.themeChangeHandler); - self.handleEvent("update_weather_charts", ({ weather_history }: UpdateWeatherChartsPayload) => { - self.el.dataset.weatherHistory = weather_history; - self.renderChart(); - }); - }, - updated() { (this as any).renderChart(); }, - destroyed() { - const self = this as any; - if (self.themeChangeHandler) window.removeEventListener('themeChanged', self.themeChangeHandler); - unregisterChartInstance(self.el as HTMLElement); - }, - renderChart() { - const self = this as any; - if (self.chart) self.chart.destroy(); - const data: WeatherHistoryDatum[] = JSON.parse(self.el.dataset.weatherHistory!); - const labels = getLabels(self.el); - const times = data.map(d => new Date(d.timestamp)); - const pressure = data.map(d => d.pressure); - const colors = getThemeColors(); - const canvas = self.el.querySelector('canvas') as HTMLCanvasElement; - if (!canvas) return; - self.chart = new window.Chart(canvas, { - type: 'line', - data: { labels: times, datasets: [{ label: labels.pressure_label || 'Pressure (mb)', data: pressure, borderColor: 'purple', backgroundColor: 'rgba(128, 0, 128, 0.1)', tension: 0.1, pointRadius: 0 }] }, - options: { - adapters: { date: { locale: 'en-GB' } }, - responsive: true, - maintainAspectRatio: false, - plugins: { title: { display: true, text: labels.pressure_title || 'Pressure (mb)', color: colors.text }, legend: { labels: { color: colors.text } } }, - scales: { - x: { - type: 'time', - time: { - unit: 'minute', - tooltipFormat: 'HH:mm', - displayFormats: { minute: 'HH:mm', hour: 'HH:mm' }, - locale: 'en-GB' - }, - title: { display: true, text: labels.time || 'Time', color: colors.text }, - ticks: { color: colors.text, maxTicksLimit: 8 }, - grid: { color: colors.grid } - }, - y: { title: { display: true, text: labels.mb || 'mb', color: colors.text }, ticks: { color: colors.text }, grid: { color: colors.grid } } - } - } - }); - } + pressure: { + type: 'line', + datasets: (data, labels) => [{ + label: labels.prs_label || 'Pressure (mb)', + data: data.map(d => d.pressure), + borderColor: 'purple', + backgroundColor: 'rgba(128, 0, 128, 0.1)', + tension: 0.1, + pointRadius: 0 + }], + title: (labels) => labels.prs_title || 'Barometric Pressure (mb)', + yAxisLabel: (labels) => labels.mb || 'mb' }, - ChartJSWindChart: { - mounted() { - const self = this as any; - registerChartInstance(self.el as HTMLElement, self); - self.renderChart(); - self.themeChangeHandler = () => self.renderChart(); - window.addEventListener('themeChanged', self.themeChangeHandler); - self.handleEvent("update_weather_charts", ({ weather_history }: UpdateWeatherChartsPayload) => { - self.el.dataset.weatherHistory = weather_history; - self.renderChart(); - }); - }, - updated() { (this as any).renderChart(); }, - destroyed() { - const self = this as any; - if (self.themeChangeHandler) window.removeEventListener('themeChanged', self.themeChangeHandler); - unregisterChartInstance(self.el as HTMLElement); - }, - renderChart() { - const self = this as any; - if (self.chart) self.chart.destroy(); - const data: WeatherHistoryDatum[] = JSON.parse(self.el.dataset.weatherHistory!); - const times = data.map(d => new Date(d.timestamp)); - const windSpeed = data.map(d => d.wind_speed); - const windGust = data.map(d => d.wind_gust); - const colors = getThemeColors(); - const canvas = self.el.querySelector('canvas') as HTMLCanvasElement; - if (!canvas) return; - self.chart = new window.Chart(canvas, { - type: 'line', - data: { - labels: times, datasets: [ - { label: 'Wind Speed (mph)', data: windSpeed, borderColor: 'orange', backgroundColor: 'rgba(255, 165, 0, 0.1)', tension: 0.1, pointRadius: 0 }, - { label: 'Wind Gust (mph)', data: windGust, borderColor: 'brown', backgroundColor: 'rgba(165, 42, 42, 0.1)', tension: 0.1, pointRadius: 0 } - ] - }, - options: { - adapters: { date: { locale: 'en-GB' } }, - responsive: true, - maintainAspectRatio: false, - plugins: { title: { display: true, text: 'Wind Speed & Gust (mph)', color: colors.text }, legend: { labels: { color: colors.text } } }, - scales: { - x: { - type: 'time', - time: { - unit: 'minute', - tooltipFormat: 'HH:mm', - displayFormats: { minute: 'HH:mm', hour: 'HH:mm' }, - locale: 'en-GB' - }, - title: { display: true, text: 'Time', color: colors.text }, - ticks: { color: colors.text, maxTicksLimit: 8 }, - grid: { color: colors.grid } - }, - y: { title: { display: true, text: 'mph', color: colors.text }, ticks: { color: colors.text }, grid: { color: colors.grid } } - } - } - }); - } + wind: { + type: 'line', + datasets: (data, labels) => [ + { + label: labels.spd_label || 'Wind Speed (mph)', + data: data.map(d => d.wind_speed), + borderColor: 'orange', + backgroundColor: 'rgba(255, 165, 0, 0.1)', + tension: 0.1, + pointRadius: 0 + }, + { + label: labels.gst_label || 'Wind Gust (mph)', + data: data.map(d => d.wind_gust), + borderColor: 'red', + backgroundColor: 'rgba(255, 0, 0, 0.1)', + tension: 0.1, + pointRadius: 0 + } + ], + title: (labels) => labels.wnd_title || 'Wind Speed & Gust (mph)', + yAxisLabel: (labels) => labels.mph || 'mph', + yAxisOptions: { min: 0 } }, - ChartJSRainChart: { - mounted() { - const self = this as any; - registerChartInstance(self.el as HTMLElement, self); - self.renderChart(); - self.themeChangeHandler = () => self.renderChart(); - window.addEventListener('themeChanged', self.themeChangeHandler); - self.handleEvent("update_weather_charts", ({ weather_history }: UpdateWeatherChartsPayload) => { - self.el.dataset.weatherHistory = weather_history; - self.renderChart(); - }); - }, - updated() { (this as any).renderChart(); }, - destroyed() { - const self = this as any; - if (self.themeChangeHandler) window.removeEventListener('themeChanged', self.themeChangeHandler); - unregisterChartInstance(self.el as HTMLElement); - }, - renderChart() { - const self = this as any; - if (self.chart) self.chart.destroy(); - const data: WeatherHistoryDatum[] = JSON.parse(self.el.dataset.weatherHistory!); - const times = data.map(d => new Date(d.timestamp)); - const rain1h = data.map(d => d.rain_1h); - const rain24h = data.map(d => d.rain_24h); - const rainSinceMidnight = data.map(d => d.rain_since_midnight); - const colors = getThemeColors(); - const canvas = self.el.querySelector('canvas') as HTMLCanvasElement; - if (!canvas) return; - self.chart = new window.Chart(canvas, { - type: 'line', - data: { - labels: times, datasets: [ - { label: 'Rain (1h)', data: rain1h, borderColor: 'blue', backgroundColor: 'rgba(0, 0, 255, 0.1)', tension: 0.1, pointRadius: 0 }, - { label: 'Rain (24h)', data: rain24h, borderColor: 'cyan', backgroundColor: 'rgba(0, 255, 255, 0.1)', tension: 0.1, pointRadius: 0 }, - { label: 'Rain (since midnight)', data: rainSinceMidnight, borderColor: 'navy', backgroundColor: 'rgba(0, 0, 128, 0.1)', tension: 0.1, pointRadius: 0 } - ] - }, - options: { - adapters: { date: { locale: 'en-GB' } }, - responsive: true, - maintainAspectRatio: false, - plugins: { title: { display: true, text: 'Rainfall (inches)', color: colors.text }, legend: { labels: { color: colors.text } } }, - scales: { - x: { - type: 'time', - time: { - unit: 'minute', - tooltipFormat: 'HH:mm', - displayFormats: { minute: 'HH:mm', hour: 'HH:mm' }, - locale: 'en-GB' - }, - title: { display: true, text: 'Time', color: colors.text }, - ticks: { color: colors.text, maxTicksLimit: 8 }, - grid: { color: colors.grid } - }, - y: { title: { display: true, text: 'inches', color: colors.text }, ticks: { color: colors.text }, grid: { color: colors.grid } } - } - } - }); - } + rain: { + type: 'bar', + datasets: (data, labels) => [ + { + label: labels.h1_label || 'Rain 1h (in)', + data: data.map(d => d.rain_1h), + backgroundColor: 'rgba(54, 162, 235, 0.8)', + borderColor: 'rgba(54, 162, 235, 1)', + borderWidth: 1 + }, + { + label: labels.h24_label || 'Rain 24h (in)', + data: data.map(d => d.rain_24h ? d.rain_24h / 10 : null), + backgroundColor: 'rgba(153, 102, 255, 0.8)', + borderColor: 'rgba(153, 102, 255, 1)', + borderWidth: 1 + } + ], + title: (labels) => labels.rain_title || 'Rainfall (inches)', + yAxisLabel: (labels) => labels.inches || 'inches', + yAxisOptions: { min: 0 } }, - ChartJSLuminosityChart: { - mounted() { - const self = this as any; - registerChartInstance(self.el as HTMLElement, self); - self.renderChart(); - self.themeChangeHandler = () => self.renderChart(); - window.addEventListener('themeChanged', self.themeChangeHandler); - self.handleEvent("update_weather_charts", ({ weather_history }: UpdateWeatherChartsPayload) => { - self.el.dataset.weatherHistory = weather_history; - self.renderChart(); - }); - }, - updated() { (this as any).renderChart(); }, - destroyed() { - const self = this as any; - if (self.themeChangeHandler) window.removeEventListener('themeChanged', self.themeChangeHandler); - unregisterChartInstance(self.el as HTMLElement); - }, - renderChart() { - const self = this as any; - if (self.chart) self.chart.destroy(); - const data: WeatherHistoryDatum[] = JSON.parse(self.el.dataset.weatherHistory!); - const times = data.map(d => new Date(d.timestamp)); - const luminosity = data.map(d => d.luminosity); - const colors = getThemeColors(); - const canvas = self.el.querySelector('canvas') as HTMLCanvasElement; - if (!canvas) return; - self.chart = new window.Chart(canvas, { - type: 'line', - data: { labels: times, datasets: [{ label: 'Luminosity', data: luminosity, borderColor: 'yellow', backgroundColor: 'rgba(255, 255, 0, 0.1)', tension: 0.1, pointRadius: 0 }] }, - options: { - adapters: { date: { locale: 'en-GB' } }, - responsive: true, - maintainAspectRatio: false, - plugins: { title: { display: true, text: 'Luminosity', color: colors.text }, legend: { labels: { color: colors.text } } }, - scales: { - x: { - type: 'time', - time: { - unit: 'minute', - tooltipFormat: 'HH:mm', - displayFormats: { minute: 'HH:mm', hour: 'HH:mm' }, - locale: 'en-GB' - }, - title: { display: true, text: 'Time', color: colors.text }, - ticks: { color: colors.text, maxTicksLimit: 8 }, - grid: { color: colors.grid } - }, - y: { title: { display: true, text: 'Luminosity', color: colors.text }, ticks: { color: colors.text }, grid: { color: colors.grid } } - } - } - }); - } + luminosity: { + type: 'line', + datasets: (data, labels) => [{ + label: labels.lum_label || 'Luminosity (W/m²)', + data: data.map(d => d.luminosity), + borderColor: 'gold', + backgroundColor: 'rgba(255, 215, 0, 0.1)', + tension: 0.1, + pointRadius: 0 + }], + title: (labels) => labels.lum_title || 'Solar Radiation (W/m²)', + yAxisLabel: (labels) => labels.wm2 || 'W/m²', + yAxisOptions: { min: 0 } } }; -export default WeatherChartHooks; \ No newline at end of file +// Create a chart hook +function createChartHook(configKey: string): Hook { + const config = chartConfigs[configKey]; + if (!config) { + throw new Error(`Unknown chart config: ${configKey}`); + } + + return { + mounted() { + const self = this as ChartHookContext; + registerChartInstance(self.el, self); + self.renderChart = () => { + if (self.chart) self.chart.destroy(); + + const data: WeatherHistoryDatum[] = parseWeatherHistory(self.el.dataset.weatherHistory); + if (data.length === 0) return; + + const canvas = self.el.querySelector('canvas') as HTMLCanvasElement | null; + if (!canvas) { + console.error("Canvas element not found for chart"); + return; + } + + const labels = getLabels(self.el); + const times = data.map(d => new Date(d.timestamp)); + const colors = getThemeColors(); + + const chartConfig: ChartConfiguration = { + type: config.type, + data: { + labels: times, + datasets: config.datasets(data, labels) + }, + options: { + adapters: { date: { locale: 'en-GB' } }, + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: config.title(labels), + color: colors.text + }, + legend: { labels: { color: colors.text } } + }, + scales: { + x: { + type: 'time', + time: { + unit: 'minute', + tooltipFormat: 'HH:mm', + displayFormats: { minute: 'HH:mm', hour: 'HH:mm' }, + locale: 'en-GB' + }, + title: { display: true, text: labels.time || 'Time', color: colors.text }, + ticks: { color: colors.text, maxTicksLimit: 8 }, + grid: { color: colors.grid } + }, + y: { + title: { + display: true, + text: config.yAxisLabel ? config.yAxisLabel(labels) : '', + color: colors.text + }, + ticks: { color: colors.text }, + grid: { color: colors.grid }, + ...(config.yAxisOptions || {}) + } + } + } + }; + + self.chart = new window.Chart(canvas, chartConfig); + }; + + self.renderChart(); + self.themeChangeHandler = () => self.renderChart(); + window.addEventListener('themeChanged', self.themeChangeHandler); + self.handleEvent("update_weather_charts", ({ weather_history }: UpdateWeatherChartsPayload) => { + self.el.dataset.weatherHistory = weather_history; + self.renderChart(); + }); + }, + + updated() { + (this as ChartHookContext).renderChart(); + }, + + destroyed() { + const self = this as ChartHookContext; + if (self.themeChangeHandler) { + window.removeEventListener('themeChanged', self.themeChangeHandler); + } + unregisterChartInstance(self.el); + } + }; +} + +// Export weather chart hooks +export const WeatherChartHooks: Record = { + ChartJSTempChart: createChartHook('temperature'), + ChartJSHumidityChart: createChartHook('humidity'), + ChartJSPressureChart: createChartHook('pressure'), + ChartJSWindChart: createChartHook('wind'), + ChartJSRainChart: createChartHook('rain'), + ChartJSLuminosityChart: createChartHook('luminosity') +}; + +export default WeatherChartHooks; \ No newline at end of file diff --git a/assets/js/hooks/error_boundary.js b/assets/js/hooks/error_boundary.js index d9c7813..90c02f5 100644 --- a/assets/js/hooks/error_boundary.js +++ b/assets/js/hooks/error_boundary.js @@ -3,46 +3,41 @@ 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'); + // 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() { - // 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; - }; + // 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); - // 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); - } - }; + 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) { @@ -81,25 +76,35 @@ const ErrorBoundary = { console.error('Error caught by boundary:', error); // Hide content and show fallback - if (this.content) { + if (this.content && this.content.style) { this.content.style.display = 'none'; } - if (this.fallback) { + if (this.fallback && this.fallback.classList) { 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 - }); + // 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() { - // Restore original error handlers - window.onerror = this.originalErrorHandler; - window.onunhandledrejection = this.originalUnhandledRejection; + // Remove event listeners + if (this.errorHandler) { + window.removeEventListener('error', this.errorHandler, true); + } + if (this.unhandledRejectionHandler) { + window.removeEventListener('unhandledrejection', this.unhandledRejectionHandler, true); + } } }; diff --git a/assets/js/map_helpers.ts b/assets/js/map_helpers.ts index dd3c46b..9fd1951 100644 --- a/assets/js/map_helpers.ts +++ b/assets/js/map_helpers.ts @@ -1,5 +1,7 @@ // Helper functions for map.ts to reduce code duplication +import type * as L from 'leaflet'; + export interface MapState { lat: number; lng: number; @@ -16,7 +18,7 @@ export interface BoundsData { /** * Parse timestamp to milliseconds */ -export function parseTimestamp(timestamp: any): number { +export function parseTimestamp(timestamp: string | number | Date | undefined): number { if (!timestamp) return Date.now(); if (typeof timestamp === "number") { @@ -37,7 +39,10 @@ export function getTrailId(data: { callsign_group?: string; callsign?: string; i /** * Save map state to localStorage and send to server */ -export function saveMapState(map: any, pushEvent: Function) { +// Define the pushEvent function type +type PushEventFunction = (event: string, payload: Record) => void; + +export function saveMapState(map: L.Map, pushEvent: PushEventFunction) { if (!map || !pushEvent) { console.warn("saveMapState called with invalid map or pushEvent"); return; @@ -84,7 +89,7 @@ export function saveMapState(map: any, pushEvent: Function) { /** * Safely push event to LiveView */ -export function safePushEvent(pushEvent: Function | undefined, event: string, payload: any): boolean { +export function safePushEvent(pushEvent: PushEventFunction | undefined, event: string, payload: Record): boolean { if (!pushEvent || typeof pushEvent !== 'function') { console.debug(`pushEvent not available for ${event} event`); return false; @@ -103,12 +108,16 @@ export function safePushEvent(pushEvent: Function | undefined, event: string, pa * Check if LiveView socket is available */ export function isLiveViewConnected(): boolean { - return !!(window as any).liveSocket; + return typeof window !== 'undefined' && !!(window as any).liveSocket; } /** * Get LiveView socket */ -export function getLiveSocket(): any { - return (window as any).liveSocket; +interface LiveSocket { + pushHistoryPatch(href: string, type: string, target: HTMLElement): void; +} + +export function getLiveSocket(): LiveSocket | null { + return typeof window !== 'undefined' ? (window as any).liveSocket : null; } \ No newline at end of file