4989 lines
194 KiB
PHP
4989 lines
194 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
session_start();
|
||
|
||
$loggedIn = isset($_SESSION['user_id']);
|
||
$pageTitle = 'Monatsvorhersage - AstroTools';
|
||
$bodyClass = 'admin-page monatsvorhersage-page';
|
||
$publicBasePath = './';
|
||
|
||
$currentYear = (int) date('Y');
|
||
$selectedYear = isset($_GET['year']) ? (int) $_GET['year'] : $currentYear;
|
||
$selectedMonth = isset($_GET['month']) ? max(1, min(12, (int) $_GET['month'])) : (int) date('n');
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Bereits berechnete Bausteine der Monatsvorschau
|
||
// -------------------------------------------------------------------------
|
||
// Stand heute erzeugt diese Seite bereits:
|
||
// - Monats-Sternkarten für Nordhimmel und Südhimmel
|
||
// - Mond- und Planetenkontext für die feste Monatszeit
|
||
// - Mondphasen des Monats
|
||
// - Goldener-Henkel-Termine
|
||
// - Jahreszeitenwechsel und Zeitumstellungen
|
||
// - Sonnen- und Mondfinsternisse
|
||
// - Perigäum/Apogäum des Mondes sowie Perihel/Aphel der Sonne
|
||
// - Größte Elongationen von Merkur/Venus und größte Venushelligkeit
|
||
// - Ereignisse äußerer Planeten und deren Stillstände
|
||
// - Jupitermonde auf nur einer Planetenseite
|
||
// - Annäherungen Mond-Planet, Mond-Deep-Sky und Planet-heller-Stern
|
||
// - Planetenkonjunktionen
|
||
// - Gute Merkursichtbarkeit
|
||
// - Planetenparaden
|
||
// - Sternbildwechsel der Planeten
|
||
// - Sternbedeckungen durch den Mond
|
||
// - Meteorstrom-Maxima
|
||
// - Gute Überflüge des Favoriten-Satelliten
|
||
// - Kometenereignisse für ausgewählte Kometen
|
||
// - Zusammengeführte Monatsereignisliste für Übersicht, SQL-Export und iCal
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Grenzwerte fuer Sternkarte und Objektanzeige
|
||
// -------------------------------------------------------------------------
|
||
// Maximale Helligkeit (Magnitude) der Sterne, die aus der Datenbank geladen
|
||
// werden. Hoehere Werte laden mehr, aber auch schwaechere Sterne.
|
||
$starDatabaseMagnitudeLimit = 6.0;
|
||
|
||
// Maximale Helligkeit der Sterne, die auf der Monats-Sternkarte sichtbar
|
||
// gezeichnet werden sollen.
|
||
$starDisplayMagnitudeLimit = 4.4;
|
||
|
||
// Sterne bis zu dieser Helligkeit erhalten auf der Sternkarte einen Namen.
|
||
$starLabelMagnitudeLimit = 2.0;
|
||
|
||
// Maximale Helligkeit der Messier-Objekte, die aus der Datenbank geladen
|
||
// werden.
|
||
$messierDatabaseMagnitudeLimit = 2.0;
|
||
|
||
// Messier-Objekte bis zu dieser Helligkeit werden auf der Monatskarte
|
||
// eingeblendet.
|
||
$messierDisplayMagnitudeLimit = 1.0;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Grenzwerte fuer Monatsereignisse
|
||
// -------------------------------------------------------------------------
|
||
// Maximaler Winkelabstand fuer Begegnungen zwischen Mond und Planeten.
|
||
$moonPlanetMaxSeparationDeg = 1.5;
|
||
|
||
// Maximaler Winkelabstand fuer Begegnungen zwischen Mond und ausgewaehlten
|
||
// Sternhaufen wie Plejaden oder Praesepe.
|
||
$moonDeepSkyMaxSeparationDeg = 1.0;
|
||
|
||
// Maximaler Winkelabstand fuer Begegnungen zwischen hellen Planeten und
|
||
// ausgewaehlten hellen Sternen wie Regulus oder Spica.
|
||
$planetBrightStarMaxSeparationDeg = 1.0;
|
||
|
||
// Maximaler Winkelabstand fuer Planet-Planet-Konjunktionen.
|
||
$planetConjunctionMaxSeparationDeg = 1.0;
|
||
|
||
// Nur Sterne bis zu dieser Helligkeit werden fuer Mondbedeckungen
|
||
// beruecksichtigt.
|
||
$moonOccultationMaxMag = 2.0;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Schwellen fuer "Gute Satelliten-Ueberfluege"
|
||
// -------------------------------------------------------------------------
|
||
// Es wird nur der Favoriten-Satellit des angemeldeten Users geprueft.
|
||
//
|
||
// Ein Ueberflug zaehlt als guter Beobachtungstermin nur dann, wenn er am
|
||
// Hoehepunkt mindestens diese Hoehe ueber dem Horizont erreicht.
|
||
$favoriteSatelliteBestPassMinAltitudeDeg = 80.0;
|
||
|
||
// Gleichzeitig muss die Sonne zum Maximum des Ueberflugs mindestens so weit
|
||
// unter dem Horizont stehen. -12 Grad entspricht dem Beginn/Ende der
|
||
// nautischen Daemmerung und sorgt fuer ausreichend dunklen Himmel.
|
||
$favoriteSatelliteBestPassMaxSunAltitudeDeg = -14.0;
|
||
|
||
// Optional koennen wir die Liste auf die erste Nachthaelfte begrenzen.
|
||
// "Erste Nachthaelfte" ist hier bewusst pragmatisch als lokaler Abend vor
|
||
// Mitternacht definiert. Ueberfluege nach 00:00 Ortszeit werden damit
|
||
// ausgefiltert.
|
||
$favoriteSatelliteBestPassOnlyFirstNightHalf = true;
|
||
|
||
// Optional koennen wir nur besonders helle Ueberfluege anzeigen.
|
||
// Bei Magnituden gilt: kleinere Werte sind heller. 0.0 mag bedeutet also,
|
||
// dass nur Ueberfluege mit Peak-Helligkeit von 0 mag oder heller bleiben.
|
||
$favoriteSatelliteBestPassMaxPeakMagnitude = 0.0;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Schwellen fuer "Gute Merkur-Sichtbarkeit"
|
||
// -------------------------------------------------------------------------
|
||
// Gute Merkur-Sichtbarkeit wird nicht nur ueber Elongation definiert, sondern
|
||
// ueber echte Beobachtbarkeit in der Daemmerung.
|
||
// Geprueft wird pro Tag:
|
||
// morgens eine feste Zahl Minuten VOR Sonnenaufgang,
|
||
// abends eine feste Zahl Minuten NACH Sonnenuntergang.
|
||
$mercuryGoodVisibilitySampleOffsetMinutes = 45;
|
||
|
||
// Merkur muss zum Pruefzeitpunkt mindestens diese Hoehe ueber dem Horizont
|
||
// erreichen, damit der Tag als gut sichtbar gilt.
|
||
$mercuryGoodVisibilityMinAltitudeDeg = 7.0;
|
||
|
||
// Gleichzeitig muss die Sonne ausreichend tief unter dem Horizont stehen.
|
||
// So vermeiden wir Treffer, bei denen Merkur zwar formal sichtbar waere, aber
|
||
// noch zu stark in der hellen Daemmerung untergeht.
|
||
$mercuryGoodVisibilityMaxSunAltitudeDeg = -3.0;
|
||
|
||
// Erst ab dieser Anzahl aufeinanderfolgender guter Tage erzeugen wir ein
|
||
// Monatsereignis. Einzelne Ausreisser-Tage werden dadurch herausgefiltert.
|
||
$mercuryGoodVisibilityMinConsecutiveDays = 3;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Schwellen fuer "Planetenparade"
|
||
// -------------------------------------------------------------------------
|
||
// Geprueft wird morgens eine feste Zahl Minuten VOR Sonnenaufgang und abends
|
||
// eine feste Zahl Minuten NACH Sonnenuntergang.
|
||
$planetParadeSampleOffsetMinutes = 45;
|
||
|
||
// Ein Planet zaehlt fuer die Parade nur dann mit, wenn er zum Pruefzeitpunkt
|
||
// mindestens diese Hoehe ueber dem Horizont erreicht.
|
||
$planetParadeMinAltitudeDeg = 7.0;
|
||
|
||
// Gleichzeitig muss die Sonne bereits tief genug unter dem Horizont stehen,
|
||
// damit die Parade nicht nur rechnerisch, sondern auch praktisch sichtbar ist.
|
||
$planetParadeMaxSunAltitudeDeg = -4.0;
|
||
|
||
// Die beteiligten Planeten muessen innerhalb dieses Azimutbogens liegen.
|
||
// So vermeiden wir Treffer, bei denen die Planeten zu weit ueber den Himmel
|
||
// verteilt sind und nicht mehr als zusammenhaengende Parade wirken.
|
||
$planetParadeMaxAzimuthSpanDeg = 120.0;
|
||
|
||
// Mindestens so viele helle Planeten muessen gleichzeitig sichtbar sein,
|
||
// damit wir von einer Planetenparade sprechen.
|
||
$planetParadeMinPlanetCount = 4;
|
||
|
||
// Erst ab dieser Anzahl aufeinanderfolgender Tage erzeugen wir ein Ereignis.
|
||
// Das filtert kurzlebige Ein-Tages-Treffer heraus.
|
||
$planetParadeMinConsecutiveDays = 2;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Schwellen fuer ausgewaehlte Kometen
|
||
// -------------------------------------------------------------------------
|
||
// Gute Kometensichtbarkeit wird morgens vor Sonnenaufgang und abends nach
|
||
// Sonnenuntergang geprueft.
|
||
$favoriteCometVisibilitySampleOffsetMinutes = 60;
|
||
|
||
// Der Komet sollte ausreichend hoch stehen, damit die Beobachtung praktisch
|
||
// sinnvoll bleibt.
|
||
$favoriteCometVisibilityMinAltitudeDeg = 10.0;
|
||
|
||
// Die Sonne muss tief genug unter dem Horizont stehen.
|
||
$favoriteCometVisibilityMaxSunAltitudeDeg = -8.0;
|
||
|
||
// Optional koennen wir eine grobe Helligkeitsschwelle setzen, damit nur
|
||
// realistisch interessante Fenster erscheinen.
|
||
$favoriteCometVisibilityMaxMagnitude = 10.0;
|
||
|
||
// Einzelne Zufallstreffer sollen nicht als eigenes Monatsereignis erscheinen.
|
||
$favoriteCometVisibilityMinConsecutiveDays = 2;
|
||
|
||
$years = range($currentYear - 2, $currentYear + 5);
|
||
$months = [
|
||
1 => 'Januar',
|
||
2 => 'Februar',
|
||
3 => 'März',
|
||
4 => 'April',
|
||
5 => 'Mai',
|
||
6 => 'Juni',
|
||
7 => 'Juli',
|
||
8 => 'August',
|
||
9 => 'September',
|
||
10 => 'Oktober',
|
||
11 => 'November',
|
||
12 => 'Dezember',
|
||
];
|
||
$monthSkyTimes = [
|
||
1 => ['hour' => 22, 'minute' => 0],
|
||
2 => ['hour' => 22, 'minute' => 0],
|
||
3 => ['hour' => 22, 'minute' => 0],
|
||
4 => ['hour' => 23, 'minute' => 0],
|
||
5 => ['hour' => 23, 'minute' => 0],
|
||
6 => ['hour' => 23, 'minute' => 30],
|
||
7 => ['hour' => 23, 'minute' => 30],
|
||
8 => ['hour' => 23, 'minute' => 0],
|
||
9 => ['hour' => 23, 'minute' => 0],
|
||
10 => ['hour' => 23, 'minute' => 0],
|
||
11 => ['hour' => 22, 'minute' => 0],
|
||
12 => ['hour' => 22, 'minute' => 0],
|
||
];
|
||
|
||
$starData = [];
|
||
$lineStarData = [];
|
||
$constellationLines = [];
|
||
$messierData = [];
|
||
$planetData = [];
|
||
$moonData = null;
|
||
$moonPhaseEvents = [];
|
||
$goldenHandleEvents = [];
|
||
$moonPlanetApproaches = [];
|
||
$moonDeepSkyApproaches = [];
|
||
$planetBrightStarApproaches = [];
|
||
$mercuryGoodVisibilityEvents = [];
|
||
$planetParadeEvents = [];
|
||
$planetConstellationChangeEvents = [];
|
||
$planetConjunctions = [];
|
||
$eclipseEvents = [];
|
||
$moonApsisEvents = [];
|
||
$sunApsisEvents = [];
|
||
$innerPlanetElongationEvents = [];
|
||
$venusPeakMagnitudeEvents = [];
|
||
$outerPlanetEvents = [];
|
||
$outerPlanetStationEvents = [];
|
||
$jupiterMoonSideEvents = [];
|
||
$moonOccultations = [];
|
||
$meteorShowerPeaks = [];
|
||
$favoriteSatellitePasses = [];
|
||
$favoriteSatelliteName = null;
|
||
$favoriteSatellitePayload = [];
|
||
$favoriteCometPayload = [];
|
||
$favoriteCometEvents = [];
|
||
$seasonChanges = [];
|
||
$timeChanges = [];
|
||
$monthEventList = [];
|
||
$starDataError = null;
|
||
|
||
if (!function_exists('buildMonthOccultationStarLabel')) {
|
||
function buildMonthOccultationStarLabel(array $row): string
|
||
{
|
||
$proper = trim((string) ($row['proper'] ?? ''));
|
||
if ($proper !== '') {
|
||
return $proper;
|
||
}
|
||
|
||
$bayer = trim((string) ($row['bayer'] ?? ''));
|
||
$con = trim((string) ($row['con'] ?? ''));
|
||
if ($bayer !== '') {
|
||
return $con !== '' ? $bayer . ' ' . $con : $bayer;
|
||
}
|
||
|
||
$hip = isset($row['hip']) ? (int) $row['hip'] : 0;
|
||
return $hip > 0 ? 'HIP ' . $hip : 'Unbenannter Stern';
|
||
}
|
||
}
|
||
|
||
if (!function_exists('normalizeMonthForecastDegree')) {
|
||
function normalizeMonthForecastDegree(float $value): float
|
||
{
|
||
$normalized = fmod($value, 360.0);
|
||
if ($normalized < 0.0) {
|
||
$normalized += 360.0;
|
||
}
|
||
|
||
return $normalized;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastSolarLongitude')) {
|
||
function monthForecastSolarLongitude(DateTimeImmutable $dateTime): float
|
||
{
|
||
$julianDay = ((float) $dateTime->format('U')) / 86400.0 + 2440587.5;
|
||
$daysSinceJ2000 = $julianDay - 2451545.0;
|
||
$meanLongitude = normalizeMonthForecastDegree(280.46 + 0.9856474 * $daysSinceJ2000);
|
||
$meanAnomalyRad = deg2rad(normalizeMonthForecastDegree(357.528 + 0.9856003 * $daysSinceJ2000));
|
||
|
||
return normalizeMonthForecastDegree(
|
||
$meanLongitude
|
||
+ 1.915 * sin($meanAnomalyRad)
|
||
+ 0.020 * sin(2.0 * $meanAnomalyRad)
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastForwardDegreeDistance')) {
|
||
function monthForecastForwardDegreeDistance(float $from, float $to): float
|
||
{
|
||
return normalizeMonthForecastDegree($to - $from);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastParseMeteorZhrRange')) {
|
||
function monthForecastParseMeteorZhrRange(?int $zhr, string $zhrVariable): array
|
||
{
|
||
if ($zhr !== null && $zhr >= 0) {
|
||
return ['min' => $zhr, 'max' => $zhr];
|
||
}
|
||
|
||
if ($zhrVariable === '') {
|
||
return ['min' => null, 'max' => null];
|
||
}
|
||
|
||
preg_match_all('/\d+/', $zhrVariable, $matches);
|
||
$numbers = array_map('intval', $matches[0] ?? []);
|
||
if ($numbers === []) {
|
||
return ['min' => null, 'max' => null];
|
||
}
|
||
|
||
return [
|
||
'min' => min($numbers),
|
||
'max' => max($numbers),
|
||
];
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastRadiantIsVisible')) {
|
||
function monthForecastRadiantIsVisible(float $latitudeDeg, ?float $declinationDeg): bool
|
||
{
|
||
if ($declinationDeg === null) {
|
||
return false;
|
||
}
|
||
|
||
return abs($latitudeDeg - $declinationDeg) < 90.0;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastTleChecksum')) {
|
||
function monthForecastTleChecksum(string $line): int
|
||
{
|
||
$sum = 0;
|
||
for ($i = 0; $i < 68; $i++) {
|
||
$c = $line[$i] ?? ' ';
|
||
if (ctype_digit($c)) {
|
||
$sum += (int) $c;
|
||
} elseif ($c === '-') {
|
||
$sum += 1;
|
||
}
|
||
}
|
||
|
||
return $sum % 10;
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastTleExp')) {
|
||
function monthForecastTleExp(float $value): string
|
||
{
|
||
if (abs($value) < 1e-99) {
|
||
return ' 00000-0';
|
||
}
|
||
|
||
$sign = $value >= 0 ? '+' : '-';
|
||
$abs = abs($value);
|
||
$exp = (int) floor(log10($abs)) + 1;
|
||
$mantissa = (int) round($abs / pow(10, $exp) * 1e5);
|
||
if ($mantissa >= 100000) {
|
||
$mantissa = 10000;
|
||
$exp++;
|
||
}
|
||
|
||
$expSign = $exp >= 0 ? '+' : '-';
|
||
return sprintf('%s%05d%s%d', $sign, $mantissa, $expSign, abs($exp));
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastTleMmDot')) {
|
||
function monthForecastTleMmDot(float $value): string
|
||
{
|
||
$sign = $value >= 0 ? ' ' : '-';
|
||
return sprintf('%s.%08d', $sign, (int) round(abs($value) * 1e8));
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastTleEpoch')) {
|
||
function monthForecastTleEpoch(string $epochStr): string
|
||
{
|
||
$dt = new DateTimeImmutable($epochStr, new DateTimeZone('UTC'));
|
||
$yy = (int) $dt->format('Y') % 100;
|
||
$jan1 = new DateTimeImmutable($dt->format('Y') . '-01-01 00:00:00', new DateTimeZone('UTC'));
|
||
$secs = (float) $dt->format('U') - (float) $jan1->format('U');
|
||
$day = $secs / 86400.0 + 1.0;
|
||
|
||
return sprintf('%02d%012.8f', $yy, $day);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastTleIntlDesig')) {
|
||
function monthForecastTleIntlDesig(?string $objectId): string
|
||
{
|
||
if (!$objectId) {
|
||
return str_repeat(' ', 8);
|
||
}
|
||
|
||
if (preg_match('/^(\d{4})-(\d+)([A-Z]*)$/i', trim($objectId), $match)) {
|
||
$yy = substr($match[1], 2, 2);
|
||
$launch = str_pad($match[2], 3, '0', STR_PAD_LEFT);
|
||
$piece = str_pad(strtoupper($match[3]), 3, ' ', STR_PAD_RIGHT);
|
||
return substr(str_pad($yy . $launch . $piece, 8, ' '), 0, 8);
|
||
}
|
||
|
||
return str_pad(substr($objectId, 0, 8), 8);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastBuildTleLine1')) {
|
||
function monthForecastBuildTleLine1(array $sat, array $tle): string
|
||
{
|
||
$norad = (int) ($sat['norad_cat_id'] ?? 0);
|
||
$class = in_array($sat['classification_type'] ?? '', ['U', 'C', 'S'], true)
|
||
? (string) $sat['classification_type']
|
||
: 'U';
|
||
|
||
$line = sprintf(
|
||
'1 %05d%1s %-8s %14s %10s %8s %8s %1d %4d',
|
||
$norad,
|
||
$class,
|
||
monthForecastTleIntlDesig($sat['object_id'] ?? null),
|
||
monthForecastTleEpoch((string) ($tle['epoch'] ?? '')),
|
||
monthForecastTleMmDot((float) ($tle['mean_motion_dot'] ?? 0)),
|
||
monthForecastTleExp((float) ($tle['mean_motion_ddot'] ?? 0)),
|
||
monthForecastTleExp((float) ($tle['bstar'] ?? 0)),
|
||
(int) ($tle['ephemeris_type'] ?? 0),
|
||
(int) ($tle['element_set_no'] ?? 0)
|
||
);
|
||
$line = str_pad($line, 68);
|
||
|
||
return $line . monthForecastTleChecksum($line);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastBuildTleLine2')) {
|
||
function monthForecastBuildTleLine2(array $sat, array $tle): string
|
||
{
|
||
$norad = (int) ($sat['norad_cat_id'] ?? 0);
|
||
$ecc = (float) ($tle['eccentricity'] ?? 0);
|
||
$eccStr = substr(sprintf('%.7f', $ecc), 2);
|
||
|
||
$line = sprintf(
|
||
'2 %05d %8.4f %8.4f %7s %8.4f %8.4f %11.8f%5d',
|
||
$norad,
|
||
(float) ($tle['inclination'] ?? 0),
|
||
(float) ($tle['ra_of_asc_node'] ?? 0),
|
||
$eccStr,
|
||
(float) ($tle['arg_of_pericenter'] ?? 0),
|
||
(float) ($tle['mean_anomaly'] ?? 0),
|
||
(float) ($tle['mean_motion'] ?? 0),
|
||
(int) ($tle['rev_at_epoch'] ?? 0)
|
||
);
|
||
$line = str_pad($line, 68);
|
||
|
||
return $line . monthForecastTleChecksum($line);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('findMonthForecastSolarLongitudeCrossing')) {
|
||
function findMonthForecastSolarLongitudeCrossing(
|
||
DateTimeImmutable $rangeStartLocal,
|
||
DateTimeImmutable $rangeEndLocal,
|
||
float $targetLongitude,
|
||
DateTimeZone $displayTimezone
|
||
): ?DateTimeImmutable {
|
||
$utc = new DateTimeZone('UTC');
|
||
$rangeStartUtc = $rangeStartLocal->setTimezone($utc);
|
||
$rangeEndUtc = $rangeEndLocal->setTimezone($utc);
|
||
|
||
$startLongitude = monthForecastSolarLongitude($rangeStartUtc);
|
||
$endLongitude = monthForecastSolarLongitude($rangeEndUtc);
|
||
$targetDelta = monthForecastForwardDegreeDistance($startLongitude, $targetLongitude);
|
||
$rangeDelta = monthForecastForwardDegreeDistance($startLongitude, $endLongitude);
|
||
|
||
if ($targetDelta > $rangeDelta + 0.0001) {
|
||
return null;
|
||
}
|
||
|
||
$low = (int) $rangeStartUtc->format('U');
|
||
$high = (int) $rangeEndUtc->format('U');
|
||
|
||
for ($i = 0; $i < 40; $i++) {
|
||
$mid = (int) floor(($low + $high) / 2);
|
||
$midLongitude = monthForecastSolarLongitude(new DateTimeImmutable('@' . $mid));
|
||
$midDelta = monthForecastForwardDegreeDistance($startLongitude, $midLongitude);
|
||
|
||
if ($midDelta < $targetDelta) {
|
||
$low = $mid;
|
||
} else {
|
||
$high = $mid;
|
||
}
|
||
}
|
||
|
||
return (new DateTimeImmutable('@' . $high))->setTimezone($displayTimezone);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('buildObservationTipInsertSql')) {
|
||
function buildObservationTipInsertSql(array $events, DateTimeZone $timezone): string
|
||
{
|
||
$rows = [];
|
||
|
||
foreach ($events as $event) {
|
||
if (!is_array($event)) {
|
||
continue;
|
||
}
|
||
|
||
$tip = trim((string) ($event['event'] ?? ''));
|
||
if ($tip === '') {
|
||
continue;
|
||
}
|
||
|
||
$moment = null;
|
||
$localIso = trim((string) ($event['local_iso'] ?? ''));
|
||
if ($localIso !== '') {
|
||
try {
|
||
$moment = new DateTimeImmutable($localIso);
|
||
} catch (Throwable $e) {
|
||
$moment = null;
|
||
}
|
||
}
|
||
|
||
if (!$moment instanceof DateTimeImmutable) {
|
||
$date = trim((string) ($event['date'] ?? ''));
|
||
$time = trim((string) ($event['time'] ?? ''));
|
||
if ($date !== '' && $time !== '') {
|
||
$moment = DateTimeImmutable::createFromFormat('d.m.Y H:i', $date . ' ' . $time, $timezone) ?: null;
|
||
}
|
||
}
|
||
|
||
if (!$moment instanceof DateTimeImmutable) {
|
||
continue;
|
||
}
|
||
|
||
$rows[] = sprintf(
|
||
"('%s', '%s')",
|
||
$moment->setTimezone($timezone)->format('Y-m-d H:i:s'),
|
||
str_replace("'", "''", $tip)
|
||
);
|
||
}
|
||
|
||
if ($rows === []) {
|
||
return '';
|
||
}
|
||
|
||
return "INSERT INTO `astro_sonobs_observationtips` (`moment`, `tip`)\nVALUES\n "
|
||
. implode(",\n ", $rows)
|
||
. ";\n";
|
||
}
|
||
}
|
||
|
||
if (!function_exists('escapeIcalText')) {
|
||
function escapeIcalText(string $value): string
|
||
{
|
||
return str_replace(
|
||
["\\", ";", ",", "\r\n", "\n", "\r"],
|
||
["\\\\", "\\;", "\\,", "\\n", "\\n", "\\n"],
|
||
$value
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!function_exists('foldIcalLine')) {
|
||
function foldIcalLine(string $line): string
|
||
{
|
||
$chunks = preg_split('/(.{1,73})/u', $line, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
|
||
if ($chunks === false || $chunks === []) {
|
||
return $line;
|
||
}
|
||
|
||
$firstChunk = array_shift($chunks);
|
||
return $firstChunk . ($chunks ? "\r\n " . implode("\r\n ", $chunks) : '');
|
||
}
|
||
}
|
||
|
||
if (!function_exists('buildObservationTipCalendarIcs')) {
|
||
function buildObservationTipCalendarIcs(array $events, DateTimeZone $timezone): string
|
||
{
|
||
$lines = [
|
||
'BEGIN:VCALENDAR',
|
||
'VERSION:2.0',
|
||
'PRODID:-//AstroTools//Monatsvorhersage//DE',
|
||
'CALSCALE:GREGORIAN',
|
||
'METHOD:PUBLISH',
|
||
'X-WR-CALNAME:AstroTools Monatsereignisse',
|
||
];
|
||
|
||
$createdUtc = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Ymd\THis\Z');
|
||
|
||
foreach ($events as $index => $event) {
|
||
if (!is_array($event)) {
|
||
continue;
|
||
}
|
||
|
||
$summary = trim((string) ($event['event'] ?? ''));
|
||
if ($summary === '') {
|
||
continue;
|
||
}
|
||
|
||
$moment = null;
|
||
$localIso = trim((string) ($event['local_iso'] ?? ''));
|
||
if ($localIso !== '') {
|
||
try {
|
||
$moment = new DateTimeImmutable($localIso);
|
||
} catch (Throwable $e) {
|
||
$moment = null;
|
||
}
|
||
}
|
||
|
||
if (!$moment instanceof DateTimeImmutable) {
|
||
$date = trim((string) ($event['date'] ?? ''));
|
||
$time = trim((string) ($event['time'] ?? ''));
|
||
if ($date !== '' && $time !== '') {
|
||
$moment = DateTimeImmutable::createFromFormat('d.m.Y H:i', $date . ' ' . $time, $timezone) ?: null;
|
||
}
|
||
}
|
||
|
||
if (!$moment instanceof DateTimeImmutable) {
|
||
continue;
|
||
}
|
||
|
||
$startUtc = $moment->setTimezone(new DateTimeZone('UTC'));
|
||
$endMoment = null;
|
||
$endLocalIso = trim((string) ($event['end_local_iso'] ?? ''));
|
||
if ($endLocalIso !== '') {
|
||
try {
|
||
$endMoment = new DateTimeImmutable($endLocalIso);
|
||
} catch (Throwable $e) {
|
||
$endMoment = null;
|
||
}
|
||
}
|
||
|
||
if (!$endMoment instanceof DateTimeImmutable) {
|
||
$durationMinutes = isset($event['duration_minutes']) ? max(1, (int) $event['duration_minutes']) : 15;
|
||
$endMoment = $moment->modify('+' . $durationMinutes . ' minutes');
|
||
}
|
||
|
||
$endUtc = $endMoment->setTimezone(new DateTimeZone('UTC'));
|
||
$uidBase = sha1($startUtc->format(DateTimeInterface::ATOM) . '|' . $summary . '|' . (string) $index);
|
||
|
||
$eventLines = [
|
||
'BEGIN:VEVENT',
|
||
'UID:' . $uidBase . '@astrotools.local',
|
||
'DTSTAMP:' . $createdUtc,
|
||
'DTSTART:' . $startUtc->format('Ymd\THis\Z'),
|
||
'DTEND:' . $endUtc->format('Ymd\THis\Z'),
|
||
'SUMMARY:' . escapeIcalText($summary),
|
||
'DESCRIPTION:' . escapeIcalText($summary),
|
||
'END:VEVENT',
|
||
];
|
||
|
||
foreach ($eventLines as $eventLine) {
|
||
$lines[] = foldIcalLine($eventLine);
|
||
}
|
||
}
|
||
|
||
$lines[] = 'END:VCALENDAR';
|
||
|
||
return implode("\r\n", $lines) . "\r\n";
|
||
}
|
||
}
|
||
|
||
if (!function_exists('monthForecastBuildNarrativeText')) {
|
||
function monthForecastBuildNarrativeText(array $monthEventList, int $monthNumber, string $monthName, int $year): string
|
||
{
|
||
$filteredEvents = array_values(array_filter(
|
||
$monthEventList,
|
||
static fn (array $item): bool => (string) ($item['type'] ?? '') !== 'favorite_satellite_pass'
|
||
));
|
||
|
||
$skyIntroByMonth = [
|
||
1 => "Im Januar fällt in Südrichtung besonders das bekannteste Wintersternbild auf: der Orion. Seine sieben Hauptsterne sind auffallend hell, und in ihrer Anordnung lässt sich mit etwas Vorstellungskraft gut die Gestalt des Himmelsjägers erkennen. Unterhalb der drei Gürtelsterne kann man schon mit einem Fernglas den blass schimmernden Orionnebel entdecken. Der Stern Rigel bildet dabei die rechte untere Ecke des Orion.\n\nRigel ist zugleich Teil einer großen Sternfigur, des Wintersechsecks. Dabei handelt es sich nicht um ein eigenes Sternbild, sondern um eine auffällige Formation aus sechs hellen Sternen verschiedener Sternbilder. Im Uhrzeigersinn folgen auf Rigel zunächst Sirius im Großen Hund, dann Prokyon im Kleinen Hund, Castor in den Zwillingen, Capella im Fuhrmann und schließlich Aldebaran im Stier. Auch die Wintermilchstraße zieht durch diesen Himmelsbereich. Sie verläuft durch die Sternbilder Großer Hund, Einhorn, Zwillinge, Stier, Fuhrmann, Perseus und Kassiopeia, wirkt jedoch deutlich schwächer als im Sommer, da unser Blick nun eher zum Rand unserer Galaxis gerichtet ist.",
|
||
2 => "In den Abendstunden des Februar zeigt sich der Sternenhimmel besonders eindrucksvoll. Die Wintersternbilder rund um den Orion prägen nun den Himmel und sind für ihre vielen hellen Sterne bekannt. Um 22 Uhr, auf diese Zeit bezieht sich die Sternkarte, haben sie den Meridian bereits überschritten. Unterhalb des Orion funkelt Sirius im Großen Hund, der hellste Stern des Nachthimmels. Oberhalb des Orion stehen rechts der Stier mit seinem Hauptstern Aldebaran, noch höher der Fuhrmann mit der gelblich leuchtenden Capella und links die Zwillinge mit Castor und Pollux. Unterhalb der Zwillinge fällt zudem Prokyon auf, der Hauptstern des Kleinen Hundes.\n\nAm Osthimmel kündigen sich bereits die Frühlingssternbilder an. Der Löwe mit seinem Hauptstern Regulus ist schon vollständig aufgegangen. Unter ihm leuchtet mit Alphard ein auffälliger Einzelstern in der Wasserschlange. Die Jungfrau folgt erst später in der Nacht und steigt nach Mitternacht vollständig über den Horizont.",
|
||
3 => "Im März zeigt der Sternenhimmel den Übergang vom Winter zum Frühling. Am Abend sind noch viele markante Wintersternbilder zu sehen, darunter Orion, Großer Hund, Kleiner Hund, Zwillinge, Fuhrmann und Stier. Im Lauf der Nacht sinken sie jedoch immer weiter nach Westen, während im Osten und Süden die Frühlingssternbilder aufsteigen. Gegen Mitternacht prägen dann vor allem die Sternbilder rund um den Löwen den Himmel.\n\nZwischen den Zwillingen und dem Löwen liegt das Tierkreissternbild Krebs. Es ist eher unscheinbar und deshalb am Himmel nicht leicht zu erkennen. Bei dunklem, klarem Himmel lässt sich in seiner Mitte jedoch ein schwacher, milchiger Lichtfleck entdecken: der offene Sternhaufen Praesepe, auch „Krippe“ genannt. Hoch über unseren Köpfen steht jetzt der Große Bär. Seine sieben hellsten Sterne bilden die bekannte Figur des Großen Wagens. Im Südosten geht Spica auf, der hellste Stern im Sternbild Jungfrau. Weiter nördlich, im Osten bis Nordosten, fällt der orange leuchtende Arktur im Sternbild Bärenhüter auf. Tief über dem Südhorizont erscheint bereits der vordere Teil des ausgedehnten Sternbilds Wasserschlange. Bei weniger klarem Himmel ist davon oft nur der hellste Stern Alphard zu erkennen, dessen Name passend „der Einsame“ bedeutet. Auch das kleine Sternbild Rabe steht nun knapp über dem Horizont. Obwohl seine Sterne nicht besonders hell sind, ist es wegen seiner kompakten Form vergleichsweise gut auszumachen.",
|
||
4 => "Im April prägen die Frühlingssternbilder den Sternenhimmel. Genau im Süden steht nun das Sternbild Löwe, dessen Figur mit etwas Vorstellungskraft tatsächlich an eine liegende Raubkatze erinnert. In südöstlicher Richtung fallen zwei helle Sterne besonders auf: Spica, der hellste Stern im Sternbild Jungfrau, und höher am Himmel der orange leuchtende Arktur im Sternbild Bärenhüter. Gemeinsam mit Regulus im Löwen bilden sie das sogenannte Frühlingsdreieck.\n\nDer Große Wagen, ein markanter Teil des Sternbilds Großer Bär, steht jetzt hoch über uns und erscheint nahezu senkrecht aufgerichtet. Zwischen ihm und dem Löwen liegen nur die eher unauffälligen Sternbilder Kleiner Löwe, Jagdhunde und Haar der Berenike. Ähnlich sternarm wirkt auch der Bereich zwischen dem Löwen und dem Horizont: Dort zieht sich das ausgedehnte, aber lichtschwache Sternbild Wasserschlange entlang. Der Sextant ist nur schwer zu erkennen; meist lassen sich in diesem Himmelsabschnitt vor allem Becher und Rabe ausmachen, da ihre Sternfiguren vergleichsweise markant sind.",
|
||
5 => "Im Mai steht am frühen Abend das Sternbild Jungfrau über dem Südhorizont. Sein hellster Stern ist Spica. Etwas höher am Himmel leuchtet Arktur, der auffällig orange Hauptstern im Sternbild Bärenhüter. In südwestlicher Richtung ist noch Regulus im Löwen zu sehen. Diese drei hellen Sterne – Spica, Arktur und Regulus – bilden zusammen das sogenannte Frühlingsdreieck. Oberhalb davon liegt das eher unauffällige Sternbild Haar der Berenike.\n\nZum Südosthorizont hin kündigt sich bereits der Übergang vom Frühlings- zum Sommerhimmel an. Dort geht das Sternbild Skorpion auf, zunächst allerdings noch sehr tief über dem Horizont. Wer den Sternenhimmel im Mai am Abend beobachtet, kann im Westen noch einige helle Sterne der Wintersternbilder erkennen; sie sinken jedoch bald unter den Horizont. Im Osten steigen dagegen schon die ersten Sommersternbilder empor. Besonders auffällig ist dort Wega, der helle Hauptstern des Sternbilds Leier. Zwischen Wega und Arktur liegen das lichtschwächere Sternbild Herkules und das markante Halbrund der Nördlichen Krone.",
|
||
6 => "Im Juni kündigt sich der Sommerhimmel bereits deutlich an. Tief über dem südöstlichen Horizont fällt der rötlich leuchtende Antares im Sternbild Skorpion auf. Da er in unseren Breiten nie besonders hoch steigt, bleibt er stets ein Objekt in Horizontnähe. Hoch über unseren Köpfen steht dagegen der Große Wagen, der auffälligste Teil des Sternbilds Großer Bär. Seine Deichsel dient zugleich als praktischer Wegweiser am Himmel: Verlängert man ihren geschwungenen Bogen, gelangt man zunächst zu Arktur, dem hellen Hauptstern des Sternbilds Bärenhüter, und weiter zu Spica in der Jungfrau. Noch weiter westlich steht der Löwe bereits tief über dem südwestlichen Horizont und nähert sich langsam seinem Untergang.\n\nLinks oberhalb von Arktur ist ein kleines, aber sehr charakteristisches Sternbild zu erkennen: die Nördliche Krone. Ihre Sterne bilden einen zarten Halbkreis, der sich auch für ungeübte Beobachter gut auffinden lässt. Ihr hellster Stern heißt Gemma, also „Edelstein“ – ein passender Name für dieses auffällige Himmelsbild. Unterhalb davon breiten sich der Schlangenträger und die aus zwei Teilen bestehende Schlange aus. Beide Sternbilder sind jedoch recht lichtschwach und daher vor allem bei klarem, dunklem Himmel gut zu verfolgen. Besonders eindrucksvoll wirkt der Himmel nun in östlicher Richtung. Dort sind bereits die ersten markanten Sommersternbilder aufgegangen. Vor allem das Sommerdreieck zieht die Aufmerksamkeit auf sich. Es wird von drei hellen Sternen gebildet: Wega in der Leier, Deneb im Schwan und Altair im Adler. Diese drei Sterne gehören zu den bekanntesten Orientierungspunkten des Sommerhimmels und begleiten uns in den kommenden Monaten durch die hellen Nächte.",
|
||
7 => "Im Juli sind die südlichen Sternbilder Skorpion und Schütze besonders gut zu beobachten. Von Deutschland aus bleiben sie allerdings stets tief über dem Horizont. Deutlich günstiger sind die Bedingungen weiter südlich, etwa im Mittelmeerraum: Dort steigen beide Sternbilder merklich höher und zeigen sich wesentlich eindrucksvoller.\n\nOberhalb von Skorpion und Schütze liegen die eher lichtschwachen Sternbilder Schlangenträger und Schlange; noch darüber befindet sich Herkules. Zwar sind diese Sternbilder weniger auffällig, sie gehören aber zu den charakteristischen Bereichen des Sommerhimmels. Besonders ins Auge fallen in dieser Jahreszeit die hellen Sterne Arktur im Bärenhüter, Wega in der Leier, Deneb im Schwan und Altair im Adler. Auch die Milchstraße zieht sich nun als helles Band quer über den Himmel. Im Juli sind die Nächte in unseren Breiten jedoch noch nicht vollständig dunkel, sodass sie oft nur schwach oder gar nicht zu erkennen ist. Für ihre Beobachtung sind die Bedingungen meist erst im August günstiger. Der Große Bär ist auf der Sternkarte nur noch teilweise zu sehen. Er steht inzwischen in nordwestlicher Richtung und sinkt im Lauf der Nacht immer tiefer zum Horizont.",
|
||
8 => "Im August zeigt sich der Sommersternhimmel von seiner schönsten Seite. Wenn es nach 22 Uhr langsam dunkel wird, treten nach und nach die ersten helleren Sterne hervor. Gegen 23 Uhr bietet sich dann ein besonders eindrucksvoller Anblick: Hoch über unseren Köpfen steht Wega im Sternbild Leier, einer der auffälligsten Sterne des Sommerhimmels. Zusammen mit Deneb im Sternbild Schwan und Altair im Adler bildet sie das sogenannte Sommerdreieck, eine der markantesten Sternfiguren dieser Jahreszeit. Deneb steht dabei links von Wega, während Altair deutlich tiefer in Richtung Horizont zu finden ist. Diese drei hellen Sterne sind auch für ungeübte Beobachter leicht auszumachen und eignen sich gut zur Orientierung am Nachthimmel.\n\nMit den dunkler werdenden Nächten wird nun auch die Milchstraße wieder besser sichtbar. Sie zieht sich als zartes, helles Band quer über den Himmel und verläuft mitten durch den Bereich des Sommerdreiecks. Besonders fernab künstlicher Lichtquellen lässt sich ihr schimmernder Verlauf gut erkennen. Während der Blick noch von den Sommersternbildern beherrscht wird, kündigt sich im Osten schon die nächste Jahreszeit an. Dort steigen mit Pegasus und Andromeda bereits die ersten Herbststernbilder über den Horizont. Um den 12. August sind zudem besonders viele Sternschnuppen zu sehen. Dann erreicht der Meteorstrom der Perseiden sein Maximum. Diese Sternschnuppen entstehen, wenn kleine Staubteilchen des Kometen Swift-Tuttle in die Erdatmosphäre eindringen und dort verglühen.",
|
||
9 => "Im September wird es nach den hellen Sommernächten wieder deutlich früher dunkel. Schon gegen 21.30 Uhr sind die meisten Sternbilder gut zu erkennen. Hoch über unseren Köpfen fällt zunächst der helle Stern Wega auf. Wer später, wenn es ganz dunkel geworden ist, noch einmal in diese Himmelsregion blickt, kann auch das kleine rautenförmige Sternbild Leier erkennen, dessen hellster Stern Wega ist. Wer diese Sternfigur nicht ausmacht, hat möglicherweise stattdessen Altair im Adler oder Deneb im Schwan im Blick. Diese drei hellen Sterne bilden gemeinsam das große Sommerdreieck.\n\nMitten im Sommerdreieck und etwas links davon liegen die kleinen Sternbilder Pfeil und Delfin. Zwar bestehen sie nicht aus besonders hellen Sternen, doch ihre kompakten und einprägsamen Formen machen sie vergleichsweise leicht auffindbar. Nun wird auch die Milchstraße wieder gut sichtbar: Als fahles, schimmerndes Band zieht sie sich hoch über den Himmel. Sowohl im Westen, wo die Frühsommersternbilder allmählich tiefer sinken, als auch im Osten, wo die Herbststernbilder aufsteigen, zeigt sich der Himmel vergleichsweise arm an auffällig hellen Sternen.",
|
||
10 => "Im Oktober sind die Nächte bereits wieder länger als die Tage. Der Grund dafür ist, dass die Sonne Ende September den Himmelsäquator nach Süden überschritten hat und nun auf ihrer scheinbaren Jahresbahn weiter südlich steht. Am frühen Abend erinnert der Sternenhimmel noch an den Sommer. Gut zu erkennen ist das Sommerdreieck, das von Wega in der Leier, Deneb im Schwan und Altair im Adler gebildet wird.\n\nIm Lauf des Abends rücken dann immer mehr die Herbststernbilder in den Mittelpunkt. Gegen 23 Uhr Sommerzeit steht hoch am Himmel das große Herbstviereck. Drei seiner Sterne gehören zum Sternbild Pegasus, während die linke obere Ecke bereits zur Sternkette der Andromeda zählt. Diese markante Figur ist eines der bekanntesten Himmelsbilder des Herbstes. Unterhalb von Pegasus und Andromeda liegen die Tierkreissternbilder Steinbock, Wassermann und Fische. Sie bestehen allerdings überwiegend aus eher schwachen Sternen und sind deshalb nur an einem dunklen Beobachtungsort gut zu erkennen. In diesem Himmelsbereich fällt vor allem Diphda im Sternbild Walfisch auf. Sehr tief über dem Südhorizont kann man außerdem Fomalhaut im Südlichen Fisch sehen, einen der wenigen auffällig hellen Sterne dieser Himmelsgegend.",
|
||
11 => "Im November prägen vor allem die Sternbilder des Herbsthimmels das Bild. Besonders auffällig ist das große Herbstviereck, das meist auch Pegasus-Quadrat genannt wird. Drei seiner Sterne gehören zum Sternbild Pegasus, während die linke obere Ecke bereits zur Andromeda zählt. In diesem Sternbild liegt auch die Andromedagalaxie, das fernste Objekt, das unter guten Bedingungen mit bloßem Auge zu erkennen ist. An einem dunklen, klaren Himmel erscheint sie als schwacher, nebliger Lichtfleck.\n\nUnterhalb von Pegasus und Andromeda liegen vor allem eher lichtschwache Sternbilder: im Südwesten der Wassermann, im Süden der Walfisch und darüber die Fische. Im Walfisch befindet sich zudem Mira, einer der bekanntesten veränderlichen Sterne des Himmels. Ihre Helligkeit schwankt so stark, dass sie zeitweise gut zu sehen ist und dann wieder fast verschwindet. Im Osten kündigt sich dagegen schon der Winterhimmel an, dessen Sternbilder deutlich auffälliger sind. Hoch über uns steht die Kassiopeia mit ihrer markanten W-Form. Weiter südöstlich folgen Perseus mit dem bekannten veränderlichen Stern Algol und der Stier. In diesem Sternbild fallen besonders der Sternhaufen der Plejaden und der rötlich leuchtende Aldebaran auf. Über dem östlichen Horizont ist nun auch Orion erschienen, eines der markantesten Sternbilder des Winterhimmels mit seinen auffallend hellen Sternen.",
|
||
12 => "Im Dezember wird es schon früh dunkel, und so verabschieden sich die Sommersternbilder Schwan, Leier und Adler am Abend allmählich vom Himmel. Stattdessen zeigt sich nun bereits die ganze Pracht des Winterhimmels. Orion, Stier, Zwillinge und Fuhrmann sind schon gut aufgegangen und prägen den Abendhimmel. Selbst Sirius, der hellste Stern des Nachthimmels, erscheint bereits über dem Südosthorizont. Zum Jahreswechsel gibt es dabei einen besonderen Orientierungspunkt: In der Nacht vom 31. Dezember auf den 1. Januar erreicht Sirius gegen Mitternacht seine höchste Stellung am Himmel und steht dann von Deutschland aus nahezu genau im Süden.\n\nIm Süden und Südwesten halten sich noch einige Sternbilder des Herbsthimmels. Unterhalb des Herbstvierecks – des großen Quadrats aus drei Sternen des Pegasus und einem Stern der Andromeda – liegen die ausgedehnten, aber eher lichtschwachen Sternbilder Wassermann, Fische und Walfisch. Auch der Eridanus, das Sternbild zwischen Walfisch und Orion, besteht überwiegend aus wenig auffälligen Sternen. In einer klaren Nacht lohnt es sich jedoch, seinen gewundenen Verlauf am Himmel einmal genauer zu verfolgen.",
|
||
];
|
||
|
||
foreach ($skyIntroByMonth as $introMonthNumber => $introText) {
|
||
$monthLabel = $monthName;
|
||
if ($introMonthNumber !== $monthNumber) {
|
||
$monthLabel = [
|
||
1 => 'Januar',
|
||
2 => 'Februar',
|
||
3 => 'März',
|
||
4 => 'April',
|
||
5 => 'Mai',
|
||
6 => 'Juni',
|
||
7 => 'Juli',
|
||
8 => 'August',
|
||
9 => 'September',
|
||
10 => 'Oktober',
|
||
11 => 'November',
|
||
12 => 'Dezember',
|
||
][$introMonthNumber] ?? $monthName;
|
||
}
|
||
|
||
$skyIntroByMonth[$introMonthNumber] = [
|
||
$introText,
|
||
$monthLabel,
|
||
$monthLabel,
|
||
];
|
||
}
|
||
|
||
$paragraphs = [];
|
||
$monthIntroVariants = $skyIntroByMonth[$monthNumber] ?? [($monthName . ' ' . $year . ' bietet einen abwechslungsreichen Himmel zwischen den jahreszeitlichen Sternbildern.')];
|
||
$monthIntroIndex = (($year * 100) + $monthNumber) % count($monthIntroVariants);
|
||
$paragraphs[] = $monthIntroVariants[$monthIntroIndex];
|
||
|
||
$findEvents = static function (array $events, callable $filter, int $limit = 3): array {
|
||
$matches = [];
|
||
foreach ($events as $event) {
|
||
if ($filter($event)) {
|
||
$matches[] = $event;
|
||
if (count($matches) >= $limit) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return $matches;
|
||
};
|
||
|
||
$describeEvents = static function (array $events): string {
|
||
$parts = [];
|
||
foreach ($events as $event) {
|
||
$label = trim((string) ($event['event'] ?? ''));
|
||
$date = trim((string) ($event['date'] ?? ''));
|
||
if ($label === '' || $date === '') {
|
||
continue;
|
||
}
|
||
$parts[] = $label . ' am ' . $date;
|
||
}
|
||
return implode(', ', $parts);
|
||
};
|
||
|
||
$pickRandomText = static function (array $options, string $fallback): string {
|
||
$options = array_values(array_filter($options, static fn ($option): bool => trim((string) $option) !== ''));
|
||
if ($options === []) {
|
||
return $fallback;
|
||
}
|
||
|
||
try {
|
||
return (string) $options[random_int(0, count($options) - 1)];
|
||
} catch (Throwable) {
|
||
return (string) $options[array_rand($options)];
|
||
}
|
||
};
|
||
|
||
$buildSeasonNarrative = static function (array $event): ?string {
|
||
$label = trim((string) ($event['event'] ?? ''));
|
||
$date = trim((string) ($event['date'] ?? ''));
|
||
if ($label === '' || $date === '') {
|
||
return null;
|
||
}
|
||
|
||
return match ($label) {
|
||
'Fruehlingsanfang' => 'Der astronomische Frühling beginnt am ' . $date . '. Dann überschreitet die Sonne den Himmelsäquator nach Norden; Tag und Nacht sind zu diesem Zeitpunkt annähernd gleich lang.',
|
||
'Sommeranfang' => 'Der astronomische Sommer beginnt am ' . $date . '. Dann erreicht die Sonne ihren nördlichsten Punkt auf der scheinbaren Jahresbahn und steht senkrecht über dem Wendekreis des Krebses.',
|
||
'Herbstanfang' => 'Der astronomische Herbst beginnt am ' . $date . '. Dann überschreitet die Sonne den Himmelsäquator wieder nach Süden; Tag und Nacht sind dabei annähernd gleich lang.',
|
||
'Winteranfang' => 'Der astronomische Winter beginnt am ' . $date . '. Dann erreicht die Sonne ihren südlichsten Punkt auf der scheinbaren Jahresbahn und steht senkrecht über dem Wendekreis des Steinbocks.',
|
||
default => null,
|
||
};
|
||
};
|
||
|
||
$seasonEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => (string) ($event['type'] ?? '') === 'season_change',
|
||
1
|
||
);
|
||
if ($seasonEvents !== []) {
|
||
$seasonNarrative = $buildSeasonNarrative($seasonEvents[0]);
|
||
if ($seasonNarrative !== null) {
|
||
$paragraphs[] = $seasonNarrative;
|
||
}
|
||
}
|
||
|
||
$moonEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['moon_phase', 'golden_handle', 'moon_planet_approach', 'moon_deep_sky_approach', 'moon_occultation', 'moon_apsis'], true),
|
||
4
|
||
);
|
||
if ($moonEvents !== []) {
|
||
$moonIntro = $pickRandomText([
|
||
'Auch der Mond liefert im Laufe des Monats einige markante Beobachtungsmomente.',
|
||
'Der Mond zeigt im Monatsverlauf mehrere gut beobachtbare Phasen und Konstellationen.',
|
||
'Am Mond ergeben sich im Laufe des Monats einige auffällige Termine und Begegnungen.',
|
||
'Der Mond bietet im Monatsverlauf mehrere charakteristische Beobachtungssituationen.',
|
||
], 'Auch der Mond liefert im Laufe des Monats einige markante Beobachtungsmomente.');
|
||
$paragraphs[] = $moonIntro . ' Besonders auffällig sind ' . $describeEvents($moonEvents) . '.';
|
||
}
|
||
|
||
$planetObservationEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['inner_planet_elongation', 'venus_peak_magnitude', 'outer_planet_event', 'planet_conjunction', 'planet_parade', 'mercury_good_visibility'], true),
|
||
5
|
||
);
|
||
if ($planetObservationEvents !== []) {
|
||
$planetIntro = $pickRandomText([
|
||
'Bei den Planeten lohnt sich vor allem der Blick auf',
|
||
'Planetarisch besonders interessant sind in diesem Monat',
|
||
'Bei den Planeten stechen in diesem Monat vor allem',
|
||
'Einen planetaren Schwerpunkt bilden in diesem Monat',
|
||
], 'Bei den Planeten lohnt sich vor allem der Blick auf');
|
||
$planetOutro = $pickRandomText([
|
||
'Diese Termine geben dem Monat seinen charakteristischen planetaren Akzent.',
|
||
'Damit bekommt der Monat einen klaren planetaren Schwerpunkt.',
|
||
'Solche Termine prägen den planetaren Charakter dieses Monats besonders deutlich.',
|
||
'Sie setzen im Monatsverlauf markante planetare Akzente.',
|
||
], 'Diese Termine geben dem Monat seinen charakteristischen planetaren Akzent.');
|
||
$paragraphs[] = $planetIntro . ' ' . $describeEvents($planetObservationEvents) . '. ' . $planetOutro;
|
||
}
|
||
|
||
$planetMotionEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['outer_planet_station', 'planet_constellation_change'], true),
|
||
4
|
||
);
|
||
if ($planetMotionEvents !== []) {
|
||
$planetMotionIntro = $pickRandomText([
|
||
'Im Monatsverlauf zeigen sich bei den Planeten auch auffällige Positionswechsel.',
|
||
'Auch in ihrer Stellung am Himmel verändern sich die Planeten in diesem Monat sichtbar.',
|
||
'Neben den Beobachtungshighlights fallen bei den Planeten auch einige Positionswechsel auf.',
|
||
'Auch die Bewegung der Planeten vor dem Sternhintergrund macht sich in diesem Monat bemerkbar.',
|
||
], 'Im Monatsverlauf zeigen sich bei den Planeten auch auffaellige Positionswechsel.');
|
||
$paragraphs[] = $planetMotionIntro . ' Dazu gehören ' . $describeEvents($planetMotionEvents) . '.';
|
||
}
|
||
|
||
$cometEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['favorite_comet_good_visibility', 'favorite_comet_perihelion', 'favorite_comet_geocentric_minimum'], true),
|
||
4
|
||
);
|
||
if ($cometEvents !== []) {
|
||
$cometIntro = $pickRandomText([
|
||
'Bei den Kometen sind in diesem Monat besonders',
|
||
'Auch bei den Kometen lohnt sich in diesem Monat der Blick auf',
|
||
'Unter den Kometen fallen in diesem Monat vor allem',
|
||
'Kometenfreunde können sich in diesem Monat besonders für',
|
||
], 'Bei den Kometen sind in diesem Monat besonders');
|
||
$cometOutro = $pickRandomText([
|
||
'interessant.',
|
||
'beobachtenswert.',
|
||
'bemerkenswert.',
|
||
'einen genaueren Blick wert.',
|
||
], 'interessant.');
|
||
$paragraphs[] = $cometIntro . ' ' . $describeEvents($cometEvents) . ' ' . $cometOutro;
|
||
}
|
||
|
||
$eclipseEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['eclipse', 'lunar_eclipse', 'solar_eclipse'], true),
|
||
3
|
||
);
|
||
if ($eclipseEvents !== []) {
|
||
$eclipseIntro = $pickRandomText([
|
||
'Besondere Aufmerksamkeit verdienen außerdem die Finsternistermine.',
|
||
'Auch die Finsternisse setzen in diesem Monat markante Akzente.',
|
||
'Ein weiterer Höhepunkt können in diesem Monat die Finsternistermine sein.',
|
||
'Nicht zuletzt lohnt sich in diesem Monat auch ein Blick auf mögliche Finsternisse.',
|
||
], 'Besondere Aufmerksamkeit verdienen ausserdem die Finsternistermine.');
|
||
$paragraphs[] = $eclipseIntro . ' In diesem Monat stechen ' . $describeEvents($eclipseEvents) . ' hervor.';
|
||
}
|
||
|
||
$meteorEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => (string) ($event['type'] ?? '') === 'meteor_shower_peak'
|
||
&& (
|
||
(int) ($event['zhr_min_estimate'] ?? 0) >= 20
|
||
|| (int) ($event['zhr_max_estimate'] ?? 0) >= 30
|
||
),
|
||
2
|
||
);
|
||
if ($meteorEvents !== []) {
|
||
$meteorIntro = $pickRandomText([
|
||
'Freunde von Meteoren sollten zudem die Aktivität der Meteorströme im Blick behalten.',
|
||
'Auch die Meteorströme verdienen in diesem Monat Aufmerksamkeit.',
|
||
'Wer nach Sternschnuppen Ausschau halten möchte, sollte auch die Meteorströme beachten.',
|
||
'Ein Blick auf die Meteorstroeme kann sich in diesem Monat ebenfalls lohnen.',
|
||
], 'Freunde von Meteoren sollten zudem die Aktivitaet der Meteorstroeme im Blick behalten.');
|
||
$paragraphs[] = $meteorIntro . ' Erwähnenswert sind besonders ' . $describeEvents($meteorEvents) . '.';
|
||
} else {
|
||
$meteorEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => (string) ($event['type'] ?? '') === 'meteor_shower_peak'
|
||
&& (
|
||
(int) ($event['zhr_min_estimate'] ?? 0) >= 10
|
||
|| (int) ($event['zhr_max_estimate'] ?? 0) >= 20
|
||
),
|
||
2
|
||
);
|
||
if ($meteorEvents !== []) {
|
||
$meteorModerateIntro = $pickRandomText([
|
||
'Bei den Meteorströmen lohnt in diesem Monat vor allem ein Blick auf',
|
||
'Unter den Meteorströmen verdienen in diesem Monat besonders',
|
||
'Auch bei den Meteorströmen fallen in diesem Monat vor allem',
|
||
'Wer Meteorströme verfolgen möchte, sollte in diesem Monat vor allem auf',
|
||
], 'Bei den Meteorstroemen lohnt in diesem Monat vor allem ein Blick auf');
|
||
$paragraphs[] = $meteorModerateIntro . ' ' . $describeEvents($meteorEvents) . '.';
|
||
}
|
||
}
|
||
|
||
if (count($paragraphs) === 1 && $filteredEvents !== []) {
|
||
$highlights = array_slice($filteredEvents, 0, 4);
|
||
$highlightIntro = $pickRandomText([
|
||
'Zu den auffälligen Terminen des Monats gehören',
|
||
'Unter den markanten Terminen dieses Monats finden sich',
|
||
'Im Monatsverlauf fallen vor allem',
|
||
'Besonders bemerkenswert im Monatskalender sind',
|
||
], 'Zu den auffaelligen Terminen des Monats gehoeren');
|
||
$paragraphs[] = $highlightIntro . ' ' . $describeEvents($highlights) . '.';
|
||
}
|
||
|
||
return implode("\n\n", $paragraphs);
|
||
}
|
||
}
|
||
|
||
try {
|
||
$dbConfig = require __DIR__ . '/../config/database.php';
|
||
$dsn = sprintf(
|
||
'mysql:host=%s;dbname=%s;charset=%s',
|
||
$dbConfig['host'],
|
||
$dbConfig['dbname'],
|
||
$dbConfig['charset'] ?? 'utf8mb4'
|
||
);
|
||
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], [
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||
]);
|
||
|
||
$starStmt = $pdo->query(
|
||
"SELECT hip, proper, bayer, con, spect, mag, ci, rarad, decrad
|
||
FROM star_hipparcos
|
||
WHERE mag IS NOT NULL
|
||
AND rarad IS NOT NULL
|
||
AND decrad IS NOT NULL
|
||
AND mag <= " . (float) $starDatabaseMagnitudeLimit . "
|
||
ORDER BY mag ASC"
|
||
);
|
||
|
||
while ($row = $starStmt->fetch()) {
|
||
$starData[] = [
|
||
'hip' => $row['hip'] !== null ? (int) $row['hip'] : null,
|
||
'proper' => trim((string) ($row['proper'] ?? '')),
|
||
'bayer' => trim((string) ($row['bayer'] ?? '')),
|
||
'con' => trim((string) ($row['con'] ?? '')),
|
||
'spect' => trim((string) ($row['spect'] ?? '')),
|
||
'ra' => (float) $row['rarad'],
|
||
'dec' => (float) $row['decrad'],
|
||
'mag' => (float) $row['mag'],
|
||
'ci' => $row['ci'] !== null ? (float) $row['ci'] : null,
|
||
];
|
||
}
|
||
|
||
$constellationIndex = [];
|
||
$ensureConstellation = static function (string $id) use (&$constellationIndex): void {
|
||
if (!isset($constellationIndex[$id])) {
|
||
$constellationIndex[$id] = [
|
||
'id' => $id,
|
||
'lines' => [],
|
||
'label_positions' => [],
|
||
'common_name' => [
|
||
'german' => null,
|
||
'native' => null,
|
||
'byname' => null,
|
||
],
|
||
];
|
||
}
|
||
};
|
||
|
||
$nameStmt = $pdo->query(
|
||
"SELECT constellation_id, german_name, native_name, byname
|
||
FROM constellation_names
|
||
ORDER BY constellation_id"
|
||
);
|
||
foreach ($nameStmt as $row) {
|
||
$constellationId = (string) $row['constellation_id'];
|
||
$ensureConstellation($constellationId);
|
||
$constellationIndex[$constellationId]['common_name'] = [
|
||
'german' => $row['german_name'] !== null ? (string) $row['german_name'] : null,
|
||
'native' => $row['native_name'] !== null ? (string) $row['native_name'] : null,
|
||
'byname' => $row['byname'] !== null ? (string) $row['byname'] : null,
|
||
];
|
||
}
|
||
|
||
$labelStmt = $pdo->query(
|
||
"SELECT constellation_id, position_index, ra_hours, dec_degrees
|
||
FROM constellation_label_positions
|
||
ORDER BY constellation_id, position_index"
|
||
);
|
||
foreach ($labelStmt as $row) {
|
||
$constellationId = (string) $row['constellation_id'];
|
||
$ensureConstellation($constellationId);
|
||
$constellationIndex[$constellationId]['label_positions'][] = [
|
||
(float) $row['ra_hours'],
|
||
(float) $row['dec_degrees'],
|
||
];
|
||
}
|
||
|
||
$lineStmt = $pdo->query(
|
||
"SELECT cl.constellation_id, cl.line_index, clp.point_index, clp.hip_number
|
||
FROM constellation_lines cl
|
||
INNER JOIN constellation_line_points clp ON clp.line_id = cl.id
|
||
ORDER BY cl.constellation_id, cl.line_index, clp.point_index"
|
||
);
|
||
|
||
foreach ($lineStmt as $row) {
|
||
$constellationId = (string) $row['constellation_id'];
|
||
$lineIndex = (int) $row['line_index'];
|
||
|
||
$ensureConstellation($constellationId);
|
||
|
||
$constellationIndex[$constellationId]['lines'][$lineIndex] ??= [];
|
||
$constellationIndex[$constellationId]['lines'][$lineIndex][] = (int) $row['hip_number'];
|
||
}
|
||
|
||
foreach ($constellationIndex as &$constellation) {
|
||
if ($constellation['lines']) {
|
||
ksort($constellation['lines']);
|
||
$constellation['lines'] = array_values($constellation['lines']);
|
||
}
|
||
}
|
||
unset($constellation);
|
||
|
||
$constellationLines = array_values($constellationIndex);
|
||
|
||
$lineStarStmt = $pdo->query(
|
||
"SELECT DISTINCT sh.hip, sh.proper, sh.bayer, sh.con, sh.spect, sh.mag, sh.ci, sh.rarad, sh.decrad
|
||
FROM constellation_line_points clp
|
||
INNER JOIN star_hipparcos sh ON sh.hip = clp.hip_number
|
||
WHERE sh.rarad IS NOT NULL
|
||
AND sh.decrad IS NOT NULL"
|
||
);
|
||
|
||
while ($row = $lineStarStmt->fetch()) {
|
||
$lineStarData[] = [
|
||
'hip' => $row['hip'] !== null ? (int) $row['hip'] : null,
|
||
'proper' => trim((string) ($row['proper'] ?? '')),
|
||
'bayer' => trim((string) ($row['bayer'] ?? '')),
|
||
'con' => trim((string) ($row['con'] ?? '')),
|
||
'spect' => trim((string) ($row['spect'] ?? '')),
|
||
'ra' => (float) $row['rarad'],
|
||
'dec' => (float) $row['decrad'],
|
||
'mag' => $row['mag'] !== null ? (float) $row['mag'] : 99.0,
|
||
'ci' => $row['ci'] !== null ? (float) $row['ci'] : null,
|
||
];
|
||
}
|
||
|
||
$messierStmt = $pdo->query(
|
||
"SELECT messier, name, object_type_abrev, ra_deg, dec_deg, v_mag, major_axis
|
||
FROM dso_objects
|
||
WHERE messier IS NOT NULL
|
||
AND messier <> ''
|
||
AND ra_deg IS NOT NULL
|
||
AND dec_deg IS NOT NULL
|
||
AND v_mag IS NOT NULL
|
||
AND v_mag <= " . (float) $messierDatabaseMagnitudeLimit . "
|
||
ORDER BY v_mag ASC, CAST(REGEXP_REPLACE(messier, '[^0-9]', '') AS UNSIGNED)"
|
||
);
|
||
|
||
while ($row = $messierStmt->fetch()) {
|
||
if (!preg_match('/(\d+)/', (string) $row['messier'], $match)) {
|
||
continue;
|
||
}
|
||
|
||
$messierData[] = [
|
||
'm' => (int) $match[1],
|
||
'name' => trim((string) ($row['name'] ?? '')),
|
||
'type' => trim((string) ($row['object_type_abrev'] ?? 'As')),
|
||
'ra' => ((float) $row['ra_deg']) / 15.0,
|
||
'dec' => (float) $row['dec_deg'],
|
||
'mag' => (float) $row['v_mag'],
|
||
'size' => $row['major_axis'] !== null ? (float) $row['major_axis'] : 8.0,
|
||
];
|
||
}
|
||
|
||
$meteorStmt = $pdo->query(
|
||
"SELECT designation, shower_code, radiant_delta_deg, peak_sol, zhr, zhr_variable
|
||
FROM meteorshowers
|
||
WHERE peak_sol IS NOT NULL
|
||
ORDER BY peak_sol ASC, designation ASC"
|
||
);
|
||
|
||
while ($row = $meteorStmt->fetch()) {
|
||
$meteorShowerPeaks[] = [
|
||
'designation' => trim((string) ($row['designation'] ?? '')),
|
||
'shower_code' => trim((string) ($row['shower_code'] ?? '')),
|
||
'radiant_delta_deg' => $row['radiant_delta_deg'] !== null ? (float) $row['radiant_delta_deg'] : null,
|
||
'peak_sol' => (float) $row['peak_sol'],
|
||
'zhr' => $row['zhr'] !== null ? (int) $row['zhr'] : null,
|
||
'zhr_variable' => trim((string) ($row['zhr_variable'] ?? '')),
|
||
];
|
||
}
|
||
|
||
if ($loggedIn && isset($_SESSION['user_id'])) {
|
||
$favoriteCometStmt = $pdo->prepare(
|
||
"SELECT c.id, c.designation_and_name, c.designation_packed, c.orbit_type,
|
||
c.year_of_perihelion, c.month_of_perihelion, c.day_of_perihelion,
|
||
c.perihelion_dist_au, c.eccentricity, c.arg_perihelion_deg,
|
||
c.ascending_node_deg, c.inclination_deg, c.absolute_magnitude_h,
|
||
c.slope_parameter_g
|
||
FROM app_user_comets uc
|
||
INNER JOIN comets_mpc c ON c.id = uc.comet_id
|
||
WHERE uc.user_id = :user_id
|
||
ORDER BY c.designation_and_name ASC, c.id ASC"
|
||
);
|
||
$favoriteCometStmt->execute([
|
||
':user_id' => (int) $_SESSION['user_id'],
|
||
]);
|
||
foreach ($favoriteCometStmt->fetchAll() as $favoriteCometRow) {
|
||
$favoriteCometPayload[] = [
|
||
'id' => (int) ($favoriteCometRow['id'] ?? 0),
|
||
'designation_and_name' => trim((string) ($favoriteCometRow['designation_and_name'] ?? '')),
|
||
'designation_packed' => trim((string) ($favoriteCometRow['designation_packed'] ?? '')),
|
||
'orbit_type' => trim((string) ($favoriteCometRow['orbit_type'] ?? '')),
|
||
'year_of_perihelion' => (int) ($favoriteCometRow['year_of_perihelion'] ?? 0),
|
||
'month_of_perihelion' => (int) ($favoriteCometRow['month_of_perihelion'] ?? 0),
|
||
'day_of_perihelion' => (float) ($favoriteCometRow['day_of_perihelion'] ?? 0.0),
|
||
'perihelion_dist_au' => (float) ($favoriteCometRow['perihelion_dist_au'] ?? 0.0),
|
||
'eccentricity' => (float) ($favoriteCometRow['eccentricity'] ?? 0.0),
|
||
'arg_perihelion_deg' => (float) ($favoriteCometRow['arg_perihelion_deg'] ?? 0.0),
|
||
'ascending_node_deg' => (float) ($favoriteCometRow['ascending_node_deg'] ?? 0.0),
|
||
'inclination_deg' => (float) ($favoriteCometRow['inclination_deg'] ?? 0.0),
|
||
'absolute_magnitude_h' => $favoriteCometRow['absolute_magnitude_h'] !== null ? (float) $favoriteCometRow['absolute_magnitude_h'] : null,
|
||
'slope_parameter_g' => $favoriteCometRow['slope_parameter_g'] !== null ? (float) $favoriteCometRow['slope_parameter_g'] : null,
|
||
'is_favorite' => true,
|
||
];
|
||
}
|
||
|
||
$favoriteSatelliteStmt = $pdo->prepare(
|
||
"SELECT s.id, s.norad_cat_id, s.object_name, s.object_id, s.classification_type,
|
||
s.std_mag, s.rcs_size, s.rcs,
|
||
t.epoch, t.mean_motion, t.eccentricity, t.inclination,
|
||
t.ra_of_asc_node, t.arg_of_pericenter, t.mean_anomaly,
|
||
t.ephemeris_type, t.element_set_no, t.rev_at_epoch,
|
||
t.bstar, t.mean_motion_dot, t.mean_motion_ddot
|
||
FROM app_user_satellites us
|
||
INNER JOIN sat_satellites s ON s.id = us.satellite_id
|
||
INNER JOIN sat_tle_current t ON t.satellite_id = s.id
|
||
WHERE us.user_id = :user_id
|
||
AND us.is_favorite = 1
|
||
AND t.epoch IS NOT NULL
|
||
AND t.mean_motion IS NOT NULL
|
||
ORDER BY s.object_name ASC
|
||
LIMIT 1"
|
||
);
|
||
$favoriteSatelliteStmt->execute([
|
||
':user_id' => (int) $_SESSION['user_id'],
|
||
]);
|
||
$favoriteSatelliteRow = $favoriteSatelliteStmt->fetch();
|
||
if (is_array($favoriteSatelliteRow)) {
|
||
$favoriteSatelliteName = trim((string) ($favoriteSatelliteRow['object_name'] ?? ''));
|
||
$favoriteSatellitePayload = [[
|
||
'id' => (int) ($favoriteSatelliteRow['id'] ?? 0),
|
||
'norad_cat_id' => (int) ($favoriteSatelliteRow['norad_cat_id'] ?? 0),
|
||
'object_name' => $favoriteSatelliteName,
|
||
'is_favorite' => true,
|
||
'std_mag' => $favoriteSatelliteRow['std_mag'] !== null ? (float) $favoriteSatelliteRow['std_mag'] : null,
|
||
'rcs_size' => $favoriteSatelliteRow['rcs_size'] !== null ? (string) $favoriteSatelliteRow['rcs_size'] : null,
|
||
'rcs' => $favoriteSatelliteRow['rcs'] !== null && (float) $favoriteSatelliteRow['rcs'] > 0 ? (float) $favoriteSatelliteRow['rcs'] : null,
|
||
'tle_line1' => monthForecastBuildTleLine1($favoriteSatelliteRow, $favoriteSatelliteRow),
|
||
'tle_line2' => monthForecastBuildTleLine2($favoriteSatelliteRow, $favoriteSatelliteRow),
|
||
]];
|
||
}
|
||
}
|
||
} catch (Throwable $e) {
|
||
$starDataError = $e->getMessage();
|
||
}
|
||
|
||
require __DIR__ . '/header.php';
|
||
|
||
$chartLocationName = (string) (($currentLocation['name'] ?? null) ?: 'Sternwarte Sonneberg');
|
||
$chartLatitude = isset($currentLocation['latitude']) ? (float) $currentLocation['latitude'] : 50.3547;
|
||
$chartLongitude = isset($currentLocation['longitude']) ? (float) $currentLocation['longitude'] : 11.1900;
|
||
$chartElevation = isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float) $currentLocation['elevation'] : 640.0;
|
||
$chartTimezone = (string) (($currentLocation['timezone'] ?? null) ?: 'Europe/Berlin');
|
||
|
||
$monthTime = $monthSkyTimes[$selectedMonth];
|
||
$chartLocalTime = new DateTimeImmutable(
|
||
sprintf('%04d-%02d-15 %02d:%02d:00', $selectedYear, $selectedMonth, $monthTime['hour'], $monthTime['minute']),
|
||
new DateTimeZone($chartTimezone)
|
||
);
|
||
$chartUtcTime = $chartLocalTime->setTimezone(new DateTimeZone('UTC'));
|
||
$monthStartLocal = new DateTimeImmutable(
|
||
sprintf('%04d-%02d-01 00:00:00', $selectedYear, $selectedMonth),
|
||
new DateTimeZone($chartTimezone)
|
||
);
|
||
$monthEndLocal = $monthStartLocal->modify('first day of next month');
|
||
|
||
$pythonExecutable = PHP_OS_FAMILY === 'Windows' ? 'python' : '/usr/bin/python';
|
||
$pythonScriptPath = __DIR__ . '/py/api.py';
|
||
$pythonOutput = [];
|
||
$pythonExitCode = 0;
|
||
$planetErrorText = null;
|
||
|
||
$pythonArgs = [
|
||
$pythonScriptPath,
|
||
'month_sky_context',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
(string) $monthTime['hour'],
|
||
(string) $monthTime['minute'],
|
||
$chartTimezone,
|
||
];
|
||
|
||
$pythonCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $pythonArgs)) . ' 2>&1';
|
||
exec($pythonCommand, $pythonOutput, $pythonExitCode);
|
||
$pythonRaw = trim(implode("\n", $pythonOutput));
|
||
$pythonDecoded = json_decode($pythonRaw, true);
|
||
|
||
if (is_array($pythonDecoded) && ($pythonDecoded['ok'] ?? false)) {
|
||
if (is_array($pythonDecoded['bodies']['moon'] ?? null)) {
|
||
$moon = $pythonDecoded['bodies']['moon'];
|
||
$moonData = [
|
||
'label' => (string) ($moon['label'] ?? 'Mond'),
|
||
'az' => isset($moon['azimuth_deg']) ? (float) $moon['azimuth_deg'] : null,
|
||
'alt' => isset($moon['altitude_deg']) ? (float) $moon['altitude_deg'] : null,
|
||
'visible' => !empty($moon['visible']),
|
||
'phaseFraction' => isset($moon['phase_fraction']) ? (float) $moon['phase_fraction'] : null,
|
||
'waxing' => isset($moon['waxing']) ? (bool) $moon['waxing'] : null,
|
||
];
|
||
}
|
||
foreach (($pythonDecoded['bodies']['planets'] ?? []) as $planet) {
|
||
if (!is_array($planet)) {
|
||
continue;
|
||
}
|
||
$planetData[] = [
|
||
'key' => (string) ($planet['key'] ?? ''),
|
||
'label' => (string) ($planet['label'] ?? ''),
|
||
'az' => isset($planet['azimuth_deg']) ? (float) $planet['azimuth_deg'] : null,
|
||
'alt' => isset($planet['altitude_deg']) ? (float) $planet['altitude_deg'] : null,
|
||
'mag' => isset($planet['magnitude']) ? (float) $planet['magnitude'] : null,
|
||
'visible' => !empty($planet['visible']),
|
||
];
|
||
}
|
||
} elseif ($pythonRaw !== '') {
|
||
$planetErrorText = $pythonRaw;
|
||
}
|
||
|
||
if (!empty($favoriteSatellitePayload)) {
|
||
$favoriteSatellitePassOutput = [];
|
||
$favoriteSatellitePassArgs = [
|
||
$pythonScriptPath,
|
||
'satellite_passes',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
$chartTimezone,
|
||
$monthStartLocal->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'),
|
||
$monthEndLocal->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'),
|
||
base64_encode((string) json_encode($favoriteSatellitePayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)),
|
||
];
|
||
$favoriteSatellitePassCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $favoriteSatellitePassArgs)) . ' 2>&1';
|
||
exec($favoriteSatellitePassCommand, $favoriteSatellitePassOutput);
|
||
$favoriteSatellitePassRaw = trim(implode("\n", $favoriteSatellitePassOutput));
|
||
$favoriteSatellitePassDecoded = json_decode($favoriteSatellitePassRaw, true);
|
||
|
||
if (is_array($favoriteSatellitePassDecoded) && ($favoriteSatellitePassDecoded['ok'] ?? false)) {
|
||
$favoriteSatelliteResults = is_array($favoriteSatellitePassDecoded['results'] ?? null)
|
||
? $favoriteSatellitePassDecoded['results']
|
||
: [];
|
||
$favoriteSatellitePasses = is_array($favoriteSatelliteResults[0]['passes'] ?? null)
|
||
? $favoriteSatelliteResults[0]['passes']
|
||
: [];
|
||
}
|
||
}
|
||
|
||
if (!empty($favoriteCometPayload)) {
|
||
$tempFavoriteCometFile = tempnam(sys_get_temp_dir(), 'month_comets_');
|
||
if ($tempFavoriteCometFile !== false) {
|
||
try {
|
||
file_put_contents($tempFavoriteCometFile, json_encode($favoriteCometPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||
$favoriteCometOutput = [];
|
||
$favoriteCometArgs = [
|
||
$pythonScriptPath,
|
||
'favorite_comet_events_for_month',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
(string) $favoriteCometVisibilitySampleOffsetMinutes,
|
||
(string) $favoriteCometVisibilityMinAltitudeDeg,
|
||
(string) $favoriteCometVisibilityMaxSunAltitudeDeg,
|
||
(string) $favoriteCometVisibilityMaxMagnitude,
|
||
(string) $favoriteCometVisibilityMinConsecutiveDays,
|
||
'@' . $tempFavoriteCometFile,
|
||
];
|
||
$favoriteCometCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $favoriteCometArgs)) . ' 2>&1';
|
||
exec($favoriteCometCommand, $favoriteCometOutput);
|
||
$favoriteCometRaw = trim(implode("\n", $favoriteCometOutput));
|
||
$favoriteCometDecoded = json_decode($favoriteCometRaw, true);
|
||
if (is_array($favoriteCometDecoded) && ($favoriteCometDecoded['ok'] ?? false)) {
|
||
$favoriteCometEvents = is_array($favoriteCometDecoded['events'] ?? null) ? $favoriteCometDecoded['events'] : [];
|
||
}
|
||
} catch (Throwable $e) {
|
||
} finally {
|
||
if (is_file($tempFavoriteCometFile)) {
|
||
@unlink($tempFavoriteCometFile);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
$moonPhaseOutput = [];
|
||
$moonPhaseExitCode = 0;
|
||
$moonPhaseArgs = [
|
||
$pythonScriptPath,
|
||
'moon_phases_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$moonPhaseCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonPhaseArgs)) . ' 2>&1';
|
||
exec($moonPhaseCommand, $moonPhaseOutput, $moonPhaseExitCode);
|
||
$moonPhaseRaw = trim(implode("\n", $moonPhaseOutput));
|
||
$moonPhaseDecoded = json_decode($moonPhaseRaw, true);
|
||
if (is_array($moonPhaseDecoded) && ($moonPhaseDecoded['ok'] ?? false)) {
|
||
$moonPhaseEvents = is_array($moonPhaseDecoded['phases'] ?? null) ? $moonPhaseDecoded['phases'] : [];
|
||
}
|
||
|
||
$goldenHandleOutput = [];
|
||
$goldenHandleExitCode = 0;
|
||
$goldenHandleArgs = [
|
||
$pythonScriptPath,
|
||
'golden_handle_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$goldenHandleCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $goldenHandleArgs)) . ' 2>&1';
|
||
exec($goldenHandleCommand, $goldenHandleOutput, $goldenHandleExitCode);
|
||
$goldenHandleRaw = trim(implode("\n", $goldenHandleOutput));
|
||
$goldenHandleDecoded = json_decode($goldenHandleRaw, true);
|
||
if (is_array($goldenHandleDecoded) && ($goldenHandleDecoded['ok'] ?? false)) {
|
||
$goldenHandleEvents = is_array($goldenHandleDecoded['events'] ?? null) ? $goldenHandleDecoded['events'] : [];
|
||
}
|
||
|
||
$seasonOutput = [];
|
||
$seasonExitCode = 0;
|
||
$seasonArgs = [
|
||
$pythonScriptPath,
|
||
'season_changes_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$seasonCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $seasonArgs)) . ' 2>&1';
|
||
exec($seasonCommand, $seasonOutput, $seasonExitCode);
|
||
$seasonRaw = trim(implode("\n", $seasonOutput));
|
||
$seasonDecoded = json_decode($seasonRaw, true);
|
||
if (is_array($seasonDecoded) && ($seasonDecoded['ok'] ?? false)) {
|
||
$seasonChanges = is_array($seasonDecoded['changes'] ?? null) ? $seasonDecoded['changes'] : [];
|
||
}
|
||
|
||
$timeChangeOutput = [];
|
||
$timeChangeExitCode = 0;
|
||
$timeChangeArgs = [
|
||
$pythonScriptPath,
|
||
'time_changes_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$timeChangeCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $timeChangeArgs)) . ' 2>&1';
|
||
exec($timeChangeCommand, $timeChangeOutput, $timeChangeExitCode);
|
||
$timeChangeRaw = trim(implode("\n", $timeChangeOutput));
|
||
$timeChangeDecoded = json_decode($timeChangeRaw, true);
|
||
if (is_array($timeChangeDecoded) && ($timeChangeDecoded['ok'] ?? false)) {
|
||
$timeChanges = is_array($timeChangeDecoded['events'] ?? null) ? $timeChangeDecoded['events'] : [];
|
||
}
|
||
|
||
$eclipseOutput = [];
|
||
$eclipseExitCode = 0;
|
||
$eclipseArgs = [
|
||
$pythonScriptPath,
|
||
'eclipses_for_month',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$eclipseCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $eclipseArgs)) . ' 2>&1';
|
||
exec($eclipseCommand, $eclipseOutput, $eclipseExitCode);
|
||
$eclipseRaw = trim(implode("\n", $eclipseOutput));
|
||
$eclipseDecoded = json_decode($eclipseRaw, true);
|
||
if (is_array($eclipseDecoded) && ($eclipseDecoded['ok'] ?? false)) {
|
||
$eclipseEvents = is_array($eclipseDecoded['events'] ?? null) ? $eclipseDecoded['events'] : [];
|
||
}
|
||
|
||
$moonApsisOutput = [];
|
||
$moonApsisExitCode = 0;
|
||
$moonApsisArgs = [
|
||
$pythonScriptPath,
|
||
'moon_apsides_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$moonApsisCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonApsisArgs)) . ' 2>&1';
|
||
exec($moonApsisCommand, $moonApsisOutput, $moonApsisExitCode);
|
||
$moonApsisRaw = trim(implode("\n", $moonApsisOutput));
|
||
$moonApsisDecoded = json_decode($moonApsisRaw, true);
|
||
if (is_array($moonApsisDecoded) && ($moonApsisDecoded['ok'] ?? false)) {
|
||
$moonApsisEvents = is_array($moonApsisDecoded['events'] ?? null) ? $moonApsisDecoded['events'] : [];
|
||
}
|
||
|
||
$sunApsisOutput = [];
|
||
$sunApsisExitCode = 0;
|
||
$sunApsisArgs = [
|
||
$pythonScriptPath,
|
||
'sun_apsides_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$sunApsisCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $sunApsisArgs)) . ' 2>&1';
|
||
exec($sunApsisCommand, $sunApsisOutput, $sunApsisExitCode);
|
||
$sunApsisRaw = trim(implode("\n", $sunApsisOutput));
|
||
$sunApsisDecoded = json_decode($sunApsisRaw, true);
|
||
if (is_array($sunApsisDecoded) && ($sunApsisDecoded['ok'] ?? false)) {
|
||
$sunApsisEvents = is_array($sunApsisDecoded['events'] ?? null) ? $sunApsisDecoded['events'] : [];
|
||
}
|
||
|
||
$elongationOutput = [];
|
||
$elongationExitCode = 0;
|
||
$elongationArgs = [
|
||
$pythonScriptPath,
|
||
'inner_planet_elongations_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$elongationCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $elongationArgs)) . ' 2>&1';
|
||
exec($elongationCommand, $elongationOutput, $elongationExitCode);
|
||
$elongationRaw = trim(implode("\n", $elongationOutput));
|
||
$elongationDecoded = json_decode($elongationRaw, true);
|
||
if (is_array($elongationDecoded) && ($elongationDecoded['ok'] ?? false)) {
|
||
$innerPlanetElongationEvents = is_array($elongationDecoded['events'] ?? null) ? $elongationDecoded['events'] : [];
|
||
}
|
||
|
||
$venusPeakMagnitudeOutput = [];
|
||
$venusPeakMagnitudeExitCode = 0;
|
||
$venusPeakMagnitudeArgs = [
|
||
$pythonScriptPath,
|
||
'venus_peak_magnitude_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$venusPeakMagnitudeCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $venusPeakMagnitudeArgs)) . ' 2>&1';
|
||
exec($venusPeakMagnitudeCommand, $venusPeakMagnitudeOutput, $venusPeakMagnitudeExitCode);
|
||
$venusPeakMagnitudeRaw = trim(implode("\n", $venusPeakMagnitudeOutput));
|
||
$venusPeakMagnitudeDecoded = json_decode($venusPeakMagnitudeRaw, true);
|
||
if (is_array($venusPeakMagnitudeDecoded) && ($venusPeakMagnitudeDecoded['ok'] ?? false)) {
|
||
$venusPeakMagnitudeEvents = is_array($venusPeakMagnitudeDecoded['events'] ?? null) ? $venusPeakMagnitudeDecoded['events'] : [];
|
||
}
|
||
|
||
$outerPlanetOutput = [];
|
||
$outerPlanetExitCode = 0;
|
||
$outerPlanetArgs = [
|
||
$pythonScriptPath,
|
||
'outer_planet_events_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$outerPlanetCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $outerPlanetArgs)) . ' 2>&1';
|
||
exec($outerPlanetCommand, $outerPlanetOutput, $outerPlanetExitCode);
|
||
$outerPlanetRaw = trim(implode("\n", $outerPlanetOutput));
|
||
$outerPlanetDecoded = json_decode($outerPlanetRaw, true);
|
||
if (is_array($outerPlanetDecoded) && ($outerPlanetDecoded['ok'] ?? false)) {
|
||
$outerPlanetEvents = is_array($outerPlanetDecoded['events'] ?? null) ? $outerPlanetDecoded['events'] : [];
|
||
}
|
||
|
||
$outerPlanetStationOutput = [];
|
||
$outerPlanetStationExitCode = 0;
|
||
$outerPlanetStationArgs = [
|
||
$pythonScriptPath,
|
||
'outer_planet_stations_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$outerPlanetStationCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $outerPlanetStationArgs)) . ' 2>&1';
|
||
exec($outerPlanetStationCommand, $outerPlanetStationOutput, $outerPlanetStationExitCode);
|
||
$outerPlanetStationRaw = trim(implode("\n", $outerPlanetStationOutput));
|
||
$outerPlanetStationDecoded = json_decode($outerPlanetStationRaw, true);
|
||
if (is_array($outerPlanetStationDecoded) && ($outerPlanetStationDecoded['ok'] ?? false)) {
|
||
$outerPlanetStationEvents = is_array($outerPlanetStationDecoded['events'] ?? null) ? $outerPlanetStationDecoded['events'] : [];
|
||
}
|
||
|
||
$jupiterMoonSideOutput = [];
|
||
$jupiterMoonSideExitCode = 0;
|
||
$jupiterMoonSideArgs = [
|
||
$pythonScriptPath,
|
||
'jupiter_moons_one_side_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$jupiterMoonSideCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $jupiterMoonSideArgs)) . ' 2>&1';
|
||
exec($jupiterMoonSideCommand, $jupiterMoonSideOutput, $jupiterMoonSideExitCode);
|
||
$jupiterMoonSideRaw = trim(implode("\n", $jupiterMoonSideOutput));
|
||
$jupiterMoonSideDecoded = json_decode($jupiterMoonSideRaw, true);
|
||
if (is_array($jupiterMoonSideDecoded) && ($jupiterMoonSideDecoded['ok'] ?? false)) {
|
||
$jupiterMoonSideEvents = is_array($jupiterMoonSideDecoded['events'] ?? null) ? $jupiterMoonSideDecoded['events'] : [];
|
||
}
|
||
|
||
$moonPlanetOutput = [];
|
||
$moonPlanetExitCode = 0;
|
||
$moonPlanetArgs = [
|
||
$pythonScriptPath,
|
||
'moon_planet_approaches',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
(string) $moonPlanetMaxSeparationDeg,
|
||
];
|
||
$moonPlanetCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonPlanetArgs)) . ' 2>&1';
|
||
exec($moonPlanetCommand, $moonPlanetOutput, $moonPlanetExitCode);
|
||
$moonPlanetRaw = trim(implode("\n", $moonPlanetOutput));
|
||
$moonPlanetDecoded = json_decode($moonPlanetRaw, true);
|
||
if (is_array($moonPlanetDecoded) && ($moonPlanetDecoded['ok'] ?? false)) {
|
||
$moonPlanetApproaches = is_array($moonPlanetDecoded['approaches'] ?? null) ? $moonPlanetDecoded['approaches'] : [];
|
||
}
|
||
|
||
$moonDeepSkyOutput = [];
|
||
$moonDeepSkyExitCode = 0;
|
||
$moonDeepSkyArgs = [
|
||
$pythonScriptPath,
|
||
'moon_deep_sky_approaches_for_month',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
(string) $moonDeepSkyMaxSeparationDeg,
|
||
];
|
||
$moonDeepSkyCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonDeepSkyArgs)) . ' 2>&1';
|
||
exec($moonDeepSkyCommand, $moonDeepSkyOutput, $moonDeepSkyExitCode);
|
||
$moonDeepSkyRaw = trim(implode("\n", $moonDeepSkyOutput));
|
||
$moonDeepSkyDecoded = json_decode($moonDeepSkyRaw, true);
|
||
if (is_array($moonDeepSkyDecoded) && ($moonDeepSkyDecoded['ok'] ?? false)) {
|
||
$moonDeepSkyApproaches = is_array($moonDeepSkyDecoded['approaches'] ?? null) ? $moonDeepSkyDecoded['approaches'] : [];
|
||
}
|
||
|
||
$planetBrightStarOutput = [];
|
||
$planetBrightStarExitCode = 0;
|
||
$planetBrightStarArgs = [
|
||
$pythonScriptPath,
|
||
'planet_bright_star_approaches_for_month',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
(string) $planetBrightStarMaxSeparationDeg,
|
||
];
|
||
$planetBrightStarCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $planetBrightStarArgs)) . ' 2>&1';
|
||
exec($planetBrightStarCommand, $planetBrightStarOutput, $planetBrightStarExitCode);
|
||
$planetBrightStarRaw = trim(implode("\n", $planetBrightStarOutput));
|
||
$planetBrightStarDecoded = json_decode($planetBrightStarRaw, true);
|
||
if (is_array($planetBrightStarDecoded) && ($planetBrightStarDecoded['ok'] ?? false)) {
|
||
$planetBrightStarApproaches = is_array($planetBrightStarDecoded['approaches'] ?? null) ? $planetBrightStarDecoded['approaches'] : [];
|
||
}
|
||
|
||
$planetConjunctionOutput = [];
|
||
$planetConjunctionExitCode = 0;
|
||
$planetConjunctionArgs = [
|
||
$pythonScriptPath,
|
||
'planet_conjunctions_for_month',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
(string) $planetConjunctionMaxSeparationDeg,
|
||
];
|
||
$planetConjunctionCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $planetConjunctionArgs)) . ' 2>&1';
|
||
exec($planetConjunctionCommand, $planetConjunctionOutput, $planetConjunctionExitCode);
|
||
$planetConjunctionRaw = trim(implode("\n", $planetConjunctionOutput));
|
||
$planetConjunctionDecoded = json_decode($planetConjunctionRaw, true);
|
||
if (is_array($planetConjunctionDecoded) && ($planetConjunctionDecoded['ok'] ?? false)) {
|
||
$planetConjunctions = is_array($planetConjunctionDecoded['conjunctions'] ?? null) ? $planetConjunctionDecoded['conjunctions'] : [];
|
||
}
|
||
|
||
$mercuryGoodVisibilityOutput = [];
|
||
$mercuryGoodVisibilityExitCode = 0;
|
||
$mercuryGoodVisibilityArgs = [
|
||
$pythonScriptPath,
|
||
'mercury_good_visibility_for_month',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
(string) $mercuryGoodVisibilitySampleOffsetMinutes,
|
||
(string) $mercuryGoodVisibilityMinAltitudeDeg,
|
||
(string) $mercuryGoodVisibilityMaxSunAltitudeDeg,
|
||
(string) $mercuryGoodVisibilityMinConsecutiveDays,
|
||
];
|
||
$mercuryGoodVisibilityCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $mercuryGoodVisibilityArgs)) . ' 2>&1';
|
||
exec($mercuryGoodVisibilityCommand, $mercuryGoodVisibilityOutput, $mercuryGoodVisibilityExitCode);
|
||
$mercuryGoodVisibilityRaw = trim(implode("\n", $mercuryGoodVisibilityOutput));
|
||
$mercuryGoodVisibilityDecoded = json_decode($mercuryGoodVisibilityRaw, true);
|
||
if (is_array($mercuryGoodVisibilityDecoded) && ($mercuryGoodVisibilityDecoded['ok'] ?? false)) {
|
||
$mercuryGoodVisibilityEvents = is_array($mercuryGoodVisibilityDecoded['events'] ?? null) ? $mercuryGoodVisibilityDecoded['events'] : [];
|
||
}
|
||
|
||
$planetParadeOutput = [];
|
||
$planetParadeExitCode = 0;
|
||
$planetParadeArgs = [
|
||
$pythonScriptPath,
|
||
'planet_parades_for_month',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
(string) $planetParadeSampleOffsetMinutes,
|
||
(string) $planetParadeMinAltitudeDeg,
|
||
(string) $planetParadeMaxSunAltitudeDeg,
|
||
(string) $planetParadeMaxAzimuthSpanDeg,
|
||
(string) $planetParadeMinPlanetCount,
|
||
(string) $planetParadeMinConsecutiveDays,
|
||
];
|
||
$planetParadeCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $planetParadeArgs)) . ' 2>&1';
|
||
exec($planetParadeCommand, $planetParadeOutput, $planetParadeExitCode);
|
||
$planetParadeRaw = trim(implode("\n", $planetParadeOutput));
|
||
$planetParadeDecoded = json_decode($planetParadeRaw, true);
|
||
if (is_array($planetParadeDecoded) && ($planetParadeDecoded['ok'] ?? false)) {
|
||
$planetParadeEvents = is_array($planetParadeDecoded['events'] ?? null) ? $planetParadeDecoded['events'] : [];
|
||
}
|
||
|
||
$planetConstellationChangeOutput = [];
|
||
$planetConstellationChangeExitCode = 0;
|
||
$planetConstellationChangeArgs = [
|
||
$pythonScriptPath,
|
||
'planet_constellation_changes_for_month',
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$planetConstellationChangeCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $planetConstellationChangeArgs)) . ' 2>&1';
|
||
exec($planetConstellationChangeCommand, $planetConstellationChangeOutput, $planetConstellationChangeExitCode);
|
||
$planetConstellationChangeRaw = trim(implode("\n", $planetConstellationChangeOutput));
|
||
$planetConstellationChangeDecoded = json_decode($planetConstellationChangeRaw, true);
|
||
if (is_array($planetConstellationChangeDecoded) && ($planetConstellationChangeDecoded['ok'] ?? false)) {
|
||
$planetConstellationChangeEvents = is_array($planetConstellationChangeDecoded['events'] ?? null) ? $planetConstellationChangeDecoded['events'] : [];
|
||
}
|
||
|
||
$moonOccultationStars = [];
|
||
try {
|
||
if (isset($pdo)) {
|
||
$stmtOccultStars = $pdo->prepare(
|
||
"SELECT hip, proper, bayer, con, mag, rarad, decrad, dist
|
||
FROM star_hipparcos
|
||
WHERE rarad IS NOT NULL
|
||
AND decrad IS NOT NULL
|
||
AND mag IS NOT NULL
|
||
AND mag <= :max_mag
|
||
ORDER BY mag ASC, proper ASC, bayer ASC, hip ASC"
|
||
);
|
||
$stmtOccultStars->execute([':max_mag' => $moonOccultationMaxMag]);
|
||
|
||
foreach ($stmtOccultStars->fetchAll() as $row) {
|
||
$moonOccultationStars[] = [
|
||
'hip' => isset($row['hip']) ? (int) $row['hip'] : 0,
|
||
'label' => buildMonthOccultationStarLabel($row),
|
||
'constellation' => trim((string) ($row['con'] ?? '')),
|
||
'mag' => isset($row['mag']) ? (float) $row['mag'] : 99.0,
|
||
'ra' => isset($row['rarad']) ? (float) $row['rarad'] * (12 / M_PI) : 0.0,
|
||
'dec' => isset($row['decrad']) ? (float) $row['decrad'] * (180 / M_PI) : 0.0,
|
||
'distLy' => isset($row['dist']) && $row['dist'] !== null && (float) $row['dist'] > 0.0
|
||
? (float) $row['dist'] * 3.26156
|
||
: 1000.0,
|
||
];
|
||
}
|
||
}
|
||
} catch (Throwable $e) {
|
||
}
|
||
|
||
if ($moonOccultationStars) {
|
||
$tempOccultationFile = tempnam(sys_get_temp_dir(), 'month_occult_');
|
||
if ($tempOccultationFile !== false) {
|
||
try {
|
||
file_put_contents($tempOccultationFile, json_encode($moonOccultationStars, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||
$moonOccultOutput = [];
|
||
$moonOccultExitCode = 0;
|
||
$moonOccultArgs = [
|
||
$pythonScriptPath,
|
||
'moon_star_occultations_for_month',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
$tempOccultationFile,
|
||
];
|
||
$moonOccultCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonOccultArgs)) . ' 2>&1';
|
||
exec($moonOccultCommand, $moonOccultOutput, $moonOccultExitCode);
|
||
$moonOccultRaw = trim(implode("\n", $moonOccultOutput));
|
||
$moonOccultDecoded = json_decode($moonOccultRaw, true);
|
||
if (is_array($moonOccultDecoded) && ($moonOccultDecoded['ok'] ?? false)) {
|
||
$moonOccultations = is_array($moonOccultDecoded['results'] ?? null) ? $moonOccultDecoded['results'] : [];
|
||
}
|
||
} catch (Throwable $e) {
|
||
} finally {
|
||
if (is_file($tempOccultationFile)) {
|
||
@unlink($tempOccultationFile);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach ($moonPhaseEvents as $phaseEvent) {
|
||
if (!is_array($phaseEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$phaseLabel = trim((string) ($phaseEvent['label'] ?? ''));
|
||
if ($phaseLabel === 'Erstes Viertel' || $phaseLabel === 'Letztes Viertel') {
|
||
$phaseLabel = 'Mond ' . $phaseLabel;
|
||
}
|
||
|
||
$monthEventList[] = [
|
||
'event' => $phaseLabel,
|
||
'date' => (string) ($phaseEvent['local_date'] ?? ''),
|
||
'time' => (string) ($phaseEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($phaseEvent['local_iso'] ?? ''),
|
||
'type' => 'moon_phase',
|
||
];
|
||
}
|
||
|
||
foreach ($goldenHandleEvents as $goldenHandleEvent) {
|
||
if (!is_array($goldenHandleEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$peakDate = (string) ($goldenHandleEvent['peak_local_date'] ?? '');
|
||
$peakTime = (string) ($goldenHandleEvent['peak_local_time'] ?? '');
|
||
$goldenHandleLabel = 'Goldener Henkel am Mond';
|
||
|
||
$monthEventList[] = [
|
||
'event' => $goldenHandleLabel,
|
||
'date' => $peakDate !== '' ? $peakDate : (string) ($goldenHandleEvent['local_date'] ?? ''),
|
||
'time' => $peakTime !== '' ? $peakTime : (string) ($goldenHandleEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($goldenHandleEvent['peak_local_iso'] ?? $goldenHandleEvent['local_iso'] ?? ''),
|
||
'type' => 'golden_handle',
|
||
];
|
||
}
|
||
|
||
foreach ($seasonChanges as $seasonChange) {
|
||
if (!is_array($seasonChange)) {
|
||
continue;
|
||
}
|
||
|
||
$monthEventList[] = [
|
||
'event' => (string) ($seasonChange['label'] ?? 'Jahreszeitenwechsel'),
|
||
'date' => (string) ($seasonChange['local_date'] ?? ''),
|
||
'time' => (string) ($seasonChange['local_time'] ?? ''),
|
||
'local_iso' => (string) ($seasonChange['local_iso'] ?? ''),
|
||
'type' => 'season_change',
|
||
];
|
||
}
|
||
|
||
foreach ($timeChanges as $timeChange) {
|
||
if (!is_array($timeChange)) {
|
||
continue;
|
||
}
|
||
|
||
$timeChangeKind = (string) ($timeChange['kind'] ?? '');
|
||
$timeChangeLabel = $timeChangeKind === 'dst_start'
|
||
? 'Beginn der Sommerzeit (Uhren von 02:00 auf 03:00 Uhr vorstellen)'
|
||
: ($timeChangeKind === 'dst_end'
|
||
? 'Beginn der Normalzeit (Uhren von 03:00 auf 02:00 Uhr zurückstellen)'
|
||
: 'Zeitumstellung');
|
||
$timeChangeTime = $timeChangeKind === 'dst_start'
|
||
? '02:00'
|
||
: ($timeChangeKind === 'dst_end' ? '03:00' : (string) ($timeChange['local_time'] ?? ''));
|
||
|
||
$monthEventList[] = [
|
||
'event' => $timeChangeLabel,
|
||
'date' => (string) ($timeChange['local_date'] ?? ''),
|
||
'time' => $timeChangeTime,
|
||
'local_iso' => (string) ($timeChange['local_iso'] ?? ''),
|
||
'type' => 'time_change',
|
||
];
|
||
}
|
||
|
||
foreach ($eclipseEvents as $eclipseEvent) {
|
||
if (!is_array($eclipseEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$monthEventList[] = [
|
||
'event' => (string) ($eclipseEvent['label'] ?? 'Finsternis'),
|
||
'date' => (string) ($eclipseEvent['local_date'] ?? ''),
|
||
'time' => (string) ($eclipseEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($eclipseEvent['local_iso'] ?? ''),
|
||
'type' => (string) ($eclipseEvent['category'] ?? 'eclipse'),
|
||
];
|
||
}
|
||
|
||
foreach ($moonApsisEvents as $moonApsisEvent) {
|
||
if (!is_array($moonApsisEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$moonApsisKind = (string) ($moonApsisEvent['kind'] ?? '');
|
||
$distanceText = isset($moonApsisEvent['distance_km'])
|
||
? number_format((float) $moonApsisEvent['distance_km'], 0, ',', '.') . ' km'
|
||
: '';
|
||
$moonApsisLabel = $moonApsisKind === 'perigee'
|
||
? 'Mond in Erdnähe (Perigäum)'
|
||
: ($moonApsisKind === 'apogee' ? 'Mond in Erdferne (Apogäum)' : (string) ($moonApsisEvent['label'] ?? 'Mondabstand'));
|
||
|
||
$monthEventList[] = [
|
||
'event' => trim($moonApsisLabel . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
||
'date' => (string) ($moonApsisEvent['local_date'] ?? ''),
|
||
'time' => (string) ($moonApsisEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($moonApsisEvent['local_iso'] ?? ''),
|
||
'type' => 'moon_apsis',
|
||
];
|
||
}
|
||
|
||
foreach ($sunApsisEvents as $sunApsisEvent) {
|
||
if (!is_array($sunApsisEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$sunApsisKind = (string) ($sunApsisEvent['kind'] ?? '');
|
||
$distanceText = isset($sunApsisEvent['distance_km'])
|
||
? number_format((float) $sunApsisEvent['distance_km'], 0, ',', '.') . ' km'
|
||
: '';
|
||
$sunApsisLabel = $sunApsisKind === 'perihelion'
|
||
? 'Erde erreicht sonnennächsten Punkt (Perihel)'
|
||
: ($sunApsisKind === 'aphelion' ? 'Erde erreicht sonnenfernsten Punkt (Aphel)' : (string) ($sunApsisEvent['label'] ?? 'Sonnenabstand'));
|
||
|
||
$monthEventList[] = [
|
||
'event' => trim($sunApsisLabel . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
||
'date' => (string) ($sunApsisEvent['local_date'] ?? ''),
|
||
'time' => (string) ($sunApsisEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($sunApsisEvent['local_iso'] ?? ''),
|
||
'type' => 'sun_apsis',
|
||
];
|
||
}
|
||
|
||
foreach ($innerPlanetElongationEvents as $elongationEvent) {
|
||
if (!is_array($elongationEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$elongationPlanet = (string) ($elongationEvent['planet_label'] ?? 'Planet');
|
||
$elongationKind = (string) ($elongationEvent['kind'] ?? '');
|
||
$elongationText = isset($elongationEvent['elongation_deg'])
|
||
? number_format((float) $elongationEvent['elongation_deg'], 2, ',', '') . ' Grad'
|
||
: '';
|
||
$elongationLabel = $elongationKind === 'western'
|
||
? $elongationPlanet . ' größte westliche Elongation'
|
||
: ($elongationKind === 'eastern'
|
||
? $elongationPlanet . ' größte östliche Elongation'
|
||
: (string) ($elongationEvent['label'] ?? 'Elongation'));
|
||
|
||
$monthEventList[] = [
|
||
'event' => trim($elongationLabel . ($elongationText !== '' ? ' (' . $elongationText . ')' : '')),
|
||
'date' => (string) ($elongationEvent['local_date'] ?? ''),
|
||
'time' => (string) ($elongationEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($elongationEvent['local_iso'] ?? ''),
|
||
'type' => 'planet_elongation',
|
||
];
|
||
}
|
||
|
||
foreach ($venusPeakMagnitudeEvents as $venusPeakMagnitudeEvent) {
|
||
if (!is_array($venusPeakMagnitudeEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$magnitudeText = isset($venusPeakMagnitudeEvent['mag'])
|
||
? number_format((float) $venusPeakMagnitudeEvent['mag'], 1, ',', '') . ' mag'
|
||
: '';
|
||
|
||
$monthEventList[] = [
|
||
'event' => trim('Venus in größter Helligkeit' . ($magnitudeText !== '' ? ' (' . $magnitudeText . ')' : '')),
|
||
'date' => (string) ($venusPeakMagnitudeEvent['local_date'] ?? ''),
|
||
'time' => (string) ($venusPeakMagnitudeEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($venusPeakMagnitudeEvent['local_iso'] ?? ''),
|
||
'type' => 'venus_peak_magnitude',
|
||
];
|
||
}
|
||
|
||
foreach ($outerPlanetEvents as $outerPlanetEvent) {
|
||
if (!is_array($outerPlanetEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$monthEventList[] = [
|
||
'event' => (string) ($outerPlanetEvent['label'] ?? 'Planetenereignis'),
|
||
'date' => (string) ($outerPlanetEvent['local_date'] ?? ''),
|
||
'time' => (string) ($outerPlanetEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($outerPlanetEvent['local_iso'] ?? ''),
|
||
'type' => 'outer_planet_event',
|
||
];
|
||
}
|
||
|
||
foreach ($outerPlanetStationEvents as $outerPlanetStationEvent) {
|
||
if (!is_array($outerPlanetStationEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$stationPlanet = (string) ($outerPlanetStationEvent['planet_label'] ?? 'Planet');
|
||
$stationKind = (string) ($outerPlanetStationEvent['kind'] ?? '');
|
||
$stationLabel = $stationKind === 'retrograde'
|
||
? $stationPlanet . ' stationär, dann rückläufig'
|
||
: ($stationKind === 'prograde'
|
||
? $stationPlanet . ' stationär, dann rechtläufig'
|
||
: (string) ($outerPlanetStationEvent['label'] ?? 'Stationärer Punkt'));
|
||
|
||
$monthEventList[] = [
|
||
'event' => $stationLabel,
|
||
'date' => (string) ($outerPlanetStationEvent['local_date'] ?? ''),
|
||
'time' => (string) ($outerPlanetStationEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($outerPlanetStationEvent['local_iso'] ?? ''),
|
||
'type' => 'outer_planet_station',
|
||
];
|
||
}
|
||
|
||
foreach ($jupiterMoonSideEvents as $jupiterMoonSideEvent) {
|
||
if (!is_array($jupiterMoonSideEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$eventLabel = trim((string) ($jupiterMoonSideEvent['label'] ?? 'Alle 4 Jupitermonde auf einer Seite von Jupiter'));
|
||
|
||
$monthEventList[] = [
|
||
'event' => $eventLabel,
|
||
'date' => (string) ($jupiterMoonSideEvent['local_date'] ?? ''),
|
||
'time' => (string) ($jupiterMoonSideEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($jupiterMoonSideEvent['local_iso'] ?? ''),
|
||
'end_local_iso' => (string) ($jupiterMoonSideEvent['end_local_iso'] ?? ''),
|
||
'duration_minutes' => isset($jupiterMoonSideEvent['duration_minutes']) ? (int) $jupiterMoonSideEvent['duration_minutes'] : null,
|
||
'type' => 'jupiter_moons_one_side',
|
||
];
|
||
}
|
||
|
||
foreach ($moonPlanetApproaches as $approachEvent) {
|
||
if (!is_array($approachEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$distanceText = isset($approachEvent['separation_deg'])
|
||
? number_format((float) $approachEvent['separation_deg'], 2, ',', '') . ' Grad'
|
||
: '';
|
||
|
||
$monthEventList[] = [
|
||
'event' => trim((string) ($approachEvent['label'] ?? 'Mond nahe Planet') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
||
'date' => (string) ($approachEvent['local_date'] ?? ''),
|
||
'time' => (string) ($approachEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
|
||
'type' => 'moon_planet',
|
||
];
|
||
}
|
||
|
||
foreach ($moonDeepSkyApproaches as $approachEvent) {
|
||
if (!is_array($approachEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$distanceText = isset($approachEvent['separation_deg'])
|
||
? number_format((float) ($approachEvent['separation_deg']), 2, ',', '') . ' Grad'
|
||
: '';
|
||
|
||
$monthEventList[] = [
|
||
'event' => trim((string) ($approachEvent['label'] ?? 'Mond nahe Sternhaufen') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
||
'date' => (string) ($approachEvent['local_date'] ?? ''),
|
||
'time' => (string) ($approachEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
|
||
'type' => 'moon_deep_sky',
|
||
];
|
||
}
|
||
|
||
foreach ($planetBrightStarApproaches as $approachEvent) {
|
||
if (!is_array($approachEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$distanceText = isset($approachEvent['separation_deg'])
|
||
? number_format((float) ($approachEvent['separation_deg']), 2, ',', '') . ' Grad'
|
||
: '';
|
||
|
||
$monthEventList[] = [
|
||
'event' => trim((string) ($approachEvent['label'] ?? 'Planet nahe hellem Stern') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
||
'date' => (string) ($approachEvent['local_date'] ?? ''),
|
||
'time' => (string) ($approachEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
|
||
'type' => 'planet_bright_star',
|
||
];
|
||
}
|
||
|
||
foreach ($planetConjunctions as $conjunctionEvent) {
|
||
if (!is_array($conjunctionEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$distanceText = isset($conjunctionEvent['separation_deg'])
|
||
? number_format((float) $conjunctionEvent['separation_deg'], 2, ',', '') . ' Grad'
|
||
: '';
|
||
|
||
$monthEventList[] = [
|
||
'event' => trim((string) ($conjunctionEvent['label'] ?? 'Planetenkonjunktion') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
||
'date' => (string) ($conjunctionEvent['local_date'] ?? ''),
|
||
'time' => (string) ($conjunctionEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($conjunctionEvent['local_iso'] ?? ''),
|
||
'type' => 'planet_conjunction',
|
||
];
|
||
}
|
||
|
||
foreach ($mercuryGoodVisibilityEvents as $visibilityEvent) {
|
||
if (!is_array($visibilityEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$monthEventList[] = [
|
||
'event' => (string) ($visibilityEvent['label'] ?? 'Gute Merkur-Sichtbarkeit'),
|
||
'date' => (string) ($visibilityEvent['local_date'] ?? ''),
|
||
'time' => (string) ($visibilityEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($visibilityEvent['local_iso'] ?? ''),
|
||
'end_local_iso' => (string) ($visibilityEvent['end_local_iso'] ?? ''),
|
||
'duration_minutes' => isset($visibilityEvent['duration_minutes']) ? (int) $visibilityEvent['duration_minutes'] : null,
|
||
'type' => 'mercury_good_visibility',
|
||
];
|
||
}
|
||
|
||
foreach ($planetParadeEvents as $paradeEvent) {
|
||
if (!is_array($paradeEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$monthEventList[] = [
|
||
'event' => (string) ($paradeEvent['label'] ?? 'Planetenparade'),
|
||
'date' => (string) ($paradeEvent['local_date'] ?? ''),
|
||
'time' => (string) ($paradeEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($paradeEvent['local_iso'] ?? ''),
|
||
'end_local_iso' => (string) ($paradeEvent['end_local_iso'] ?? ''),
|
||
'duration_minutes' => isset($paradeEvent['duration_minutes']) ? (int) $paradeEvent['duration_minutes'] : null,
|
||
'type' => 'planet_parade',
|
||
];
|
||
}
|
||
|
||
foreach ($planetConstellationChangeEvents as $changeEvent) {
|
||
if (!is_array($changeEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$planetLabel = trim((string) ($changeEvent['planet_label'] ?? 'Planet'));
|
||
$fromConstellationName = trim((string) ($changeEvent['from_constellation_name'] ?? ''));
|
||
$toConstellationName = trim((string) ($changeEvent['to_constellation_name'] ?? ''));
|
||
$changeLabel = (string) ($changeEvent['label'] ?? 'Planet wechselt Sternbild');
|
||
if ($fromConstellationName !== '' && $toConstellationName !== '') {
|
||
$changeLabel = sprintf(
|
||
'%s wechselt vom Sternbild %s in das Sternbild %s',
|
||
$planetLabel,
|
||
$fromConstellationName,
|
||
$toConstellationName
|
||
);
|
||
}
|
||
|
||
$monthEventList[] = [
|
||
'event' => $changeLabel,
|
||
'date' => (string) ($changeEvent['local_date'] ?? ''),
|
||
'time' => (string) ($changeEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($changeEvent['local_iso'] ?? ''),
|
||
'type' => 'planet_constellation_change',
|
||
];
|
||
}
|
||
|
||
foreach ($favoriteCometEvents as $favoriteCometEvent) {
|
||
if (!is_array($favoriteCometEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$favoriteCometKind = trim((string) ($favoriteCometEvent['kind'] ?? ''));
|
||
$favoriteCometType = match ($favoriteCometKind) {
|
||
'good_visibility' => 'favorite_comet_good_visibility',
|
||
'perihelion' => 'favorite_comet_perihelion',
|
||
'geocentric_minimum' => 'favorite_comet_geocentric_minimum',
|
||
default => 'favorite_comet_event',
|
||
};
|
||
|
||
$monthEventList[] = [
|
||
'event' => (string) ($favoriteCometEvent['label'] ?? 'Kometenereignis'),
|
||
'date' => (string) ($favoriteCometEvent['local_date'] ?? ''),
|
||
'time' => (string) ($favoriteCometEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($favoriteCometEvent['local_iso'] ?? ''),
|
||
'end_local_iso' => (string) ($favoriteCometEvent['end_local_iso'] ?? ''),
|
||
'duration_minutes' => isset($favoriteCometEvent['duration_minutes']) ? (int) $favoriteCometEvent['duration_minutes'] : null,
|
||
'type' => $favoriteCometType,
|
||
];
|
||
}
|
||
|
||
foreach ($moonOccultations as $occultationEvent) {
|
||
if (!is_array($occultationEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$maximum = is_array($occultationEvent['maximum'] ?? null) ? $occultationEvent['maximum'] : null;
|
||
$star = is_array($occultationEvent['star'] ?? null) ? $occultationEvent['star'] : null;
|
||
if ($maximum === null || $star === null) {
|
||
continue;
|
||
}
|
||
|
||
$starLabel = (string) ($star['label'] ?? 'Stern');
|
||
$starMag = isset($star['mag']) ? number_format((float) $star['mag'], 1, ',', '') : '';
|
||
$maximumLocalDate = (string) ($maximum['local_date'] ?? '');
|
||
if ($maximumLocalDate !== '') {
|
||
$parsedMaximumDate = DateTimeImmutable::createFromFormat('Y-m-d', $maximumLocalDate);
|
||
if ($parsedMaximumDate instanceof DateTimeImmutable) {
|
||
$maximumLocalDate = $parsedMaximumDate->format('d.m.Y');
|
||
}
|
||
}
|
||
$monthEventList[] = [
|
||
'event' => 'Mond bedeckt ' . $starLabel . ($starMag !== '' ? ' (' . $starMag . ' mag)' : ''),
|
||
'date' => $maximumLocalDate,
|
||
'time' => (string) ($maximum['local_time'] ?? ''),
|
||
'local_iso' => (string) ($maximum['local_iso'] ?? ''),
|
||
'type' => 'moon_occultation',
|
||
];
|
||
}
|
||
|
||
foreach ($meteorShowerPeaks as $meteorShowerPeak) {
|
||
if (!monthForecastRadiantIsVisible($chartLatitude, $meteorShowerPeak['radiant_delta_deg'] ?? null)) {
|
||
continue;
|
||
}
|
||
|
||
$peakSol = isset($meteorShowerPeak['peak_sol']) ? (float) $meteorShowerPeak['peak_sol'] : null;
|
||
if ($peakSol === null) {
|
||
continue;
|
||
}
|
||
|
||
$peakDateTime = findMonthForecastSolarLongitudeCrossing(
|
||
$monthStartLocal,
|
||
$monthEndLocal,
|
||
$peakSol,
|
||
new DateTimeZone($chartTimezone)
|
||
);
|
||
if (!$peakDateTime instanceof DateTimeImmutable) {
|
||
continue;
|
||
}
|
||
|
||
$designation = trim((string) ($meteorShowerPeak['designation'] ?? ''));
|
||
$showerCode = trim((string) ($meteorShowerPeak['shower_code'] ?? ''));
|
||
$zhr = $meteorShowerPeak['zhr'] ?? null;
|
||
$zhrVariable = trim((string) ($meteorShowerPeak['zhr_variable'] ?? ''));
|
||
$zhrRange = monthForecastParseMeteorZhrRange(is_int($zhr) ? $zhr : null, $zhrVariable);
|
||
$zhrMinEstimate = $zhrRange['min'];
|
||
$zhrMaxEstimate = $zhrRange['max'];
|
||
$rateText = '';
|
||
|
||
if (is_int($zhr) && $zhr >= 0) {
|
||
$rateText = 'ZHR ca. ' . $zhr;
|
||
} elseif ($zhrVariable !== '') {
|
||
$rateText = 'ZHR ca. ' . $zhrVariable;
|
||
}
|
||
|
||
$labelParts = [];
|
||
$labelParts[] = 'Maximum ' . ($designation !== '' ? $designation : 'Meteorstrom');
|
||
if ($showerCode !== '') {
|
||
$labelParts[] = $showerCode;
|
||
}
|
||
if ($rateText !== '') {
|
||
$labelParts[] = $rateText;
|
||
}
|
||
|
||
$monthEventList[] = [
|
||
'event' => implode(' - ', $labelParts),
|
||
'date' => $peakDateTime->format('d.m.Y'),
|
||
'time' => $peakDateTime->format('H:i'),
|
||
'local_iso' => $peakDateTime->format(DateTimeInterface::ATOM),
|
||
'zhr_min_estimate' => $zhrMinEstimate,
|
||
'zhr_max_estimate' => $zhrMaxEstimate,
|
||
'type' => 'meteor_shower_peak',
|
||
];
|
||
|
||
}
|
||
|
||
foreach ($favoriteSatellitePasses as $satellitePass) {
|
||
if (!is_array($satellitePass)) {
|
||
continue;
|
||
}
|
||
|
||
$peakUtc = trim((string) ($satellitePass['peak_utc'] ?? ''));
|
||
if ($peakUtc === '') {
|
||
continue;
|
||
}
|
||
|
||
$peakAltitude = isset($satellitePass['peak_el']) ? (float) $satellitePass['peak_el'] : null;
|
||
$peakSunAltitude = isset($satellitePass['peak_sun_altitude_deg']) ? (float) $satellitePass['peak_sun_altitude_deg'] : null;
|
||
$peakMagnitude = isset($satellitePass['peak_mag']['mag']) ? (float) $satellitePass['peak_mag']['mag'] : null;
|
||
if ($peakAltitude === null || $peakSunAltitude === null) {
|
||
continue;
|
||
}
|
||
|
||
if ($peakAltitude < $favoriteSatelliteBestPassMinAltitudeDeg) {
|
||
continue;
|
||
}
|
||
|
||
if ($peakSunAltitude > $favoriteSatelliteBestPassMaxSunAltitudeDeg) {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
$peakDateTime = (new DateTimeImmutable($peakUtc))->setTimezone(new DateTimeZone($chartTimezone));
|
||
} catch (Throwable) {
|
||
continue;
|
||
}
|
||
|
||
if ($favoriteSatelliteBestPassOnlyFirstNightHalf && (int) $peakDateTime->format('G') < 12) {
|
||
continue;
|
||
}
|
||
|
||
if ($peakMagnitude === null || $peakMagnitude > $favoriteSatelliteBestPassMaxPeakMagnitude) {
|
||
continue;
|
||
}
|
||
|
||
$peakAltitudeText = number_format($peakAltitude, 0, ',', '');
|
||
$eventLabel = 'Guter Überflug ' . ($favoriteSatelliteName !== null && $favoriteSatelliteName !== '' ? $favoriteSatelliteName : 'Favoriten-Satellit');
|
||
$peakMagnitudeText = number_format($peakMagnitude, 1, ',', '');
|
||
$eventLabel .= ' (max. ' . $peakAltitudeText . ' Grad, ' . $peakMagnitudeText . ' mag)';
|
||
|
||
$monthEventList[] = [
|
||
'event' => $eventLabel,
|
||
'date' => $peakDateTime->format('d.m.Y'),
|
||
'time' => $peakDateTime->format('H:i'),
|
||
'local_iso' => $peakDateTime->format(DateTimeInterface::ATOM),
|
||
'type' => 'favorite_satellite_pass',
|
||
];
|
||
}
|
||
|
||
usort(
|
||
$monthEventList,
|
||
static fn (array $a, array $b): int => strcmp((string) ($a['local_iso'] ?? ''), (string) ($b['local_iso'] ?? ''))
|
||
);
|
||
|
||
$monthNarrativeText = monthForecastBuildNarrativeText($monthEventList, $selectedMonth, $months[$selectedMonth], $selectedYear);
|
||
|
||
$observationTipInsertSql = buildObservationTipInsertSql($monthEventList, new DateTimeZone($chartTimezone));
|
||
$observationTipCalendarIcs = buildObservationTipCalendarIcs($monthEventList, new DateTimeZone($chartTimezone));
|
||
|
||
$chartConfig = [
|
||
'monthName' => $months[$selectedMonth],
|
||
'year' => $selectedYear,
|
||
'month' => $selectedMonth,
|
||
'location' => [
|
||
'name' => $chartLocationName,
|
||
'latitude' => $chartLatitude,
|
||
'longitude' => $chartLongitude,
|
||
'elevation' => $chartElevation,
|
||
'timezone' => $chartTimezone,
|
||
],
|
||
'localTimeLabel' => $chartLocalTime->format('d.m.Y H:i') . ' ' . $chartTimezone,
|
||
'utcTimeIso' => $chartUtcTime->format(DateTimeInterface::ATOM),
|
||
'monthSkyHour' => $monthTime['hour'],
|
||
'monthSkyMinute' => $monthTime['minute'],
|
||
'starLimit' => $starDisplayMagnitudeLimit,
|
||
'starLabelLimit' => $starLabelMagnitudeLimit,
|
||
'messierLimit' => $messierDisplayMagnitudeLimit,
|
||
];
|
||
|
||
$chartConfigJson = json_encode($chartConfig, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$starDataJson = json_encode($starData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$lineStarDataJson = json_encode($lineStarData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$constellationLinesJson = json_encode($constellationLines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$messierDataJson = json_encode($messierData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$planetDataJson = json_encode($planetData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$moonDataJson = json_encode($moonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$monthChartComets = array_map(
|
||
static function (array $comet): array {
|
||
return [
|
||
'id' => (int) ($comet['id'] ?? 0),
|
||
'name' => trim((string) (($comet['designation_and_name'] ?? '') !== '' ? $comet['designation_and_name'] : ($comet['designation_packed'] ?? 'Komet'))),
|
||
'q' => isset($comet['perihelion_dist_au']) ? (float) $comet['perihelion_dist_au'] : null,
|
||
'e' => isset($comet['eccentricity']) ? (float) $comet['eccentricity'] : null,
|
||
'w' => isset($comet['arg_perihelion_deg']) ? (float) $comet['arg_perihelion_deg'] : null,
|
||
'Om' => isset($comet['ascending_node_deg']) ? (float) $comet['ascending_node_deg'] : null,
|
||
'inc' => isset($comet['inclination_deg']) ? (float) $comet['inclination_deg'] : null,
|
||
'Tp_y' => isset($comet['year_of_perihelion']) ? (int) $comet['year_of_perihelion'] : null,
|
||
'Tp_m' => isset($comet['month_of_perihelion']) ? (int) $comet['month_of_perihelion'] : null,
|
||
'Tp_d' => isset($comet['day_of_perihelion']) ? (float) $comet['day_of_perihelion'] : null,
|
||
'H' => array_key_exists('absolute_magnitude_h', $comet) && $comet['absolute_magnitude_h'] !== null ? (float) $comet['absolute_magnitude_h'] : null,
|
||
'G' => array_key_exists('slope_parameter_g', $comet) && $comet['slope_parameter_g'] !== null ? (float) $comet['slope_parameter_g'] : null,
|
||
];
|
||
},
|
||
$favoriteCometPayload
|
||
);
|
||
$monthChartCometsJson = json_encode($monthChartComets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$moonPhaseEventsJson = json_encode($moonPhaseEvents, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$starDataErrorJson = json_encode($starDataError, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$observationTipCalendarIcsJson = json_encode($observationTipCalendarIcs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
$observationTipCalendarFilenameJson = json_encode(sprintf('astrotools-monatsereignisse-%04d-%02d.ics', $selectedYear, $selectedMonth), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||
?>
|
||
|
||
<section class="card monatsvorhersage-card">
|
||
<div class="monatsvorhersage-shell">
|
||
<form class="monatsvorhersage-form" method="get">
|
||
<div class="monatsvorhersage-form-header">
|
||
<h3>Zeitraum wählen</h3>
|
||
<p>Die Sternkarte wird für den 15. des gewählten Monats mit der festen Monatsuhrzeit erzeugt.</p>
|
||
</div>
|
||
|
||
<label class="monatsvorhersage-field">
|
||
<span>Jahr</span>
|
||
<select name="year">
|
||
<?php foreach ($years as $year): ?>
|
||
<option value="<?= htmlspecialchars((string) $year, ENT_QUOTES, 'UTF-8') ?>" <?= $year === $selectedYear ? 'selected' : '' ?>>
|
||
<?= htmlspecialchars((string) $year, ENT_QUOTES, 'UTF-8') ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</label>
|
||
|
||
<label class="monatsvorhersage-field">
|
||
<span>Monat</span>
|
||
<select name="month">
|
||
<?php foreach ($months as $monthNumber => $monthLabel): ?>
|
||
<option value="<?= htmlspecialchars((string) $monthNumber, ENT_QUOTES, 'UTF-8') ?>" <?= $monthNumber === $selectedMonth ? 'selected' : '' ?>>
|
||
<?= htmlspecialchars($monthLabel, ENT_QUOTES, 'UTF-8') ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</label>
|
||
|
||
<div class="monatsvorhersage-actions">
|
||
<button type="submit">Sternkarte laden</button>
|
||
</div>
|
||
</form>
|
||
|
||
<section class="monatsnarrativ-card">
|
||
<div class="monatskarte-header">
|
||
<div>
|
||
<h3>Monat Im Ueberblick</h3>
|
||
<p><?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></p>
|
||
</div>
|
||
</div>
|
||
<div class="monatskarte-footer">
|
||
<p class="monatsnarrativ-text"><?= htmlspecialchars($monthNarrativeText, ENT_QUOTES, 'UTF-8') ?></p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="monatskarte-card">
|
||
<div class="monatskarte-header">
|
||
<div>
|
||
<h3>Zenitprojektion <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
|
||
<p id="monatskarteMeta">
|
||
<?= htmlspecialchars($chartConfig['localTimeLabel'], ENT_QUOTES, 'UTF-8') ?>,
|
||
<?= htmlspecialchars($chartLocationName, ENT_QUOTES, 'UTF-8') ?>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="monatskarte-stage">
|
||
<canvas id="monatskarteCanvas" width="900" height="900" aria-label="Sternkarte in Zenitprojektion"></canvas>
|
||
</div>
|
||
|
||
<div class="monatskarte-footer">
|
||
<p id="monatskarteStatus">Bereite Sternkarte vor...</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="monatskarte-card">
|
||
<div class="monatskarte-header">
|
||
<div>
|
||
<h3>Südhimmel <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
|
||
<p id="monatskarteSouthMeta">
|
||
<?= htmlspecialchars($chartConfig['localTimeLabel'], ENT_QUOTES, 'UTF-8') ?>,
|
||
<?= htmlspecialchars($chartLocationName, ENT_QUOTES, 'UTF-8') ?>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="monatskarte-stage monatskarte-stage-south">
|
||
<canvas id="monatskarteSouthCanvas" width="1200" height="720" aria-label="Südhimmel-Projektion"></canvas>
|
||
</div>
|
||
|
||
<div class="monatskarte-footer">
|
||
<p id="monatskarteSouthStatus">Bereite Südhimmelkarte vor...</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="monatskarte-card">
|
||
<div class="monatskarte-header">
|
||
</div>
|
||
|
||
<div class="monatskarte-stage monatsphase-stage">
|
||
<div class="monatsphase-strip-wrap">
|
||
<img id="monatsphasenImage" alt="Mondphasen des Monats in einer Zeile">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="monatskarte-footer">
|
||
<p id="monatsphasenStatus">Bereite Mondphasenleiste vor...</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="monatskarte-card">
|
||
<div class="monatskarte-header">
|
||
<div>
|
||
<h3>Ereignisse <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
|
||
<p>Chronologische Monatsliste für <?= htmlspecialchars($chartTimezone, ENT_QUOTES, 'UTF-8') ?></p>
|
||
</div>
|
||
<div class="monatskarte-export">
|
||
<?php if ($observationTipInsertSql !== ''): ?>
|
||
<button type="button" class="monatskarte-export-button" id="toggleObservationTipSql">
|
||
SQL-Insert anzeigen
|
||
</button>
|
||
<?php endif; ?>
|
||
<?php if ($observationTipCalendarIcs !== ''): ?>
|
||
<button type="button" class="monatskarte-export-button" id="downloadObservationTipIcal">
|
||
iCal herunterladen
|
||
</button>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="monatskarte-footer">
|
||
<?php if ($observationTipInsertSql !== ''): ?>
|
||
<div class="monatskarte-sql-export" id="observationTipSqlPanel" hidden>
|
||
<label class="monatskarte-sql-label" for="observationTipSqlOutput">SQL für `astro_sonobs_observationtips`</label>
|
||
<textarea id="observationTipSqlOutput" readonly><?= htmlspecialchars($observationTipInsertSql, ENT_QUOTES, 'UTF-8') ?></textarea>
|
||
<div class="monatskarte-sql-actions">
|
||
<button type="button" class="monatskarte-export-button" id="copyObservationTipSql">SQL kopieren</button>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php if ($monthEventList): ?>
|
||
<div class="riseset-grid" style="grid-template-columns: 1.5fr 1fr 1fr; row-gap: 10px;">
|
||
<span class="riseset-label riseset-dim">Ereignis</span>
|
||
<span class="riseset-label riseset-dim">Datum</span>
|
||
<span class="riseset-label riseset-dim">Uhrzeit</span>
|
||
<?php foreach ($monthEventList as $eventItem): ?>
|
||
<span class="riseset-val"><?= htmlspecialchars((string) ($eventItem['event'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
||
<span class="riseset-val"><?= htmlspecialchars((string) ($eventItem['date'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
||
<span class="riseset-val"><?= htmlspecialchars((string) ($eventItem['time'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php else: ?>
|
||
<p>Fuer diesen Monat wurden keine Ereignisse gefunden.</p>
|
||
<?php endif; ?>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.monatsvorhersage-page main > .container {
|
||
max-width: min(1280px, calc(100% - 28px));
|
||
}
|
||
|
||
.monatsvorhersage-card {
|
||
padding: 18px;
|
||
}
|
||
|
||
.monatsvorhersage-shell {
|
||
display: grid;
|
||
gap: 18px;
|
||
}
|
||
|
||
.monatsvorhersage-form,
|
||
.monatsnarrativ-card,
|
||
.monatskarte-card {
|
||
border: 1px solid rgba(201, 168, 76, 0.14);
|
||
border-radius: calc(var(--radius) + 4px);
|
||
background:
|
||
radial-gradient(circle at top left, rgba(74, 158, 255, 0.1), transparent 34%),
|
||
linear-gradient(180deg, rgba(10, 15, 30, 0.96) 0%, rgba(7, 11, 22, 0.98) 100%);
|
||
}
|
||
|
||
.monatsvorhersage-form {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 16px;
|
||
padding: 22px 20px;
|
||
}
|
||
|
||
.monatsvorhersage-form-header,
|
||
.monatskarte-header,
|
||
.monatskarte-footer {
|
||
padding: 22px 20px;
|
||
}
|
||
|
||
.monatsvorhersage-form-header {
|
||
grid-column: 1 / -1;
|
||
padding: 0;
|
||
}
|
||
|
||
.monatsvorhersage-form-header h3,
|
||
.monatskarte-header h3 {
|
||
margin: 0 0 8px;
|
||
}
|
||
|
||
.monatsvorhersage-form-header p,
|
||
.monatskarte-header p,
|
||
.monatskarte-footer p {
|
||
margin: 0;
|
||
color: rgba(222, 232, 255, 0.86);
|
||
line-height: 1.7;
|
||
}
|
||
|
||
.monatsvorhersage-field {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
|
||
.monatsvorhersage-field span {
|
||
color: rgba(235, 242, 255, 0.95);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.monatsvorhersage-field select {
|
||
width: 100%;
|
||
min-height: 46px;
|
||
padding: 10px 12px;
|
||
border: 1px solid rgba(108, 145, 220, 0.28);
|
||
border-radius: 12px;
|
||
background: rgba(8, 14, 28, 0.92);
|
||
color: rgba(235, 242, 255, 0.95);
|
||
font: inherit;
|
||
}
|
||
|
||
.monatsvorhersage-actions {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.monatsvorhersage-actions button {
|
||
min-height: 44px;
|
||
padding: 10px 16px;
|
||
border: 1px solid rgba(150, 190, 255, 0.78);
|
||
border-radius: 999px;
|
||
background: rgba(38, 66, 122, 0.92);
|
||
color: rgba(245, 248, 255, 0.96);
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.monatskarte-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.monatskarte-export {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.monatskarte-export-button {
|
||
min-height: 40px;
|
||
padding: 8px 14px;
|
||
border: 1px solid rgba(150, 190, 255, 0.72);
|
||
border-radius: 999px;
|
||
background: rgba(24, 43, 83, 0.92);
|
||
color: rgba(245, 248, 255, 0.96);
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.monatskarte-sql-export {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-bottom: 18px;
|
||
padding: 16px;
|
||
border: 1px solid rgba(108, 145, 220, 0.22);
|
||
border-radius: 16px;
|
||
background: rgba(6, 11, 22, 0.72);
|
||
}
|
||
|
||
.monatskarte-sql-label {
|
||
color: rgba(235, 242, 255, 0.95);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.monatskarte-sql-export textarea {
|
||
width: 100%;
|
||
min-height: 220px;
|
||
padding: 12px 14px;
|
||
border: 1px solid rgba(108, 145, 220, 0.28);
|
||
border-radius: 12px;
|
||
background: rgba(3, 7, 14, 0.96);
|
||
color: rgba(233, 239, 250, 0.94);
|
||
font: 13px/1.5 Consolas, Monaco, monospace;
|
||
resize: vertical;
|
||
}
|
||
|
||
.monatskarte-sql-actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.monatsnarrativ-text {
|
||
font-size: 1.02rem;
|
||
white-space: pre-line;
|
||
}
|
||
|
||
.monatskarte-stage {
|
||
padding: 0 20px 20px;
|
||
}
|
||
|
||
.monatskarte-stage canvas {
|
||
display: block;
|
||
width: min(100%, 900px);
|
||
aspect-ratio: 1;
|
||
margin: 0 auto;
|
||
border-radius: 20px;
|
||
background: #010203;
|
||
box-shadow: inset 0 0 0 1px rgba(140, 165, 220, 0.12);
|
||
}
|
||
|
||
.monatskarte-stage-south canvas {
|
||
width: min(100%, 1100px);
|
||
aspect-ratio: 5 / 3;
|
||
}
|
||
|
||
.monatsphase-stage {
|
||
padding-top: 8px;
|
||
}
|
||
|
||
.monatsphase-strip-wrap {
|
||
width: 100%;
|
||
overflow-x: auto;
|
||
padding-bottom: 4px;
|
||
}
|
||
|
||
#monatsphasenImage {
|
||
display: block;
|
||
width: min(100%, 1200px);
|
||
min-width: 920px;
|
||
height: auto;
|
||
margin: 0 auto;
|
||
border-radius: 20px;
|
||
background:
|
||
radial-gradient(circle at 50% 0%, rgba(92, 122, 186, 0.16), transparent 42%),
|
||
linear-gradient(180deg, rgba(3, 6, 12, 0.98) 0%, rgba(5, 9, 16, 0.99) 100%);
|
||
box-shadow: inset 0 0 0 1px rgba(140, 165, 220, 0.12);
|
||
}
|
||
|
||
.monatskarte-footer {
|
||
padding-top: 0;
|
||
}
|
||
|
||
@media (max-width: 820px) {
|
||
.monatsvorhersage-form {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.monatskarte-header {
|
||
flex-direction: column;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<script src="<?= htmlspecialchars($publicBasePath . 'js/astronomy.browser.min.js', ENT_QUOTES, 'UTF-8') ?>"></script>
|
||
<script>
|
||
const MONTH_CHART_CONFIG = <?= $chartConfigJson ?: 'null' ?>;
|
||
const MONTH_CHART_STARS = <?= $starDataJson ?: '[]' ?>;
|
||
const MONTH_CHART_LINE_STARS = <?= $lineStarDataJson ?: '[]' ?>;
|
||
const MONTH_CHART_CONSTELLATIONS = <?= $constellationLinesJson ?: '[]' ?>;
|
||
const MONTH_CHART_MESSIER = <?= $messierDataJson ?: '[]' ?>;
|
||
const MONTH_CHART_PLANETS = <?= $planetDataJson ?: '[]' ?>;
|
||
const MONTH_CHART_MOON = <?= $moonDataJson ?: 'null' ?>;
|
||
const MONTH_CHART_COMETS = <?= $monthChartCometsJson ?: '[]' ?>;
|
||
const MONTH_PHASE_EVENTS = <?= $moonPhaseEventsJson ?: '[]' ?>;
|
||
const MONTH_CHART_ERROR = <?= $starDataErrorJson ?: 'null' ?>;
|
||
const OBSERVATION_TIP_ICAL = <?= $observationTipCalendarIcsJson ?: '""' ?>;
|
||
const OBSERVATION_TIP_ICAL_FILENAME = <?= $observationTipCalendarFilenameJson ?: '"astrotools-monatsereignisse.ics"' ?>;
|
||
|
||
const MONTH_CHART_COMET_K = 0.01720209895;
|
||
const MONTH_CHART_COMET_OBLIQ = 23.439279444444445 * Math.PI / 180;
|
||
|
||
function monthChartCometSolveElliptic(M, e) {
|
||
let E = e < 0.8 ? M : (M >= 0 ? Math.PI : -Math.PI);
|
||
for (let i = 0; i < 30; i++) {
|
||
const d = (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
|
||
E -= d;
|
||
if (Math.abs(d) < 1e-12) break;
|
||
}
|
||
return E;
|
||
}
|
||
|
||
function monthChartCometSolveHyperbolic(M, e) {
|
||
let F = M === 0 ? 0 : Math.asinh(M / e);
|
||
for (let i = 0; i < 40; i++) {
|
||
const d = (e * Math.sinh(F) - F - M) / (e * Math.cosh(F) - 1);
|
||
F -= d;
|
||
if (Math.abs(d) < 1e-12) break;
|
||
}
|
||
return F;
|
||
}
|
||
|
||
function monthChartCometSolveParabolic(deltaDays, q) {
|
||
const s = MONTH_CHART_COMET_K * deltaDays / Math.sqrt(2 * q * q * q);
|
||
let W = s;
|
||
for (let i = 0; i < 40; i++) {
|
||
const d = (W + W * W * W / 3 - s) / (1 + W * W);
|
||
W -= d;
|
||
if (Math.abs(d) < 1e-12) break;
|
||
}
|
||
return W;
|
||
}
|
||
|
||
function monthChartCometNuR(deltaDays, q, e) {
|
||
const TOL = 1e-6;
|
||
if (e < 1 - TOL) {
|
||
const a = q / (1 - e);
|
||
const n = MONTH_CHART_COMET_K / Math.pow(a, 1.5);
|
||
const M = (n * deltaDays) % (2 * Math.PI);
|
||
const E = monthChartCometSolveElliptic(M, e);
|
||
const r = a * (1 - e * Math.cos(E));
|
||
const nu = 2 * Math.atan2(Math.sqrt(1 + e) * Math.sin(E / 2), Math.sqrt(1 - e) * Math.cos(E / 2));
|
||
return [nu, r];
|
||
}
|
||
if (e > 1 + TOL) {
|
||
const a = q / (e - 1);
|
||
const M = MONTH_CHART_COMET_K * deltaDays / Math.pow(a, 1.5);
|
||
const F = monthChartCometSolveHyperbolic(M, e);
|
||
const r = a * (e * Math.cosh(F) - 1);
|
||
const nu = 2 * Math.atan2(Math.sqrt(e + 1) * Math.sinh(F / 2), Math.sqrt(e - 1) * Math.cosh(F / 2));
|
||
return [nu, r];
|
||
}
|
||
const W = monthChartCometSolveParabolic(deltaDays, q);
|
||
return [2 * Math.atan(W), q * (1 + W * W)];
|
||
}
|
||
|
||
function monthChartCometRaDec(comet, date) {
|
||
if (!comet || !Number.isFinite(Number(comet.q)) || !Number.isFinite(Number(comet.e)) ||
|
||
!Number.isFinite(Number(comet.w)) || !Number.isFinite(Number(comet.Om)) ||
|
||
!Number.isFinite(Number(comet.inc)) || !Number.isFinite(Number(comet.Tp_y)) ||
|
||
!Number.isFinite(Number(comet.Tp_m)) || !Number.isFinite(Number(comet.Tp_d))) {
|
||
return null;
|
||
}
|
||
|
||
const dayFloor = Math.floor(Number(comet.Tp_d));
|
||
const fracMs = (Number(comet.Tp_d) - dayFloor) * 86400000;
|
||
const tpMs = Date.UTC(Number(comet.Tp_y), Number(comet.Tp_m) - 1, dayFloor) + fracMs;
|
||
const deltaDays = (date.getTime() - tpMs) / 86400000;
|
||
|
||
const [nu, r] = monthChartCometNuR(deltaDays, Number(comet.q), Number(comet.e));
|
||
const w = Number(comet.w) * Math.PI / 180;
|
||
const Om = Number(comet.Om) * Math.PI / 180;
|
||
const inc = Number(comet.inc) * Math.PI / 180;
|
||
const u = w + nu;
|
||
|
||
const cOs = Math.cos(Om);
|
||
const sOs = Math.sin(Om);
|
||
const cI = Math.cos(inc);
|
||
const sI = Math.sin(inc);
|
||
const cU = Math.cos(u);
|
||
const sU = Math.sin(u);
|
||
|
||
const xE = r * (cOs * cU - sOs * sU * cI);
|
||
const yE = r * (sOs * cU + cOs * sU * cI);
|
||
const zE = r * (sU * sI);
|
||
|
||
const cosEps = Math.cos(MONTH_CHART_COMET_OBLIQ);
|
||
const sinEps = Math.sin(MONTH_CHART_COMET_OBLIQ);
|
||
const xEq = xE;
|
||
const yEq = yE * cosEps - zE * sinEps;
|
||
const zEq = yE * sinEps + zE * cosEps;
|
||
|
||
const astTime = Astronomy.MakeTime(date);
|
||
const earth = Astronomy.HelioVector(Astronomy.Body.Earth, astTime);
|
||
const gx = xEq - earth.x;
|
||
const gy = yEq - earth.y;
|
||
const gz = zEq - earth.z;
|
||
|
||
const dist = Math.sqrt(gx * gx + gy * gy + gz * gz);
|
||
if (dist === 0) {
|
||
return null;
|
||
}
|
||
|
||
let ra = Math.atan2(gy, gx) * (180 / Math.PI) / 15;
|
||
if (ra < 0) ra += 24;
|
||
const dec = Math.asin(gz / dist) * (180 / Math.PI);
|
||
|
||
const rH = Math.sqrt(xEq * xEq + yEq * yEq + zEq * zEq);
|
||
let mag = null;
|
||
if (comet.H !== null && comet.G !== null && rH > 0 && dist > 0) {
|
||
mag = Number(comet.H) + 5 * Math.log10(dist) + 2.5 * Number(comet.G) * Math.log10(rH);
|
||
}
|
||
|
||
return { ra, dec, mag, rH };
|
||
}
|
||
|
||
(function () {
|
||
const toggleButton = document.getElementById('toggleObservationTipSql');
|
||
const copyButton = document.getElementById('copyObservationTipSql');
|
||
const icalButton = document.getElementById('downloadObservationTipIcal');
|
||
const panel = document.getElementById('observationTipSqlPanel');
|
||
const output = document.getElementById('observationTipSqlOutput');
|
||
|
||
if (toggleButton && panel) {
|
||
toggleButton.addEventListener('click', function () {
|
||
const isHidden = panel.hasAttribute('hidden');
|
||
if (isHidden) {
|
||
panel.removeAttribute('hidden');
|
||
toggleButton.textContent = 'SQL-Insert ausblenden';
|
||
} else {
|
||
panel.setAttribute('hidden', '');
|
||
toggleButton.textContent = 'SQL-Insert anzeigen';
|
||
}
|
||
});
|
||
}
|
||
|
||
if (copyButton && output && navigator.clipboard) {
|
||
copyButton.addEventListener('click', async function () {
|
||
try {
|
||
await navigator.clipboard.writeText(output.value);
|
||
copyButton.textContent = 'SQL kopiert';
|
||
window.setTimeout(function () {
|
||
copyButton.textContent = 'SQL kopieren';
|
||
}, 1800);
|
||
} catch (error) {
|
||
copyButton.textContent = 'Kopieren fehlgeschlagen';
|
||
window.setTimeout(function () {
|
||
copyButton.textContent = 'SQL kopieren';
|
||
}, 1800);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (icalButton && OBSERVATION_TIP_ICAL) {
|
||
icalButton.addEventListener('click', function () {
|
||
const blob = new Blob([OBSERVATION_TIP_ICAL], { type: 'text/calendar;charset=utf-8' });
|
||
const objectUrl = window.URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = objectUrl;
|
||
link.download = OBSERVATION_TIP_ICAL_FILENAME || 'astrotools-monatsereignisse.ics';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.setTimeout(function () {
|
||
window.URL.revokeObjectURL(objectUrl);
|
||
}, 0);
|
||
});
|
||
}
|
||
})();
|
||
|
||
(function () {
|
||
const imageEl = document.getElementById('monatsphasenImage');
|
||
const statusEl = document.getElementById('monatsphasenStatus');
|
||
|
||
if (!imageEl || !statusEl || !window.Astronomy || !MONTH_CHART_CONFIG) {
|
||
return;
|
||
}
|
||
|
||
const canvas = document.createElement('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const phaseImage = new Image();
|
||
phaseImage.src = <?= json_encode($publicBasePath . 'images/moons/moon4geocron.png', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||
|
||
function clamp01(value) {
|
||
return Math.max(0, Math.min(1, value));
|
||
}
|
||
|
||
function daysInMonth(year, month) {
|
||
return new Date(year, month, 0).getDate();
|
||
}
|
||
|
||
function phaseInfoForDay(year, month, day) {
|
||
const monthHour = Number(MONTH_CHART_CONFIG?.monthSkyHour ?? 12);
|
||
const monthMinute = Number(MONTH_CHART_CONFIG?.monthSkyMinute ?? 0);
|
||
const localPhaseTime = new Date(year, month - 1, day, monthHour, monthMinute, 0);
|
||
const phaseAngle = Number(Astronomy.MoonPhase(localPhaseTime));
|
||
const normalizedPhase = ((phaseAngle % 360) + 360) % 360;
|
||
|
||
return {
|
||
phaseAngleDeg: normalizedPhase,
|
||
illumination: clamp01((1 - Math.cos(normalizedPhase * Math.PI / 180)) / 2),
|
||
waxing: normalizedPhase <= 180,
|
||
};
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
const cssWidth = Math.max(920, Math.floor(imageEl.clientWidth || 1200));
|
||
const cssHeight = 220;
|
||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||
|
||
canvas.width = Math.floor(cssWidth * dpr);
|
||
canvas.height = Math.floor(cssHeight * dpr);
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
draw();
|
||
}
|
||
|
||
function drawMoonDisc(x, y, radius, phaseInfo) {
|
||
const phaseRad = phaseInfo.phaseAngleDeg * Math.PI / 180;
|
||
const cosPhase = Math.cos(phaseRad);
|
||
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
ctx.fillStyle = 'rgba(8, 10, 16, 0.96)';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.globalAlpha = 0.96;
|
||
ctx.drawImage(phaseImage, x - radius, y - radius, radius * 2, radius * 2);
|
||
ctx.globalAlpha = 1;
|
||
|
||
ctx.fillStyle = 'rgba(8, 10, 16, 0.96)';
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
if (phaseInfo.illumination >= 0.999) {
|
||
ctx.drawImage(phaseImage, x - radius, y - radius, radius * 2, radius * 2);
|
||
} else if (phaseInfo.illumination > 0.001) {
|
||
ctx.beginPath();
|
||
for (let i = 0; i <= 72; i++) {
|
||
const t = -radius + (2 * radius * i / 72);
|
||
const xr = Math.sqrt(Math.max(0, radius * radius - t * t));
|
||
const xt = (phaseInfo.waxing ? cosPhase : -cosPhase) * xr;
|
||
const px = x + xt;
|
||
const py = y + t;
|
||
if (i === 0) ctx.moveTo(px, py);
|
||
else ctx.lineTo(px, py);
|
||
}
|
||
for (let i = 72; i >= 0; i--) {
|
||
const t = -radius + (2 * radius * i / 72);
|
||
const xr = Math.sqrt(Math.max(0, radius * radius - t * t));
|
||
const px = x + (phaseInfo.waxing ? xr : -xr);
|
||
const py = y + t;
|
||
ctx.lineTo(px, py);
|
||
}
|
||
ctx.closePath();
|
||
ctx.clip();
|
||
ctx.drawImage(phaseImage, x - radius, y - radius, radius * 2, radius * 2);
|
||
}
|
||
|
||
ctx.restore();
|
||
ctx.restore();
|
||
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.16)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function draw() {
|
||
if (!phaseImage.complete) {
|
||
return;
|
||
}
|
||
|
||
const width = canvas.width / Math.max(1, window.devicePixelRatio || 1);
|
||
const height = canvas.height / Math.max(1, window.devicePixelRatio || 1);
|
||
const year = Number(MONTH_CHART_CONFIG.year);
|
||
const month = Number(MONTH_CHART_CONFIG.month);
|
||
const monthName = String(MONTH_CHART_CONFIG.monthName || '');
|
||
const totalDays = daysInMonth(year, month);
|
||
|
||
ctx.clearRect(0, 0, width, height);
|
||
|
||
const bg = ctx.createLinearGradient(0, 0, 0, height);
|
||
bg.addColorStop(0, '#02050a');
|
||
bg.addColorStop(1, '#04080f');
|
||
ctx.fillStyle = bg;
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
ctx.fillStyle = 'rgba(240, 215, 132, 0.96)';
|
||
ctx.font = '600 16px Rajdhani, sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText('Mondphasen im ' + monthName + ' ' + year, 28, 18);
|
||
|
||
const leftPad = 22;
|
||
const rightPad = 22;
|
||
const usableWidth = width - leftPad - rightPad;
|
||
const spacing = usableWidth / totalDays;
|
||
const radius = Math.max(11, Math.min(18, spacing * 0.42));
|
||
const centerY = 82;
|
||
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.72)';
|
||
ctx.font = '500 10px \"Share Tech Mono\", monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'top';
|
||
|
||
for (let day = 1; day <= totalDays; day++) {
|
||
const x = leftPad + spacing * (day - 0.5);
|
||
drawMoonDisc(x, centerY, radius, phaseInfoForDay(year, month, day));
|
||
ctx.fillText(String(day), x, centerY + radius + 8);
|
||
}
|
||
|
||
ctx.font = '600 12px Rajdhani, sans-serif';
|
||
ctx.fillStyle = 'rgba(225, 232, 246, 0.94)';
|
||
|
||
for (const event of Array.isArray(MONTH_PHASE_EVENTS) ? MONTH_PHASE_EVENTS : []) {
|
||
const parts = String(event.local_date || '').split('.');
|
||
if (parts.length < 3) {
|
||
continue;
|
||
}
|
||
|
||
const day = Number(parts[0]);
|
||
if (!Number.isFinite(day) || day < 1 || day > totalDays) {
|
||
continue;
|
||
}
|
||
|
||
const x = leftPad + spacing * (day - 0.5);
|
||
ctx.fillText(String(event.label || ''), x, 158);
|
||
ctx.fillStyle = 'rgba(182, 196, 224, 0.94)';
|
||
ctx.fillText(day + '.' + month + '.', x, 175);
|
||
ctx.fillStyle = 'rgba(225, 232, 246, 0.94)';
|
||
}
|
||
|
||
imageEl.src = canvas.toDataURL('image/png');
|
||
statusEl.textContent = 'Mondphasenleiste für ' + monthName + ' ' + year;
|
||
}
|
||
|
||
phaseImage.onload = resizeCanvas;
|
||
phaseImage.onerror = function () {
|
||
statusEl.textContent = 'Mondgrafik konnte nicht geladen werden.';
|
||
};
|
||
|
||
window.addEventListener('resize', resizeCanvas);
|
||
|
||
if (phaseImage.complete) {
|
||
resizeCanvas();
|
||
}
|
||
})();
|
||
|
||
(function () {
|
||
const canvas = document.getElementById('monatskarteCanvas');
|
||
const statusEl = document.getElementById('monatskarteStatus');
|
||
|
||
if (!canvas || !statusEl) {
|
||
return;
|
||
}
|
||
|
||
if (!window.Astronomy) {
|
||
statusEl.textContent = 'Astronomy Engine konnte nicht geladen werden.';
|
||
return;
|
||
}
|
||
|
||
if (!MONTH_CHART_CONFIG) {
|
||
statusEl.textContent = 'Die Monatskonfiguration konnte nicht geladen werden.';
|
||
return;
|
||
}
|
||
|
||
if (MONTH_CHART_ERROR) {
|
||
statusEl.textContent = 'Stern-Datenbank konnte nicht geladen werden: ' + MONTH_CHART_ERROR;
|
||
return;
|
||
}
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const date = new Date(MONTH_CHART_CONFIG.utcTimeIso);
|
||
const observer = new Astronomy.Observer(
|
||
Number(MONTH_CHART_CONFIG.location.latitude),
|
||
Number(MONTH_CHART_CONFIG.location.longitude),
|
||
Number(MONTH_CHART_CONFIG.location.elevation || 0)
|
||
);
|
||
const starByHip = new Map();
|
||
const constellationHipSet = new Set();
|
||
const displayStarMap = new Map();
|
||
let labelPlacer = null;
|
||
let projectionLayout = null;
|
||
const projCenterAzDeg = 180;
|
||
const projCenterAltDeg = 45;
|
||
|
||
for (const star of MONTH_CHART_LINE_STARS) {
|
||
if (Number.isFinite(Number(star.hip))) {
|
||
starByHip.set(Number(star.hip), star);
|
||
}
|
||
}
|
||
|
||
for (const star of MONTH_CHART_STARS) {
|
||
if (Number.isFinite(Number(star.hip))) {
|
||
displayStarMap.set(Number(star.hip), star);
|
||
} else {
|
||
displayStarMap.set(`star-${displayStarMap.size}`, star);
|
||
}
|
||
}
|
||
|
||
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
||
if (!Array.isArray(constellation.lines)) {
|
||
continue;
|
||
}
|
||
|
||
for (const segment of constellation.lines) {
|
||
if (!Array.isArray(segment)) {
|
||
continue;
|
||
}
|
||
|
||
for (const hip of segment) {
|
||
if (Number.isFinite(Number(hip))) {
|
||
constellationHipSet.add(Number(hip));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const hip of constellationHipSet) {
|
||
if (!displayStarMap.has(hip) && starByHip.has(hip)) {
|
||
displayStarMap.set(hip, starByHip.get(hip));
|
||
}
|
||
}
|
||
|
||
function clamp01(value) {
|
||
return Math.max(0, Math.min(1, value));
|
||
}
|
||
|
||
function createLabelPlacer(context) {
|
||
const occupiedBoxes = [];
|
||
|
||
function overlaps(box) {
|
||
return occupiedBoxes.some((other) =>
|
||
box.x < other.x + other.width &&
|
||
box.x + box.width > other.x &&
|
||
box.y < other.y + other.height &&
|
||
box.y + box.height > other.y
|
||
);
|
||
}
|
||
|
||
function insideStage(box) {
|
||
return box.x >= 0 &&
|
||
box.y >= 0 &&
|
||
box.x + box.width <= canvas.clientWidth &&
|
||
box.y + box.height <= canvas.clientHeight;
|
||
}
|
||
|
||
return {
|
||
reset() {
|
||
occupiedBoxes.length = 0;
|
||
},
|
||
drawText(options) {
|
||
const {
|
||
text,
|
||
x,
|
||
y,
|
||
font = '12px Georgia, serif',
|
||
color = 'rgba(232, 239, 255, 0.82)',
|
||
align = 'left',
|
||
baseline = 'middle',
|
||
padding = 2,
|
||
} = options;
|
||
|
||
if (!text) {
|
||
return false;
|
||
}
|
||
|
||
context.save();
|
||
context.font = font;
|
||
context.textAlign = align;
|
||
context.textBaseline = baseline;
|
||
const metrics = context.measureText(text);
|
||
const textWidth = metrics.width;
|
||
const ascent = metrics.actualBoundingBoxAscent || 8;
|
||
const descent = metrics.actualBoundingBoxDescent || 4;
|
||
|
||
let boxX = x;
|
||
if (align === 'center') {
|
||
boxX = x - textWidth / 2;
|
||
} else if (align === 'right') {
|
||
boxX = x - textWidth;
|
||
}
|
||
|
||
let boxY = y - ascent;
|
||
if (baseline === 'middle') {
|
||
boxY = y - (ascent + descent) / 2;
|
||
} else if (baseline === 'bottom' || baseline === 'alphabetic') {
|
||
boxY = y - ascent - descent;
|
||
}
|
||
|
||
const box = {
|
||
x: boxX - padding,
|
||
y: boxY - padding,
|
||
width: textWidth + padding * 2,
|
||
height: ascent + descent + padding * 2,
|
||
};
|
||
|
||
if (!insideStage(box) || overlaps(box)) {
|
||
context.restore();
|
||
return false;
|
||
}
|
||
|
||
occupiedBoxes.push(box);
|
||
context.fillStyle = color;
|
||
context.fillText(text, x, y);
|
||
context.restore();
|
||
return true;
|
||
},
|
||
};
|
||
}
|
||
|
||
function placePlanetLabel(text, x, y, radius) {
|
||
const attempts = [
|
||
{ x: x + radius + 6, y: y - 1, align: 'left', baseline: 'middle' },
|
||
{ x: x - radius - 6, y: y - 1, align: 'right', baseline: 'middle' },
|
||
{ x, y: y - radius - 7, align: 'center', baseline: 'bottom' },
|
||
{ x, y: y + radius + 7, align: 'center', baseline: 'top' },
|
||
{ x: x + radius + 6, y: y - radius - 5, align: 'left', baseline: 'bottom' },
|
||
{ x: x - radius - 6, y: y - radius - 5, align: 'right', baseline: 'bottom' },
|
||
{ x: x + radius + 6, y: y + radius + 5, align: 'left', baseline: 'top' },
|
||
{ x: x - radius - 6, y: y + radius + 5, align: 'right', baseline: 'top' },
|
||
];
|
||
|
||
function drawLabelBackdrop(drawX, drawY, align, baseline) {
|
||
ctx.save();
|
||
ctx.font = '600 11px Rajdhani, sans-serif';
|
||
const metrics = ctx.measureText(text);
|
||
const textWidth = metrics.width;
|
||
const ascent = metrics.actualBoundingBoxAscent || 8;
|
||
const descent = metrics.actualBoundingBoxDescent || 4;
|
||
const padX = 5;
|
||
const padY = 3;
|
||
|
||
let boxX = drawX;
|
||
if (align === 'center') boxX = drawX - textWidth / 2;
|
||
else if (align === 'right') boxX = drawX - textWidth;
|
||
|
||
let boxY = drawY - ascent;
|
||
if (baseline === 'middle') boxY = drawY - (ascent + descent) / 2;
|
||
else if (baseline === 'bottom' || baseline === 'alphabetic') boxY = drawY - ascent - descent;
|
||
|
||
const rectX = boxX - padX;
|
||
const rectY = boxY - padY;
|
||
const rectW = textWidth + padX * 2;
|
||
const rectH = ascent + descent + padY * 2;
|
||
const radiusPx = 6;
|
||
|
||
ctx.fillStyle = 'rgba(3, 6, 12, 0.52)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(rectX + radiusPx, rectY);
|
||
ctx.lineTo(rectX + rectW - radiusPx, rectY);
|
||
ctx.quadraticCurveTo(rectX + rectW, rectY, rectX + rectW, rectY + radiusPx);
|
||
ctx.lineTo(rectX + rectW, rectY + rectH - radiusPx);
|
||
ctx.quadraticCurveTo(rectX + rectW, rectY + rectH, rectX + rectW - radiusPx, rectY + rectH);
|
||
ctx.lineTo(rectX + radiusPx, rectY + rectH);
|
||
ctx.quadraticCurveTo(rectX, rectY + rectH, rectX, rectY + rectH - radiusPx);
|
||
ctx.lineTo(rectX, rectY + radiusPx);
|
||
ctx.quadraticCurveTo(rectX, rectY, rectX + radiusPx, rectY);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
for (const attempt of attempts) {
|
||
if (labelPlacer.drawText({
|
||
text,
|
||
x: attempt.x,
|
||
y: attempt.y,
|
||
font: '600 11px Rajdhani, sans-serif',
|
||
color: 'rgba(246, 235, 214, 0.84)',
|
||
align: attempt.align,
|
||
baseline: attempt.baseline,
|
||
padding: 2,
|
||
})) {
|
||
drawLabelBackdrop(attempt.x, attempt.y, attempt.align, attempt.baseline);
|
||
ctx.save();
|
||
ctx.font = '600 11px Rajdhani, sans-serif';
|
||
ctx.textAlign = attempt.align;
|
||
ctx.textBaseline = attempt.baseline;
|
||
ctx.fillStyle = 'rgba(246, 235, 214, 0.84)';
|
||
ctx.fillText(text, attempt.x, attempt.y);
|
||
ctx.restore();
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function placePlanetLabel(text, x, y, radius) {
|
||
const attempts = [
|
||
{ x: x + radius + 6, y: y - 1, align: 'left', baseline: 'middle' },
|
||
{ x: x - radius - 6, y: y - 1, align: 'right', baseline: 'middle' },
|
||
{ x, y: y - radius - 7, align: 'center', baseline: 'bottom' },
|
||
{ x, y: y + radius + 7, align: 'center', baseline: 'top' },
|
||
{ x: x + radius + 6, y: y - radius - 5, align: 'left', baseline: 'bottom' },
|
||
{ x: x - radius - 6, y: y - radius - 5, align: 'right', baseline: 'bottom' },
|
||
{ x: x + radius + 6, y: y + radius + 5, align: 'left', baseline: 'top' },
|
||
{ x: x - radius - 6, y: y + radius + 5, align: 'right', baseline: 'top' },
|
||
];
|
||
|
||
function drawLabelBackdrop(drawX, drawY, align, baseline) {
|
||
ctx.save();
|
||
ctx.font = '600 11px Rajdhani, sans-serif';
|
||
const metrics = ctx.measureText(text);
|
||
const textWidth = metrics.width;
|
||
const ascent = metrics.actualBoundingBoxAscent || 8;
|
||
const descent = metrics.actualBoundingBoxDescent || 4;
|
||
const padX = 5;
|
||
const padY = 3;
|
||
|
||
let boxX = drawX;
|
||
if (align === 'center') boxX = drawX - textWidth / 2;
|
||
else if (align === 'right') boxX = drawX - textWidth;
|
||
|
||
let boxY = drawY - ascent;
|
||
if (baseline === 'middle') boxY = drawY - (ascent + descent) / 2;
|
||
else if (baseline === 'bottom' || baseline === 'alphabetic') boxY = drawY - ascent - descent;
|
||
|
||
const rectX = boxX - padX;
|
||
const rectY = boxY - padY;
|
||
const rectW = textWidth + padX * 2;
|
||
const rectH = ascent + descent + padY * 2;
|
||
const radiusPx = 6;
|
||
|
||
ctx.fillStyle = 'rgba(3, 6, 12, 0.52)';
|
||
ctx.beginPath();
|
||
ctx.moveTo(rectX + radiusPx, rectY);
|
||
ctx.lineTo(rectX + rectW - radiusPx, rectY);
|
||
ctx.quadraticCurveTo(rectX + rectW, rectY, rectX + rectW, rectY + radiusPx);
|
||
ctx.lineTo(rectX + rectW, rectY + rectH - radiusPx);
|
||
ctx.quadraticCurveTo(rectX + rectW, rectY + rectH, rectX + rectW - radiusPx, rectY + rectH);
|
||
ctx.lineTo(rectX + radiusPx, rectY + rectH);
|
||
ctx.quadraticCurveTo(rectX, rectY + rectH, rectX, rectY + rectH - radiusPx);
|
||
ctx.lineTo(rectX, rectY + radiusPx);
|
||
ctx.quadraticCurveTo(rectX, rectY, rectX + radiusPx, rectY);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
for (const attempt of attempts) {
|
||
if (labelPlacer.drawText({
|
||
text,
|
||
x: attempt.x,
|
||
y: attempt.y,
|
||
font: '600 11px Rajdhani, sans-serif',
|
||
color: 'rgba(246, 235, 214, 0.84)',
|
||
align: attempt.align,
|
||
baseline: attempt.baseline,
|
||
padding: 2,
|
||
})) {
|
||
drawLabelBackdrop(attempt.x, attempt.y, attempt.align, attempt.baseline);
|
||
ctx.save();
|
||
ctx.font = '600 11px Rajdhani, sans-serif';
|
||
ctx.textAlign = attempt.align;
|
||
ctx.textBaseline = attempt.baseline;
|
||
ctx.fillStyle = 'rgba(246, 235, 214, 0.84)';
|
||
ctx.fillText(text, attempt.x, attempt.y);
|
||
ctx.restore();
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
const cssSize = Math.max(320, Math.min(canvas.parentElement.clientWidth, 900));
|
||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||
canvas.width = Math.round(cssSize * ratio);
|
||
canvas.height = Math.round(cssSize * ratio);
|
||
canvas.style.width = cssSize + 'px';
|
||
canvas.style.height = cssSize + 'px';
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(ratio, ratio);
|
||
labelPlacer = createLabelPlacer(ctx);
|
||
draw();
|
||
}
|
||
|
||
function project(altDeg, azDeg, radius, cx, cy) {
|
||
if (!Number.isFinite(altDeg) || !Number.isFinite(azDeg) || altDeg < 0) {
|
||
return null;
|
||
}
|
||
const rho = (90 - altDeg) / 90;
|
||
const azRad = azDeg * Math.PI / 180;
|
||
return {
|
||
x: cx - rho * radius * Math.sin(azRad),
|
||
y: cy - rho * radius * Math.cos(azRad),
|
||
};
|
||
}
|
||
|
||
function starHorizon(star) {
|
||
const raHours = star.ra * (12 / Math.PI);
|
||
const decDeg = star.dec * (180 / Math.PI);
|
||
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
|
||
}
|
||
|
||
function starColor(star) {
|
||
if (typeof star.ci === 'number') {
|
||
if (star.ci < -0.1) return '#b9d4ff';
|
||
if (star.ci < 0.3) return '#dbe7ff';
|
||
if (star.ci < 0.7) return '#fff6e0';
|
||
if (star.ci < 1.1) return '#ffe0b5';
|
||
return '#ffc98a';
|
||
}
|
||
|
||
const spect = String(star.spect || '').trim().toUpperCase();
|
||
if (spect.startsWith('O') || spect.startsWith('B')) return '#b9d4ff';
|
||
if (spect.startsWith('A')) return '#dbe7ff';
|
||
if (spect.startsWith('F') || spect.startsWith('G')) return '#fff6e0';
|
||
if (spect.startsWith('K')) return '#ffd4a0';
|
||
if (spect.startsWith('M')) return '#ffbd85';
|
||
return '#f4f7ff';
|
||
}
|
||
|
||
function getStarLabel(star) {
|
||
const proper = String(star.proper || '').trim();
|
||
if (proper !== '') {
|
||
return proper;
|
||
}
|
||
|
||
const bayer = String(star.bayer || '').trim();
|
||
const con = String(star.con || '').trim();
|
||
if (bayer !== '') {
|
||
return con !== '' ? `${bayer} ${con}` : bayer;
|
||
}
|
||
|
||
return '';
|
||
}
|
||
|
||
function drawSkyBackground(cx, cy, radius) {
|
||
const baseGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||
baseGrad.addColorStop(0, '#010203');
|
||
baseGrad.addColorStop(0.45, '#02050b');
|
||
baseGrad.addColorStop(0.78, '#06101d');
|
||
baseGrad.addColorStop(1, '#0d1f38');
|
||
ctx.fillStyle = baseGrad;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
const zenithDarkening = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius * 0.92);
|
||
zenithDarkening.addColorStop(0, 'rgba(0, 0, 0, 0.44)');
|
||
zenithDarkening.addColorStop(0.42, 'rgba(0, 0, 0, 0.2)');
|
||
zenithDarkening.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||
ctx.fillStyle = zenithDarkening;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, radius * 0.92, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
const horizonGlow = ctx.createRadialGradient(cx, cy, radius * 0.74, cx, cy, radius * 1.04);
|
||
horizonGlow.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
||
horizonGlow.addColorStop(0.72, 'rgba(78, 126, 214, 0.025)');
|
||
horizonGlow.addColorStop(0.9, 'rgba(118, 170, 255, 0.09)');
|
||
horizonGlow.addColorStop(1, 'rgba(168, 208, 255, 0.14)');
|
||
ctx.strokeStyle = horizonGlow;
|
||
ctx.lineWidth = Math.max(8, radius * 0.035);
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, radius * 0.97, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
ctx.strokeStyle = 'rgba(183, 205, 255, 0.28)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawGuides(cx, cy, radius) {
|
||
ctx.strokeStyle = 'rgba(140, 165, 220, 0.18)';
|
||
ctx.lineWidth = 1;
|
||
|
||
for (const frac of [0.33, 0.66]) {
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, radius * frac, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx, cy - radius);
|
||
ctx.lineTo(cx, cy + radius);
|
||
ctx.moveTo(cx - radius, cy);
|
||
ctx.lineTo(cx + radius, cy);
|
||
ctx.stroke();
|
||
|
||
function drawDirectionLabel(angleDeg, label, offset, font, alpha) {
|
||
const angleRad = angleDeg * Math.PI / 180;
|
||
const x = cx + Math.sin(angleRad) * (radius + offset);
|
||
const y = cy - Math.cos(angleRad) * (radius + offset);
|
||
let rotation = angleRad;
|
||
|
||
// Text tangential zum Horizontkreis ausrichten, dabei moeglichst gut lesbar halten.
|
||
if (rotation > Math.PI / 2 && rotation < Math.PI * 1.5) {
|
||
rotation -= Math.PI;
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.translate(x, y);
|
||
ctx.rotate(rotation);
|
||
ctx.font = font;
|
||
ctx.fillStyle = `rgba(214, 226, 255, ${alpha})`;
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(label, 0, 0);
|
||
ctx.restore();
|
||
}
|
||
|
||
drawDirectionLabel(0, 'Norden', 18, '600 13px Rajdhani, sans-serif', 0.78);
|
||
drawDirectionLabel(90, 'Westen', 22, '600 13px Rajdhani, sans-serif', 0.78);
|
||
drawDirectionLabel(180, 'Süden', 18, '600 13px Rajdhani, sans-serif', 0.78);
|
||
drawDirectionLabel(270, 'Osten', 22, '600 13px Rajdhani, sans-serif', 0.78);
|
||
|
||
drawDirectionLabel(45, 'Nordosten', 18, '600 11px Rajdhani, sans-serif', 0.64);
|
||
drawDirectionLabel(315, 'Nordwesten', 18, '600 11px Rajdhani, sans-serif', 0.64);
|
||
drawDirectionLabel(135, 'Südosten', 18, '600 11px Rajdhani, sans-serif', 0.64);
|
||
drawDirectionLabel(225, 'Südwesten', 18, '600 11px Rajdhani, sans-serif', 0.64);
|
||
|
||
ctx.fillStyle = 'rgba(214, 226, 255, 0.62)';
|
||
ctx.font = '600 11px Rajdhani, sans-serif';
|
||
ctx.fillText('60°', cx, cy - radius * 0.66 - 6);
|
||
ctx.fillText('30°', cx, cy - radius * 0.33 - 6);
|
||
ctx.fillText('Zenit', cx, cy);
|
||
}
|
||
|
||
function drawConstellations(cx, cy, radius) {
|
||
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
||
if (!Array.isArray(constellation.lines)) {
|
||
continue;
|
||
}
|
||
|
||
for (const segment of constellation.lines) {
|
||
if (!Array.isArray(segment) || segment.length < 2) {
|
||
continue;
|
||
}
|
||
|
||
const points = [];
|
||
|
||
for (const hip of segment) {
|
||
const star = starByHip.get(Number(hip));
|
||
if (!star) {
|
||
continue;
|
||
}
|
||
|
||
const hor = starHorizon(star);
|
||
if (!hor || hor.altitude < 0) {
|
||
continue;
|
||
}
|
||
|
||
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
|
||
if (!pt) {
|
||
continue;
|
||
}
|
||
points.push(pt);
|
||
}
|
||
|
||
if (points.length < 2) {
|
||
continue;
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
|
||
ctx.strokeStyle = 'rgba(210, 214, 224, 0.16)';
|
||
ctx.lineWidth = 2.1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0].x, points[0].y);
|
||
for (let i = 1; i < points.length; i++) {
|
||
ctx.lineTo(points[i].x, points[i].y);
|
||
}
|
||
ctx.stroke();
|
||
|
||
ctx.strokeStyle = 'rgba(36, 40, 48, 0.34)';
|
||
ctx.lineWidth = 0.95;
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0].x + 0.18, points[0].y - 0.12);
|
||
for (let i = 1; i < points.length; i++) {
|
||
const jitterX = (i % 2 === 0 ? 0.22 : -0.12);
|
||
const jitterY = (i % 2 === 0 ? -0.14 : 0.2);
|
||
ctx.lineTo(points[i].x + jitterX, points[i].y + jitterY);
|
||
}
|
||
ctx.stroke();
|
||
|
||
ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawConstellationLabels(cx, cy, radius) {
|
||
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
||
const label = constellation.common_name?.german || constellation.common_name?.native || constellation.id;
|
||
if (!label) {
|
||
continue;
|
||
}
|
||
|
||
const labelPositions = Array.isArray(constellation.label_positions)
|
||
? constellation.label_positions
|
||
.filter((pos) => Array.isArray(pos) && pos.length >= 2)
|
||
.map((pos) => [Number(pos[0]), Number(pos[1])])
|
||
.filter(([raH, decD]) => Number.isFinite(raH) && Number.isFinite(decD))
|
||
: [];
|
||
|
||
for (const [raHours, decDeg] of labelPositions) {
|
||
const hor = equatorialHorizon(raHours, decDeg);
|
||
if (!hor || hor.altitude < 8) {
|
||
continue;
|
||
}
|
||
|
||
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
|
||
if (!pt) {
|
||
continue;
|
||
}
|
||
|
||
const placed = labelPlacer.drawText({
|
||
text: label,
|
||
x: pt.x,
|
||
y: pt.y,
|
||
font: '600 11px Rajdhani, sans-serif',
|
||
color: 'rgba(198, 214, 255, 0.54)',
|
||
align: 'center',
|
||
baseline: 'middle',
|
||
padding: 3,
|
||
});
|
||
|
||
if (placed) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function equatorialHorizon(raHours, decDeg) {
|
||
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
|
||
}
|
||
|
||
function getMessierStyle(type) {
|
||
switch (type) {
|
||
case 'Gc':
|
||
return { stroke: 'rgba(255, 180, 96, 0.92)', fill: 'rgba(255, 180, 96, 0.12)' };
|
||
case 'Oc':
|
||
return { stroke: 'rgba(255, 224, 150, 0.84)', fill: 'rgba(255, 224, 150, 0.08)' };
|
||
case 'Gx':
|
||
return { stroke: 'rgba(255, 140, 188, 0.88)', fill: 'rgba(255, 140, 188, 0.08)' };
|
||
case 'Pn':
|
||
return { stroke: 'rgba(156, 255, 186, 0.88)', fill: 'rgba(156, 255, 186, 0.08)' };
|
||
case 'Nb':
|
||
case 'Sc':
|
||
return { stroke: 'rgba(118, 204, 255, 0.88)', fill: 'rgba(118, 204, 255, 0.08)' };
|
||
default:
|
||
return { stroke: 'rgba(210, 218, 232, 0.74)', fill: 'rgba(210, 218, 232, 0.06)' };
|
||
}
|
||
}
|
||
|
||
function getMessierLabel(obj) {
|
||
const messierId = Number(obj.m);
|
||
if (!Number.isFinite(messierId)) {
|
||
return '';
|
||
}
|
||
|
||
const name = String(obj.name || '').trim();
|
||
return name !== '' ? `M${messierId} ${name}` : `M${messierId}`;
|
||
}
|
||
|
||
function getPlanetStyle(planet) {
|
||
switch (String(planet.key || '')) {
|
||
case 'mercury':
|
||
return { fill: '#c9b9a7', glow: 'rgba(201, 185, 167, 0.34)' };
|
||
case 'venus':
|
||
return { fill: '#f4ddb2', glow: 'rgba(244, 221, 178, 0.4)' };
|
||
case 'mars':
|
||
return { fill: '#d7724c', glow: 'rgba(215, 114, 76, 0.34)' };
|
||
case 'jupiter':
|
||
return { fill: '#d9b07e', glow: 'rgba(217, 176, 126, 0.36)' };
|
||
case 'saturn':
|
||
return { fill: '#d8ca8b', glow: 'rgba(216, 202, 139, 0.34)' };
|
||
case 'uranus':
|
||
return { fill: '#8fd3df', glow: 'rgba(143, 211, 223, 0.3)' };
|
||
case 'neptune':
|
||
return { fill: '#5a78d6', glow: 'rgba(90, 120, 214, 0.3)' };
|
||
default:
|
||
return { fill: '#d8dde8', glow: 'rgba(216, 221, 232, 0.28)' };
|
||
}
|
||
}
|
||
|
||
function drawMoon(cx, cy, radius) {
|
||
if (!MONTH_CHART_MOON) {
|
||
return;
|
||
}
|
||
|
||
const alt = Number(MONTH_CHART_MOON.alt);
|
||
const az = Number(MONTH_CHART_MOON.az);
|
||
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) {
|
||
return;
|
||
}
|
||
|
||
const pt = project(alt, az, radius, cx, cy);
|
||
if (!pt) {
|
||
return;
|
||
}
|
||
|
||
const phaseFraction = clamp01(Number(MONTH_CHART_MOON.phaseFraction ?? 0.5));
|
||
const waxing = Boolean(MONTH_CHART_MOON.waxing);
|
||
const moonRadius = 10.5;
|
||
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, moonRadius * 2.1);
|
||
glow.addColorStop(0, 'rgba(220, 226, 238, 0.28)');
|
||
glow.addColorStop(0.5, 'rgba(220, 226, 238, 0.08)');
|
||
glow.addColorStop(1, 'rgba(220, 226, 238, 0)');
|
||
ctx.fillStyle = glow;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, moonRadius * 2.1, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.save();
|
||
ctx.translate(pt.x, pt.y);
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
ctx.fillStyle = '#515760';
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = '#d6d9df';
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
const phaseScale = 1 - 2 * phaseFraction;
|
||
ctx.fillStyle = '#515760';
|
||
ctx.beginPath();
|
||
ctx.ellipse((waxing ? -1 : 1) * moonRadius * phaseScale, 0, moonRadius, moonRadius, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.restore();
|
||
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, moonRadius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawPlanets(cx, cy, radius) {
|
||
for (const planet of MONTH_CHART_PLANETS) {
|
||
const alt = Number(planet.alt);
|
||
const az = Number(planet.az);
|
||
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) {
|
||
continue;
|
||
}
|
||
|
||
const pt = project(alt, az, radius, cx, cy);
|
||
if (!pt) {
|
||
continue;
|
||
}
|
||
|
||
const style = getPlanetStyle(planet);
|
||
const mag = Number(planet.mag);
|
||
const baseRadius = Number.isFinite(mag)
|
||
? Math.max(3.8, Math.min(8.6, 6.7 - mag * 0.72))
|
||
: 5.4;
|
||
const glowRadius = baseRadius * 2.2;
|
||
|
||
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
|
||
glow.addColorStop(0, style.glow);
|
||
glow.addColorStop(0.45, style.glow.replace(/0\.\d+\)$/, '0.12)'));
|
||
glow.addColorStop(1, style.glow.replace(/0\.\d+\)$/, '0)'));
|
||
ctx.fillStyle = glow;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = style.fill;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.26)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
if (planet.key === 'jupiter') {
|
||
ctx.save();
|
||
ctx.translate(pt.x, pt.y);
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, baseRadius - 0.3, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
ctx.strokeStyle = 'rgba(116, 84, 54, 0.78)';
|
||
ctx.lineWidth = Math.max(1, baseRadius * 0.34);
|
||
ctx.beginPath();
|
||
ctx.moveTo(-baseRadius * 0.95, -baseRadius * 0.34);
|
||
ctx.lineTo(baseRadius * 0.95, -baseRadius * 0.34);
|
||
ctx.moveTo(-baseRadius * 0.95, baseRadius * 0.2);
|
||
ctx.lineTo(baseRadius * 0.95, baseRadius * 0.2);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = 'rgba(194, 92, 68, 0.95)';
|
||
ctx.beginPath();
|
||
ctx.ellipse(baseRadius * 0.42, baseRadius * 0.06, baseRadius * 0.3, baseRadius * 0.17, -0.18, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
if (planet.key === 'saturn') {
|
||
ctx.save();
|
||
ctx.translate(pt.x, pt.y);
|
||
ctx.rotate(-0.35);
|
||
ctx.strokeStyle = 'rgba(232, 221, 170, 0.72)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.ellipse(0, 0, baseRadius * 1.7, baseRadius * 0.72, 0, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
const label = String(planet.label || '').trim();
|
||
if (label !== '') {
|
||
placePlanetLabel(label, pt.x, pt.y, baseRadius);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawComets(cx, cy, radius) {
|
||
if (!Array.isArray(MONTH_CHART_COMETS) || MONTH_CHART_COMETS.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const sunEqu = Astronomy.Equator(Astronomy.Body.Sun, date, observer, true, true);
|
||
const sunHor = Astronomy.Horizon(date, observer, sunEqu.ra, sunEqu.dec, 'normal');
|
||
const sunPt = Number.isFinite(Number(sunHor.altitude)) && Number.isFinite(Number(sunHor.azimuth))
|
||
? project(Number(sunHor.altitude), Number(sunHor.azimuth), radius, cx, cy)
|
||
: null;
|
||
|
||
for (const comet of MONTH_CHART_COMETS) {
|
||
let pos = null;
|
||
try {
|
||
pos = monthChartCometRaDec(comet, date);
|
||
} catch (error) {
|
||
pos = null;
|
||
}
|
||
if (!pos) {
|
||
continue;
|
||
}
|
||
|
||
const hor = Astronomy.Horizon(date, observer, pos.ra, pos.dec, 'normal');
|
||
if (!hor || Number(hor.altitude) < 0) {
|
||
continue;
|
||
}
|
||
|
||
const pt = project(Number(hor.altitude), Number(hor.azimuth), radius, cx, cy);
|
||
if (!pt) {
|
||
continue;
|
||
}
|
||
|
||
let tailAngle = -Math.PI * 0.65;
|
||
if (sunPt) {
|
||
tailAngle = Math.atan2(pt.y - sunPt.y, pt.x - sunPt.x);
|
||
}
|
||
|
||
const tailLen = pos.rH > 0 ? Math.max(8, Math.min(28, 18 / pos.rH)) : 12;
|
||
const tx = pt.x + Math.cos(tailAngle) * tailLen;
|
||
const ty = pt.y + Math.sin(tailAngle) * tailLen;
|
||
|
||
const grd = ctx.createLinearGradient(pt.x, pt.y, tx, ty);
|
||
grd.addColorStop(0, 'rgba(201, 168, 76, 0.82)');
|
||
grd.addColorStop(0.55, 'rgba(240, 217, 144, 0.34)');
|
||
grd.addColorStop(1, 'rgba(201, 168, 76, 0)');
|
||
ctx.beginPath();
|
||
ctx.moveTo(pt.x, pt.y);
|
||
ctx.lineTo(tx, ty);
|
||
ctx.strokeStyle = grd;
|
||
ctx.lineWidth = 2.2;
|
||
ctx.lineCap = 'round';
|
||
ctx.stroke();
|
||
|
||
const comaR = 4.2;
|
||
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, comaR * 2.6);
|
||
glow.addColorStop(0, 'rgba(255, 245, 200, 0.94)');
|
||
glow.addColorStop(0.45, 'rgba(201, 168, 76, 0.54)');
|
||
glow.addColorStop(1, 'rgba(201, 168, 76, 0)');
|
||
ctx.fillStyle = glow;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, comaR * 2.6, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = 'rgba(255, 250, 220, 0.96)';
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, comaR * 0.72, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
const label = String(comet.name || '').trim();
|
||
if (label !== '') {
|
||
placePlanetLabel(label, pt.x, pt.y, comaR);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawMessier(cx, cy, radius) {
|
||
for (const obj of MONTH_CHART_MESSIER) {
|
||
if (Number(obj.mag) > Number(MONTH_CHART_CONFIG.messierLimit)) {
|
||
continue;
|
||
}
|
||
|
||
const hor = equatorialHorizon(Number(obj.ra), Number(obj.dec));
|
||
if (!hor || hor.altitude < 0) {
|
||
continue;
|
||
}
|
||
|
||
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
|
||
if (!pt) {
|
||
continue;
|
||
}
|
||
|
||
const style = getMessierStyle(String(obj.type || 'As'));
|
||
const symbolRadius = Math.max(3.4, Math.min(6.8, 3.2 + Math.max(0, 6.5 - Number(obj.mag)) * 0.5));
|
||
ctx.strokeStyle = style.stroke;
|
||
ctx.fillStyle = style.fill;
|
||
ctx.lineWidth = 1;
|
||
|
||
if (obj.type === 'Gc') {
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(pt.x - symbolRadius * 0.8, pt.y);
|
||
ctx.lineTo(pt.x + symbolRadius * 0.8, pt.y);
|
||
ctx.moveTo(pt.x, pt.y - symbolRadius * 0.8);
|
||
ctx.lineTo(pt.x, pt.y + symbolRadius * 0.8);
|
||
ctx.stroke();
|
||
} else if (obj.type === 'Oc') {
|
||
ctx.setLineDash([2, 2]);
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
} else if (obj.type === 'Gx') {
|
||
ctx.save();
|
||
ctx.translate(pt.x, pt.y);
|
||
ctx.rotate(Math.PI / 5);
|
||
ctx.beginPath();
|
||
ctx.ellipse(0, 0, symbolRadius, symbolRadius * 0.58, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
} else if (obj.type === 'Nb' || obj.type === 'Sc') {
|
||
ctx.beginPath();
|
||
ctx.rect(pt.x - symbolRadius, pt.y - symbolRadius * 0.62, symbolRadius * 2, symbolRadius * 1.24);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
} else if (obj.type === 'Pn') {
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, symbolRadius * 0.62, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
} else {
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, symbolRadius * 0.68, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
}
|
||
|
||
const label = getMessierLabel(obj);
|
||
if (label !== '') {
|
||
labelPlacer.drawText({
|
||
text: label,
|
||
x: pt.x + symbolRadius + 5,
|
||
y: pt.y,
|
||
font: '600 11px Rajdhani, sans-serif',
|
||
color: 'rgba(210, 224, 255, 0.78)',
|
||
align: 'left',
|
||
baseline: 'middle',
|
||
padding: 2,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawStars(cx, cy, radius) {
|
||
for (const star of displayStarMap.values()) {
|
||
const hip = Number(star.hip);
|
||
const isConstellationStar = Number.isFinite(hip) && constellationHipSet.has(hip);
|
||
|
||
if (!isConstellationStar && Number(star.mag) > Number(MONTH_CHART_CONFIG.starLimit)) {
|
||
continue;
|
||
}
|
||
|
||
const hor = starHorizon(star);
|
||
if (!hor || hor.altitude < 0) {
|
||
continue;
|
||
}
|
||
|
||
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
|
||
if (!pt) {
|
||
continue;
|
||
}
|
||
|
||
const magRange = Math.max(0.5, Number(MONTH_CHART_CONFIG.starLimit) + 1.6);
|
||
const effectiveMagnitude = isConstellationStar
|
||
? Math.min(Number(star.mag), Number(MONTH_CHART_CONFIG.starLimit) + 0.35)
|
||
: Number(star.mag);
|
||
const brightness = clamp01((Number(MONTH_CHART_CONFIG.starLimit) - effectiveMagnitude + 1.6) / magRange);
|
||
const starRadius = (isConstellationStar ? 0.85 : 0.65) + Math.pow(brightness, 1.9) * 5.2;
|
||
const glowRadius = starRadius * (effectiveMagnitude <= 1 ? 2.8 : 1.9);
|
||
const color = starColor(star);
|
||
|
||
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
|
||
glow.addColorStop(0, color + '66');
|
||
glow.addColorStop(0.35, color + '22');
|
||
glow.addColorStop(1, color + '00');
|
||
ctx.fillStyle = glow;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, starRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
if (star.mag <= 0.8) {
|
||
ctx.strokeStyle = color + '44';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pt.x - starRadius * 1.35, pt.y);
|
||
ctx.lineTo(pt.x + starRadius * 1.35, pt.y);
|
||
ctx.moveTo(pt.x, pt.y - starRadius * 1.35);
|
||
ctx.lineTo(pt.x, pt.y + starRadius * 1.35);
|
||
ctx.stroke();
|
||
}
|
||
|
||
if (Number(star.mag) <= Number(MONTH_CHART_CONFIG.starLabelLimit)) {
|
||
const label = getStarLabel(star);
|
||
if (label !== '') {
|
||
labelPlacer.drawText({
|
||
text: label,
|
||
x: pt.x + starRadius + 5,
|
||
y: pt.y - 1,
|
||
font: '600 11px Rajdhani, sans-serif',
|
||
color: 'rgba(232, 239, 255, 0.82)',
|
||
align: 'left',
|
||
baseline: 'middle',
|
||
padding: 2,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
const width = canvas.clientWidth;
|
||
const height = canvas.clientHeight;
|
||
const cx = width / 2;
|
||
const cy = height / 2;
|
||
const radius = Math.min(width, height) * 0.44;
|
||
|
||
ctx.clearRect(0, 0, width, height);
|
||
labelPlacer.reset();
|
||
drawSkyBackground(cx, cy, radius);
|
||
drawGuides(cx, cy, radius);
|
||
drawConstellations(cx, cy, radius);
|
||
drawConstellationLabels(cx, cy, radius);
|
||
drawMessier(cx, cy, radius);
|
||
drawMoon(cx, cy, radius);
|
||
drawPlanets(cx, cy, radius);
|
||
drawComets(cx, cy, radius);
|
||
drawStars(cx, cy, radius);
|
||
|
||
statusEl.textContent = 'Sternkarte für ' + MONTH_CHART_CONFIG.localTimeLabel;
|
||
}
|
||
|
||
resizeCanvas();
|
||
window.addEventListener('resize', resizeCanvas);
|
||
})();
|
||
|
||
(function () {
|
||
const canvas = document.getElementById('monatskarteSouthCanvas');
|
||
const statusEl = document.getElementById('monatskarteSouthStatus');
|
||
|
||
if (!canvas || !statusEl || !window.Astronomy || !MONTH_CHART_CONFIG || MONTH_CHART_ERROR) {
|
||
return;
|
||
}
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const date = new Date(MONTH_CHART_CONFIG.utcTimeIso);
|
||
const observer = new Astronomy.Observer(
|
||
Number(MONTH_CHART_CONFIG.location.latitude),
|
||
Number(MONTH_CHART_CONFIG.location.longitude),
|
||
Number(MONTH_CHART_CONFIG.location.elevation || 0)
|
||
);
|
||
const starByHip = new Map();
|
||
const constellationHipSet = new Set();
|
||
const displayStarMap = new Map();
|
||
let labelPlacer = null;
|
||
let projectionLayout = null;
|
||
const projCenterAzDeg = 180;
|
||
const projCenterAltDeg = 45;
|
||
|
||
for (const star of MONTH_CHART_LINE_STARS) {
|
||
if (Number.isFinite(Number(star.hip))) {
|
||
starByHip.set(Number(star.hip), star);
|
||
}
|
||
}
|
||
|
||
for (const star of MONTH_CHART_STARS) {
|
||
if (Number.isFinite(Number(star.hip))) {
|
||
displayStarMap.set(Number(star.hip), star);
|
||
} else {
|
||
displayStarMap.set(`south-star-${displayStarMap.size}`, star);
|
||
}
|
||
}
|
||
|
||
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
||
if (!Array.isArray(constellation.lines)) {
|
||
continue;
|
||
}
|
||
for (const segment of constellation.lines) {
|
||
if (!Array.isArray(segment)) {
|
||
continue;
|
||
}
|
||
for (const hip of segment) {
|
||
if (Number.isFinite(Number(hip))) {
|
||
constellationHipSet.add(Number(hip));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const hip of constellationHipSet) {
|
||
if (!displayStarMap.has(hip) && starByHip.has(hip)) {
|
||
displayStarMap.set(hip, starByHip.get(hip));
|
||
}
|
||
}
|
||
|
||
function clamp01(value) {
|
||
return Math.max(0, Math.min(1, value));
|
||
}
|
||
|
||
function createLabelPlacer(context) {
|
||
const occupiedBoxes = [];
|
||
|
||
function overlaps(box) {
|
||
return occupiedBoxes.some((other) =>
|
||
box.x < other.x + other.width &&
|
||
box.x + box.width > other.x &&
|
||
box.y < other.y + other.height &&
|
||
box.y + box.height > other.y
|
||
);
|
||
}
|
||
|
||
function insideStage(box) {
|
||
return box.x >= 0 &&
|
||
box.y >= 0 &&
|
||
box.x + box.width <= canvas.clientWidth &&
|
||
box.y + box.height <= canvas.clientHeight;
|
||
}
|
||
|
||
return {
|
||
reset() {
|
||
occupiedBoxes.length = 0;
|
||
},
|
||
drawText(options) {
|
||
const {
|
||
text,
|
||
x,
|
||
y,
|
||
font = '600 11px Rajdhani, sans-serif',
|
||
color = 'rgba(232, 239, 255, 0.82)',
|
||
align = 'left',
|
||
baseline = 'middle',
|
||
padding = 2,
|
||
} = options;
|
||
|
||
if (!text) {
|
||
return false;
|
||
}
|
||
|
||
context.save();
|
||
context.font = font;
|
||
context.textAlign = align;
|
||
context.textBaseline = baseline;
|
||
const metrics = context.measureText(text);
|
||
const textWidth = metrics.width;
|
||
const ascent = metrics.actualBoundingBoxAscent || 8;
|
||
const descent = metrics.actualBoundingBoxDescent || 4;
|
||
|
||
let boxX = x;
|
||
if (align === 'center') boxX = x - textWidth / 2;
|
||
else if (align === 'right') boxX = x - textWidth;
|
||
|
||
let boxY = y - ascent;
|
||
if (baseline === 'middle') boxY = y - (ascent + descent) / 2;
|
||
else if (baseline === 'bottom' || baseline === 'alphabetic') boxY = y - ascent - descent;
|
||
|
||
const box = {
|
||
x: boxX - padding,
|
||
y: boxY - padding,
|
||
width: textWidth + padding * 2,
|
||
height: ascent + descent + padding * 2,
|
||
};
|
||
|
||
if (!insideStage(box) || overlaps(box)) {
|
||
context.restore();
|
||
return false;
|
||
}
|
||
|
||
occupiedBoxes.push(box);
|
||
context.fillStyle = color;
|
||
context.fillText(text, x, y);
|
||
context.restore();
|
||
return true;
|
||
},
|
||
};
|
||
}
|
||
|
||
function placePlanetLabel(text, x, y, radius) {
|
||
const attempts = [
|
||
{ x: x + radius + 6, y: y - 1, align: 'left', baseline: 'middle' },
|
||
{ x: x - radius - 6, y: y - 1, align: 'right', baseline: 'middle' },
|
||
{ x, y: y - radius - 7, align: 'center', baseline: 'bottom' },
|
||
{ x, y: y + radius + 7, align: 'center', baseline: 'top' },
|
||
{ x: x + radius + 6, y: y - radius - 5, align: 'left', baseline: 'bottom' },
|
||
{ x: x - radius - 6, y: y - radius - 5, align: 'right', baseline: 'bottom' },
|
||
{ x: x + radius + 6, y: y + radius + 5, align: 'left', baseline: 'top' },
|
||
{ x: x - radius - 6, y: y + radius + 5, align: 'right', baseline: 'top' },
|
||
];
|
||
|
||
for (const attempt of attempts) {
|
||
if (labelPlacer.drawText({
|
||
text,
|
||
x: attempt.x,
|
||
y: attempt.y,
|
||
font: '600 11px Rajdhani, sans-serif',
|
||
color: 'rgba(246, 235, 214, 0.84)',
|
||
align: attempt.align,
|
||
baseline: attempt.baseline,
|
||
padding: 2,
|
||
})) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
const cssWidth = Math.max(420, Math.min(canvas.parentElement.clientWidth, 1100));
|
||
const cssHeight = Math.round(cssWidth * 0.6);
|
||
const ratio = Math.min(window.devicePixelRatio || 1, 2);
|
||
canvas.width = Math.round(cssWidth * ratio);
|
||
canvas.height = Math.round(cssHeight * ratio);
|
||
canvas.style.width = cssWidth + 'px';
|
||
canvas.style.height = cssHeight + 'px';
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(ratio, ratio);
|
||
labelPlacer = createLabelPlacer(ctx);
|
||
draw();
|
||
}
|
||
|
||
function vectorFromAltAz(altDeg, azDeg) {
|
||
const altRad = altDeg * Math.PI / 180;
|
||
const azRad = azDeg * Math.PI / 180;
|
||
const cosAlt = Math.cos(altRad);
|
||
return {
|
||
x: cosAlt * Math.sin(azRad),
|
||
y: cosAlt * Math.cos(azRad),
|
||
z: Math.sin(altRad),
|
||
};
|
||
}
|
||
|
||
function dot(a, b) {
|
||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||
}
|
||
|
||
function cross(a, b) {
|
||
return {
|
||
x: a.y * b.z - a.z * b.y,
|
||
y: a.z * b.x - a.x * b.z,
|
||
z: a.x * b.y - a.y * b.x,
|
||
};
|
||
}
|
||
|
||
function normalize(v) {
|
||
const len = Math.hypot(v.x, v.y, v.z) || 1;
|
||
return { x: v.x / len, y: v.y / len, z: v.z / len };
|
||
}
|
||
|
||
const projectionCenter = vectorFromAltAz(projCenterAltDeg, projCenterAzDeg);
|
||
const basisX = normalize(cross({ x: 0, y: 0, z: 1 }, projectionCenter));
|
||
const basisY = normalize(cross(projectionCenter, basisX));
|
||
|
||
function rawSouthProjection(altDeg, azDeg) {
|
||
if (!Number.isFinite(altDeg) || !Number.isFinite(azDeg) || altDeg < 0) {
|
||
return null;
|
||
}
|
||
|
||
const p = vectorFromAltAz(altDeg, azDeg);
|
||
const denom = 1 + dot(projectionCenter, p);
|
||
if (denom <= 1e-6) {
|
||
return null;
|
||
}
|
||
|
||
const k = 2 / denom;
|
||
return {
|
||
u: k * dot(p, basisX),
|
||
v: k * dot(p, basisY),
|
||
};
|
||
}
|
||
|
||
function computeProjectionLayout(width, height) {
|
||
let minU = Infinity;
|
||
let maxU = -Infinity;
|
||
let minV = Infinity;
|
||
let maxV = -Infinity;
|
||
|
||
for (let az = 90; az <= 270; az += 2) {
|
||
for (let alt = 0; alt <= 90; alt += 2) {
|
||
const raw = rawSouthProjection(alt, az);
|
||
if (!raw) {
|
||
continue;
|
||
}
|
||
minU = Math.min(minU, raw.u);
|
||
maxU = Math.max(maxU, raw.u);
|
||
minV = Math.min(minV, raw.v);
|
||
maxV = Math.max(maxV, raw.v);
|
||
}
|
||
}
|
||
|
||
const marginX = width * 0.08;
|
||
const marginY = height * 0.08;
|
||
const usableWidth = width - marginX * 2;
|
||
const usableHeight = height - marginY * 2;
|
||
const scale = Math.min(
|
||
usableWidth / Math.max(0.001, maxU - minU),
|
||
usableHeight / Math.max(0.001, maxV - minV)
|
||
);
|
||
|
||
return {
|
||
scale,
|
||
offsetX: width / 2 - ((minU + maxU) * 0.5) * scale,
|
||
offsetY: height / 2 + ((minV + maxV) * 0.5) * scale,
|
||
};
|
||
}
|
||
|
||
function southProject(altDeg, azDeg) {
|
||
const raw = rawSouthProjection(altDeg, azDeg);
|
||
if (!raw || !projectionLayout) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
x: projectionLayout.offsetX - raw.u * projectionLayout.scale,
|
||
y: projectionLayout.offsetY - raw.v * projectionLayout.scale,
|
||
};
|
||
}
|
||
|
||
function starHorizon(star) {
|
||
const raHours = star.ra * (12 / Math.PI);
|
||
const decDeg = star.dec * (180 / Math.PI);
|
||
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
|
||
}
|
||
|
||
function equatorialHorizon(raHours, decDeg) {
|
||
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
|
||
}
|
||
|
||
function starColor(star) {
|
||
if (typeof star.ci === 'number') {
|
||
if (star.ci < -0.1) return '#b9d4ff';
|
||
if (star.ci < 0.3) return '#dbe7ff';
|
||
if (star.ci < 0.7) return '#fff6e0';
|
||
if (star.ci < 1.1) return '#ffe0b5';
|
||
return '#ffc98a';
|
||
}
|
||
const spect = String(star.spect || '').trim().toUpperCase();
|
||
if (spect.startsWith('O') || spect.startsWith('B')) return '#b9d4ff';
|
||
if (spect.startsWith('A')) return '#dbe7ff';
|
||
if (spect.startsWith('F') || spect.startsWith('G')) return '#fff6e0';
|
||
if (spect.startsWith('K')) return '#ffd4a0';
|
||
if (spect.startsWith('M')) return '#ffbd85';
|
||
return '#f4f7ff';
|
||
}
|
||
|
||
function getStarLabel(star) {
|
||
const proper = String(star.proper || '').trim();
|
||
if (proper !== '') return proper;
|
||
const bayer = String(star.bayer || '').trim();
|
||
const con = String(star.con || '').trim();
|
||
if (bayer !== '') return con !== '' ? `${bayer} ${con}` : bayer;
|
||
return '';
|
||
}
|
||
|
||
function getMessierStyle(type) {
|
||
switch (type) {
|
||
case 'Gc': return { stroke: 'rgba(255, 180, 96, 0.92)', fill: 'rgba(255, 180, 96, 0.12)' };
|
||
case 'Oc': return { stroke: 'rgba(255, 224, 150, 0.84)', fill: 'rgba(255, 224, 150, 0.08)' };
|
||
case 'Gx': return { stroke: 'rgba(255, 140, 188, 0.88)', fill: 'rgba(255, 140, 188, 0.08)' };
|
||
case 'Pn': return { stroke: 'rgba(156, 255, 186, 0.88)', fill: 'rgba(156, 255, 186, 0.08)' };
|
||
case 'Nb':
|
||
case 'Sc': return { stroke: 'rgba(118, 204, 255, 0.88)', fill: 'rgba(118, 204, 255, 0.08)' };
|
||
default: return { stroke: 'rgba(210, 218, 232, 0.74)', fill: 'rgba(210, 218, 232, 0.06)' };
|
||
}
|
||
}
|
||
|
||
function getMessierLabel(obj) {
|
||
const messierId = Number(obj.m);
|
||
if (!Number.isFinite(messierId)) return '';
|
||
const name = String(obj.name || '').trim();
|
||
return name !== '' ? `M${messierId} ${name}` : `M${messierId}`;
|
||
}
|
||
|
||
function getPlanetStyle(planet) {
|
||
switch (String(planet.key || '')) {
|
||
case 'mercury':
|
||
return { fill: '#c9b9a7', glow: 'rgba(201, 185, 167, 0.34)' };
|
||
case 'venus':
|
||
return { fill: '#f4ddb2', glow: 'rgba(244, 221, 178, 0.4)' };
|
||
case 'mars':
|
||
return { fill: '#d7724c', glow: 'rgba(215, 114, 76, 0.34)' };
|
||
case 'jupiter':
|
||
return { fill: '#d9b07e', glow: 'rgba(217, 176, 126, 0.36)' };
|
||
case 'saturn':
|
||
return { fill: '#d8ca8b', glow: 'rgba(216, 202, 139, 0.34)' };
|
||
case 'uranus':
|
||
return { fill: '#8fd3df', glow: 'rgba(143, 211, 223, 0.3)' };
|
||
case 'neptune':
|
||
return { fill: '#5a78d6', glow: 'rgba(90, 120, 214, 0.3)' };
|
||
default:
|
||
return { fill: '#d8dde8', glow: 'rgba(216, 221, 232, 0.28)' };
|
||
}
|
||
}
|
||
|
||
function drawMoon(width, height) {
|
||
if (!MONTH_CHART_MOON) return;
|
||
|
||
const alt = Number(MONTH_CHART_MOON.alt);
|
||
const az = Number(MONTH_CHART_MOON.az);
|
||
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) return;
|
||
|
||
const pt = southProject(alt, az);
|
||
if (!pt) return;
|
||
|
||
const phaseFraction = clamp01(Number(MONTH_CHART_MOON.phaseFraction ?? 0.5));
|
||
const waxing = Boolean(MONTH_CHART_MOON.waxing);
|
||
const moonRadius = 10.5;
|
||
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, moonRadius * 2.1);
|
||
glow.addColorStop(0, 'rgba(220, 226, 238, 0.28)');
|
||
glow.addColorStop(0.5, 'rgba(220, 226, 238, 0.08)');
|
||
glow.addColorStop(1, 'rgba(220, 226, 238, 0)');
|
||
ctx.fillStyle = glow;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, moonRadius * 2.1, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.save();
|
||
ctx.translate(pt.x, pt.y);
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
ctx.fillStyle = '#515760';
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = '#d6d9df';
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
const phaseScale = 1 - 2 * phaseFraction;
|
||
ctx.fillStyle = '#515760';
|
||
ctx.beginPath();
|
||
ctx.ellipse((waxing ? -1 : 1) * moonRadius * phaseScale, 0, moonRadius, moonRadius, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.restore();
|
||
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, moonRadius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawPlanets(width, height) {
|
||
for (const planet of MONTH_CHART_PLANETS) {
|
||
const alt = Number(planet.alt);
|
||
const az = Number(planet.az);
|
||
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) continue;
|
||
|
||
const pt = southProject(alt, az);
|
||
if (!pt) continue;
|
||
|
||
const style = getPlanetStyle(planet);
|
||
const mag = Number(planet.mag);
|
||
const baseRadius = Number.isFinite(mag)
|
||
? Math.max(3.8, Math.min(8.6, 6.7 - mag * 0.72))
|
||
: 5.4;
|
||
const glowRadius = baseRadius * 2.2;
|
||
|
||
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
|
||
glow.addColorStop(0, style.glow);
|
||
glow.addColorStop(0.45, style.glow.replace(/0\.\d+\)$/, '0.12)'));
|
||
glow.addColorStop(1, style.glow.replace(/0\.\d+\)$/, '0)'));
|
||
ctx.fillStyle = glow;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = style.fill;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.26)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
if (planet.key === 'jupiter') {
|
||
ctx.save();
|
||
ctx.translate(pt.x, pt.y);
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, baseRadius - 0.3, 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
ctx.strokeStyle = 'rgba(116, 84, 54, 0.78)';
|
||
ctx.lineWidth = Math.max(1, baseRadius * 0.34);
|
||
ctx.beginPath();
|
||
ctx.moveTo(-baseRadius * 0.95, -baseRadius * 0.34);
|
||
ctx.lineTo(baseRadius * 0.95, -baseRadius * 0.34);
|
||
ctx.moveTo(-baseRadius * 0.95, baseRadius * 0.2);
|
||
ctx.lineTo(baseRadius * 0.95, baseRadius * 0.2);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = 'rgba(194, 92, 68, 0.95)';
|
||
ctx.beginPath();
|
||
ctx.ellipse(baseRadius * 0.42, baseRadius * 0.06, baseRadius * 0.3, baseRadius * 0.17, -0.18, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
if (planet.key === 'saturn') {
|
||
ctx.save();
|
||
ctx.translate(pt.x, pt.y);
|
||
ctx.rotate(-0.35);
|
||
ctx.strokeStyle = 'rgba(232, 221, 170, 0.72)';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.ellipse(0, 0, baseRadius * 1.7, baseRadius * 0.72, 0, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
const label = String(planet.label || '').trim();
|
||
if (label !== '') {
|
||
placePlanetLabel(label, pt.x, pt.y, baseRadius);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawComets(width, height) {
|
||
if (!Array.isArray(MONTH_CHART_COMETS) || MONTH_CHART_COMETS.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const sunEqu = Astronomy.Equator(Astronomy.Body.Sun, date, observer, true, true);
|
||
const sunHor = Astronomy.Horizon(date, observer, sunEqu.ra, sunEqu.dec, 'normal');
|
||
const sunPt = Number.isFinite(Number(sunHor.altitude)) && Number.isFinite(Number(sunHor.azimuth))
|
||
? southProject(Number(sunHor.altitude), Number(sunHor.azimuth))
|
||
: null;
|
||
|
||
for (const comet of MONTH_CHART_COMETS) {
|
||
let pos = null;
|
||
try {
|
||
pos = monthChartCometRaDec(comet, date);
|
||
} catch (error) {
|
||
pos = null;
|
||
}
|
||
if (!pos) {
|
||
continue;
|
||
}
|
||
|
||
const hor = Astronomy.Horizon(date, observer, pos.ra, pos.dec, 'normal');
|
||
if (!hor || Number(hor.altitude) < 0) {
|
||
continue;
|
||
}
|
||
|
||
const pt = southProject(Number(hor.altitude), Number(hor.azimuth));
|
||
if (!pt) {
|
||
continue;
|
||
}
|
||
|
||
let tailAngle = -Math.PI * 0.65;
|
||
if (sunPt) {
|
||
tailAngle = Math.atan2(pt.y - sunPt.y, pt.x - sunPt.x);
|
||
}
|
||
|
||
const tailLen = pos.rH > 0 ? Math.max(8, Math.min(28, 18 / pos.rH)) : 12;
|
||
const tx = pt.x + Math.cos(tailAngle) * tailLen;
|
||
const ty = pt.y + Math.sin(tailAngle) * tailLen;
|
||
|
||
const grd = ctx.createLinearGradient(pt.x, pt.y, tx, ty);
|
||
grd.addColorStop(0, 'rgba(201, 168, 76, 0.82)');
|
||
grd.addColorStop(0.55, 'rgba(240, 217, 144, 0.34)');
|
||
grd.addColorStop(1, 'rgba(201, 168, 76, 0)');
|
||
ctx.beginPath();
|
||
ctx.moveTo(pt.x, pt.y);
|
||
ctx.lineTo(tx, ty);
|
||
ctx.strokeStyle = grd;
|
||
ctx.lineWidth = 2.2;
|
||
ctx.lineCap = 'round';
|
||
ctx.stroke();
|
||
|
||
const comaR = 4.2;
|
||
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, comaR * 2.6);
|
||
glow.addColorStop(0, 'rgba(255, 245, 200, 0.94)');
|
||
glow.addColorStop(0.45, 'rgba(201, 168, 76, 0.54)');
|
||
glow.addColorStop(1, 'rgba(201, 168, 76, 0)');
|
||
ctx.fillStyle = glow;
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, comaR * 2.6, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
ctx.fillStyle = 'rgba(255, 250, 220, 0.96)';
|
||
ctx.beginPath();
|
||
ctx.arc(pt.x, pt.y, comaR * 0.72, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
const label = String(comet.name || '').trim();
|
||
if (label !== '') {
|
||
placePlanetLabel(label, pt.x, pt.y, comaR);
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawBackground(width, height) {
|
||
const zenitPt = southProject(88, 180) || { x: width / 2, y: height * 0.18 };
|
||
const horizonPt = southProject(0, 180) || { x: width / 2, y: height * 0.88 };
|
||
const baseRadius = Math.max(width, height) * 0.9;
|
||
|
||
const baseGrad = ctx.createRadialGradient(zenitPt.x, zenitPt.y, 0, zenitPt.x, zenitPt.y, baseRadius);
|
||
baseGrad.addColorStop(0, '#010203');
|
||
baseGrad.addColorStop(0.4, '#02050b');
|
||
baseGrad.addColorStop(0.74, '#06101d');
|
||
baseGrad.addColorStop(1, '#0d1f38');
|
||
ctx.fillStyle = baseGrad;
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
const zenithDarkening = ctx.createRadialGradient(zenitPt.x, zenitPt.y, 0, zenitPt.x, zenitPt.y, baseRadius * 0.62);
|
||
zenithDarkening.addColorStop(0, 'rgba(0, 0, 0, 0.44)');
|
||
zenithDarkening.addColorStop(0.42, 'rgba(0, 0, 0, 0.2)');
|
||
zenithDarkening.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||
ctx.fillStyle = zenithDarkening;
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
const horizonGlow = ctx.createRadialGradient(
|
||
horizonPt.x,
|
||
horizonPt.y,
|
||
width * 0.14,
|
||
horizonPt.x,
|
||
horizonPt.y,
|
||
width * 0.62
|
||
);
|
||
horizonGlow.addColorStop(0, 'rgba(118, 170, 255, 0.11)');
|
||
horizonGlow.addColorStop(0.36, 'rgba(78, 126, 214, 0.055)');
|
||
horizonGlow.addColorStop(0.7, 'rgba(24, 46, 86, 0.02)');
|
||
horizonGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||
ctx.fillStyle = horizonGlow;
|
||
ctx.fillRect(0, 0, width, height);
|
||
}
|
||
|
||
function strokeProjectedCurve(samples, strokeStyle, lineWidth) {
|
||
let penDown = false;
|
||
ctx.strokeStyle = strokeStyle;
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.beginPath();
|
||
for (const sample of samples) {
|
||
const pt = southProject(sample.alt, sample.az);
|
||
if (!pt) {
|
||
penDown = false;
|
||
continue;
|
||
}
|
||
if (!penDown) {
|
||
ctx.moveTo(pt.x, pt.y);
|
||
penDown = true;
|
||
} else {
|
||
ctx.lineTo(pt.x, pt.y);
|
||
}
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
function drawGuides(width, height) {
|
||
ctx.strokeStyle = 'rgba(140, 165, 220, 0.14)';
|
||
ctx.lineWidth = 1;
|
||
|
||
for (const alt of [0, 30, 60]) {
|
||
const samples = [];
|
||
for (let az = 90; az <= 270; az += 2) {
|
||
samples.push({ alt, az });
|
||
}
|
||
strokeProjectedCurve(samples, alt === 0 ? 'rgba(183, 205, 255, 0.18)' : 'rgba(140, 165, 220, 0.14)', 1);
|
||
}
|
||
|
||
for (const az of [90, 135, 180, 225, 270]) {
|
||
const samples = [];
|
||
for (let alt = 0; alt <= 90; alt += 2) {
|
||
samples.push({ alt, az });
|
||
}
|
||
strokeProjectedCurve(samples, 'rgba(140, 165, 220, 0.12)', 1);
|
||
}
|
||
|
||
function drawDirectionLabelOutside(azDeg, text, offset = 18) {
|
||
const horizonPt = southProject(0, azDeg);
|
||
const innerPt = southProject(4, azDeg);
|
||
const prevPt = southProject(0, azDeg - 2);
|
||
const nextPt = southProject(0, azDeg + 2);
|
||
if (!horizonPt || !innerPt || !prevPt || !nextPt) {
|
||
return;
|
||
}
|
||
|
||
let nx = horizonPt.x - innerPt.x;
|
||
let ny = horizonPt.y - innerPt.y;
|
||
const normalLen = Math.hypot(nx, ny) || 1;
|
||
nx /= normalLen;
|
||
ny /= normalLen;
|
||
|
||
let angle = Math.atan2(nextPt.y - prevPt.y, nextPt.x - prevPt.x);
|
||
if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
|
||
angle += Math.PI;
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.translate(horizonPt.x + nx * offset, horizonPt.y + ny * offset);
|
||
ctx.rotate(angle);
|
||
ctx.fillStyle = 'rgba(214, 226, 255, 0.72)';
|
||
ctx.font = '600 12px Rajdhani, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(text, 0, 0);
|
||
ctx.restore();
|
||
}
|
||
|
||
const directionLabels = [
|
||
{ az: 90, text: 'Osten' },
|
||
{ az: 135, text: 'Südosten' },
|
||
{ az: 180, text: 'Süden' },
|
||
{ az: 225, text: 'Südwesten' },
|
||
{ az: 270, text: 'Westen' },
|
||
];
|
||
|
||
for (const label of directionLabels) {
|
||
drawDirectionLabelOutside(label.az, label.text, label.az === 180 ? 22 : 18);
|
||
}
|
||
|
||
const zenitPt = southProject(88, 180);
|
||
if (zenitPt) {
|
||
ctx.fillStyle = 'rgba(214, 226, 255, 0.72)';
|
||
ctx.font = '600 12px Rajdhani, sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'bottom';
|
||
ctx.fillText('Zenit', zenitPt.x, zenitPt.y - 8);
|
||
}
|
||
|
||
for (const alt of [30, 60]) {
|
||
const pt = southProject(alt, 182);
|
||
if (!pt) {
|
||
continue;
|
||
}
|
||
ctx.fillStyle = 'rgba(214, 226, 255, 0.58)';
|
||
ctx.font = '600 11px Rajdhani, sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(`${alt}°`, pt.x + 8, pt.y);
|
||
}
|
||
}
|
||
|
||
function drawConstellations(width, height) {
|
||
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
||
if (!Array.isArray(constellation.lines)) continue;
|
||
for (const segment of constellation.lines) {
|
||
if (!Array.isArray(segment) || segment.length < 2) continue;
|
||
const points = [];
|
||
for (const hip of segment) {
|
||
const star = starByHip.get(Number(hip));
|
||
if (!star) continue;
|
||
const hor = starHorizon(star);
|
||
const pt = southProject(hor.altitude, hor.azimuth);
|
||
if (pt) points.push(pt);
|
||
}
|
||
if (points.length < 2) continue;
|
||
|
||
ctx.save();
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
ctx.strokeStyle = 'rgba(210, 214, 224, 0.16)';
|
||
ctx.lineWidth = 2.1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0].x, points[0].y);
|
||
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
|
||
ctx.stroke();
|
||
|
||
ctx.strokeStyle = 'rgba(36, 40, 48, 0.34)';
|
||
ctx.lineWidth = 0.95;
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0].x + 0.18, points[0].y - 0.12);
|
||
for (let i = 1; i < points.length; i++) {
|
||
const jitterX = (i % 2 === 0 ? 0.22 : -0.12);
|
||
const jitterY = (i % 2 === 0 ? -0.14 : 0.2);
|
||
ctx.lineTo(points[i].x + jitterX, points[i].y + jitterY);
|
||
}
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawConstellationLabels(width, height) {
|
||
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
|
||
const label = constellation.common_name?.german || constellation.common_name?.native || constellation.id;
|
||
if (!label) continue;
|
||
const labelPositions = Array.isArray(constellation.label_positions)
|
||
? constellation.label_positions.filter((pos) => Array.isArray(pos) && pos.length >= 2)
|
||
: [];
|
||
|
||
for (const [raH, decD] of labelPositions) {
|
||
const hor = equatorialHorizon(Number(raH), Number(decD));
|
||
const pt = southProject(hor.altitude, hor.azimuth);
|
||
if (!pt || hor.altitude < 8) continue;
|
||
if (labelPlacer.drawText({
|
||
text: label,
|
||
x: pt.x,
|
||
y: pt.y,
|
||
font: '600 11px Rajdhani, sans-serif',
|
||
color: 'rgba(198, 214, 255, 0.54)',
|
||
align: 'center',
|
||
baseline: 'middle',
|
||
padding: 3,
|
||
})) break;
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawMessier(width, height) {
|
||
for (const obj of MONTH_CHART_MESSIER) {
|
||
if (Number(obj.mag) > Number(MONTH_CHART_CONFIG.messierLimit)) continue;
|
||
const hor = equatorialHorizon(Number(obj.ra), Number(obj.dec));
|
||
const pt = southProject(hor.altitude, hor.azimuth);
|
||
if (!pt) continue;
|
||
const style = getMessierStyle(String(obj.type || 'As'));
|
||
const symbolRadius = Math.max(3.4, Math.min(6.8, 3.2 + Math.max(0, 6.5 - Number(obj.mag)) * 0.5));
|
||
ctx.strokeStyle = style.stroke;
|
||
ctx.fillStyle = style.fill;
|
||
ctx.lineWidth = 1;
|
||
|
||
if (obj.type === 'Gc') {
|
||
ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||
ctx.beginPath();
|
||
ctx.moveTo(pt.x - symbolRadius * 0.8, pt.y); ctx.lineTo(pt.x + symbolRadius * 0.8, pt.y);
|
||
ctx.moveTo(pt.x, pt.y - symbolRadius * 0.8); ctx.lineTo(pt.x, pt.y + symbolRadius * 0.8);
|
||
ctx.stroke();
|
||
} else if (obj.type === 'Oc') {
|
||
ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
|
||
} else if (obj.type === 'Gx') {
|
||
ctx.save(); ctx.translate(pt.x, pt.y); ctx.rotate(Math.PI / 5); ctx.beginPath(); ctx.ellipse(0, 0, symbolRadius, symbolRadius * 0.58, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.restore();
|
||
} else if (obj.type === 'Nb' || obj.type === 'Sc') {
|
||
ctx.beginPath(); ctx.rect(pt.x - symbolRadius, pt.y - symbolRadius * 0.62, symbolRadius * 2, symbolRadius * 1.24); ctx.fill(); ctx.stroke();
|
||
} else if (obj.type === 'Pn') {
|
||
ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius * 0.62, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||
} else {
|
||
ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius * 0.68, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
||
}
|
||
|
||
const label = getMessierLabel(obj);
|
||
if (label) {
|
||
labelPlacer.drawText({
|
||
text: label,
|
||
x: pt.x + symbolRadius + 5,
|
||
y: pt.y,
|
||
font: '600 11px Rajdhani, sans-serif',
|
||
color: 'rgba(210, 224, 255, 0.78)',
|
||
align: 'left',
|
||
baseline: 'middle',
|
||
padding: 2,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function drawStars(width, height) {
|
||
for (const star of displayStarMap.values()) {
|
||
const hip = Number(star.hip);
|
||
const isConstellationStar = Number.isFinite(hip) && constellationHipSet.has(hip);
|
||
if (!isConstellationStar && Number(star.mag) > Number(MONTH_CHART_CONFIG.starLimit)) continue;
|
||
const hor = starHorizon(star);
|
||
const pt = southProject(hor.altitude, hor.azimuth);
|
||
if (!pt) continue;
|
||
|
||
const magRange = Math.max(0.5, Number(MONTH_CHART_CONFIG.starLimit) + 1.6);
|
||
const effectiveMagnitude = isConstellationStar
|
||
? Math.min(Number(star.mag), Number(MONTH_CHART_CONFIG.starLimit) + 0.35)
|
||
: Number(star.mag);
|
||
const brightness = clamp01((Number(MONTH_CHART_CONFIG.starLimit) - effectiveMagnitude + 1.6) / magRange);
|
||
const starRadius = (isConstellationStar ? 0.85 : 0.65) + Math.pow(brightness, 1.9) * 5.2;
|
||
const glowRadius = starRadius * (effectiveMagnitude <= 1 ? 2.8 : 1.9);
|
||
const color = starColor(star);
|
||
|
||
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
|
||
glow.addColorStop(0, color + '66');
|
||
glow.addColorStop(0.35, color + '22');
|
||
glow.addColorStop(1, color + '00');
|
||
ctx.fillStyle = glow;
|
||
ctx.beginPath(); ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2); ctx.fill();
|
||
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath(); ctx.arc(pt.x, pt.y, starRadius, 0, Math.PI * 2); ctx.fill();
|
||
|
||
if (star.mag <= 0.8) {
|
||
ctx.strokeStyle = color + '44';
|
||
ctx.lineWidth = 1;
|
||
ctx.beginPath();
|
||
ctx.moveTo(pt.x - starRadius * 1.35, pt.y); ctx.lineTo(pt.x + starRadius * 1.35, pt.y);
|
||
ctx.moveTo(pt.x, pt.y - starRadius * 1.35); ctx.lineTo(pt.x, pt.y + starRadius * 1.35);
|
||
ctx.stroke();
|
||
}
|
||
|
||
if (Number(star.mag) <= Number(MONTH_CHART_CONFIG.starLabelLimit)) {
|
||
const label = getStarLabel(star);
|
||
if (label) {
|
||
labelPlacer.drawText({
|
||
text: label,
|
||
x: pt.x + starRadius + 5,
|
||
y: pt.y - 1,
|
||
font: '600 11px Rajdhani, sans-serif',
|
||
color: 'rgba(232, 239, 255, 0.82)',
|
||
align: 'left',
|
||
baseline: 'middle',
|
||
padding: 2,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
const width = canvas.clientWidth;
|
||
const height = canvas.clientHeight;
|
||
projectionLayout = computeProjectionLayout(width, height);
|
||
ctx.clearRect(0, 0, width, height);
|
||
labelPlacer.reset();
|
||
drawBackground(width, height);
|
||
drawGuides(width, height);
|
||
drawConstellations(width, height);
|
||
drawConstellationLabels(width, height);
|
||
drawMessier(width, height);
|
||
drawMoon(width, height);
|
||
drawPlanets(width, height);
|
||
drawComets(width, height);
|
||
drawStars(width, height);
|
||
statusEl.textContent = 'Südhimmel für ' + MONTH_CHART_CONFIG.localTimeLabel;
|
||
}
|
||
|
||
resizeCanvas();
|
||
window.addEventListener('resize', resizeCanvas);
|
||
})();
|
||
</script>
|
||
|
||
<?php require __DIR__ . '/footer.php'; ?>
|