V 1.7.0 feat: Sonnenauf-/untergang und Mondphase berechnet

Die Header-Zeile zeigte bisher feste Platzhalter ("06:45", "18:30",
"abnehmend 50%"). Jetzt werden die Werte tatsaechlich gerechnet, ohne
neue Abhaengigkeit:

- utils/astro.js: getSunTimes() nach dem NOAA-Sonnenstandsalgorithmus
  inkl. Hoehenkorrektur, getMoonPhase() nach gekuerzten Meeus-Reihen.
- Standort steht als Konstante STATION in App.jsx und speist sowohl
  Anzeige als auch Berechnung; Koordinaten auf die echten Stationswerte
  korrigiert (48° 52′ 27″ N, 9° 35′ 46″ E, 547 m NN).

Geprueft gegen unabhaengige Referenzimplementierungen: Sonnenzeiten
<= 50 s Abweichung gegen den vollstaendigen NOAA-Algorithmus, Neu- und
Vollmondzeitpunkte <= 39 min gegen Meeus Kap. 49.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 18:37:45 +02:00
parent 8aa528ff5b
commit a5f36e8f92
3 changed files with 245 additions and 7 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "wetterstation-frontend",
"private": true,
"version": "1.6.1",
"version": "1.7.0",
"type": "module",
"scripts": {
"dev": "vite",
+31 -6
View File
@@ -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 (
<div className="app">
@@ -205,7 +228,9 @@ function App() {
<h1>Aktuelle Wetterdaten</h1>
<div className="header-datetime">{dateStr} - {timeStr} Uhr</div>
<div className="header-spacer"></div>
<div className="header-coordinates">48.6 N - 9.6 E - 574 m NN</div>
<div className="header-coordinates">
{formatDms(STATION.lat)} N - {formatDms(STATION.lon)} E - {STATION.elevation} m NN
</div>
<div className="header-astro">
Sonnen-Aufgang: {sunrise} - Untergang: {sunset} &nbsp;&nbsp; Mond-Phase: {moonPhase}
</div>
+213
View File
@@ -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' })
}