js refactor
This commit is contained in:
parent
566198afa7
commit
1d1195ae87
5 changed files with 381 additions and 455 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue