diff --git a/frontend/package.json b/frontend/package.json index 88af69d..679b2d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,11 +9,10 @@ "preview": "vite preview" }, "dependencies": { - "chart.js": "^4.4.1", - "chartjs-adapter-date-fns": "^3.0.0", "date-fns": "^3.3.1", + "highcharts": "^11.4.0", + "highcharts-react-official": "^3.2.1", "react": "^18.3.1", - "react-chartjs-2": "^5.2.0", "react-dom": "^18.3.1" }, "devDependencies": { diff --git a/frontend/src/components/WeatherDashboard.jsx b/frontend/src/components/WeatherDashboard.jsx index a0feb64..f3a3248 100644 --- a/frontend/src/components/WeatherDashboard.jsx +++ b/frontend/src/components/WeatherDashboard.jsx @@ -1,33 +1,22 @@ import { useMemo } from 'react' -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - TimeScale, - PointElement, - LineElement, - Title, - Tooltip, - Legend, - Filler -} from 'chart.js' -import 'chartjs-adapter-date-fns' -import { Line } from 'react-chartjs-2' +import Highcharts from 'highcharts' +import HighchartsReact from 'highcharts-react-official' import { format } from 'date-fns' import { de } from 'date-fns/locale' import './WeatherDashboard.css' -ChartJS.register( - CategoryScale, - LinearScale, - TimeScale, - PointElement, - LineElement, - Title, - Tooltip, - Legend, - Filler -) +// 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 }) => { // Daten vorbereiten und nach Zeit sortieren (älteste zuerst) @@ -35,292 +24,289 @@ const WeatherDashboard = ({ data }) => { return [...data].sort((a, b) => new Date(a.datetime) - new Date(b.datetime)) }, [data]) - // Labels für X-Achse (Zeit) - const labels = useMemo(() => { - return sortedData.map(item => - format(new Date(item.datetime), 'HH:mm', { locale: de }) - ) - }, [sortedData]) - - // Chart-Konfiguration - const commonOptions = { - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: 'index', - intersect: false, + // Gemeinsame Chart-Optionen + const getCommonOptions = () => ({ + chart: { + height: 250, + animation: false, + backgroundColor: 'transparent' }, - elements: { - point: { - radius: 0, - hitRadius: 10, - hoverRadius: 5, - } + credits: { + enabled: false }, - plugins: { - legend: { - display: false, - }, - tooltip: { - callbacks: { - title: (context) => { - const index = context[0].dataIndex - return format(new Date(sortedData[index].datetime), 'dd.MM.yyyy HH:mm', { locale: de }) - } - } - } + title: { + text: null }, - scales: { - x: { - grid: { - display: true, - color: 'rgba(0, 0, 0, 0.1)', - }, - ticks: { - type: 'time', - time: { - unit: 'hour', - stepSize: 4 - }, - ticks: { - autoSkip: false - } -/* - maxRotation: 0, - autoSkip: false, - callback: function(value, index) { - if (sortedData.length === 0) return '' - - const date = new Date(sortedData[index]?.datetime) - const hours = date.getHours() - const minutes = date.getMinutes() - - // Berechne die nächste 4-Stunden-Zeit - const nearestFourHour = Math.round(hours / 4) * 4 - - // Wenn die Stunde durch 4 teilbar ist UND die Minuten <= 2 sind (also 00:00, 00:05 zählen), - // dann ist dies der Datenpunkt, der der 4-Stunden-Zeit am nächsten liegt - if (hours % 4 === 0 && minutes <= 2) { - return format(new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, 0), 'HH:mm', { locale: de }) + legend: { + enabled: false + }, + tooltip: { + shared: true, + crosshairs: true, + xDateFormat: '%d.%m.%Y %H:%M' + }, + plotOptions: { + series: { + marker: { + enabled: false, + states: { + hover: { + enabled: true, + radius: 5 } - - return '' } -*/ - } - }, - y: { - grid: { - color: 'rgba(0, 0, 0, 0.05)', } } + }, + xAxis: { + type: 'datetime', + tickInterval: 4 * 3600 * 1000, // 4 Stunden in Millisekunden + labels: { + format: '{value:%H:%M}', + align: 'center' + }, + gridLineWidth: 1, + gridLineColor: 'rgba(0, 0, 0, 0.1)' + }, + yAxis: { + gridLineColor: 'rgba(0, 0, 0, 0.05)' } - } + }) // Temperatur Chart - const temperatureData = { - labels, - datasets: [ - { - label: 'Temperatur (°C)', - data: sortedData.map(item => item.temperature), - borderColor: 'rgb(255, 99, 132)', - backgroundColor: 'rgba(255, 99, 132, 0.1)', - fill: 'start', - tension: 0.4, - } - ] - } + const temperatureOptions = useMemo(() => { + const temps = sortedData.map(item => item.temperature) + const min = Math.min(...temps) + const max = Math.max(...temps) + const range = max - min + + let yMin = min + let yMax = max + + if (range < 15) { + const center = (max + min) / 2 + yMin = center - 7.5 + yMax = center + 7.5 + } - const temperatureOptions = { - ...commonOptions, - scales: { - ...commonOptions.scales, - y: { - ...commonOptions.scales.y, - afterDataLimits: (axis) => { - const range = axis.max - axis.min - if (range < 15) { - const center = (axis.max + axis.min) / 2 - axis.max = center + 7.5 - axis.min = center - 7.5 - } + return { + ...getCommonOptions(), + yAxis: { + ...getCommonOptions().yAxis, + title: { text: 'Temperatur (°C)' }, + min: yMin, + max: yMax + }, + series: [{ + name: 'Temperatur', + data: sortedData.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: 'area', + threshold: yMin, + tooltip: { + valueSuffix: ' °C' } - } + }] } - } + }, [sortedData]) - // Feuchte Chart - const humidityData = { - labels, - datasets: [ - { - label: 'Luftfeuchtigkeit (%)', - data: sortedData.map(item => item.humidity), - borderColor: 'rgb(54, 162, 235)', - backgroundColor: 'rgba(54, 162, 235, 0.1)', - fill: true, - tension: 0.4, + // Luftfeuchtigkeit Chart + const humidityOptions = useMemo(() => ({ + ...getCommonOptions(), + yAxis: { + ...getCommonOptions().yAxis, + title: { text: 'Luftfeuchtigkeit (%)' }, + min: 0, + max: 100 + }, + series: [{ + name: 'Luftfeuchtigkeit', + data: sortedData.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', + tooltip: { + valueSuffix: ' %' } - ] - } + }] + }), [sortedData]) - const humidityOptions = { - ...commonOptions, - scales: { - ...commonOptions.scales, - y: { - ...commonOptions.scales.y, - min: 0, - max: 100 - } + // Luftdruck Chart + const pressureOptions = useMemo(() => { + const pressures = sortedData.map(item => item.pressure) + const min = Math.min(...pressures) + const max = Math.max(...pressures) + const range = max - min + + let yMin = min + let yMax = max + + if (range < 50) { + const center = (max + min) / 2 + yMin = center - 25 + yMax = center + 25 } - } - // Druck Chart - const pressureData = { - labels, - datasets: [ - { - label: 'Luftdruck (hPa)', - data: sortedData.map(item => item.pressure), - borderColor: 'rgb(75, 192, 192)', - backgroundColor: 'rgba(75, 192, 192, 0.1)', - fill: true, - tension: 0.4, - } - ] - } - - const pressureOptions = { - ...commonOptions, - scales: { - ...commonOptions.scales, - y: { - ...commonOptions.scales.y, - afterDataLimits: (axis) => { - const range = axis.max - axis.min - if (range < 50) { - const center = (axis.max + axis.min) / 2 - axis.max = center + 25 - axis.min = center - 25 - } + return { + ...getCommonOptions(), + yAxis: { + ...getCommonOptions().yAxis, + title: { text: 'Luftdruck (hPa)' }, + min: yMin, + max: yMax + }, + series: [{ + name: 'Luftdruck', + data: sortedData.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', + tooltip: { + valueSuffix: ' hPa' } - } + }] } - } + }, [sortedData]) // Regen Chart - const rainData = { - labels, - datasets: [ - { - label: 'Regen (mm)', - data: sortedData.map(item => item.rain), - borderColor: 'rgb(54, 162, 235)', - backgroundColor: 'rgba(54, 162, 235, 0.3)', - fill: true, - tension: 0.4, - }, - { - label: 'Regenrate (mm/h)', - data: sortedData.map(item => item.rain_rate), - borderColor: 'rgb(59, 130, 246)', - backgroundColor: 'rgba(59, 130, 246, 0.1)', - borderDash: [5, 5], - fill: false, - tension: 0.4, + const rainOptions = useMemo(() => ({ + ...getCommonOptions(), + legend: { + enabled: true, + align: 'center', + verticalAlign: 'top' + }, + yAxis: { + ...getCommonOptions().yAxis, + title: { text: 'Regen (mm) / Rate (mm/h)' } + }, + series: [{ + name: 'Regen', + data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain]), + color: 'rgb(54, 162, 235)', + fillColor: 'rgba(54, 162, 235, 0.3)', + type: 'area', + tooltip: { + valueSuffix: ' mm' } - ] - } - - const rainOptions = { - ...commonOptions, - plugins: { - ...commonOptions.plugins, - legend: { - display: true, - position: 'top', + }, { + name: 'Regenrate', + data: sortedData.map(item => [new Date(item.datetime).getTime(), item.rain_rate]), + color: 'rgb(59, 130, 246)', + dashStyle: 'Dash', + type: 'line', + tooltip: { + valueSuffix: ' mm/h' } - } - } + }] + }), [sortedData]) // Windgeschwindigkeit Chart - const windSpeedData = { - labels, - datasets: [ - { - label: 'Windgeschwindigkeit (km/h)', - data: sortedData.map(item => item.wind_speed), - borderColor: 'rgb(153, 102, 255)', - backgroundColor: 'rgba(153, 102, 255, 0.1)', - fill: true, - tension: 0, + const windSpeedOptions = useMemo(() => ({ + ...getCommonOptions(), + legend: { + enabled: true, + align: 'center', + verticalAlign: 'top' + }, + plotOptions: { + series: { + marker: { + enabled: false + }, + lineWidth: 2 }, - { - label: 'Windböen (km/h)', - data: sortedData.map(item => item.wind_gust), - borderColor: 'rgb(255, 159, 64)', - backgroundColor: 'rgba(255, 159, 64, 0.1)', - fill: true, - tension: 0, + line: { + step: 'left' // Keine Glättung } - ] - } - - const windSpeedOptions = { - ...commonOptions, - plugins: { - ...commonOptions.plugins, - legend: { - display: true, - position: 'top', + }, + yAxis: { + ...getCommonOptions().yAxis, + title: { text: 'Windgeschwindigkeit (km/h)' } + }, + series: [{ + name: 'Windgeschwindigkeit', + data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_speed]), + color: 'rgb(153, 102, 255)', + fillColor: 'rgba(153, 102, 255, 0.1)', + type: 'area', + tooltip: { + valueSuffix: ' km/h' } - } - } + }, { + name: 'Windböen', + data: sortedData.map(item => [new Date(item.datetime).getTime(), item.wind_gust]), + color: 'rgb(255, 159, 64)', + fillColor: 'rgba(255, 159, 64, 0.1)', + type: 'area', + tooltip: { + valueSuffix: ' km/h' + } + }] + }), [sortedData]) // Windrichtung Chart - const windDirData = { - labels, - datasets: [ - { - label: 'Windrichtung (°)', - data: sortedData.map(item => item.wind_dir), - borderColor: 'rgb(255, 205, 86)', - backgroundColor: 'rgb(255, 205, 86)', - pointRadius: 4, - pointHoverRadius: 6, - showLine: false, - fill: false, - } - ] - } - - const windDirOptions = { - ...commonOptions, - scales: { - ...commonOptions.scales, - y: { - ...commonOptions.scales.y, - min: 0, - max: 360, - ticks: { - stepSize: 45, - callback: (value) => { - if (value === 0 || value === 360) return 'N' - if (value === 45) return 'NO' - if (value === 90) return 'O' - if (value === 135) return 'SO' - if (value === 180) return 'S' - if (value === 225) return 'SW' - if (value === 270) return 'W' - if (value === 315) return 'NW' - return '' + const windDirOptions = useMemo(() => ({ + ...getCommonOptions(), + plotOptions: { + scatter: { + marker: { + enabled: true, + radius: 4, + states: { + hover: { + enabled: true, + radius: 6 + } } } } - } - } + }, + yAxis: { + ...getCommonOptions().yAxis, + title: { text: 'Windrichtung' }, + 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.map(item => [new Date(item.datetime).getTime(), item.wind_dir]), + color: 'rgb(255, 205, 86)', + type: 'scatter', + tooltip: { + valueSuffix: ' °' + } + }] + }), [sortedData]) // Aktuellste Werte für Übersicht const current = sortedData[sortedData.length - 1] || {} @@ -356,42 +342,42 @@ const WeatherDashboard = ({ data }) => {