Files
wetter_station/frontend/src/components/WeatherDashboard.jsx
T
2026-05-08 17:22:28 +02:00

1207 lines
42 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState, useCallback } from 'react'
import Highcharts from 'highcharts'
import { HighchartsReact } from 'highcharts-react-official'
import { format } from 'date-fns'
import { de } from 'date-fns/locale'
import './WeatherDashboard.css'
// Build-Informationen (werden beim Build eingef\u00fcgt)
const buildDate = __BUILD_DATE__
const version = __VERSION__
// Deutsche Lokalisierung für Highcharts
Highcharts.setOptions({
lang: {
months: ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'],
shortMonths: ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'],
weekdays: ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'],
resetZoom: 'Zoom zurücksetzen'
},
time: {
useUTC: false
}
})
const WeatherDashboard = ({ data, currentData = [], rainData = [], timeRange = '24h', onTimeRangeChange, showTable = false, onToggleTable }) => {
// State für Anleitung
const [showAnleitung, setShowAnleitung] = useState(false)
// Schwellwert für Datenlücken (abhängig vom Zeitraum)
const gapThresholdMs = useMemo(() => {
if (timeRange === '24h') return 2 * 60 * 60 * 1000 // 2 Stunden
return 1.5 * 24 * 3600 * 1000 // 1,5 Tage
}, [timeRange])
// Fügt null-Einträge in Lücken ein, damit Highcharts die Linie unterbricht
const withGaps = useCallback((pairs) => {
const result = []
for (let i = 0; i < pairs.length; i++) {
result.push(pairs[i])
if (i < pairs.length - 1 && pairs[i + 1][0] - pairs[i][0] > gapThresholdMs) {
result.push([(pairs[i][0] + pairs[i + 1][0]) / 2, null])
}
}
return result
}, [gapThresholdMs])
// State für benutzerdefinierten Zeitbereich
const [showCustomRangeModal, setShowCustomRangeModal] = useState(false)
const [customStartDate, setCustomStartDate] = useState('')
const [customEndDate, setCustomEndDate] = useState('')
const [customError, setCustomError] = useState('')
// Handler für benutzerdefinierten Zeitbereich
const handleOpenCustomRange = () => {
// Versuche gespeicherten Zeitbereich zu laden
try {
const savedRange = localStorage.getItem('customTimeRange')
if (savedRange) {
const { start, end } = JSON.parse(savedRange)
setCustomStartDate(start.split('T')[0])
setCustomEndDate(end.split('T')[0])
} else {
// Setze Standardwerte (letzte 7 Tage)
const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
setCustomStartDate(format(start, 'yyyy-MM-dd'))
setCustomEndDate(format(end, 'yyyy-MM-dd'))
}
} catch (e) {
// Bei Fehler: Standardwerte verwenden
const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
setCustomStartDate(format(start, 'yyyy-MM-dd'))
setCustomEndDate(format(end, 'yyyy-MM-dd'))
}
setCustomError('')
setShowCustomRangeModal(true)
}
const handleApplyCustomRange = () => {
if (!customStartDate || !customEndDate) {
setCustomError('Bitte Start- und Enddatum auswählen')
return
}
const diffDays = Math.floor((new Date(customEndDate) - new Date(customStartDate)) / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
setCustomError('Enddatum muss nach dem Startdatum liegen')
return
}
if (diffDays > 365) {
setCustomError('Maximaler Zeitraum ist 1 Jahr (365 Tage)')
return
}
const startStr = customStartDate + 'T00:00'
const endStr = customEndDate + 'T23:59'
// Zeitbereich im localStorage speichern
try {
localStorage.setItem('customTimeRange', JSON.stringify({
start: customStartDate,
end: customEndDate
}))
} catch (e) {
// Fehler beim Speichern ignorieren
console.warn('Konnte Zeitbereich nicht speichern:', e)
}
// Anwenden
onTimeRangeChange('custom', { start: startStr, end: endStr })
setShowCustomRangeModal(false)
}
const handleCancelCustomRange = () => {
setShowCustomRangeModal(false)
setCustomError('')
}
// Daten vorbereiten und nach Zeit sortieren (älteste zuerst)
const sortedData = useMemo(() => {
return [...data].sort((a, b) => new Date(a.datetime) - new Date(b.datetime))
}, [data])
// Aktuelle Werte aus separaten currentData (immer 24h)
const sortedCurrentData = useMemo(() => {
return [...currentData].sort((a, b) => new Date(a.datetime) - new Date(b.datetime))
}, [currentData])
// Zeitraum-Label
const timeRangeLabel = useMemo(() => {
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const start = new Date(timeRange.start)
const end = new Date(timeRange.end)
return `${format(start, 'dd.MM.yyyy HH:mm', { locale: de })} - ${format(end, 'dd.MM.yyyy HH:mm', { locale: de })}`
}
switch (timeRange) {
case '24h': return 'Die letzten 24 Stunden'
case '7d': return 'Die letzten 7 Tage'
case '30d': return 'Die letzten 30 Tage'
case '365d': return 'Die letzten 365 Tage'
default: return 'Die letzten 24 Stunden'
}
}, [timeRange])
// Aggregations-Zusatz für Chart-Titel
const aggregationSuffix = useMemo(() => {
// Custom range: basierend auf days
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const days = timeRange.days || 1
if (days >= 7) {
return ' (Tagesmittel)'
} else {
return ' (Stundenmittel)'
}
}
// Vordefinierte Bereiche
switch (timeRange) {
case '7d':
case '30d':
case '365d':
return ' (Tagesmittel)'
default:
return ''
}
}, [timeRange])
// Spezieller Suffix für Regen
const rainSuffix = useMemo(() => {
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const days = timeRange.days || 1
return days >= 7 ? ' (pro Tag)' : ''
}
switch (timeRange) {
case '7d':
case '30d':
case '365d':
return ' (pro Tag)'
default:
return ''
}
}, [timeRange])
// Spezieller Suffix für Temperatur bei 30d/365d
const temperatureSuffix = useMemo(() => {
// Custom range: basierend auf days
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const days = timeRange.days || 1
if (days >= 7) {
return ' (Tages-Min/Max)'
}
return ''
}
// Vordefinierte Bereiche
switch (timeRange) {
case '7d':
case '30d':
case '365d':
return ' (Tages-Min/Max)'
default:
return ''
}
}, [timeRange])
// Spezieller Suffix für Windböen bei 30d/365d
const windGustSuffix = useMemo(() => {
// Custom range: basierend auf days
if (typeof timeRange === 'object' && timeRange.type === 'custom') {
const days = timeRange.days || 1
if (days >= 7) {
return ' (TagesMax)'
}
return ''
}
// Vordefinierte Bereiche
switch (timeRange) {
case '7d':
case '30d':
case '365d':
return ' (TagesMax)'
default:
return ''
}
}, [timeRange])
// Hilfsfunktion: Dynamischen Y-Bereich berechnen.
// minHalfSpan: halbe Mindestspanne (z.B. 5 → Bereich mind. 10 Einheiten)
const calcYRange = (values, minHalfSpan) => {
if (values.length === 0) return { yMin: null, yMax: null }
const min = Math.min(...values)
const max = Math.max(...values)
if (max - min < minHalfSpan * 2) {
const center = (max + min) / 2
return { yMin: center - minHalfSpan, yMax: center + minHalfSpan }
}
return { yMin: min, yMax: max }
}
// Hilfsfunktion: BarTrend-Wert (Davis VantagePro) → Pfeil + Label
// Werte laut Davis Serial Communication Reference Rev 2.6.1:
// -60 = Falling Rapidly, -20 = Falling Slowly, 0 = Steady,
// 20 = Rising Slowly, 60 = Rising Rapidly, 80/'P' = kein Trend
const barTrendArrow = (trend) => {
switch (trend) {
case -60: return { arrow: '⬇⬇', label: 'Fällt schnell' }
case -20: return { arrow: '⬇', label: 'Fällt langsam' }
case 0: return { arrow: '→', label: 'Stabil' }
case 20: return { arrow: '⬆', label: 'Steigt langsam' }
case 60: return { arrow: '⬆⬆', label: 'Steigt schnell' }
default: return null
}
}
// Gemeinsame Chart-Optionen (angepasst an Zeitraum)
const getCommonOptions = () => {
// Prüfe, ob es ein custom range ist
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
// X-Achsen-Konfiguration basierend auf Zeitraum
let xAxisConfig = {
type: 'datetime',
gridLineWidth: 1,
gridLineColor: 'rgba(0, 0, 0, 0.1)'
}
// Zeitspanne für X-Achse berechnen (für festen Zeitrahmen)
const now = new Date().getTime()
let xAxisMin, xAxisMax
let tooltipDateFormat = '%d.%m.%Y'
if (isCustomRange) {
// Custom range Konfiguration - Min/Max aus Daten nehmen
if (customDays >= 7) {
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' }
tooltipDateFormat = '%d.%m.%Y'
} else {
xAxisConfig.labels = { format: '{value:%d.%m %H:%M}', align: 'center' }
tooltipDateFormat = '%d.%m.%Y %H:%M'
}
// X-Achsen-Bereich aus den tatsächlichen Daten bestimmen
if (sortedData.length > 0) {
xAxisMin = new Date(sortedData[0].datetime).getTime()
xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime()
} else {
xAxisMin = null
xAxisMax = null
}
} else {
// Vordefinierte Bereiche
switch (timeRange) {
case '24h':
xAxisConfig.tickInterval = 4 * 3600 * 1000 // 4 Stunden
xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' }
xAxisMin = now - 24 * 3600 * 1000
xAxisMax = now
tooltipDateFormat = '%d.%m.%Y %H:%M'
break
case '7d':
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' }
xAxisMin = now - 7 * 24 * 3600 * 1000
xAxisMax = now
tooltipDateFormat = '%d.%m.%Y'
break
case '30d':
xAxisConfig.labels = { format: '{value:%d.%m}', align: 'center' }
xAxisMin = now - 30 * 24 * 3600 * 1000
xAxisMax = now
tooltipDateFormat = '%d.%m.%Y'
break
case '365d':
xAxisConfig.labels = { format: '{value:%b %Y}', align: 'center' }
tooltipDateFormat = '%b %Y'
// Bei 365d: Min/Max aus vorhandenen Daten berechnen
if (sortedData.length > 0) {
xAxisMin = new Date(sortedData[0].datetime).getTime()
xAxisMax = new Date(sortedData[sortedData.length - 1].datetime).getTime()
} else {
xAxisMin = null
xAxisMax = null
}
break
default:
xAxisConfig.tickInterval = 4 * 3600 * 1000
xAxisConfig.labels = { format: '{value:%H:%M}', align: 'center' }
xAxisMin = now - 24 * 3600 * 1000
xAxisMax = now
tooltipDateFormat = '%d.%m.%Y %H:%M'
}
}
// Min/Max für X-Achse setzen
xAxisConfig.min = xAxisMin
xAxisConfig.max = xAxisMax
return {
chart: {
height: '50%',
animation: false,
backgroundColor: 'transparent'
},
accessibility: {
enabled: false
},
credits: {
enabled: false
},
title: {
text: null
},
legend: {
enabled: false
},
tooltip: {
shared: true,
crosshairs: true,
xDateFormat: tooltipDateFormat
},
plotOptions: {
series: {
marker: {
enabled: false,
states: {
hover: {
enabled: true,
radius: 5
}
}
}
}
},
xAxis: xAxisConfig,
yAxis: {
gridLineColor: 'rgba(0, 0, 0, 0.05)'
}
}
}
// Temperatur Chart
const temperatureOptions = useMemo(() => {
// Prüfe, ob Min/Max-Temperaturen angezeigt werden sollen
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
const showMinMax = (timeRange === '7d' || timeRange === '30d' || timeRange === '365d') || (isCustomRange && customDays >= 7)
// Bei 7d, 30d, 365d und custom >= 7 Tage: Min/Max-Temperaturen anzeigen
if (showMinMax) {
const minTemps = sortedData.filter(item => item.min_temperature != null).map(item => item.min_temperature)
const maxTemps = sortedData.filter(item => item.max_temperature != null).map(item => item.max_temperature)
// Prüfe, ob Daten vorhanden sind
if (minTemps.length === 0 || maxTemps.length === 0) {
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: null }
},
series: []
}
}
let yMin = Math.min(...minTemps)
let yMax = Math.max(...maxTemps)
// Füge einen kleinen Puffer hinzu (2°C oben/unten), um den Platz optimal zu nutzen
yMin = Math.floor(yMin - 2)
yMax = Math.ceil(yMax + 2)
return {
...getCommonOptions(),
yAxis: {
title: { text: null },
gridLineColor: 'rgba(0, 0, 0, 0.05)',
min: yMin,
max: yMax,
startOnTick: false,
endOnTick: false
},
series: [
{
name: 'Maximaltemperatur',
data: withGaps(sortedData.filter(item => item.max_temperature != null).map(item => [new Date(item.datetime).getTime(), item.max_temperature])),
color: 'rgb(255, 99, 132)',
type: 'line',
lineWidth: 2,
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 1,
valueSuffix: ' °C'
}
},
{
name: 'Minimaltemperatur',
data: withGaps(sortedData.filter(item => item.min_temperature != null).map(item => [new Date(item.datetime).getTime(), item.min_temperature])),
color: 'rgb(54, 162, 235)',
type: 'line',
lineWidth: 2,
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 1,
valueSuffix: ' °C'
}
}
]
}
}
// Standard: Temperatur als Flächendiagramm
const temps = sortedData.filter(item => item.temperature != null).map(item => item.temperature)
if (temps.length === 0) {
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: null }
},
series: []
}
}
const { yMin, yMax } = calcYRange(temps, 5)
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: null },
min: yMin,
max: yMax
},
series: [{
name: 'Temperatur',
data: withGaps(sortedData.filter(item => item.temperature != null).map(item => [new Date(item.datetime).getTime(), item.temperature])),
color: 'rgb(255, 99, 132)',
fillColor: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
stops: [
[0, 'rgba(255, 99, 132, 0.3)'],
[1, 'rgba(255, 99, 132, 0.1)']
]
},
type: 'areaspline',
threshold: null,
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 1,
valueSuffix: ' °C'
}
}]
}
}, [sortedData, temperatureSuffix, timeRange, withGaps])
// Luftfeuchtigkeit Chart
const humidityOptions = useMemo(() => {
const humidities = sortedData.filter(item => item.humidity != null).map(item => item.humidity)
const { yMin, yMax } = calcYRange(humidities, 10)
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: null },
min: yMin,
max: yMax
},
series: [{
name: 'Feuchte',
data: withGaps(sortedData.filter(item => item.humidity != null).map(item => [new Date(item.datetime).getTime(), item.humidity])),
color: 'rgb(54, 162, 235)',
fillColor: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
stops: [
[0, 'rgba(54, 162, 235, 0.3)'],
[1, 'rgba(54, 162, 235, 0.1)']
]
},
type: 'area',
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 0,
valueSuffix: ' %'
}
}]
}
}, [sortedData, timeRange, withGaps])
// Luftdruck Chart
const pressureOptions = useMemo(() => {
const pressures = sortedData.filter(item => item.pressure != null).map(item => item.pressure)
if (pressures.length === 0) {
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: null }
},
series: []
}
}
const { yMin, yMax } = calcYRange(pressures, 20)
return {
...getCommonOptions(),
yAxis: {
...getCommonOptions().yAxis,
title: { text: null },
min: yMin,
max: yMax
},
series: [{
name: 'Luftdruck',
data: withGaps(sortedData.filter(item => item.pressure != null).map(item => [new Date(item.datetime).getTime(), item.pressure])),
color: 'rgb(75, 192, 192)',
fillColor: {
linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
stops: [
[0, 'rgba(75, 192, 192, 0.3)'],
[1, 'rgba(75, 192, 192, 0.1)']
]
},
type: 'area',
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 0,
valueSuffix: ' hPa'
}
}]
}
}, [sortedData, timeRange, withGaps])
// Regen Chart (angepasst an Zeitraum)
const rainOptions = useMemo(() => {
let series = []
let yAxisTitle = 'Regen (mm) / Rate (mm/h)'
if (timeRange === '24h') {
// 24h: Area Chart mit Regen und Regenrate
yAxisTitle = 'Regen (mm) / Rate (mm/h)'
series = [{
name: 'Regen',
data: sortedData.filter(item => item.rain != null).map(item => [new Date(item.datetime).getTime(), item.rain]),
color: 'rgb(54, 162, 235)',
fillColor: 'rgba(54, 162, 235, 0.3)',
type: 'area',
tooltip: {
valueDecimals: 1,
valueSuffix: ' mm'
}
}, {
name: 'Regenrate',
data: sortedData.filter(item => item.rain_rate != null).map(item => [new Date(item.datetime).getTime(), item.rain_rate]),
color: 'rgb(59, 130, 246)',
dashStyle: 'Dash',
type: 'line',
tooltip: {
valueSuffix: ' mm/h'
}
}]
} else if (timeRange === '7d' || timeRange === '30d') {
// 7d/30d: Balkendiagramm mit täglichen Summen
yAxisTitle = 'Regen (mm pro Tag)'
series = [{
name: 'Regen',
data: rainData.filter(item => item.total_rain != null && item.total_rain > 0).map(item => [new Date(item.date).getTime(), item.total_rain]),
color: 'rgb(54, 162, 235)',
type: 'column',
tooltip: {
valueDecimals: 1,
valueSuffix: ' mm'
}
}]
} else if (timeRange === '365d') {
// 365d: Balkendiagramm mit wöchentlichen Summen
yAxisTitle = 'Regen (mm pro Woche)'
series = [{
name: 'Regen',
data: rainData.filter(item => item.total_rain != null && item.total_rain > 0).map(item => [new Date(item.week_start).getTime(), item.total_rain]),
color: 'rgb(54, 162, 235)',
type: 'column',
tooltip: {
valueDecimals: 1,
valueSuffix: ' mm'
}
}]
} else if (typeof timeRange === 'object' && timeRange.type === 'custom') {
// Custom range: tägliche Summen aus sortedData (total_rain ist im daily-aggregated-range enthalten)
yAxisTitle = 'Regen (mm pro Tag)'
series = [{
name: 'Regen',
data: sortedData
.filter(item => item.total_rain != null && item.total_rain > 0)
.map(item => [new Date(item.datetime).getTime(), item.total_rain]),
color: 'rgb(54, 162, 235)',
type: 'column',
tooltip: {
valueDecimals: 1,
valueSuffix: ' mm'
}
}]
}
return {
...getCommonOptions(),
legend: {
enabled: series.length > 1,
align: 'right',
verticalAlign: 'top',
floating: true,
itemStyle: { fontSize: '11px', fontWeight: 'normal' }
},
yAxis: {
...getCommonOptions().yAxis,
title: { text: null }
},
series
}
}, [sortedData, rainData, timeRange, withGaps])
// Windgeschwindigkeit Chart
const windSpeedOptions = useMemo(() => {
// Prüfe, ob Böen angezeigt werden sollen (nicht bei 365d oder custom >= 365 Tage)
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
const hideGusts = (timeRange === '365d') || (isCustomRange && customDays >= 365)
const windSpeedSeries = {
name: 'Windgeschwindigkeit',
data: withGaps(sortedData
.filter(item => item.wind_speed != null)
.map(item => [new Date(item.datetime).getTime(), item.wind_speed])),
color: 'rgb(153, 102, 255)',
fillColor: 'rgba(153, 102, 255, 0.1)',
type: 'area',
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 1,
valueSuffix: ' km/h'
}
}
const gustSeries = {
name: 'Böe' + windGustSuffix,
data: withGaps(sortedData
.filter(item => item.wind_gust != null)
.map(item => [new Date(item.datetime).getTime(), item.wind_gust])),
color: 'rgba(255, 160, 80, 0.6)',
fillColor: 'rgba(255, 160, 80, 0.08)',
type: 'area',
lineWidth: 1.5,
connectNulls: false,
gapSize: 2 * 24 * 3600 * 1000,
gapUnit: 'value',
tooltip: {
valueDecimals: 1,
valueSuffix: ' km/h'
}
}
const series = hideGusts
? [windSpeedSeries]
: [gustSeries, windSpeedSeries]
return {
...getCommonOptions(),
legend: {
enabled: true,
align: 'right',
verticalAlign: 'top',
floating: true,
itemStyle: { fontSize: '11px', fontWeight: 'normal' }
},
plotOptions: {
series: {
marker: {
enabled: false
},
lineWidth: 2
},
line: {
step: 'left' // Keine Glättung
}
},
yAxis: {
...getCommonOptions().yAxis,
title: { text: null },
min: 0
},
series
}
}, [sortedData, timeRange, windGustSuffix, withGaps])
// Windrichtung Chart
const windDirOptions = useMemo(() => ({
...getCommonOptions(),
tooltip: {
formatter: function() {
const dateFormat = timeRange === '24h' ? '%d.%m.%Y %H:%M' : '%d.%m.%Y'
const dateStr = Highcharts.dateFormat(dateFormat, this.x)
return `${dateStr}<br/><span style="color:${this.color}">\u25CF</span> ${this.series.name}: <b>${this.y.toFixed(0)}°</b>`
}
},
plotOptions: {
scatter: {
marker: {
enabled: true,
radius: 2,
states: {
hover: {
enabled: true,
radius: 3
}
}
}
}
},
yAxis: {
...getCommonOptions().yAxis,
title: { text: null },
min: 0,
max: 360,
tickInterval: 45,
labels: {
formatter: function() {
const directions = {
0: 'N', 45: 'NO', 90: 'O', 135: 'SO',
180: 'S', 225: 'SW', 270: 'W', 315: 'NW', 360: 'N'
}
return directions[this.value] || ''
}
}
},
series: [{
name: 'Windrichtung',
data: sortedData.filter(item => item.wind_dir != null).map(item => [new Date(item.datetime).getTime(), item.wind_dir]),
color: 'rgb(54, 162, 235)',
type: 'scatter'
}]
}), [sortedData, timeRange])
// Aktuellste Werte für Übersicht (immer aus den 24h-Daten, Fallback auf sortedData)
const current = (sortedCurrentData.length > 0 ? sortedCurrentData[sortedCurrentData.length - 1] : sortedData[sortedData.length - 1]) || {}
// Berechne Min/Max für den gewählten Zeitraum
const periodStats = useMemo(() => {
// Für den gewählten Zeitraum alle Daten verwenden
const periodData = sortedData
if (periodData.length === 0) {
return {
minTemp: null, maxTemp: null, minTempTime: null, maxTempTime: null,
minHumidity: null, maxHumidity: null, minHumidityTime: null, maxHumidityTime: null,
minPressure: null, maxPressure: null, minPressureTime: null, maxPressureTime: null
}
}
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
const is24h = timeRange === '24h' || (isCustomRange && customDays < 7)
const timeFormat = is24h ? 'HH:mm' : 'dd.MM HH:mm'
// Gibt die anzuzeigende Zeit zurück: bei aggregierten Daten das spezifische *_time-Feld,
// bei Rohdaten (24h) das datetime des Datenpunkts selbst.
const itemTime = (item, timeField) => {
if (!item) return null
const raw = item[timeField] ?? item.datetime
return format(new Date(raw), timeFormat, { locale: de })
}
// Temperatur
const minTempItem = periodData.reduce((min, item) =>
item.temperature != null && (min === null || item.temperature < min.temperature) ? item : min, null)
const maxTempItem = periodData.reduce((max, item) =>
item.temperature != null && (max === null || item.temperature > max.temperature) ? item : max, null)
// Luftfeuchtigkeit
const minHumidityItem = periodData.reduce((min, item) =>
item.humidity != null && (min === null || item.humidity < min.humidity) ? item : min, null)
const maxHumidityItem = periodData.reduce((max, item) =>
item.humidity != null && (max === null || item.humidity > max.humidity) ? item : max, null)
// Luftdruck
const minPressureItem = periodData.reduce((min, item) =>
item.pressure != null && (min === null || item.pressure < min.pressure) ? item : min, null)
const maxPressureItem = periodData.reduce((max, item) =>
item.pressure != null && (max === null || item.pressure > max.pressure) ? item : max, null)
// Wind
const maxWindGustItem = periodData.reduce((max, item) =>
item.wind_gust != null && (max === null || item.wind_gust > max.wind_gust) ? item : max, null)
return {
minTemp: minTempItem?.temperature ?? null,
maxTemp: maxTempItem?.temperature ?? null,
minTempTime: itemTime(minTempItem, 'min_temperature_time'),
maxTempTime: itemTime(maxTempItem, 'max_temperature_time'),
minHumidity: minHumidityItem?.humidity ?? null,
maxHumidity: maxHumidityItem?.humidity ?? null,
minHumidityTime: itemTime(minHumidityItem, 'min_humidity_time'),
maxHumidityTime: itemTime(maxHumidityItem, 'max_humidity_time'),
minPressure: minPressureItem?.pressure ?? null,
maxPressure: maxPressureItem?.pressure ?? null,
minPressureTime: itemTime(minPressureItem, 'min_pressure_time'),
maxPressureTime: itemTime(maxPressureItem, 'max_pressure_time'),
maxWindGust: maxWindGustItem?.wind_gust ?? null,
maxWindGustTime: itemTime(maxWindGustItem, 'max_wind_gust_time'),
}
}, [sortedData, timeRange])
// Regen-Lookup: date-string → total_rain
const rainByDate = useMemo(() => {
const map = {}
rainData.forEach(item => {
const key = item.date
? item.date.split('T')[0]
: item.week_start
? item.week_start.split('T')[0]
: null
if (key) map[key] = item.total_rain
})
return map
}, [rainData])
// Tabellen-Daten: ein Eintrag pro Tag
const tableData = useMemo(() => {
const isCustomRange = typeof timeRange === 'object' && timeRange.type === 'custom'
const customDays = isCustomRange ? (timeRange.days || 1) : 0
const isDailyAggregated =
['7d', '30d', '365d'].includes(timeRange) || (isCustomRange && customDays >= 7)
if (isDailyAggregated) {
return sortedData.map(item => {
const dateKey = item.datetime.split('T')[0]
return {
date: format(new Date(item.datetime), 'dd.MM.yyyy', { locale: de }),
tempMin: item.min_temperature ?? null,
tempMax: item.max_temperature ?? null,
humMin: item.min_humidity ?? null,
humMax: item.max_humidity ?? null,
pressMin: item.min_pressure ?? null,
pressMax: item.max_pressure ?? null,
rain: item.total_rain ?? rainByDate[dateKey] ?? null,
windMax: item.wind_gust ?? null,
}
})
} else {
// Stundenwerte → pro Tag aggregieren
const byDay = {}
sortedData.forEach(item => {
const d = new Date(item.datetime)
const dateKey = format(d, 'yyyy-MM-dd')
const dateLabel = format(d, 'dd.MM.yyyy', { locale: de })
if (!byDay[dateKey]) {
byDay[dateKey] = { date: dateLabel, temps: [], hums: [], pressures: [], rains: [], windGusts: [] }
}
if (item.temperature != null) byDay[dateKey].temps.push(item.temperature)
if (item.humidity != null) byDay[dateKey].hums.push(item.humidity)
if (item.pressure != null) byDay[dateKey].pressures.push(item.pressure)
if (item.rain != null) byDay[dateKey].rains.push(item.rain)
if (item.wind_gust != null) byDay[dateKey].windGusts.push(item.wind_gust)
})
const startKey = isCustomRange ? timeRange.start.split('T')[0] : null
const endKey = isCustomRange ? timeRange.end.split('T')[0] : null
return Object.entries(byDay)
.sort(([a], [b]) => a.localeCompare(b))
.filter(([k]) => !startKey || (k >= startKey && k <= endKey))
.map(([, d]) => ({
date: d.date,
tempMin: d.temps.length ? Math.min(...d.temps) : null,
tempMax: d.temps.length ? Math.max(...d.temps) : null,
humMin: d.hums.length ? Math.round(Math.min(...d.hums)) : null,
humMax: d.hums.length ? Math.round(Math.max(...d.hums)) : null,
pressMin: d.pressures.length ? Math.min(...d.pressures) : null,
pressMax: d.pressures.length ? Math.max(...d.pressures) : null,
rain: d.rains.length ? Math.max(...d.rains) : null,
windMax: d.windGusts.length ? Math.max(...d.windGusts) : null,
}))
}
}, [sortedData, rainByDate, timeRange])
return (
<div className="dashboard">
{/* Navigation für Zeitraum-Auswahl */}
<div className="time-range-nav">
<button
className={timeRange === '24h' ? 'active' : ''}
onClick={() => onTimeRangeChange('24h')}
>
<span className="time-range-full">24 Stunden</span>
<span className="time-range-short">24h</span>
</button>
<button
className={timeRange === '7d' ? 'active' : ''}
onClick={() => onTimeRangeChange('7d')}
>
<span className="time-range-full">7 Tage</span>
<span className="time-range-short">7d</span>
</button>
<button
className={timeRange === '30d' ? 'active' : ''}
onClick={() => onTimeRangeChange('30d')}
>
<span className="time-range-full">30 Tage</span>
<span className="time-range-short">30d</span>
</button>
<button
className={timeRange === '365d' ? 'active' : ''}
onClick={() => onTimeRangeChange('365d')}
>
<span className="time-range-full">365 Tage</span>
<span className="time-range-short">365d</span>
</button>
<button
className={(typeof timeRange === 'object' && timeRange.type === 'custom') ? 'active' : ''}
onClick={handleOpenCustomRange}
>
<span className="time-range-full">Bereich</span>
<span className="time-range-short">Bereich</span>
</button>
<button
className={`table-toggle-btn${showTable ? ' active' : ''}`}
onClick={onToggleTable}
>
<span>{showTable ? 'Grafik' : 'Tabelle'}</span>
</button>
</div>
{/* Zeitraum-Beschreibung */}
<div className="time-range-label">
{timeRangeLabel}
</div>
{/* Charts Grid / Tabellenansicht */}
{showTable ? (
<div className="table-view">
<div className="table-actions no-print">
<button className="btn-print" onClick={() => window.print()}>🖨 Drucken</button>
</div>
<table className="weather-table">
<thead>
<tr>
<th rowSpan={2}>Datum</th>
<th colSpan={2}>Temperatur<br/>°C</th>
<th colSpan={2}>Feuchte<br/>%</th>
<th colSpan={2}>Luftdruck<br/>hPa</th>
<th rowSpan={2}>Regen<br/>mm</th>
<th rowSpan={2}>Wind-V<br/>max km/h</th>
</tr>
<tr>
<th>min</th>
<th>max</th>
<th>min</th>
<th>max</th>
<th>min</th>
<th>max</th>
</tr>
</thead>
<tbody>
{tableData.map((row, i) => (
<tr key={i}>
<td>{row.date}</td>
<td>{row.tempMin != null ? row.tempMin.toFixed(1) : '-'}</td>
<td>{row.tempMax != null ? row.tempMax.toFixed(1) : '-'}</td>
<td>{row.humMin != null ? row.humMin : '-'}</td>
<td>{row.humMax != null ? row.humMax : '-'}</td>
<td>{row.pressMin != null ? row.pressMin.toFixed(0) : '-'}</td>
<td>{row.pressMax != null ? row.pressMax.toFixed(0) : '-'}</td>
<td>{row.rain != null ? row.rain.toFixed(1) : '0'}</td>
<td>{row.windMax != null ? row.windMax.toFixed(1) : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="charts-grid">
<div className="chart-item">
<div className="current-value">Aktuell: {current.temperature?.toFixed(1) || '-'}°C</div>
<div className="chart-container">
<h3><span>🌡 Temperatur{temperatureSuffix}</span><span className="unit">[°C]</span></h3>
<div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={temperatureOptions} />
</div>
<div className="chart-stats">
Min: {periodStats.minTemp?.toFixed(1) || '-'}°C ({periodStats.minTempTime || '-'}) | Max: {periodStats.maxTemp?.toFixed(1) || '-'}°C ({periodStats.maxTempTime || '-'})
</div>
</div>
</div>
<div className="chart-item">
<div className="current-value">Aktuell: {current.pressure?.toFixed(0) || '-'} hPa</div>
<div className="chart-container">
<h3><span>🌐 Luftdruck{aggregationSuffix}{(() => { const t = barTrendArrow(current.bar_trend); return t ? <span className="bar-trend" title={t.label}> {t.arrow}</span> : null })()}</span><span className="unit">[hPa]</span></h3>
<div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={pressureOptions} />
</div>
<div className="chart-stats">
Min: {periodStats.minPressure?.toFixed(0) || '-'} hPa ({periodStats.minPressureTime || '-'}) | Max: {periodStats.maxPressure?.toFixed(0) || '-'} hPa ({periodStats.maxPressureTime || '-'})
</div>
</div>
</div>
<div className="chart-item">
<div className="current-value">Aktuell: {current.humidity || '-'}%</div>
<div className="chart-container">
<h3><span>💧 Luftfeuchtigkeit{aggregationSuffix}</span><span className="unit">[%]</span></h3>
<div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={humidityOptions} />
</div>
<div className="chart-stats">
Min: {periodStats.minHumidity || '-'}% ({periodStats.minHumidityTime || '-'}) | Max: {periodStats.maxHumidity || '-'}% ({periodStats.maxHumidityTime || '-'})
</div>
</div>
</div>
<div className="chart-item">
<div className="current-value">Aktuell: {current.rain?.toFixed(1) || '-'} mm</div>
<div className="chart-container">
<h3><span>🌧 Regen{rainSuffix}</span><span className="unit">[mm]</span></h3>
<div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={rainOptions} />
</div>
<div className="chart-stats"> </div>
</div>
</div>
<div className="chart-item">
<div className="current-value">Aktuell: {current.wind_dir ?? '-'}°</div>
<div className="chart-container">
<h3><span>🧭 Windrichtung{aggregationSuffix}</span><span className="unit">[°]</span></h3>
<div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={windDirOptions} />
</div>
<div className="chart-stats"> </div>
</div>
</div>
<div className="chart-item">
<div className="current-value">Aktuell: {current.wind_speed?.toFixed(1) || '-'} km/h</div>
<div className="chart-container">
<h3><span>💨 Wind{aggregationSuffix}</span><span className="unit">[km/h]</span></h3>
<div className="chart-wrapper">
<HighchartsReact highcharts={Highcharts} options={windSpeedOptions} />
</div>
<div className="chart-stats">
Max: {periodStats.maxWindGust?.toFixed(1) || '-'} km/h ({periodStats.maxWindGustTime || '-'})
</div>
</div>
</div>
</div>
)} {/* end showTable ternary */}
{/* Modal Anleitung */}
{showAnleitung && (
<div className="modal-overlay" onClick={() => setShowAnleitung(false)}>
<div className="anleitung-modal" onClick={(e) => e.stopPropagation()}>
<div className="anleitung-modal-header">
<span>Bedienungsanleitung</span>
<button className="anleitung-modal-close" onClick={() => setShowAnleitung(false)}></button>
</div>
<iframe
src="/ANLEITUNG.html"
title="Bedienungsanleitung"
className="anleitung-frame"
/>
</div>
</div>
)}
{/* Modal für benutzerdefinierten Zeitbereich */}
{showCustomRangeModal && (
<div className="modal-overlay" onClick={handleCancelCustomRange}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<h2>Benutzerdefinierten Zeitbereich wählen</h2>
<div className="modal-form">
<div className="form-group">
<label htmlFor="startDate">Startdatum:</label>
<input
type="date"
id="startDate"
value={customStartDate}
onChange={(e) => setCustomStartDate(e.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="endDate">Enddatum:</label>
<input
type="date"
id="endDate"
value={customEndDate}
onChange={(e) => setCustomEndDate(e.target.value)}
/>
</div>
{customError && (
<div className="error-message">{customError}</div>
)}
<div className="modal-info">
<p> Enddatum muss nach dem Startdatum liegen</p>
<p> Maximaler Zeitraum: 1 Jahr (365 Tage)</p>
</div>
<div className="modal-buttons">
<button className="btn-cancel" onClick={handleCancelCustomRange}>
Abbrechen
</button>
<button className="btn-apply" onClick={handleApplyCustomRange}>
Anwenden
</button>
</div>
</div>
</div>
</div>
)}
{/* Footer */}
<div className="dashboard-footer">
<div className="version-line">
<div><a href="mailto:rxf@gmx.de">
mailto:rxf@gmx.de
</a>
</div>
<div>
<button
className="anleitung-btn"
onClick={() => setShowAnleitung(true)}
>
Anleitung
</button>
</div>
<div>
<span className="version-full">Version</span>
<span className="version-short">V</span>
{' '}{version} {buildDate}
</div>
</div>
<hr className="footer-divider" />
<div className="footer-credits">
<div className="footer-left">Daten-Erfassung mit einer Davis VantagePro.</div>
<div className="footer-right">Grafiken erzeugt mit HighCharts</div>
</div>
</div>
</div>
)
}
export default WeatherDashboard