diff --git a/frontend/package.json b/frontend/package.json
index 810d1e2..7a76afe 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "wetterstation-frontend",
"private": true,
- "version": "1.6.1",
+ "version": "1.7.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0ccef6f..0b16c79 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,7 +1,28 @@
import { useState, useEffect, useRef } from 'react'
import WeatherDashboard from './components/WeatherDashboard'
+import { getSunTimes, getMoonPhase, formatTime, formatMoonPhase } from './utils/astro'
import './App.css'
+// Grad/Minuten/Sekunden in Dezimalgrad
+const dms = (deg, min, sec) => deg + min / 60 + sec / 3600
+
+// Dezimalgrad zurueck nach Grad/Minuten/Sekunden fuer die Anzeige
+function formatDms(value) {
+ let deg = Math.floor(value)
+ let min = Math.floor((value - deg) * 60)
+ let sec = Math.round(((value - deg) * 60 - min) * 60)
+ if (sec === 60) { sec = 0; min += 1 } // Rundungsuebertrag
+ if (min === 60) { min = 0; deg += 1 }
+ return `${deg}° ${min}′ ${sec}″`
+}
+
+// Standort der Wetterstation — Basis fuer Header-Anzeige und Astro-Berechnung
+const STATION = {
+ lat: dms(48, 52, 27), // noerdliche Breite
+ lon: dms(9, 35, 46), // oestliche Laenge
+ elevation: 547, // Meter ueber NN
+}
+
// API-Basis-URL: in Dev direkt auf Backend, in Prod ueber Nginx-Proxy
const API_BASE = import.meta.env.DEV ? 'http://localhost:8000' : '/api'
@@ -193,11 +214,13 @@ function App() {
minute: '2-digit'
})
- // TODO: Sonnenauf-/untergang und Mondphase berechnen
- // Aktuell Platzhalter - benoetigt Bibliothek wie 'suncalc'
- const sunrise = "06:45"
- const sunset = "18:30"
- const moonPhase = "abnehmend 50%"
+ // Sonnenauf-/untergang fuer heute am Stationsstandort
+ const sunTimes = getSunTimes(now, STATION.lat, STATION.lon, STATION.elevation)
+ const sunrise = formatTime(sunTimes.sunrise)
+ const sunset = formatTime(sunTimes.sunset)
+
+ // Mondphase zum aktuellen Zeitpunkt
+ const moonPhase = formatMoonPhase(getMoonPhase(now))
return (
@@ -205,7 +228,9 @@ function App() {
Aktuelle Wetterdaten
{dateStr} - {timeStr} Uhr
-
48.6 N - 9.6 E - 574 m NN
+
+ {formatDms(STATION.lat)} N - {formatDms(STATION.lon)} E - {STATION.elevation} m NN
+
Sonnen-Aufgang: {sunrise} - Untergang: {sunset} Mond-Phase: {moonPhase}
diff --git a/frontend/src/utils/astro.js b/frontend/src/utils/astro.js
new file mode 100644
index 0000000..7a1e725
--- /dev/null
+++ b/frontend/src/utils/astro.js
@@ -0,0 +1,213 @@
+// Astronomische Berechnungen fuer die Header-Anzeige: Sonnenauf-/untergang
+// nach dem NOAA-Sonnenstandsalgorithmus (vereinfachte "Sunrise Equation") und
+// die Mondphase nach gekuerzten Reihen aus Meeus, "Astronomical Algorithms".
+// Genauigkeit reicht fuer die Anzeige und spart eine externe Bibliothek.
+
+const DEG = Math.PI / 180
+const J1970 = 2440588 // Julianischer Tag von 1970-01-01
+const J2000 = 2451545 // Julianischer Tag von 2000-01-01 12:00 UT
+const DAY_MS = 86400000
+
+// Schiefe der Ekliptik
+const OBLIQUITY = 23.4397
+
+// Standard-Hoehe der Sonnenmitte bei Auf-/Untergang: -0.833 Grad
+// (Refraktion + halber Sonnendurchmesser)
+const ZENITH = -0.833
+
+const toJulian = date => date.getTime() / DAY_MS - 0.5 + J1970
+const fromJulian = j => new Date((j + 0.5 - J1970) * DAY_MS)
+
+// Mittlere Entfernung Erde-Sonne in km (fuer den Phasenwinkel des Mondes)
+const SUN_DISTANCE = 149598000
+
+const sin = x => Math.sin(x * DEG)
+const cos = x => Math.cos(x * DEG)
+const tan = x => Math.tan(x * DEG)
+
+// Mittlere Anomalie der Sonne, d Tage seit J2000
+const sunMeanAnomaly = d => 357.5291 + 0.98560028 * d
+
+// Ekliptikale Laenge der Sonne in Grad. Die mittlere Laenge hat eine leicht
+// groessere Rate als die mittlere Anomalie — die Differenz ist die Praezession
+// des Fruehlingspunkts. Wer sie weglaesst, liegt pro Jahr rund 1.7 Bogenminuten
+// daneben; das verschiebt vor allem den berechneten Mondphasen-Zeitpunkt.
+function sunLongitude(d) {
+ const M = sunMeanAnomaly(d)
+ const L0 = 280.4665 + 0.98564736 * d // mittlere Laenge
+ const C = 1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)
+ return L0 + C // Mittelpunktsgleichung
+}
+
+// Hoehenkorrektur: aus grosser Hoehe sieht man die Sonne frueher aufgehen.
+// Naeherung: 2.076 * sqrt(Hoehe in m) Bogenminuten.
+const elevationCorrection = elevation =>
+ elevation > 0 ? (-2.076 * Math.sqrt(elevation)) / 60 : 0
+
+/**
+ * Berechnet Sonnenauf- und -untergang fuer einen Tag und Standort.
+ *
+ * @param {Date} date Tag, fuer den gerechnet wird (lokale Zeit)
+ * @param {number} lat Breitengrad in Grad, Nord positiv
+ * @param {number} lon Laengengrad in Grad, Ost positiv
+ * @param {number} elevation Hoehe ueber NN in Metern
+ * @returns {{sunrise: Date|null, sunset: Date|null}}
+ * null bei Polartag/Polarnacht (Sonne geht nicht auf bzw. unter)
+ */
+export function getSunTimes(date, lat, lon, elevation = 0) {
+ // Bezugspunkt ist der lokale Kalendertag von `date`, nicht der UTC-Zeitpunkt.
+ // Sonst faellt z.B. 00:30 Sommerzeit (= 22:30 UTC des Vortags) noch auf den
+ // Vortag und wir zeigen nachts kurz die Zeiten von gestern an.
+ const noon = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 12)
+
+ // Tagesnummer seit J2000 und der zugehoerige mittlere Sonnenmittag am
+ // Standort (oestliche Laenge => Mittag frueher in Weltzeit).
+ const n = Math.round(toJulian(noon) - J2000 + lon / 360)
+ const jStar = n - lon / 360
+
+ const M = sunMeanAnomaly(jStar)
+ const lambda = sunLongitude(jStar)
+
+ // Sonnenhoechststand (Transit)
+ const jTransit = J2000 + jStar + 0.0053 * sin(M) - 0.0069 * sin(2 * lambda)
+
+ // Deklination der Sonne
+ const sinDec = sin(lambda) * sin(OBLIQUITY)
+ const cosDec = Math.sqrt(1 - sinDec * sinDec)
+
+ // Stundenwinkel bei Auf-/Untergang
+ const h = ZENITH + elevationCorrection(elevation)
+ const cosOmega = (sin(h) - sin(lat) * sinDec) / (cos(lat) * cosDec)
+
+ // |cos| > 1 => Sonne bleibt den ganzen Tag ueber bzw. unter dem Horizont
+ if (cosOmega > 1) return { sunrise: null, sunset: null } // Polarnacht
+ if (cosOmega < -1) return { sunrise: null, sunset: null } // Polartag
+
+ const omega = Math.acos(cosOmega) / DEG
+
+ return {
+ sunrise: fromJulian(jTransit - omega / 360),
+ sunset: fromJulian(jTransit + omega / 360),
+ }
+}
+
+// --- Mondphase ---------------------------------------------------------
+
+// Umrechnung ekliptikaler in aequatoriale Koordinaten (alles in Grad)
+const rightAscension = (l, b) =>
+ Math.atan2(sin(l) * cos(OBLIQUITY) - tan(b) * sin(OBLIQUITY), cos(l)) / DEG
+const declination = (l, b) =>
+ Math.asin(sin(b) * cos(OBLIQUITY) + cos(b) * sin(OBLIQUITY) * sin(l)) / DEG
+
+// Position der Sonne (aequatorial) fuer d Tage seit J2000
+function sunPosition(d) {
+ const l = sunLongitude(d)
+ return { ra: rightAscension(l, 0), dec: declination(l, 0) }
+}
+
+// Position des Mondes (aequatorial) und Entfernung in km, d Tage seit J2000.
+// Gekuerzte Reihe nach Meeus: mittlere Elemente plus die groessten
+// periodischen Stoerungen (Evektion, Variation, jaehrliche Gleichung).
+function moonPosition(d) {
+ const L = 218.3165 + 13.17639648 * d // mittlere Laenge
+ const D = 297.8502 + 12.19074912 * d // mittlere Elongation
+ const M = 357.5291 + 0.98560028 * d // mittlere Anomalie der Sonne
+ const Mp = 134.9634 + 13.06499295 * d // mittlere Anomalie des Mondes
+ const F = 93.2721 + 13.22935024 * d // Argument der Breite
+
+ const l =
+ L +
+ 6.289 * sin(Mp) +
+ 1.274 * sin(2 * D - Mp) +
+ 0.658 * sin(2 * D) +
+ 0.214 * sin(2 * Mp) -
+ 0.186 * sin(M) -
+ 0.114 * sin(2 * F)
+
+ const b =
+ 5.128 * sin(F) +
+ 0.281 * sin(Mp + F) -
+ 0.278 * sin(F - Mp) -
+ 0.173 * sin(2 * D - F)
+
+ const dist =
+ 385001 -
+ 20905 * cos(Mp) -
+ 3699 * cos(2 * D - Mp) -
+ 2956 * cos(2 * D) -
+ 570 * cos(2 * Mp)
+
+ return { ra: rightAscension(l, b), dec: declination(l, b), dist }
+}
+
+/**
+ * Berechnet die Mondphase zu einem Zeitpunkt.
+ *
+ * @param {Date} date Zeitpunkt der Berechnung
+ * @returns {{fraction: number, phase: number, waxing: boolean}}
+ * fraction: beleuchteter Anteil der Mondscheibe, 0 = neu, 1 = voll
+ * phase: Position im Zyklus, 0 = Neumond, 0.25 = erstes Viertel,
+ * 0.5 = Vollmond, 0.75 = letztes Viertel
+ * waxing: true, solange der Mond zunimmt
+ */
+export function getMoonPhase(date) {
+ const d = toJulian(date) - J2000
+ const s = sunPosition(d)
+ const m = moonPosition(d)
+
+ // Elongation Sonne-Mond von der Erde aus gesehen
+ const elongation =
+ Math.acos(
+ sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra)
+ ) / DEG
+
+ // Phasenwinkel: Winkel Sonne-Mond-Erde
+ const inc =
+ Math.atan2(
+ SUN_DISTANCE * sin(elongation),
+ m.dist - SUN_DISTANCE * cos(elongation)
+ ) / DEG
+
+ // Vorzeichen entscheidet, ob zu- oder abnehmend
+ const angle =
+ Math.atan2(
+ cos(s.dec) * sin(s.ra - m.ra),
+ sin(s.dec) * cos(m.dec) - cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra)
+ ) / DEG
+
+ const waxing = angle < 0
+ return {
+ fraction: (1 + cos(inc)) / 2,
+ phase: 0.5 + (0.5 * inc * (waxing ? -1 : 1)) / 180,
+ waxing,
+ }
+}
+
+// Abstand im Mondzyklus, ab dem eine Hauptphase namentlich genannt wird.
+// 0.02 Zyklen entsprechen gut einem halben Tag.
+const PHASE_TOLERANCE = 0.02
+
+// Formatiert die Mondphase als deutschen Text, z.B. "Vollmond" oder
+// "abnehmend 63%". Die Prozentangabe ist der beleuchtete Anteil der Scheibe.
+export function formatMoonPhase({ fraction, phase, waxing }) {
+ // Abstand zyklisch messen, sonst faellt die Haelfte des Neumond-Fensters
+ // (phase kurz vor 1) durch das Raster.
+ const near = target => {
+ const diff = Math.abs(phase - target) % 1
+ return Math.min(diff, 1 - diff) < PHASE_TOLERANCE
+ }
+
+ if (near(0)) return 'Neumond'
+ if (near(0.25)) return 'erstes Viertel'
+ if (near(0.5)) return 'Vollmond'
+ if (near(0.75)) return 'letztes Viertel'
+
+ const percent = Math.round(fraction * 100)
+ return `${waxing ? 'zunehmend' : 'abnehmend'} ${percent}%`
+}
+
+// Formatiert eine Zeit als "HH:MM" in lokaler Zeit; "--:--" wenn nicht vorhanden.
+export function formatTime(date) {
+ if (!date) return '--:--'
+ return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
+}