js refactor

This commit is contained in:
Graham McIntire 2025-07-16 09:23:33 -05:00
parent 566198afa7
commit 1d1195ae87
No known key found for this signature in database
5 changed files with 381 additions and 455 deletions

View file

@ -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');

View file

@ -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<string, TrailState>;
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 = [];

View file

@ -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<string, any>;
Chart: typeof Chart;
chartInstances?: Map<string, ChartHookContext>;
}
}
// 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<string, any>;
}
}
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<string, string> => {
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<string, Hook> = {
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<string, string>) => any[];
title: (labels: Record<string, string>) => string;
yAxisLabel?: (labels: Record<string, string>) => string;
yAxisOptions?: any;
}
const chartConfigs: Record<string, ChartConfig> = {
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;
// 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<string, Hook> = {
ChartJSTempChart: createChartHook('temperature'),
ChartJSHumidityChart: createChartHook('humidity'),
ChartJSPressureChart: createChartHook('pressure'),
ChartJSWindChart: createChartHook('wind'),
ChartJSRainChart: createChartHook('rain'),
ChartJSLuminosityChart: createChartHook('luminosity')
};
export default WeatherChartHooks;

View file

@ -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);
}
}
};

View file

@ -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<string, any>) => 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<string, any>): 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;
}