4695419565
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1207 lines
42 KiB
React
1207 lines
42 KiB
React
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
|