Fix JavaScript/TypeScript bugs in plotting and validation
- Fix rain chart: remove incorrect /10 division on rain_24h values - Fix RF path: change dashArray null to undefined for type safety - Clarify trail proximity threshold: use kilometers directly instead of degrees - Improve coordinate validation: add isFinite() checks to prevent infinity values - Add Sobelow skip annotations for existing security warnings
This commit is contained in:
parent
b2c25a152d
commit
ff06c13224
4 changed files with 873 additions and 575 deletions
17
.sobelow-skips
Normal file
17
.sobelow-skips
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
|
||||
Traversal.FileModule: Directory Traversal in `File.read`,lib/aprsme/devices_seeder.ex:8,1BF74FD
|
||||
SQL.Query: SQL injection,lib/aprsme_web/live/info_live/show.ex:347,1CB06D3
|
||||
SQL.Query: SQL injection,lib/aprsme/db_optimizer.ex:152,1DA970
|
||||
Config.HTTPS: HTTPS Not Enabled,config/prod.exs:0,2B5C077
|
||||
Config.Headers: Missing Secure Browser Headers,lib/aprsme_web/router.ex:11,32F6644
|
||||
SQL.Query: SQL injection,lib/aprsme/db_optimizer.ex:33,339B6E6
|
||||
SQL.Query: SQL injection,lib/aprsme_web/live/info_live/show.ex:462,3418D79
|
||||
XSS.Raw: XSS,lib/aprsme_web/live/info_live/show.ex:632,395070D
|
||||
XSS.Raw: XSS,lib/aprsme_web/live/info_live/show.ex:650,44312C
|
||||
SQL.Query: SQL injection,lib/aprsme/packets.ex:661,498E4DF
|
||||
Traversal.FileModule: Directory Traversal in `File.read`,lib/aprsme_web/components/core_components.ex:37,4DB3CEF
|
||||
SQL.Query: SQL injection,lib/aprsme/release.ex:159,5D9BE66
|
||||
SQL.Query: SQL injection,lib/aprsme/db_optimizer.ex:107,5ECC490
|
||||
SQL.Query: SQL injection,lib/aprsme_web/live/info_live/show.ex:576,6A503E9
|
||||
XSS.Raw: XSS,lib/aprsme_web/components/core_components.ex:52,6B2C0EC
|
||||
SQL.Query: SQL injection,lib/aprsme/db_optimizer.ex:129,711E151
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
// Trail management module for APRS position history visualization
|
||||
|
||||
import type { LayerGroup, Polyline, CircleMarker, PolylineOptions } from 'leaflet';
|
||||
import type {
|
||||
LayerGroup,
|
||||
Polyline,
|
||||
CircleMarker,
|
||||
PolylineOptions,
|
||||
} from "leaflet";
|
||||
|
||||
// Declare Leaflet as a global
|
||||
declare const L: typeof import('leaflet');
|
||||
declare const L: typeof import("leaflet");
|
||||
|
||||
export interface PositionHistory {
|
||||
lat: number;
|
||||
|
|
@ -26,23 +31,23 @@ export class TrailManager {
|
|||
private maxTrails: number = 500; // Maximum number of trails to keep in memory
|
||||
private maxPositionsPerTrail: number = 1000; // Maximum positions per trail
|
||||
private colorPalette: string[] = [
|
||||
'#1E90FF', // Dodger Blue
|
||||
'#00CED1', // Dark Turquoise
|
||||
'#32CD32', // Lime Green
|
||||
'#8B008B', // Dark Magenta
|
||||
'#9370DB', // Medium Purple
|
||||
'#FF8C00', // Dark Orange
|
||||
'#4682B4', // Steel Blue
|
||||
'#00FA9A', // Medium Spring Green
|
||||
'#DA70D6', // Orchid
|
||||
'#008B8B', // Dark Cyan
|
||||
'#48D1CC', // Medium Turquoise
|
||||
'#228B22', // Forest Green
|
||||
'#6495ED', // Cornflower Blue
|
||||
'#FF1493', // Deep Pink (distinct from highways)
|
||||
'#20B2AA', // Light Sea Green
|
||||
"#1E90FF", // Dodger Blue
|
||||
"#00CED1", // Dark Turquoise
|
||||
"#32CD32", // Lime Green
|
||||
"#8B008B", // Dark Magenta
|
||||
"#9370DB", // Medium Purple
|
||||
"#FF8C00", // Dark Orange
|
||||
"#4682B4", // Steel Blue
|
||||
"#00FA9A", // Medium Spring Green
|
||||
"#DA70D6", // Orchid
|
||||
"#008B8B", // Dark Cyan
|
||||
"#48D1CC", // Medium Turquoise
|
||||
"#228B22", // Forest Green
|
||||
"#6495ED", // Cornflower Blue
|
||||
"#FF1493", // Deep Pink (distinct from highways)
|
||||
"#20B2AA", // Light Sea Green
|
||||
];
|
||||
private proximityThreshold: number = 0.05; // ~5.5km at equator
|
||||
private proximityThreshold: number = 5.5; // kilometers
|
||||
|
||||
constructor(trailLayer: LayerGroup, trailDuration: number = 60 * 60 * 1000) {
|
||||
this.trailLayer = trailLayer;
|
||||
|
|
@ -80,17 +85,20 @@ export class TrailManager {
|
|||
|
||||
setTrailDuration(durationHours: number) {
|
||||
this.trailDuration = durationHours * 60 * 60 * 1000; // Convert hours to milliseconds
|
||||
|
||||
|
||||
// Clean up existing trails based on new duration
|
||||
const cutoffTime = Date.now() - this.trailDuration;
|
||||
|
||||
|
||||
this.trails.forEach((trailState, baseCallsign) => {
|
||||
// Filter positions based on new duration (skip historical dots)
|
||||
trailState.positions = trailState.positions.filter((pos) => {
|
||||
const posTimestamp = typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp;
|
||||
const posTimestamp =
|
||||
typeof pos.timestamp === "string"
|
||||
? new Date(pos.timestamp).getTime()
|
||||
: pos.timestamp;
|
||||
return posTimestamp >= cutoffTime;
|
||||
});
|
||||
|
||||
|
||||
// Update trail visualization
|
||||
this.updateTrailVisualization(baseCallsign, trailState, true);
|
||||
});
|
||||
|
|
@ -107,8 +115,8 @@ export class TrailManager {
|
|||
|
||||
// Validate coordinates before processing
|
||||
if (
|
||||
typeof lat !== 'number' ||
|
||||
typeof lng !== 'number' ||
|
||||
typeof lat !== "number" ||
|
||||
typeof lng !== "number" ||
|
||||
isNaN(lat) ||
|
||||
isNaN(lng) ||
|
||||
!isFinite(lat) ||
|
||||
|
|
@ -118,7 +126,12 @@ export class TrailManager {
|
|||
lng < -180 ||
|
||||
lng > 180
|
||||
) {
|
||||
console.warn("Invalid coordinates provided to addPosition:", { markerId, lat, lng, timestamp });
|
||||
console.warn("Invalid coordinates provided to addPosition:", {
|
||||
markerId,
|
||||
lat,
|
||||
lng,
|
||||
timestamp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -153,11 +166,13 @@ 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);
|
||||
trailState.positions = trailState.positions.slice(
|
||||
-this.maxPositionsPerTrail,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +182,9 @@ export class TrailManager {
|
|||
trailState.positions = trailState.positions.filter((pos) => {
|
||||
// Ensure timestamp is a number for comparison
|
||||
const posTimestamp =
|
||||
typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp;
|
||||
typeof pos.timestamp === "string"
|
||||
? new Date(pos.timestamp).getTime()
|
||||
: pos.timestamp;
|
||||
return posTimestamp >= cutoffTime;
|
||||
});
|
||||
}
|
||||
|
|
@ -194,64 +211,94 @@ export class TrailManager {
|
|||
}
|
||||
|
||||
// Calculate distance between two points using Haversine formula
|
||||
private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
private calculateDistance(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number,
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180;
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180;
|
||||
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLng/2) * Math.sin(dLng/2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLng / 2) *
|
||||
Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
// Get the average position of a trail
|
||||
private getTrailCenter(positions: PositionHistory[]): { lat: number, lng: number } {
|
||||
private getTrailCenter(positions: PositionHistory[]): {
|
||||
lat: number;
|
||||
lng: number;
|
||||
} {
|
||||
if (positions.length === 0) return { lat: 0, lng: 0 };
|
||||
|
||||
const sum = positions.reduce((acc, pos) => ({
|
||||
lat: acc.lat + pos.lat,
|
||||
lng: acc.lng + pos.lng
|
||||
}), { lat: 0, lng: 0 });
|
||||
|
||||
|
||||
const sum = positions.reduce(
|
||||
(acc, pos) => ({
|
||||
lat: acc.lat + pos.lat,
|
||||
lng: acc.lng + pos.lng,
|
||||
}),
|
||||
{ lat: 0, lng: 0 },
|
||||
);
|
||||
|
||||
return {
|
||||
lat: sum.lat / positions.length,
|
||||
lng: sum.lng / positions.length
|
||||
lng: sum.lng / positions.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Find nearby trails and get their colors
|
||||
private getNearbyTrailColors(baseCallsign: string, center: { lat: number, lng: number }): Set<string> {
|
||||
private getNearbyTrailColors(
|
||||
baseCallsign: string,
|
||||
center: { lat: number; lng: number },
|
||||
): Set<string> {
|
||||
const nearbyColors = new Set<string>();
|
||||
|
||||
|
||||
this.trails.forEach((trailState, callsign) => {
|
||||
if (callsign === baseCallsign || !trailState.color || trailState.positions.length === 0) return;
|
||||
|
||||
if (
|
||||
callsign === baseCallsign ||
|
||||
!trailState.color ||
|
||||
trailState.positions.length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
const otherCenter = this.getTrailCenter(trailState.positions);
|
||||
const distance = this.calculateDistance(center.lat, center.lng, otherCenter.lat, otherCenter.lng);
|
||||
|
||||
if (distance < this.proximityThreshold * 111) { // Convert degrees to km (rough approximation)
|
||||
const distance = this.calculateDistance(
|
||||
center.lat,
|
||||
center.lng,
|
||||
otherCenter.lat,
|
||||
otherCenter.lng,
|
||||
);
|
||||
|
||||
if (distance < this.proximityThreshold) {
|
||||
nearbyColors.add(trailState.color);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return nearbyColors;
|
||||
}
|
||||
|
||||
// Assign a color to a trail based on nearby trails
|
||||
private assignTrailColor(baseCallsign: string, positions: PositionHistory[]): string {
|
||||
private assignTrailColor(
|
||||
baseCallsign: string,
|
||||
positions: PositionHistory[],
|
||||
): string {
|
||||
if (positions.length === 0) return this.colorPalette[0];
|
||||
|
||||
|
||||
const center = this.getTrailCenter(positions);
|
||||
const nearbyColors = this.getNearbyTrailColors(baseCallsign, center);
|
||||
|
||||
|
||||
// Find the first available color not used by nearby trails
|
||||
for (const color of this.colorPalette) {
|
||||
if (!nearbyColors.has(color)) {
|
||||
return color;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If all colors are used, return the first color
|
||||
return this.colorPalette[0];
|
||||
}
|
||||
|
|
@ -278,8 +325,8 @@ export class TrailManager {
|
|||
// Validate coordinates are finite numbers within valid ranges
|
||||
return (
|
||||
pos &&
|
||||
typeof pos.lat === 'number' &&
|
||||
typeof pos.lng === 'number' &&
|
||||
typeof pos.lat === "number" &&
|
||||
typeof pos.lng === "number" &&
|
||||
!isNaN(pos.lat) &&
|
||||
!isNaN(pos.lng) &&
|
||||
isFinite(pos.lat) &&
|
||||
|
|
@ -296,7 +343,10 @@ export class TrailManager {
|
|||
if (latLngs.length >= 2) {
|
||||
// Assign color if not already assigned
|
||||
if (!trailState.color) {
|
||||
trailState.color = this.assignTrailColor(baseCallsign, trailState.positions);
|
||||
trailState.color = this.assignTrailColor(
|
||||
baseCallsign,
|
||||
trailState.positions,
|
||||
);
|
||||
}
|
||||
|
||||
// Create polyline with assigned color
|
||||
|
|
@ -310,11 +360,18 @@ export class TrailManager {
|
|||
lineJoin: "round",
|
||||
className: "historical-trail-line",
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
trailState.trail = L.polyline(latLngs, polylineOptions).addTo(this.trailLayer);
|
||||
trailState.trail = L.polyline(latLngs, polylineOptions).addTo(
|
||||
this.trailLayer,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating trail polyline for", baseCallsign, ":", error);
|
||||
console.error(
|
||||
"Error creating trail polyline for",
|
||||
baseCallsign,
|
||||
":",
|
||||
error,
|
||||
);
|
||||
console.error("Invalid coordinates:", latLngs);
|
||||
}
|
||||
}
|
||||
|
|
@ -352,7 +409,9 @@ export class TrailManager {
|
|||
trailState.positions = trailState.positions.filter((pos) => {
|
||||
// Ensure timestamp is a number for comparison
|
||||
const posTimestamp =
|
||||
typeof pos.timestamp === "string" ? new Date(pos.timestamp).getTime() : pos.timestamp;
|
||||
typeof pos.timestamp === "string"
|
||||
? new Date(pos.timestamp).getTime()
|
||||
: pos.timestamp;
|
||||
// Keep all positions newer than 24 hours (includes all historical data we care about)
|
||||
return posTimestamp >= veryOldCutoff;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,356 +1,383 @@
|
|||
// Chart.js and date adapter are loaded globally from vendor bundle
|
||||
// We'll access it later when it's actually loaded
|
||||
|
||||
import type { ChartConfiguration, ChartType } from 'chart.js';
|
||||
import type { WeatherChartDataset, YAxisOptions } from '../types/chart-types';
|
||||
import type { HandleEventFunction } from '../types/events';
|
||||
import type { ChartConfiguration, ChartType } from "chart.js";
|
||||
import type { WeatherChartDataset, YAxisOptions } from "../types/chart-types";
|
||||
import type { HandleEventFunction } from "../types/events";
|
||||
|
||||
// Declare global Chart object
|
||||
declare global {
|
||||
interface Window {
|
||||
Chart: typeof Chart;
|
||||
chartInstances?: Map<string, ChartHookContext>;
|
||||
}
|
||||
interface Window {
|
||||
Chart: typeof Chart;
|
||||
chartInstances?: Map<string, ChartHookContext>;
|
||||
}
|
||||
}
|
||||
|
||||
// Type for LiveView hooks
|
||||
interface Hook {
|
||||
mounted?: () => void;
|
||||
updated?: () => void;
|
||||
destroyed?: () => void;
|
||||
el: HTMLElement;
|
||||
handleEvent: HandleEventFunction;
|
||||
mounted?: () => void;
|
||||
updated?: () => void;
|
||||
destroyed?: () => void;
|
||||
el: HTMLElement;
|
||||
handleEvent: HandleEventFunction;
|
||||
}
|
||||
|
||||
// Define chart hook context type
|
||||
interface ChartHookContext extends Hook {
|
||||
chart?: Chart;
|
||||
themeChangeHandler?: () => void;
|
||||
renderChart: () => void;
|
||||
chart?: Chart;
|
||||
themeChangeHandler?: () => void;
|
||||
renderChart: () => void;
|
||||
}
|
||||
|
||||
// Type for weather history data
|
||||
interface WeatherHistoryDatum {
|
||||
timestamp: string;
|
||||
temperature?: number;
|
||||
dew_point?: number;
|
||||
humidity?: number;
|
||||
pressure?: number;
|
||||
wind_direction?: number;
|
||||
wind_speed?: number;
|
||||
wind_gust?: number;
|
||||
rain_1h?: number;
|
||||
rain_24h?: number;
|
||||
rain_since_midnight?: number;
|
||||
luminosity?: number;
|
||||
timestamp: string;
|
||||
temperature?: number;
|
||||
dew_point?: number;
|
||||
humidity?: number;
|
||||
pressure?: number;
|
||||
wind_direction?: number;
|
||||
wind_speed?: number;
|
||||
wind_gust?: number;
|
||||
rain_1h?: number;
|
||||
rain_24h?: number;
|
||||
rain_since_midnight?: number;
|
||||
luminosity?: number;
|
||||
}
|
||||
|
||||
// Type for the event payload
|
||||
interface UpdateWeatherChartsPayload {
|
||||
weather_history: string;
|
||||
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 [];
|
||||
}
|
||||
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';
|
||||
return {
|
||||
text: isDark ? '#e5e7eb' : '#111827',
|
||||
grid: isDark ? '#374151' : '#9ca3af',
|
||||
background: isDark ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)'
|
||||
};
|
||||
const isDark = document.documentElement.getAttribute("data-theme") === "dark";
|
||||
return {
|
||||
text: isDark ? "#e5e7eb" : "#111827",
|
||||
grid: isDark ? "#374151" : "#9ca3af",
|
||||
background: isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.1)",
|
||||
};
|
||||
};
|
||||
|
||||
// Register a chart instance
|
||||
const registerChartInstance = (element: HTMLElement, instance: ChartHookContext) => {
|
||||
if (!window.chartInstances) {
|
||||
window.chartInstances = new Map();
|
||||
}
|
||||
const elementId = element.id || `chart-${Date.now()}`;
|
||||
if (!element.id) element.id = elementId;
|
||||
window.chartInstances.set(elementId, instance);
|
||||
const registerChartInstance = (
|
||||
element: HTMLElement,
|
||||
instance: ChartHookContext,
|
||||
) => {
|
||||
if (!window.chartInstances) {
|
||||
window.chartInstances = new Map();
|
||||
}
|
||||
const elementId = element.id || `chart-${Date.now()}`;
|
||||
if (!element.id) element.id = elementId;
|
||||
window.chartInstances.set(elementId, instance);
|
||||
};
|
||||
|
||||
// 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);
|
||||
if (window.chartInstances && element.id) {
|
||||
const instance = window.chartInstances.get(element.id);
|
||||
if (instance?.chart) {
|
||||
instance.chart.destroy();
|
||||
}
|
||||
window.chartInstances.delete(element.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
if (!el || !el.dataset.labels) return {};
|
||||
const raw = el.dataset.labels;
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
// Chart configurations
|
||||
interface ChartConfig {
|
||||
type: ChartType;
|
||||
datasets: (data: WeatherHistoryDatum[], labels: Record<string, string>) => WeatherChartDataset[];
|
||||
title: (labels: Record<string, string>) => string;
|
||||
yAxisLabel?: (labels: Record<string, string>) => string;
|
||||
yAxisOptions?: YAxisOptions;
|
||||
type: ChartType;
|
||||
datasets: (
|
||||
data: WeatherHistoryDatum[],
|
||||
labels: Record<string, string>,
|
||||
) => WeatherChartDataset[];
|
||||
title: (labels: Record<string, string>) => string;
|
||||
yAxisLabel?: (labels: Record<string, string>) => string;
|
||||
yAxisOptions?: YAxisOptions;
|
||||
}
|
||||
|
||||
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'
|
||||
},
|
||||
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 }
|
||||
},
|
||||
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'
|
||||
},
|
||||
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 }
|
||||
},
|
||||
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 }
|
||||
},
|
||||
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 }
|
||||
}
|
||||
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",
|
||||
},
|
||||
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 },
|
||||
},
|
||||
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",
|
||||
},
|
||||
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 },
|
||||
},
|
||||
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),
|
||||
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 },
|
||||
},
|
||||
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 },
|
||||
},
|
||||
};
|
||||
|
||||
// Create a chart hook
|
||||
function createChartHook(configKey: string): Hook {
|
||||
const config = chartConfigs[configKey];
|
||||
if (!config) {
|
||||
throw new Error(`Unknown chart config: ${configKey}`);
|
||||
}
|
||||
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) {
|
||||
console.log('No weather data available for chart');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip rendering if we have less than 2 data points (can't create a meaningful time series)
|
||||
if (data.length < 2) {
|
||||
console.log('Insufficient weather data for chart (need at least 2 data points)');
|
||||
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 || {})
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check if Chart.js is loaded
|
||||
if (!window.Chart) {
|
||||
console.warn('Chart.js not loaded yet, retrying...');
|
||||
setTimeout(() => self.renderChart(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
console.log("No weather data available for chart");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip rendering if we have less than 2 data points (can't create a meaningful time series)
|
||||
if (data.length < 2) {
|
||||
console.log(
|
||||
"Insufficient weather data for chart (need at least 2 data points)",
|
||||
);
|
||||
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 || {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Check if Chart.js is loaded
|
||||
if (!window.Chart) {
|
||||
console.warn("Chart.js not loaded yet, retrying...");
|
||||
setTimeout(() => self.renderChart(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
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')
|
||||
ChartJSTempChart: createChartHook("temperature"),
|
||||
ChartJSHumidityChart: createChartHook("humidity"),
|
||||
ChartJSPressureChart: createChartHook("pressure"),
|
||||
ChartJSWindChart: createChartHook("wind"),
|
||||
ChartJSRainChart: createChartHook("rain"),
|
||||
ChartJSLuminosityChart: createChartHook("luminosity"),
|
||||
};
|
||||
|
||||
export default WeatherChartHooks;
|
||||
export default WeatherChartHooks;
|
||||
|
|
|
|||
627
assets/js/map.ts
627
assets/js/map.ts
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue