6573 lines
276 KiB
PHP
6573 lines
276 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
session_start();
|
||
|
||
require_once __DIR__ . '/auth_helpers.php';
|
||
|
||
if (isLocalDevelopmentRequest()) {
|
||
set_time_limit(180);
|
||
}
|
||
|
||
$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');
|
||
$selectedWeek = isset($_GET['week']) ? max(1, min(53, (int) $_GET['week'])) : (int) date('W');
|
||
$selectedWeekYear = isset($_GET['week_year']) ? (int) $_GET['week_year'] : $currentYear;
|
||
$shouldRunForecast = isset($_GET['run']) && $_GET['run'] === '1';
|
||
$includeComets = isset($_GET['include_comets']) && $_GET['include_comets'] === '1';
|
||
if (!isset($_SESSION['observation_tip_insert_token']) || !is_string($_SESSION['observation_tip_insert_token'])) {
|
||
$_SESSION['observation_tip_insert_token'] = bin2hex(random_bytes(16));
|
||
}
|
||
$observationTipInsertToken = $_SESSION['observation_tip_insert_token'];
|
||
if (!isset($_SESSION['joomla_monthly_preview_token']) || !is_string($_SESSION['joomla_monthly_preview_token'])) {
|
||
$_SESSION['joomla_monthly_preview_token'] = bin2hex(random_bytes(16));
|
||
}
|
||
$joomlaMonthlyPreviewToken = $_SESSION['joomla_monthly_preview_token'];
|
||
|
||
// -------------------------------------------------------------------------
|
||
// 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 sowie untere/obere Konjunktionen 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 für Sternkarte und Objektanzeige
|
||
// -------------------------------------------------------------------------
|
||
// Maximale Helligkeit (Magnitude) der Sterne, die aus der Datenbank geladen
|
||
// werden. Höhere Werte laden mehr, aber auch schwächere 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 für Monatsereignisse
|
||
// -------------------------------------------------------------------------
|
||
// Maximaler Winkelabstand für Begegnungen zwischen Mond und Planeten.
|
||
$moonPlanetMaxSeparationDeg = 1.5;
|
||
|
||
// Maximaler Winkelabstand für Begegnungen zwischen Mond und ausgewählten
|
||
// Sternhaufen wie Plejaden oder Praesepe.
|
||
$moonDeepSkyMaxSeparationDeg = 1.0;
|
||
|
||
// Maximaler Winkelabstand für Begegnungen zwischen hellen Planeten und
|
||
// ausgewählten hellen Sternen wie Regulus oder Spica.
|
||
$planetBrightStarMaxSeparationDeg = 1.0;
|
||
|
||
// Maximaler Winkelabstand für Planet-Planet-Konjunktionen.
|
||
$planetConjunctionMaxSeparationDeg = 1.0;
|
||
|
||
// Nur Sterne bis zu dieser Helligkeit werden für Mondbedeckungen
|
||
// berücksichtigt.
|
||
$moonOccultationMaxMag = 2.0;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Schwellen für "Gute Satelliten-Überflüge"
|
||
// -------------------------------------------------------------------------
|
||
// Es wird nur der Favoriten-Satellit des angemeldeten Users geprüft.
|
||
//
|
||
// Ein Überflug zählt als guter Beobachtungstermin nur dann, wenn er am
|
||
// Höhepunkt mindestens diese Höhe über dem Horizont erreicht.
|
||
$favoriteSatelliteBestPassMinAltitudeDeg = 80.0;
|
||
|
||
// Gleichzeitig muss die Sonne zum Maximum des Überflugs mindestens so weit
|
||
// unter dem Horizont stehen. -12 Grad entspricht dem Beginn/Ende der
|
||
// nautischen Dämmerung und sorgt für ausreichend dunklen Himmel.
|
||
$favoriteSatelliteBestPassMaxSunAltitudeDeg = -14.0;
|
||
|
||
// Optional können wir die Liste auf die erste Nachthälfte begrenzen.
|
||
// "Erste Nachthälfte" ist hier bewusst pragmatisch als lokaler Abend vor
|
||
// Mitternacht definiert. Überflüge nach 00:00 Ortszeit werden damit
|
||
// ausgefiltert.
|
||
$favoriteSatelliteBestPassOnlyFirstNightHalf = true;
|
||
|
||
// Optional können wir nur besonders helle Überflüge anzeigen.
|
||
// Bei Magnituden gilt: kleinere Werte sind heller. 0.0 mag bedeutet also,
|
||
// dass nur Überflüge mit Peak-Helligkeit von 0 mag oder heller bleiben.
|
||
$favoriteSatelliteBestPassMaxPeakMagnitude = 0.0;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Schwellen für "Gute Merkur-Sichtbarkeit"
|
||
// -------------------------------------------------------------------------
|
||
// Gute Merkur-Sichtbarkeit wird nicht nur über Elongation definiert, sondern
|
||
// über echte Beobachtbarkeit in der Dämmerung.
|
||
// Geprüft wird pro Tag:
|
||
// morgens eine feste Zahl Minuten VOR Sonnenaufgang,
|
||
// abends eine feste Zahl Minuten NACH Sonnenuntergang.
|
||
$mercuryGoodVisibilitySampleOffsetMinutes = 45;
|
||
|
||
// Merkur muss zum Prüfzeitpunkt mindestens diese Höhe über 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 wäre, aber
|
||
// noch zu stark in der hellen Dämmerung untergeht.
|
||
$mercuryGoodVisibilityMaxSunAltitudeDeg = -3.0;
|
||
|
||
// Erst ab dieser Anzahl aufeinanderfolgender guter Tage erzeugen wir ein
|
||
// Monatsereignis. Einzelne Ausreißer-Tage werden dadurch herausgefiltert.
|
||
$mercuryGoodVisibilityMinConsecutiveDays = 3;
|
||
|
||
// -------------------------------------------------------------------------
|
||
// Schwellen für "Planetenparade"
|
||
// -------------------------------------------------------------------------
|
||
// Geprüft wird morgens eine feste Zahl Minuten VOR Sonnenaufgang und abends
|
||
// eine feste Zahl Minuten NACH Sonnenuntergang.
|
||
$planetParadeSampleOffsetMinutes = 45;
|
||
|
||
// Ein Planet zählt für die Parade nur dann mit, wenn er zum Prüfzeitpunkt
|
||
// mindestens diese Höhe über 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 müssen innerhalb dieses Azimutbogens liegen.
|
||
// So vermeiden wir Treffer, bei denen die Planeten zu weit über den Himmel
|
||
// verteilt sind und nicht mehr als zusammenhängende Parade wirken.
|
||
$planetParadeMaxAzimuthSpanDeg = 120.0;
|
||
|
||
// Mindestens so viele helle Planeten müssen 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 für ausgewählte Kometen
|
||
// -------------------------------------------------------------------------
|
||
// Gute Kometensichtbarkeit wird morgens vor Sonnenaufgang und abends nach
|
||
// Sonnenuntergang geprüft.
|
||
$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 können wir eine grobe Helligkeitsschwelle setzen, damit nur
|
||
// realistisch interessante Fenster erscheinen.
|
||
$favoriteCometVisibilityMaxMagnitude = 10.0;
|
||
|
||
// Einzelne Zufallstreffer sollen nicht als eigenes Monatsereignis erscheinen.
|
||
$favoriteCometVisibilityMinConsecutiveDays = 2;
|
||
|
||
if (!function_exists('getIsoWeekCountForYear')) {
|
||
function getIsoWeekCountForYear(int $year): int
|
||
{
|
||
return (int) (new DateTimeImmutable(sprintf('%04d-12-28', $year)))->format('W');
|
||
}
|
||
}
|
||
|
||
$years = range($currentYear - 2, $currentYear + 5);
|
||
$selectedWeek = max(1, min($selectedWeek, getIsoWeekCountForYear($selectedWeekYear)));
|
||
$weekNumbers = range(1, getIsoWeekCountForYear($selectedWeekYear));
|
||
$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 = [];
|
||
$goldenGatePlanetEvents = [];
|
||
$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;
|
||
$monthNarrativeText = '';
|
||
$observationTipInsertSql = '';
|
||
$observationTipCalendarIcs = '';
|
||
$observationTipInsertPayloadJson = '[]';
|
||
$observationTipInsertEndpoint = $publicBasePath . 'ajax/insert_observationtips.php';
|
||
$joomlaMonthlyPreviewEndpoint = $publicBasePath . 'ajax/save_monthly_preview_to_joomla.php';
|
||
$chartConfig = null;
|
||
$chartConfigJson = 'null';
|
||
$starDataJson = '[]';
|
||
$lineStarDataJson = '[]';
|
||
$constellationLinesJson = '[]';
|
||
$messierDataJson = '[]';
|
||
$planetDataJson = '[]';
|
||
$moonDataJson = 'null';
|
||
$monthChartCometsJson = '[]';
|
||
$moonPhaseEventsJson = '[]';
|
||
$monthEventListJson = '[]';
|
||
$starDataErrorJson = 'null';
|
||
$observationTipCalendarIcsJson = '""';
|
||
$observationTipCalendarFilenameJson = json_encode(
|
||
sprintf('astrotools-monatsereignisse-%04d-%02d.ics', $selectedYear, $selectedMonth),
|
||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||
);
|
||
|
||
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('normalizeObservationTipEvents')) {
|
||
function normalizeObservationTipEvents(array $events, DateTimeZone $timezone): array
|
||
{
|
||
$normalized = [];
|
||
|
||
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) {
|
||
$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;
|
||
}
|
||
|
||
$normalized[] = [
|
||
'moment' => $moment->setTimezone($timezone)->format('Y-m-d H:i:s'),
|
||
'tip' => $tip,
|
||
];
|
||
}
|
||
|
||
return $normalized;
|
||
}
|
||
}
|
||
|
||
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'
|
||
));
|
||
|
||
// Variante wird jährlich rotiert: Jahr mod 3 → 0=V1, 1=V2, 2=V3
|
||
$skyIntroByMonthV1 = [
|
||
1 => "Das bekannteste Wintersternbild prägt im Januar den Südhimmel: 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 => "Wer an einem klaren Februarabend nach draußen tritt, blickt in einen besonders eindrucksvollen Sternenhimmel. Die Wintersternbilder rund um den Orion prägen nun das Bild 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 => "Noch halten die Wintersternbilder den Abendhimmel im März fest in ihrem Griff: Orion, Großer Hund, Kleiner Hund, Zwillinge, Fuhrmann und Stier sind vertraut. 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 => "Der Frühjahrsabendhimmel zeigt sich im April ganz im Zeichen der Frühlingssternbilder. 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 => "Hoch über dem Südhorizont thront am Maiabend das Sternbild Jungfrau. 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 => "Unverkennbar kündigt sich der Sommerhimmel im Juni 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.\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. 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.",
|
||
7 => "Für Beobachter weiter südlich im Mittelmeerraum steigen Skorpion und Schütze im Juli imposant hoch – von Deutschland aus dagegen bleiben beide Sternbilder stets tief über dem Horizont. Deutlich günstiger zeigt sich der Sommerhimmel in unseren Breiten bei einem Blick nach oben und in Richtung Osten.\n\nOberhalb von Skorpion und Schütze liegen die eher lichtschwachen Sternbilder Schlangenträger und Schlange; noch darüber befindet sich Herkules. 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 steht inzwischen in nordwestlicher Richtung und sinkt im Lauf der Nacht immer tiefer zum Horizont.",
|
||
8 => "Wenn es nach 22 Uhr langsam dunkel wird, taucht der Augusthimmel in seine ganze sommerliche Pracht. Nach und nach treten 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. 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.\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. 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.",
|
||
9 => "Schon gegen 21.30 Uhr sind die meisten Sternbilder im September wieder gut zu erkennen – die hellen Sommernächte sind vorbei, und die Dunkelheit kehrt merklich früher zurück. 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. Wega, Altair im Adler und Deneb im Schwan 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 => "Längere Nächte als Tage – das ist das Markenzeichen des Oktobers, seit die Sonne Ende September den Himmelsäquator nach Süden überschritten hat. Am frühen Abend erinnert der Sternenhimmel noch an den Sommer: Das Sommerdreieck aus Wega, Deneb und Altair ist gut zu erkennen.\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. 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.",
|
||
11 => "Das Herbstviereck dominiert den Novemberhimmel – dieses markante Quadrat, das meist auch Pegasus-Quadrat genannt wird, ist kaum zu übersehen. 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. Im Osten kündigt sich dagegen schon der Winterhimmel an. 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.",
|
||
12 => "Schon früh am Abend enthüllt der Dezemberhimmel die ganze Pracht des Winters. Die Sommersternbilder Schwan, Leier und Adler verabschieden sich; Orion, Stier, Zwillinge und Fuhrmann sind schon gut aufgegangen. 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 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 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.",
|
||
];
|
||
|
||
$skyIntroByMonthV2 = [
|
||
1 => "Kein Sternbild des Winters ist eindrucksvoller als der Orion, der im Januar hoch im Süden steht. Seine sieben Hauptsterne leuchten auffallend hell; mit etwas Vorstellungskraft erkennt man in ihrer Anordnung die Gestalt des Himmelsjägers. Unterhalb der drei markanten Gürtelsterne lässt sich bereits mit einem Fernglas der blass schimmernde Orionnebel entdecken. Rigel markiert dabei die rechte untere Ecke dieser prächtigen Sternfigur.\n\nGleichzeitig ist Rigel Teil des Wintersechsecks – nicht ein eigenes Sternbild, sondern eine auffällige Formation aus sechs hellen Sternen verschiedener Sternbilder. Im Uhrzeigersinn schließen sich an: Sirius im Großen Hund, Prokyon im Kleinen Hund, Castor in den Zwillingen, Capella im Fuhrmann und Aldebaran im Stier. Die Wintermilchstraße durchquert diesen Bereich, verläuft durch Großen Hund, Einhorn, Zwillinge, Stier, Fuhrmann, Perseus und Kassiopeia und wirkt dabei schwächer als im Sommer – unser Blick ist jetzt eher zum Rand der Galaxis gerichtet.",
|
||
2 => "Sternenreicher als im Februar zeigt sich der Winterhimmel kaum. Die markanten Sternbilder rund um den Orion beherrschen um 22 Uhr, auf diese Zeit bezieht sich die Sternkarte, die Szene, nachdem sie den Meridian bereits überschritten haben. Unterhalb des Orion glitzert Sirius im Großen Hund, der hellste Stern des gesamten Nachthimmels. Rechts oben steht der Stier mit Aldebaran, noch höher der Fuhrmann mit der gelblich leuchtenden Capella, links daneben die Zwillinge mit Castor und Pollux. Prokyon, Hauptstern des Kleinen Hundes, leuchtet unterhalb der Zwillinge.\n\nDer Osthimmel verrät unterdessen schon die nächste Jahreszeit: Der Löwe mit Regulus ist vollständig aufgegangen, und Alphard in der Wasserschlange leuchtet als auffälliger Einzelstern darunter. Die Jungfrau lässt sich erst nach Mitternacht vollständig über dem Horizont blicken.",
|
||
3 => "Noch stehen die Wintersternbilder am Märzhimmel bereit – Orion, Großer Hund, Kleiner Hund, Zwillinge, Fuhrmann und Stier beleben den Abend. Doch im Lauf der Nacht sinken sie nach Westen ab, während im Osten und Süden die Frühlingssternbilder aufsteigen. Um Mitternacht bestimmen vor allem die Sternbilder um den Löwen das Bild.\n\nDas Tierkreissternbild Krebs liegt zwischen den Zwillingen und dem Löwen. Unscheinbar und schwer zu erkennen, verbirgt es doch einen Schatz: den offenen Sternhaufen Praesepe – die „Krippe\" –, der bei klarem, dunklem Himmel als milchiger Lichtfleck sichtbar wird. Der Große Bär thront hoch über unseren Köpfen; seine sieben hellsten Sterne formen den vertrauten Großen Wagen. Spica, der hellste Stern der Jungfrau, geht im Südosten auf. Im Nordosten leuchtet der orange Arktur im Bärenhüter. Tief im Süden streckt die Wasserschlange ihren vorderen Teil empor – oft ist nur Alphard zu sehen, dessen Name treffend „der Einsame\" bedeutet. Knapp über dem Horizont steht auch der kompakte Rabe.",
|
||
4 => "Drei helle Sterne ziehen am Aprilabend besonders den Blick auf sich: Regulus im Löwen, Spica in der Jungfrau und der orange Arktur im Bärenhüter. Gemeinsam formen sie das sogenannte Frühlingsdreieck. Der Löwe selbst steht genau im Süden; wer mag, erkennt in seiner Sternfigur eine liegende Raubkatze.\n\nDer Große Wagen steht hoch über uns, beinahe senkrecht aufgerichtet – er ist das auffälligste Teilstück des Großen Bären. Zwischen ihm und dem Löwen tummeln sich eher unauffällige Sternbilder: Kleiner Löwe, Jagdhunde und Haar der Berenike. Ähnlich sternarm zeigt sich der Himmelsstreifen zwischen dem Löwen und dem Horizont, wo das ausgedehnte, aber lichtschwache Sternbild Wasserschlange entlangzieht. Von den Sternbildern dieses Himmelsabschnitts sind Becher und Rabe noch am ehesten zu erkennen.",
|
||
5 => "Das Frühlingsdreieck steht am Maiabendhimmel in voller Pracht: Arktur im Bärenhüter glüht orange hoch am Himmel, Spica in der Jungfrau leuchtet tiefer im Süden, und Regulus im Löwen hält im Südwesten die Stellung. Über dem Frühlingsdreieck liegt das eher unauffällige Sternbild Haar der Berenike.\n\nAm Südosthorizont zeigt sich schon ein Vorbote des Sommers: der Skorpion, noch sehr tief, kündigt sich an. Im Westen verschwinden langsam die letzten Wintergestirne unter dem Horizont, während im Osten Wega – der helle Hauptstern der Leier – schon erstrahlt. Zwischen Wega und Arktur liegen das lichtschwächere Herkules und der anmutige Halbkreis der Nördlichen Krone.",
|
||
6 => "Tief im Südosten glüht Antares, der rötliche Hauptstern des Skorpions, in den Juninächten dicht über dem Horizont. Höher steigt er von unseren Breiten aus nicht. Weit oben thront der Große Wagen als auffälligster Teil des Großen Bären; sein geschwungener Deichselbogen weist zuverlässig zu Arktur und weiter zu Spica. Weiter westlich sinkt der Löwe allmählich Richtung Horizont.\n\nLinks und oberhalb von Arktur wölbt sich die Nördliche Krone – ein zarter Halbkreis, leicht zu finden. Ihr hellster Stern Gemma, der „Edelstein\", trägt seinen Namen zu Recht. Unterhalb davon breiten sich Schlangenträger und die zweigeteilte Schlange aus – weniger auffällig, aber bei dunklem Himmel gut verfolgbar. Den eigentlichen Blickfang des Junihimmels bildet das Sommerdreieck im Osten: Wega (Leier), Deneb (Schwan) und Altair (Adler) leuchten auf und werden uns durch die kommenden Monate begleiten.",
|
||
7 => "Tief über dem Südhorizont zeigen sich im Juli Skorpion und Schütze – für Beobachter im Mittelmeerraum ein großartiger Anblick, von Deutschland aus jedoch stets nur knapp über dem Horizont. Oberhalb der beiden südlichen Sternbilder folgen Schlangenträger, Schlange und Herkules, alle eher unscheinbar, aber typisch für den Sommerhimmel.\n\nWirklich ins Auge fallen dagegen Arktur, Wega, Deneb und Altair – die vier Leuchttürme der warmen Jahreszeit. Wega, Deneb und Altair bilden das Sommerdreieck. Die Milchstraße zieht als Band über den Himmel, bleibt aber bei den noch hellen Julisommernächten oft kaum sichtbar – ihre Zeit kommt im August. Der Große Bär ist mittlerweile in den Nordwesten gerückt.",
|
||
8 => "Kaum eine andere Jahreszeit zeigt den Sternenhimmel so eindrucksvoll wie der August. Nach 22 Uhr wird es langsam dunkel, und Stern für Stern taucht auf. Um 23 Uhr dominiert das Sommerdreieck den Zenit: Wega in der Leier thront fast senkrecht über uns, links davon Deneb im Schwan, deutlich tiefer Altair im Adler – markant und leicht zu finden.\n\nDie kürzer werdenden Nächte schenken der Milchstraße ihre Bühne zurück: Als schimmerndes Band durchzieht sie das Sommerdreieck, am eindrucksvollsten weit draußen in der Dunkelheit. Im Osten weisen Pegasus und Andromeda auf den nahenden Herbst. Den Höhepunkt setzt der Meteorstrom der Perseiden: Um den 12. August regnen Staubteilchen des Kometen Swift-Tuttle als Sternschnuppen durch die Atmosphäre.",
|
||
9 => "Deutlich früher als noch vor wenigen Wochen wird es im September dunkel. Gegen 21.30 Uhr sind die meisten Sternbilder bereits gut erkennbar. Wega strahlt als erster auffälliger Stern über uns; wer genauer hinschaut, findet auch die kleine Raute der Leier. Zusammen mit Altair im Adler und Deneb im Schwan bildet Wega das große Sommerdreieck, das noch den Herbsthimmel überbrückt.\n\nInnerhalb und seitlich des Sommerdreiecks verstecken sich die kompakten Sternbilder Pfeil und Delfin – nicht besonders hell, aber durch ihre einprägsame Form überraschend einfach aufzufinden. Die Milchstraße erstrahlt jetzt wieder in vollem Glanz als fahles Band quer über den Himmel. Im Westen weichen die Frühsommersternbilder zurück, im Osten treten die ersten Herbstgestalten hervor – ein Himmel im Übergang.",
|
||
10 => "Mit dem Oktober kehren die langen Nächte zurück. Seit die Sonne Ende September den Himmelsäquator nach Süden überschritt, überwiegt die Dunkelheit. Am frühen Abend ist noch das Sommerdreieck mit Wega, Deneb und Altair präsent; im weiteren Verlauf der Nacht übernehmen die Herbststernbilder das Ruder.\n\nGegen 23 Uhr Sommerzeit prangt das Herbstviereck hoch am Himmel. Drei seiner Ecksterne gehören zu Pegasus, die vierte – links oben – bereits zur Andromeda-Kette. Darunter erstrecken sich die lichtschwachen Tierkreissternbilder Steinbock, Wassermann und Fische – nur an dunklen Orten richtig zu erleben. Diphda im Walfisch ist einer der wenigen helleren Sterne in dieser Himmelsregion. Tief im Süden blinkt Fomalhaut im Südlichen Fisch.",
|
||
11 => "Herbststimmung liegt über dem Novemberhimmel. Zentrales Schaustück ist das Pegasus-Quadrat – drei Sterne aus Pegasus und einer aus der Andromeda-Kette. Im Sternbild Andromeda lohnt ein gezielter Blick: Die Andromedagalaxie, mit bloßem Auge das fernste sichtbare Objekt überhaupt, erscheint an dunklen Standorten als schwacher, nebliger Lichtfleck.\n\nIm Südwesten und Süden breiten sich die lichtschwachen Sternbilder Wassermann, Walfisch und Fische aus. Im Walfisch liegt Mira, ein Stern mit stark schwankender Helligkeit – mal leicht zu sehen, mal kaum vorhanden. Der Osten hingegen zeigt schon die Vorzeichen des Winters: Die Kassiopeia formt ihr charakteristisches W, Perseus steigt mit dem veränderlichen Algol empor, der Stier zeigt Plejaden und Aldebaran. Am Osthorizont erscheint schließlich Orion – der unumstrittene König des Winterhimmels.",
|
||
12 => "Früh senkt sich im Dezember die Dunkelheit über den Himmel, und mit ihr erstrahlen die Wintergestalten. Schwan, Leier und Adler rücken in den Abendstunden schon in den Hintergrund; Orion, Stier, Zwillinge und Fuhrmann haben längst ihren Platz eingenommen. Sirius erscheint früh über dem Südosthorizont. In der Neujahrsnacht erreicht er gegen Mitternacht seinen Tageshöchstpunkt und steht dann von Deutschland aus fast genau im Süden.\n\nIm Südwesten verbleiben Reste des Herbsthimmels: Wassermann, Fische und Walfisch unterhalb des Herbstvierecks, lichtschwach und weitläufig. Zwischen Walfisch und Orion windet sich der Eridanus – seine Sterne sind unauffällig, doch sein geschwungener Verlauf lädt bei klarer Nacht zu einer kleinen Himmelsreise ein.",
|
||
];
|
||
|
||
$skyIntroByMonthV3 = [
|
||
1 => "Sieben helle Sterne zeichnen den Orion an den Januarnächten in den Südhimmel – das bekannteste Wintersternbild ist unverkennbar. Die Anordnung der Hauptsterne lässt mit etwas Fantasie die Gestalt des mythischen Jägers erahnen. Unterhalb der drei Gürtelsterne schimmert bereits im Fernglas der Orionnebel. Rigel markiert die rechte untere Ecke dieser imposanten Figur.\n\nRigel gehört zugleich zum Wintersechseck, einer auffälligen Formation aus sechs Sternen verschiedener Sternbilder. Im Uhrzeigersinn reihen sich an: Sirius (Großer Hund), Prokyon (Kleiner Hund), Castor (Zwillinge), Capella (Fuhrmann) und Aldebaran (Stier). Die Wintermilchstraße durchzieht denselben Himmelsbereich – von Großem Hund über Zwillinge, Stier, Fuhrmann und Perseus bis zur Kassiopeia. Schwächer als im Sommer, weist unser Blick doch zum Rand der Galaxis.",
|
||
2 => "Sternreich und leuchtend präsentiert sich der Himmel an einem klaren Februarabend. Um 22 Uhr, auf diese Zeit bezieht sich die Sternkarte, haben die Wintersternbilder um den Orion den Meridian bereits passiert. Unter dem Orion glänzt Sirius, der hellste Stern des Nachthimmels. Oben rechts steht der Stier mit Aldebaran, darüber der Fuhrmann mit der gelblich leuchtenden Capella, links davon die Zwillinge mit Castor und Pollux. Prokyon im Kleinen Hund leuchtet unterhalb der Zwillinge.\n\nDer Frühlingsvorgeschmack lauert im Osten: Der Löwe mit Regulus ist vollständig aufgegangen; darunter strahlt Alphard als auffälliger Einzelstern in der Wasserschlange. Die Jungfrau wartet noch bis nach Mitternacht.",
|
||
3 => "Der Märzhimmel steht zwischen den Jahreszeiten. Die vertrauten Wintersternbilder – Orion, Großer Hund, Kleiner Hund, Zwillinge, Fuhrmann, Stier – beleben den frühen Abend. Doch Stunde für Stunde wandern sie westwärts, während die Frühlingssternbilder im Osten und Süden nachrücken. Um Mitternacht gehört die Bühne den Sternbildern um den Löwen.\n\nZwischen Zwillingen und Löwen liegt der unscheinbare Krebs. Wer ihn unter dunklem Himmel aufsucht, wird mit einem Anblick belohnt: dem offenen Sternhaufen Praesepe, der „Krippe\", als milchiger Lichtfleck. Der Große Wagen steht im Zenit, hoch wie selten. Spica geht im Südosten auf; der orange Arktur leuchtet im Nordosten. Tief im Süden streckt die Wasserschlange ihren Vorderteil empor – oft nur durch den vereinsamten Alphard verraten, der seinen Namen „der Einsame\" zurecht trägt. Auch der kompakte Rabe kauert knapp über dem Horizont.",
|
||
4 => "Ein Dreieck aus hellen Sternen zieht im April den Blick unweigerlich an: Regulus im Löwen, Spica in der Jungfrau und Arktur im Bärenhüter bilden das Frühlingsdreieck. Der Löwe selbst thront genau im Süden; wer mag, erkennt in seiner Sternfigur eine liegende Raubkatze.\n\nDer Große Wagen steht hoch über uns, beinahe senkrecht aufgerichtet. Zwischen ihm und dem Löwen sind die Sternbilder Kleiner Löwe, Jagdhunde und Haar der Berenike wenig auffällig. Ebenso sternarm zeigt sich der Himmelsstreifen zwischen dem Löwen und dem Horizont, wo die Wasserschlange langgezogen dahinfließt. Markanter sind noch Becher und Rabe, deren Sternfiguren trotz mäßiger Helligkeit gut erkennbar sind.",
|
||
5 => "Arktur glüht orange am Maiabendhimmel – kaum ein Stern im Frühjahr ist auffälliger. Der Hauptstern des Bärenhüters leuchtet hoch im Süden, tiefer schimmert Spica in der Jungfrau, und Regulus im Löwen hält im Südwesten. Diese drei Sterne – Arktur, Spica und Regulus – spannen das Frühlingsdreieck. Über ihnen, etwas blasser, liegt das Sternbild Haar der Berenike.\n\nAm Südosthorizont zeigt sich der Skorpion mit seinem Hauptstern Antares – noch sehr tief, ein erster Sommergruß. Im Westen sinken die letzten Wintergestirne langsam hinab; im Osten steigt Wega in der Leier bereits auf. Zwischen Wega und Arktur liegt das lichtschwächere Herkules, flankiert vom eleganten Halbkreis der Nördlichen Krone.",
|
||
6 => "Kaum über dem Südosthorizont hält sich im Juni Antares, der rötlich leuchtende Hauptstern des Skorpions. In unseren Breiten bleibt er ein Horizontgast. Oben dominiert der Große Wagen als auffälligster Teil des Großen Bären; seine gebogene Deichsel weist zuverlässig zu Arktur und weiter zu Spica. Im Südwesten neigt sich der Löwe dem Untergang.\n\nLinks und über Arktur öffnet sich der zarte Halbkreis der Nördlichen Krone – Gemma, der „Edelstein\", ist ihr hellster Stern. Darunter breiten sich Schlangenträger und die zweigeteilte Schlange aus, lichtschwach, bei dunklem Himmel aber gut zu verfolgen. Den Hauptanziehungspunkt des Juniabends bildet das Sommerdreieck im Osten: Wega (Leier), Deneb (Schwan) und Altair (Adler) leuchten auf und werden den Himmel noch Monate lang prägen.",
|
||
7 => "Skorpion und Schütze zeigen sich im Juli von ihrer schönsten Seite – allerdings nur, wer weit genug südlich beobachtet. Von Deutschland aus bleiben beide Sternbilder flach über dem Horizont. Weiter oben am Himmel reihen sich Schlangenträger, Schlange und Herkules, alle eher unscheinbar, aber typisch für den Sommerhimmel.\n\nDeutlich auffälliger sind Arktur, Wega, Deneb und Altair – die vier Leuchttürme der warmen Jahreszeit. Wega, Deneb und Altair bilden das Sommerdreieck. Die Milchstraße zieht als Band über den Himmel, bleibt aber bei den noch hellen Julisommernächten oft kaum sichtbar – ihre Zeit kommt im August. Der Große Bär ist mittlerweile in den Nordwesten gewandert.",
|
||
8 => "Kein Monat empfängt die Sterngucker besser als der August. Nach 22 Uhr wird es langsam dunkel, und Stern für Stern taucht auf. Um 23 Uhr thront das Sommerdreieck über uns: Wega nahezu senkrecht im Zenit, links Deneb im Schwan, tiefer Altair im Adler – markant und leicht zu finden.\n\nDie kürzer werdenden Nächte schenken der Milchstraße ihre Bühne zurück: Als schimmerndes Band durchzieht sie das Sommerdreieck, am eindrucksvollsten weit draußen in der Dunkelheit. Im Osten weisen Pegasus und Andromeda auf den nahenden Herbst. Den Höhepunkt setzt der Meteorstrom der Perseiden: Um den 12. August regnen Staubteilchen des Kometen Swift-Tuttle als Sternschnuppen durch die Atmosphäre.",
|
||
9 => "Früher Dunkel, früher Sterne: Der September schenkt uns wieder richtige Nächte. Gegen 21.30 Uhr füllt sich der Himmel – Wega zeigt sich als erste Leuchtbake hoch über uns. Genauer betrachtet, entfaltet sich um sie herum die kleine Raute der Leier. Altair im Adler und Deneb im Schwan ergänzen das Sommerdreieck, das auch jetzt noch den Herbsthimmel überbrückt.\n\nPfeil und Delfin, zwei kompakte kleine Sternbilder, verstecken sich im und neben dem Sommerdreieck – nicht hell, aber durch ihre einprägsame Form überraschend gut zu finden. Oben wölbt sich die Milchstraße wieder sichtbar als fahles, schimmerndes Band. Im Westen verabschieden sich die Frühsommersternbilder, im Osten erscheinen die ersten Herbstgestalten.",
|
||
10 => "Der Herbst hat den Himmel übernommen – zumindest dem Kalender nach. Am frühen Oktoberabend erinnert noch das Sommerdreieck (Wega, Deneb, Altair) an den Sommer; im Laufe der Nacht rücken die Herbststernbilder in den Vordergrund.\n\nGegen 23 Uhr Sommerzeit prangt das Herbstviereck hoch am Himmel: drei Sterne aus Pegasus, ein vierter aus der Andromeda-Kette. Darunter erstrecken sich die lichtschwachen Tierkreissternbilder Steinbock, Wassermann und Fische – nur an dunklen Orten richtig zu erleben. Diphda im Walfisch sticht als einer der wenigen helleren Sterne in dieser Himmelsregion heraus. Tief im Süden blinkt Fomalhaut im Südlichen Fisch.",
|
||
11 => "Pegasus-Quadrat und Andromeda stehen im November im Mittelpunkt – drei Ecken aus Pegasus, eine aus der Andromeda-Kette. Im Sternbild Andromeda lohnt ein gezielter Blick: Die Andromedagalaxie, mit bloßem Auge das fernste sichtbare Objekt überhaupt, erscheint an dunklem Himmel als schwacher, nebliger Fleck.\n\nIm Südwesten und Süden breiten sich die lichtschwachen Sternbilder Wassermann, Walfisch und Fische aus. Im Walfisch liegt Mira, ein Stern mit stark schwankender Helligkeit – mal leicht zu sehen, mal kaum vorhanden. Der Osten hingegen zeigt schon die Vorzeichen des Winters: Die Kassiopeia formt ihr charakteristisches W, Perseus steigt mit dem veränderlichen Algol empor, der Stier zeigt Plejaden und Aldebaran. Am Osthorizont erscheint schließlich Orion – der unumstrittene König des Winterhimmels.",
|
||
12 => "Lange Nächte und ein glanzvoller Winterhimmel – das ist der Dezember. Die Sommersternbilder ziehen sich in den frühen Abendstunden zurück; Orion, Stier, Zwillinge und Fuhrmann haben längst ihren Platz eingenommen. Sirius erscheint früh über dem Südosthorizont. In der Neujahrsnacht erreicht er gegen Mitternacht seinen Tageshöchstpunkt und steht dann von Deutschland aus fast genau im Süden.\n\nIm Südwesten verbleiben Reste des Herbsthimmels: Wassermann, Fische und Walfisch unterhalb des Herbstvierecks, lichtschwach und weitläufig. Zwischen Walfisch und Orion windet sich der Eridanus – seine Sterne sind unauffällig, doch sein geschwungener Verlauf lädt bei klarer Nacht zu einer kleinen Himmelsreise ein.",
|
||
];
|
||
|
||
$skyIntroByMonth = match ($year % 3) {
|
||
1 => $skyIntroByMonthV2,
|
||
2 => $skyIntroByMonthV3,
|
||
default => $skyIntroByMonthV1,
|
||
};
|
||
|
||
foreach ($skyIntroByMonth as $introMonthNumber => $introText) {
|
||
$skyIntroByMonth[$introMonthNumber] = [$introText];
|
||
}
|
||
|
||
$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 = array_values(array_filter(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['moon_phase', 'golden_handle', 'moon_planet', 'moon_planet_approach', 'moon_deep_sky', 'moon_deep_sky_approach', 'moon_occultation', 'moon_apsis'], true)
|
||
));
|
||
// Jahreszahl aus Datum entfernen (z. B. "19.04.2026" → "19.04.")
|
||
$shortDate = static function (string $d): string {
|
||
return (string) preg_replace('/\.\d{4}$/', '.', $d);
|
||
};
|
||
|
||
if ($moonEvents !== []) {
|
||
$sortedMoonEvents = $moonEvents;
|
||
usort($sortedMoonEvents, static fn (array $a, array $b): int => strcmp(
|
||
(string) ($a['local_iso'] ?? $a['date'] ?? ''),
|
||
(string) ($b['local_iso'] ?? $b['date'] ?? '')
|
||
));
|
||
|
||
$phaseEvents = array_values(array_filter($sortedMoonEvents, static fn (array $e): bool => (string) ($e['type'] ?? '') === 'moon_phase'));
|
||
// Alle Mondphasen und alle Mondereignisse werden vollständig übernommen.
|
||
$nonPhaseCandidates = array_values(array_filter(
|
||
$sortedMoonEvents,
|
||
static fn (array $e): bool => (string) ($e['type'] ?? '') !== 'moon_phase'
|
||
));
|
||
$nonPhaseEvents = $nonPhaseCandidates;
|
||
usort($nonPhaseEvents, static fn (array $a, array $b): int => strcmp(
|
||
(string) ($a['local_iso'] ?? $a['date'] ?? ''),
|
||
(string) ($b['local_iso'] ?? $b['date'] ?? '')
|
||
));
|
||
|
||
$moonSentences = [];
|
||
|
||
// 1. Mondphasen in einem natürlichen Satz zusammenfassen
|
||
if ($phaseEvents !== []) {
|
||
$phaseParts = [];
|
||
foreach ($phaseEvents as $pe) {
|
||
$rawLabel = trim((string) ($pe['event'] ?? ''));
|
||
$date = trim((string) ($pe['date'] ?? ''));
|
||
if ($rawLabel === '' || $date === '') {
|
||
continue;
|
||
}
|
||
// "Mond Erstes Viertel" → "Erstes Viertel"
|
||
$cleanLabel = (string) preg_replace('/^Mond\s+/', '', $rawLabel);
|
||
$phaseParts[] = $cleanLabel . ' am ' . $shortDate($date);
|
||
}
|
||
if ($phaseParts !== []) {
|
||
$lastPhase = array_pop($phaseParts);
|
||
$phaseList = $phaseParts !== [] ? implode(', ', $phaseParts) . ' und ' . $lastPhase : $lastPhase;
|
||
$moonSentences[] = 'Die Mondphasen im Überblick: ' . $phaseList . '.';
|
||
}
|
||
}
|
||
|
||
// 2. Apsis, Begegnungen und Goldener Henkel – nach Datum gruppiert
|
||
$byDate = [];
|
||
foreach ($nonPhaseEvents as $ne) {
|
||
$date = trim((string) ($ne['date'] ?? ''));
|
||
if ($date === '') {
|
||
continue;
|
||
}
|
||
$byDate[$date][] = $ne;
|
||
}
|
||
|
||
foreach ($byDate as $date => $dateEvents) {
|
||
$apsis = null;
|
||
$approaches = [];
|
||
$goldenHandle = null;
|
||
$occultationTargets = [];
|
||
foreach ($dateEvents as $de) {
|
||
$type = (string) ($de['type'] ?? '');
|
||
if ($type === 'moon_apsis') {
|
||
$apsis = $de;
|
||
} elseif ($type === 'golden_handle') {
|
||
$goldenHandle = $de;
|
||
} elseif ($type === 'moon_occultation' && isset($de['event_kind'])) {
|
||
$occultationLabel = trim((string) ($de['event'] ?? ''));
|
||
if (str_starts_with($occultationLabel, 'Mond bedeckt ')) {
|
||
$target = substr($occultationLabel, strlen('Mond bedeckt '));
|
||
$target = trim(explode(' – ', $target, 2)[0]);
|
||
if ($target !== '') {
|
||
$occultationTargets[$target] = true;
|
||
}
|
||
}
|
||
} else {
|
||
$approaches[] = $de;
|
||
}
|
||
}
|
||
|
||
$sd = $shortDate($date);
|
||
|
||
foreach (array_keys($occultationTargets) as $target) {
|
||
$moonSentences[] = 'Am ' . $sd . ' bedeckt der Mond ' . $target . '.';
|
||
}
|
||
|
||
if ($goldenHandle !== null) {
|
||
$moonSentences[] = 'Am ' . $sd . ' zeigt der Mond den Goldenen Henkel.';
|
||
}
|
||
|
||
if ($apsis !== null) {
|
||
$apsisLabel = trim((string) ($apsis['event'] ?? ''));
|
||
// Distanz aus dem Label extrahieren: "(404.960 km)"
|
||
$distanceMatch = [];
|
||
$distanceStr = '';
|
||
if (preg_match('/\(([0-9.,]+\s*km)\)\s*$/', $apsisLabel, $distanceMatch)) {
|
||
$distanceStr = ' mit nur ' . $distanceMatch[1];
|
||
}
|
||
$isPerigee = str_contains($apsisLabel, 'Erdnähe') || str_contains($apsisLabel, 'Perigäum');
|
||
if ($isPerigee) {
|
||
$apsisText = 'Am ' . $sd . ' erreicht der Mond sein Perigäum' . $distanceStr . ' – er steht der Erde in diesem Monat am nächsten';
|
||
} else {
|
||
$apsisText = 'Am ' . $sd . ' erreicht der Mond sein Apogäum' . $distanceStr . ' – er steht der Erde in diesem Monat am fernsten';
|
||
}
|
||
if ($approaches !== []) {
|
||
// Begegnungen natürlich formulieren: "und zieht dabei in nur X Grad Abstand an Y vorbei"
|
||
$approachFragments = [];
|
||
foreach ($approaches as $ap) {
|
||
$apLabel = trim((string) ($ap['event'] ?? ''));
|
||
$sepMatch = [];
|
||
$sepStr = '';
|
||
if (preg_match('/\(([0-9.,]+\s*Grad)\)/', $apLabel, $sepMatch)) {
|
||
$apLabel = trim((string) preg_replace('/\s*\([0-9.,]+\s*Grad\)/', '', $apLabel));
|
||
$sepStr = ' in nur ' . $sepMatch[1] . ' Abstand';
|
||
}
|
||
if (str_starts_with($apLabel, 'Mond nahe ')) {
|
||
$target = substr($apLabel, strlen('Mond nahe '));
|
||
$approachFragments[] = $sepStr . ' an ' . $target . ' vorbei';
|
||
} elseif (str_starts_with($apLabel, 'Mond bedeckt ')) {
|
||
$target = substr($apLabel, strlen('Mond bedeckt '));
|
||
$approachFragments[] = ' und bedeckt dabei ' . $target;
|
||
} else {
|
||
$approachFragments[] = $sepStr !== '' ? $sepStr . ' bei ' . $apLabel : ' bei ' . $apLabel;
|
||
}
|
||
}
|
||
$approachClause = count($approachFragments) === 1
|
||
? ' und zieht dabei' . $approachFragments[0]
|
||
: ' und zieht dabei' . implode(' sowie', $approachFragments);
|
||
$moonSentences[] = $apsisText . $approachClause . '.';
|
||
} else {
|
||
$moonSentences[] = $apsisText . '.';
|
||
}
|
||
} elseif ($approaches !== []) {
|
||
foreach ($approaches as $ap) {
|
||
$apLabel = trim((string) ($ap['event'] ?? ''));
|
||
$apDate = trim((string) ($ap['date'] ?? ''));
|
||
$sepMatch = [];
|
||
$sepStr = '';
|
||
if (preg_match('/\(([0-9.,]+\s*Grad)\)/', $apLabel, $sepMatch)) {
|
||
$apLabel = trim((string) preg_replace('/\s*\([0-9.,]+\s*Grad\)/', '', $apLabel));
|
||
$sepStr = ' in nur ' . $sepMatch[1] . ' Abstand';
|
||
}
|
||
$apSd = $shortDate($apDate !== '' ? $apDate : $date);
|
||
$eventKind = (string) ($ap['event_kind'] ?? '');
|
||
if ($eventKind !== '' && str_starts_with($apLabel, 'Mond bedeckt ')) {
|
||
$target = substr($apLabel, strlen('Mond bedeckt '));
|
||
$apTime = trim((string) ($ap['time'] ?? ''));
|
||
$timeClause = $apTime !== '' ? ' um ' . $apTime . ' Uhr' : '';
|
||
$moonSentences[] = match ($eventKind) {
|
||
'start' => 'Am ' . $apSd . $timeClause . ' beginnt die Bedeckung von ' . $target . ' durch den Mond.',
|
||
'maximum' => 'Am ' . $apSd . $timeClause . ' erreicht die Bedeckung von ' . $target . ' durch den Mond ihr Maximum.',
|
||
'end' => 'Am ' . $apSd . $timeClause . ' endet die Bedeckung von ' . $target . ' durch den Mond.',
|
||
default => 'Am ' . $apSd . $timeClause . ' bedeckt der Mond ' . $target . '.',
|
||
};
|
||
continue;
|
||
}
|
||
if (str_starts_with($apLabel, 'Mond nahe ')) {
|
||
$target = substr($apLabel, strlen('Mond nahe '));
|
||
$moonSentences[] = 'Am ' . $apSd . ' zieht der Mond' . $sepStr . ' an ' . $target . ' vorbei.';
|
||
} elseif (str_starts_with($apLabel, 'Mond streift ')) {
|
||
$target = substr($apLabel, strlen('Mond streift '));
|
||
$moonSentences[] = 'Am ' . $apSd . ' streift der Mond ' . $target . '.';
|
||
} elseif (str_starts_with($apLabel, 'Mond bedeckt ')) {
|
||
$target = substr($apLabel, strlen('Mond bedeckt '));
|
||
$moonSentences[] = 'Am ' . $apSd . ' bedeckt der Mond ' . $target . '.';
|
||
} else {
|
||
$moonSentences[] = $apLabel . ' am ' . $apSd . ($sepStr !== '' ? ' – ' . ltrim($sepStr) : '') . '.';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($moonSentences !== []) {
|
||
$paragraphs[] = implode("\n", $moonSentences);
|
||
}
|
||
}
|
||
|
||
// Hilfsfunktion: natürlichen Satz aus einem Planeten-Event bauen
|
||
$buildPlanetSentence = static function (array $event) use ($shortDate): ?string {
|
||
$type = (string) ($event['type'] ?? '');
|
||
$label = trim((string) ($event['event'] ?? ''));
|
||
$date = trim((string) ($event['date'] ?? ''));
|
||
if ($label === '' || $date === '') {
|
||
return null;
|
||
}
|
||
$sd = $shortDate($date);
|
||
|
||
if ($type === 'planet_conjunction') {
|
||
$sepMatch = [];
|
||
$sep = '';
|
||
if (preg_match('/\(([0-9.,]+\s*Grad)\)/', $label, $sepMatch)) {
|
||
$sep = $sepMatch[1];
|
||
$label = trim((string) preg_replace('/\s*\([0-9.,]+\s*Grad\)/', '', $label));
|
||
}
|
||
if (preg_match('/^(.+?)\s+nahe\s+(.+)$/i', $label, $m)) {
|
||
$p1 = trim($m[1]);
|
||
$p2 = trim($m[2]);
|
||
$sepNote = $sep !== '' ? ' – der Abstand beträgt nur ' . $sep : '';
|
||
return 'Am ' . $sd . ' rücken ' . $p1 . ' und ' . $p2 . ' eng zusammen' . $sepNote . '.';
|
||
}
|
||
}
|
||
|
||
if ($type === 'golden_gate_of_ecliptic') {
|
||
$planet = '';
|
||
if (preg_match('/^(\S+)\s+im\s+Goldenen\s+Tor/i', $label, $m)) {
|
||
$planet = $m[1];
|
||
}
|
||
if ($planet !== '') {
|
||
return 'Am ' . $sd . ' durchquert ' . $planet . ' das Goldene Tor der Ekliptik und zieht dabei zwischen Hyaden und Plejaden hindurch – ein lohnender Anblick im Fernglas.';
|
||
}
|
||
}
|
||
|
||
if ($type === 'inner_planet_elongation') {
|
||
$sepMatch = [];
|
||
$sepStr = '';
|
||
if (preg_match('/\(([0-9.,]+\s*Grad)\)/', $label, $sepMatch)) {
|
||
$sepStr = ' (' . $sepMatch[1] . ')';
|
||
$label = trim((string) preg_replace('/\s*\([0-9.,]+\s*Grad\)/', '', $label));
|
||
}
|
||
$isWest = str_contains($label, 'westlich') || str_contains($label, 'West');
|
||
$planet = '';
|
||
if (preg_match('/^(\S+)\s+/', $label, $m)) {
|
||
$planet = $m[1];
|
||
}
|
||
$visibility = $isWest ? 'als Morgenstern am besten zu beobachten' : 'als Abendstern am besten zu beobachten';
|
||
return 'Am ' . $sd . ' erreicht ' . ($planet ?: 'der Planet') . ' seine größte Elongation' . $sepStr . ' und ist dann ' . $visibility . '.';
|
||
}
|
||
|
||
if ($type === 'venus_peak_magnitude') {
|
||
$magMatch = [];
|
||
$magStr = '';
|
||
if (preg_match('/\(([^)]+)\)/', $label, $magMatch)) {
|
||
$magStr = ' mit ' . $magMatch[1];
|
||
}
|
||
return 'Am ' . $sd . ' erreicht Venus ihre größte Helligkeit' . $magStr . ' – sie ist dann selbst in der Dämmerung kaum zu übersehen.';
|
||
}
|
||
|
||
if ($type === 'outer_planet_event') {
|
||
$planet = '';
|
||
if (preg_match('/^(\S+)\s+/', $label, $m)) {
|
||
$planet = $m[1];
|
||
}
|
||
if (str_contains($label, 'Opposition')) {
|
||
return 'Am ' . $sd . ' steht ' . ($planet ?: 'der Planet') . ' in Opposition – er geht bei Sonnenuntergang auf, ist die ganze Nacht sichtbar und leuchtet so hell wie selten.';
|
||
}
|
||
if (str_contains($label, 'Konjunktion')) {
|
||
return 'Am ' . $sd . ' steht ' . ($planet ?: 'der Planet') . ' in Konjunktion mit der Sonne und ist für einige Wochen nicht zu beobachten.';
|
||
}
|
||
if (str_contains($label, 'Quadratur')) {
|
||
return 'Am ' . $sd . ' befindet sich ' . ($planet ?: 'der Planet') . ' in Quadratur – er steht 90 Grad von der Sonne entfernt und ist in der ersten oder zweiten Nachthälfte gut sichtbar.';
|
||
}
|
||
}
|
||
|
||
if ($type === 'mercury_good_visibility') {
|
||
return 'Um den ' . $sd . ' zeigt sich Merkur günstig am Himmel und lässt sich in der Dämmerung tief über dem Horizont aufspüren.';
|
||
}
|
||
|
||
if ($type === 'planet_parade') {
|
||
return 'Am ' . $sd . ' sind mehrere Planeten gleichzeitig am Himmel zu sehen – eine seltene Planetenparade, die einen Blick Richtung Süden lohnt.';
|
||
}
|
||
|
||
// Fallback
|
||
return 'Am ' . $sd . ' ist zu beobachten: ' . $label . '.';
|
||
};
|
||
|
||
$buildPlanetMotionSentence = static function (array $event) use ($shortDate): ?string {
|
||
$type = (string) ($event['type'] ?? '');
|
||
$label = trim((string) ($event['event'] ?? ''));
|
||
$date = trim((string) ($event['date'] ?? ''));
|
||
if ($label === '' || $date === '') {
|
||
return null;
|
||
}
|
||
$sd = $shortDate($date);
|
||
|
||
if ($type === 'outer_planet_station') {
|
||
$planet = '';
|
||
$isRetrograde = str_contains($label, 'rückläufig') || str_contains($label, 'Rückläufig');
|
||
$isPrograde = str_contains($label, 'rechtläufig') || str_contains($label, 'Rechtläufig') || str_contains($label, 'direkt');
|
||
if (preg_match('/^(\S+)\s+/', $label, $m)) {
|
||
$planet = $m[1];
|
||
}
|
||
if ($isRetrograde) {
|
||
return 'Ab dem ' . $sd . ' läuft ' . ($planet ?: 'der Planet') . ' rückläufig – er bewegt sich scheinbar rückwärts vor dem Sternhintergrund, was seine Beobachtung besonders interessant macht.';
|
||
}
|
||
if ($isPrograde) {
|
||
return 'Ab dem ' . $sd . ' setzt ' . ($planet ?: 'der Planet') . ' seine normale Vorwärtsbewegung am Himmel wieder fort.';
|
||
}
|
||
return 'Am ' . $sd . ' wird ' . ($planet ?: 'der Planet') . ' stationär und ändert seine Bewegungsrichtung am Himmel.';
|
||
}
|
||
|
||
if ($type === 'planet_constellation_change') {
|
||
// Label-Format: "Mars wechselt vom Sternbild Wassermann in das Sternbild Fische"
|
||
if (preg_match('/^(.+?)\s+wechselt\s+vom\s+Sternbild\s+(.+?)\s+in\s+das\s+Sternbild\s+(.+)$/i', $label, $m)) {
|
||
$planet = trim($m[1]);
|
||
$from = trim($m[2]);
|
||
$to = trim($m[3]);
|
||
// Formulierung deterministisch variieren (nach erstem Buchstaben des Planetennamens)
|
||
$variant = ord($planet[0]) % 3;
|
||
if ($variant === 1) {
|
||
return 'Am ' . $sd . ' verlässt ' . $planet . ' das Sternbild ' . $from . ' und zieht weiter in das Sternbild ' . $to . '.';
|
||
}
|
||
if ($variant === 2) {
|
||
return 'Am ' . $sd . ' tritt ' . $planet . ' aus dem Sternbild ' . $from . ' in das Sternbild ' . $to . ' über.';
|
||
}
|
||
return 'Am ' . $sd . ' wechselt ' . $planet . ' vom Sternbild ' . $from . ' in das Sternbild ' . $to . '.';
|
||
}
|
||
// Fallback ohne "vom"-Angabe
|
||
if (preg_match('/^(.+?)\s+wechselt\s+(?:in\s+das\s+Sternbild\s+|ins?\s+(?:Sternbild\s+)?)(.+)$/i', $label, $m)) {
|
||
return 'Am ' . $sd . ' wechselt ' . trim($m[1]) . ' in das Sternbild ' . trim($m[2]) . '.';
|
||
}
|
||
}
|
||
|
||
return 'Am ' . $sd . ' ist zu beobachten: ' . $label . '.';
|
||
};
|
||
|
||
$planetObservationEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['inner_planet_event', 'venus_peak_magnitude', 'outer_planet_event', 'planet_conjunction', 'planet_parade', 'mercury_good_visibility', 'golden_gate_of_ecliptic'], true),
|
||
5
|
||
);
|
||
if ($planetObservationEvents !== []) {
|
||
$planetSentences = [];
|
||
foreach ($planetObservationEvents as $pe) {
|
||
$sentence = $buildPlanetSentence($pe);
|
||
if ($sentence !== null) {
|
||
$planetSentences[] = $sentence;
|
||
}
|
||
}
|
||
if ($planetSentences !== []) {
|
||
$paragraphs[] = implode(' ', $planetSentences);
|
||
}
|
||
}
|
||
|
||
$planetMotionEvents = $findEvents(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['outer_planet_station', 'planet_constellation_change'], true),
|
||
8
|
||
);
|
||
if ($planetMotionEvents !== []) {
|
||
// Sternbild-Akkusativformen für natürliche Sätze ohne "Sternbild"-Wiederholung
|
||
$constellationAkk = [
|
||
'Widder' => 'den Widder', 'Stier' => 'den Stier', 'Zwillinge' => 'die Zwillinge',
|
||
'Krebs' => 'den Krebs', 'Löwe' => 'den Löwen', 'Jungfrau' => 'die Jungfrau',
|
||
'Waage' => 'die Waage', 'Skorpion' => 'den Skorpion', 'Schütze' => 'den Schützen',
|
||
'Steinbock' => 'den Steinbock', 'Wassermann' => 'den Wassermann', 'Fische' => 'die Fische',
|
||
'Walfisch' => 'den Walfisch', 'Orion' => 'den Orion', 'Fuhrmann' => 'den Fuhrmann',
|
||
'Stier' => 'den Stier', 'Kassiopeia' => 'die Kassiopeia', 'Andromeda' => 'die Andromeda',
|
||
'Perseus' => 'den Perseus', 'Pegasus' => 'den Pegasus', 'Leier' => 'die Leier',
|
||
'Schwan' => 'den Schwan', 'Adler' => 'den Adler', 'Schlange' => 'die Schlange',
|
||
'Schlangenträger' => 'den Schlangenträger', 'Herkules' => 'den Herkules',
|
||
'Bärenhüter' => 'den Bärenhüter', 'Großer Bär' => 'den Großen Bären',
|
||
'Drache' => 'den Drachen', 'Nördliche Krone' => 'die Nördliche Krone',
|
||
'Kleiner Löwe' => 'den Kleinen Löwen', 'Rabe' => 'den Raben', 'Becher' => 'den Becher',
|
||
'Wasserschlange' => 'die Wasserschlange', 'Eridanus' => 'den Eridanus',
|
||
'Hase' => 'den Hasen', 'Großer Hund' => 'den Großen Hund',
|
||
'Kleiner Hund' => 'den Kleinen Hund', 'Einhorn' => 'das Einhorn',
|
||
'Delfin' => 'den Delfin', 'Pfeil' => 'den Pfeil', 'Fuchs' => 'den Fuchs',
|
||
'Kepheus' => 'den Kepheus', 'Südlicher Fisch' => 'den Südlichen Fisch',
|
||
'Jagdhunde' => 'die Jagdhunde', 'Löwchen' => 'das Löwchen',
|
||
];
|
||
$akkusativ = static function (string $name) use ($constellationAkk): string {
|
||
return $constellationAkk[$name] ?? 'das Sternbild ' . $name;
|
||
};
|
||
|
||
$stationEvents = array_values(array_filter($planetMotionEvents, static fn (array $e): bool => (string) ($e['type'] ?? '') === 'outer_planet_station'));
|
||
$changeEvents = array_values(array_filter($planetMotionEvents, static fn (array $e): bool => (string) ($e['type'] ?? '') === 'planet_constellation_change'));
|
||
|
||
$motionSentences = [];
|
||
|
||
// Rückläufigkeits-Ereignisse einzeln als Satz
|
||
foreach ($stationEvents as $se) {
|
||
$sentence = $buildPlanetMotionSentence($se);
|
||
if ($sentence !== null) {
|
||
$motionSentences[] = $sentence;
|
||
}
|
||
}
|
||
|
||
// Sternbild-Wechsel: nach Planet gruppieren, kompakt als eine Aussage
|
||
if ($changeEvents !== []) {
|
||
$byPlanet = [];
|
||
$planetOrder = [];
|
||
foreach ($changeEvents as $ce) {
|
||
$label = trim((string) ($ce['event'] ?? ''));
|
||
$date = trim((string) ($ce['date'] ?? ''));
|
||
if (!preg_match('/^(.+?)\s+wechselt\s+vom\s+Sternbild\s+(.+?)\s+in\s+das\s+Sternbild\s+(.+)$/i', $label, $m)) {
|
||
continue;
|
||
}
|
||
$planet = trim($m[1]);
|
||
$to = trim($m[3]);
|
||
if (!isset($byPlanet[$planet])) {
|
||
$byPlanet[$planet] = [];
|
||
$planetOrder[] = $planet;
|
||
}
|
||
$byPlanet[$planet][] = ['date' => $date, 'to' => $to];
|
||
}
|
||
|
||
if ($byPlanet !== []) {
|
||
$parts = [];
|
||
foreach ($planetOrder as $planet) {
|
||
$moves = $byPlanet[$planet];
|
||
if (count($moves) === 1) {
|
||
$parts[] = $planet . ' am ' . $shortDate($moves[0]['date']) . ' in ' . $akkusativ($moves[0]['to']);
|
||
} else {
|
||
// Mehrfacher Wechsel desselben Planeten
|
||
$moveParts = [];
|
||
foreach ($moves as $move) {
|
||
$moveParts[] = 'am ' . $shortDate($move['date']) . ' in ' . $akkusativ($move['to']);
|
||
}
|
||
$lastMove = array_pop($moveParts);
|
||
$parts[] = $planet . ' ' . ($moveParts !== [] ? implode(', ', $moveParts) . ' und dann ' . $lastMove : $lastMove);
|
||
}
|
||
}
|
||
$lastPart = array_pop($parts);
|
||
$changeText = count($byPlanet) > 1
|
||
? implode(', ', $parts) . ' und ' . $lastPart
|
||
: $lastPart;
|
||
$motionSentences[] = 'Sternbildwechsel im Monatsverlauf: ' . $changeText . '.';
|
||
}
|
||
}
|
||
|
||
if ($motionSentences !== []) {
|
||
$paragraphs[] = implode(' ', $motionSentences);
|
||
}
|
||
}
|
||
|
||
$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.';
|
||
}
|
||
|
||
$meteorCandidates = array_values(array_filter(
|
||
$filteredEvents,
|
||
static fn (array $event): bool => (string) ($event['type'] ?? '') === 'meteor_shower_peak'
|
||
&& max(
|
||
(int) ($event['zhr_min_estimate'] ?? 0),
|
||
(int) ($event['zhr_max_estimate'] ?? 0)
|
||
) >= 10
|
||
));
|
||
usort($meteorCandidates, static function (array $left, array $right): int {
|
||
$leftRate = max((int) ($left['zhr_min_estimate'] ?? 0), (int) ($left['zhr_max_estimate'] ?? 0));
|
||
$rightRate = max((int) ($right['zhr_min_estimate'] ?? 0), (int) ($right['zhr_max_estimate'] ?? 0));
|
||
return $rightRate <=> $leftRate;
|
||
});
|
||
|
||
// Der stärkste Strom wird immer genannt. Ist er kein bekannter Jahresstrom,
|
||
// ergänzen wir einen solchen, damit etwa die Leoniden nicht untergehen.
|
||
$meteorEvents = $meteorCandidates === [] ? [] : [$meteorCandidates[0]];
|
||
$establishedMeteorCodes = ['QUA', 'LYR', 'ETA', 'SDA', 'PER', 'DRA', 'ORI', 'NTA', 'STA', 'LEO', 'GEM', 'URS'];
|
||
$strongestCode = $meteorEvents === []
|
||
? ''
|
||
: strtoupper(trim((string) ($meteorEvents[0]['meteor_shower_code'] ?? '')));
|
||
if ($meteorEvents !== [] && !in_array($strongestCode, $establishedMeteorCodes, true)) {
|
||
foreach (array_slice($meteorCandidates, 1) as $meteorCandidate) {
|
||
$candidateCode = strtoupper(trim((string) ($meteorCandidate['meteor_shower_code'] ?? '')));
|
||
if (in_array($candidateCode, $establishedMeteorCodes, true)) {
|
||
$meteorEvents[] = $meteorCandidate;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
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 Meteorströme kann sich in diesem Monat ebenfalls lohnen.',
|
||
], 'Freunde von Meteoren sollten zudem die Aktivität der Meteorströme im Blick behalten.');
|
||
$paragraphs[] = $meteorIntro . ' Erwähnenswert sind besonders ' . $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');
|
||
|
||
if ($shouldRunForecast) {
|
||
|
||
$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 ($includeComets && !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'] : [];
|
||
}
|
||
|
||
$goldenGateOutput = [];
|
||
$goldenGateExitCode = 0;
|
||
$goldenGateArgs = [
|
||
$pythonScriptPath,
|
||
'golden_gate_of_ecliptic_for_month',
|
||
(string) $chartLatitude,
|
||
(string) $chartLongitude,
|
||
(string) $chartElevation,
|
||
(string) $selectedYear,
|
||
(string) $selectedMonth,
|
||
$chartTimezone,
|
||
];
|
||
$goldenGateCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $goldenGateArgs)) . ' 2>&1';
|
||
exec($goldenGateCommand, $goldenGateOutput, $goldenGateExitCode);
|
||
$goldenGateRaw = trim(implode("\n", $goldenGateOutput));
|
||
$goldenGateDecoded = json_decode($goldenGateRaw, true);
|
||
if (is_array($goldenGateDecoded) && ($goldenGateDecoded['ok'] ?? false)) {
|
||
$goldenGatePlanetEvents = is_array($goldenGateDecoded['events'] ?? null) ? $goldenGateDecoded['events'] : [];
|
||
}
|
||
|
||
$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' => 'inner_planet_event',
|
||
];
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
$eventKind = (string) ($approachEvent['event_kind'] ?? '');
|
||
$distanceText = $eventKind === '' && 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'] ?? ''),
|
||
'event_kind' => $eventKind,
|
||
'type' => $eventKind !== '' ? 'moon_occultation' : '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 ($goldenGatePlanetEvents as $goldenGateEvent) {
|
||
if (!is_array($goldenGateEvent)) {
|
||
continue;
|
||
}
|
||
|
||
$monthEventList[] = [
|
||
'event' => (string) ($goldenGateEvent['label'] ?? 'Planet im Goldenen Tor der Ekliptik'),
|
||
'date' => (string) ($goldenGateEvent['local_date'] ?? ''),
|
||
'time' => (string) ($goldenGateEvent['local_time'] ?? ''),
|
||
'local_iso' => (string) ($goldenGateEvent['local_iso'] ?? ''),
|
||
'type' => 'golden_gate_of_ecliptic',
|
||
];
|
||
}
|
||
|
||
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,
|
||
'meteor_shower_code' => $showerCode,
|
||
'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);
|
||
$monthEventListJson = json_encode($monthEventList, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
|
||
|
||
$observationTipInsertSql = buildObservationTipInsertSql($monthEventList, new DateTimeZone($chartTimezone));
|
||
$observationTipCalendarIcs = buildObservationTipCalendarIcs($monthEventList, new DateTimeZone($chartTimezone));
|
||
$observationTipInsertPayloadJson = json_encode(
|
||
normalizeObservationTipEvents($monthEventList, new DateTimeZone($chartTimezone)),
|
||
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||
) ?: '[]';
|
||
|
||
$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,
|
||
];
|
||
},
|
||
$includeComets ? $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>
|
||
|
||
<section class="monatsvorhersage-mode">
|
||
<div class="monatsvorhersage-mode-header">
|
||
<h4>Monat</h4>
|
||
<p>Bestehende Monatsansicht mit Berechnung per Button.</p>
|
||
</div>
|
||
|
||
<input type="hidden" name="run" value="1">
|
||
|
||
<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>
|
||
|
||
<label class="monatsvorhersage-checkbox">
|
||
<input type="checkbox" name="include_comets" value="1" <?= $includeComets ? 'checked' : '' ?>>
|
||
<span>Kometen in der Monatsvorhersage anzeigen</span>
|
||
</label>
|
||
|
||
<div class="monatsvorhersage-actions">
|
||
<button type="submit">Monatsvorhersage berechnen</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="monatsvorhersage-mode monatsvorhersage-mode-disabled" aria-labelledby="wochenauswahl-heading">
|
||
<div class="monatsvorhersage-mode-header">
|
||
<h4 id="wochenauswahl-heading">Woche</h4>
|
||
<p>Nur vorbereitete GUI. Die Wochenberechnung ist noch nicht angeschlossen.</p>
|
||
</div>
|
||
|
||
<label class="monatsvorhersage-field">
|
||
<span>Jahr</span>
|
||
<select name="week_year" id="weekYearSelect">
|
||
<?php foreach ($years as $year): ?>
|
||
<option value="<?= htmlspecialchars((string) $year, ENT_QUOTES, 'UTF-8') ?>" <?= $year === $selectedWeekYear ? 'selected' : '' ?>>
|
||
<?= htmlspecialchars((string) $year, ENT_QUOTES, 'UTF-8') ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</label>
|
||
|
||
<label class="monatsvorhersage-field">
|
||
<span>Woche</span>
|
||
<select name="week" id="weekSelect">
|
||
<?php foreach ($weekNumbers as $weekNumber): ?>
|
||
<?php $weekStartLabel = (new DateTimeImmutable('now'))->setISODate($selectedWeekYear, $weekNumber, 1)->format('d.m.Y'); ?>
|
||
<option value="<?= htmlspecialchars((string) $weekNumber, ENT_QUOTES, 'UTF-8') ?>" <?= $weekNumber === $selectedWeek ? 'selected' : '' ?>>
|
||
<?= htmlspecialchars('KW ' . str_pad((string) $weekNumber, 2, '0', STR_PAD_LEFT) . ' (' . $weekStartLabel . ')', ENT_QUOTES, 'UTF-8') ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</label>
|
||
|
||
<div class="monatsvorhersage-actions">
|
||
<button type="button" disabled>Wochenansicht folgt später</button>
|
||
</div>
|
||
</section>
|
||
</form>
|
||
|
||
<?php if ($shouldRunForecast): ?>
|
||
<section class="monatskarte-card monatsaktionen" aria-labelledby="monatsaktionen-heading">
|
||
<div class="monatskarte-header">
|
||
<h3 id="monatsaktionen-heading">Export und Eintragen</h3>
|
||
</div>
|
||
<div class="monatsaktionen-gruppen">
|
||
<div class="monatsaktionen-gruppe">
|
||
<h4>Monatspaket</h4>
|
||
<div class="monatskarte-export">
|
||
<button type="button" class="monatskarte-export-button" id="downloadMonatsueberblickZip">
|
||
Monatspaket als ZIP herunterladen
|
||
</button>
|
||
<button type="button" class="monatskarte-export-button" id="copyMonatsueberblickHtml">
|
||
HTML in Zwischenablage kopieren
|
||
</button>
|
||
<?php if (currentUserIsAdminLike()): ?>
|
||
<button type="button" class="monatskarte-export-button" id="saveMonatsueberblickToJoomla">
|
||
In Joomla speichern
|
||
</button>
|
||
<?php endif; ?>
|
||
<p id="monatsueberblickZipStatus" role="status" aria-live="polite"></p>
|
||
</div>
|
||
</div>
|
||
<div class="monatsaktionen-gruppe">
|
||
<h4>Grafiken</h4>
|
||
<div class="monatskarte-export">
|
||
<button type="button" class="monatskarte-export-button" id="downloadMonatskartePng">
|
||
Zenitprojektion als PNG
|
||
</button>
|
||
<button type="button" class="monatskarte-export-button" id="downloadMonatskarteSouthPng">
|
||
Südhimmel als PNG
|
||
</button>
|
||
<button type="button" class="monatskarte-export-button" id="downloadMonatsphasenPng">
|
||
Mondphasen als PNG
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="monatsaktionen-gruppe">
|
||
<h4>Ereignisse</h4>
|
||
<div class="monatskarte-export">
|
||
<?php if ($monthEventList): ?>
|
||
<form method="post" action="<?= htmlspecialchars($observationTipInsertEndpoint, ENT_QUOTES, 'UTF-8') ?>" class="monatskarte-inline-form" id="observationTipInsertForm">
|
||
<input type="hidden" name="insert_observation_tips" value="1">
|
||
<input type="hidden" name="observation_tip_insert_token" value="<?= htmlspecialchars($observationTipInsertToken, ENT_QUOTES, 'UTF-8') ?>">
|
||
<input type="hidden" name="observation_tip_payload" value="<?= htmlspecialchars($observationTipInsertPayloadJson, ENT_QUOTES, 'UTF-8') ?>">
|
||
<button type="submit" class="monatskarte-export-button" id="observationTipInsertButton">
|
||
Direkt in Datenbank eintragen
|
||
</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
<?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>
|
||
<div class="monatskarte-footer">
|
||
<p class="monatskarte-insert-status" id="observationTipInsertStatus" role="status" aria-live="polite" hidden></p>
|
||
<?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; ?>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="monatsnarrativ-card" id="monatsueberblick">
|
||
<div class="monatskarte-header">
|
||
<div>
|
||
<h3>Monat im Überblick</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>
|
||
<h3>Mondphasen</h3>
|
||
<p><?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="monatskarte-stage monatsphase-stage">
|
||
<div class="monatsphase-strip-wrap">
|
||
<img id="monatsphasenImage" alt="Mondphasen des Monats in bis zu vier Zeilen">
|
||
</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>
|
||
|
||
<div class="monatskarte-footer">
|
||
<?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>Für diesen Monat wurden keine Ereignisse gefunden.</p>
|
||
<?php endif; ?>
|
||
</div>
|
||
</section>
|
||
<?php else: ?>
|
||
<section class="monatsnarrativ-card">
|
||
<div class="monatskarte-header">
|
||
<div>
|
||
<h3>Monatsvorhersage</h3>
|
||
<p><?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></p>
|
||
</div>
|
||
</div>
|
||
<div class="monatskarte-footer">
|
||
<p class="monatsnarrativ-text">Bitte zuerst Jahr und Monat auswählen und dann auf „Monatsvorhersage berechnen“ klicken.</p>
|
||
</div>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
</div>
|
||
</section>
|
||
|
||
<style>
|
||
.monatsvorhersage-page main > .container {
|
||
max-width: min(1280px, calc(100% - 28px));
|
||
}
|
||
|
||
.monatsvorhersage-card {
|
||
padding: 18px;
|
||
}
|
||
|
||
.monatskarte-inline-form {
|
||
margin: 0;
|
||
}
|
||
|
||
.monatsaktionen-gruppen {
|
||
display: grid;
|
||
gap: 18px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.monatsaktionen-gruppe h4 {
|
||
margin: 0 0 10px;
|
||
}
|
||
|
||
#monatsueberblickZipStatus {
|
||
flex-basis: 100%;
|
||
margin: 0;
|
||
color: rgba(222, 232, 255, 0.86);
|
||
}
|
||
|
||
.monatskarte-insert-status {
|
||
margin: 0 0 14px;
|
||
padding: 10px 12px;
|
||
border-radius: 10px;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.monatskarte-insert-status.is-success {
|
||
background: #e8f7ec;
|
||
color: #1d5c2f;
|
||
border: 1px solid #b7dfc1;
|
||
}
|
||
|
||
.monatskarte-insert-status.is-error {
|
||
background: #fff0f0;
|
||
color: #7a1f1f;
|
||
border: 1px solid #efc0c0;
|
||
}
|
||
|
||
.monatskarte-export-button.is-success {
|
||
background: rgba(36, 104, 62, 0.92);
|
||
border-color: rgba(141, 214, 167, 0.82);
|
||
}
|
||
|
||
.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-mode {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 16px;
|
||
align-content: start;
|
||
padding: 18px;
|
||
border: 1px solid rgba(108, 145, 220, 0.2);
|
||
border-radius: 16px;
|
||
background: rgba(6, 12, 24, 0.38);
|
||
}
|
||
|
||
.monatsvorhersage-mode-disabled {
|
||
border-color: rgba(201, 168, 76, 0.18);
|
||
background: rgba(18, 18, 22, 0.34);
|
||
}
|
||
|
||
.monatsvorhersage-mode-header {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.monatsvorhersage-mode-header h4 {
|
||
margin: 0 0 6px;
|
||
font-size: 1.05rem;
|
||
}
|
||
|
||
.monatsvorhersage-mode-header p {
|
||
margin: 0;
|
||
color: rgba(222, 232, 255, 0.78);
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.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-checkbox {
|
||
grid-column: 1 / -1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
color: rgba(235, 242, 255, 0.95);
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.monatsvorhersage-checkbox input {
|
||
width: 18px;
|
||
height: 18px;
|
||
accent-color: #4a9eff;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.monatsvorhersage-mode-disabled .monatsvorhersage-actions button {
|
||
border-color: rgba(180, 186, 205, 0.38);
|
||
background: rgba(48, 54, 70, 0.7);
|
||
color: rgba(226, 232, 244, 0.76);
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.monatskarte-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.monatskarte-export {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.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%, 773px);
|
||
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;
|
||
}
|
||
|
||
.monatsvorhersage-mode {
|
||
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_EVENT_LIST = <?= $monthEventListJson ?: '[]' ?>;
|
||
const MONTH_CHART_ERROR = <?= $starDataErrorJson ?: 'null' ?>;
|
||
const OBSERVATION_TIP_ICAL = <?= $observationTipCalendarIcsJson ?: '""' ?>;
|
||
const OBSERVATION_TIP_ICAL_FILENAME = <?= $observationTipCalendarFilenameJson ?: '"astrotools-monatsereignisse.ics"' ?>;
|
||
const JOOMLA_MONTHLY_PREVIEW_ENDPOINT = <?= json_encode($joomlaMonthlyPreviewEndpoint, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||
const JOOMLA_MONTHLY_PREVIEW_TOKEN = <?= json_encode($joomlaMonthlyPreviewToken, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||
const MONTH_CHART_EXPORT_LOGO_SRC = <?= json_encode($publicBasePath . 'images/logoAM_ws.png', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||
|
||
const MONTH_CHART_COMET_K = 0.01720209895;
|
||
const MONTH_CHART_COMET_OBLIQ = 23.439279444444445 * Math.PI / 180;
|
||
const MONTH_CHART_EXPORT_WIDTH = 2000;
|
||
|
||
function isoWeekCount(year) {
|
||
const dec28 = new Date(Date.UTC(year, 11, 28));
|
||
const day = dec28.getUTCDay() || 7;
|
||
dec28.setUTCDate(dec28.getUTCDate() + 4 - day);
|
||
const yearStart = new Date(Date.UTC(dec28.getUTCFullYear(), 0, 1));
|
||
return Math.ceil((((dec28 - yearStart) / 86400000) + 1) / 7);
|
||
}
|
||
|
||
function isoWeekMonday(year, week) {
|
||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||
const jan4Day = jan4.getUTCDay() || 7;
|
||
const monday = new Date(jan4);
|
||
monday.setUTCDate(jan4.getUTCDate() - jan4Day + 1 + ((week - 1) * 7));
|
||
return monday;
|
||
}
|
||
|
||
function downloadCanvasAsPng(sourceCanvas, filename, outputWidth) {
|
||
if (!sourceCanvas || !sourceCanvas.width || !sourceCanvas.height) {
|
||
return false;
|
||
}
|
||
|
||
const exportCanvas = document.createElement('canvas');
|
||
const aspectRatio = sourceCanvas.height / sourceCanvas.width;
|
||
exportCanvas.width = outputWidth;
|
||
exportCanvas.height = Math.max(1, Math.round(outputWidth * aspectRatio));
|
||
|
||
const exportCtx = exportCanvas.getContext('2d');
|
||
if (!exportCtx) {
|
||
return false;
|
||
}
|
||
|
||
exportCtx.fillStyle = '#000000';
|
||
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||
exportCtx.drawImage(sourceCanvas, 0, 0, exportCanvas.width, exportCanvas.height);
|
||
|
||
const link = document.createElement('a');
|
||
link.href = exportCanvas.toDataURL('image/png');
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
return true;
|
||
}
|
||
|
||
let monthChartExportLogoPromise = null;
|
||
|
||
function loadImage(src) {
|
||
return new Promise(function (resolve, reject) {
|
||
const image = new Image();
|
||
image.onload = function () {
|
||
resolve(image);
|
||
};
|
||
image.onerror = function () {
|
||
reject(new Error('Bild konnte nicht geladen werden: ' + src));
|
||
};
|
||
image.src = src;
|
||
});
|
||
}
|
||
|
||
function getMonthChartExportLogo() {
|
||
if (!MONTH_CHART_EXPORT_LOGO_SRC) {
|
||
return Promise.resolve(null);
|
||
}
|
||
|
||
if (!monthChartExportLogoPromise) {
|
||
monthChartExportLogoPromise = loadImage(MONTH_CHART_EXPORT_LOGO_SRC).catch(function () {
|
||
return null;
|
||
});
|
||
}
|
||
|
||
return monthChartExportLogoPromise;
|
||
}
|
||
|
||
async function composeExportCanvas(sourceCanvas, logoPosition, options) {
|
||
const exportOptions = options && typeof options === 'object' ? options : {};
|
||
const showCaption = exportOptions.showCaption !== false;
|
||
const logoScale = Number.isFinite(Number(exportOptions.logoScale)) ? Number(exportOptions.logoScale) : 1;
|
||
const exportCanvas = document.createElement('canvas');
|
||
exportCanvas.width = sourceCanvas.width;
|
||
exportCanvas.height = sourceCanvas.height;
|
||
|
||
const exportCtx = exportCanvas.getContext('2d');
|
||
if (!exportCtx) {
|
||
return null;
|
||
}
|
||
|
||
exportCtx.fillStyle = '#000000';
|
||
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||
exportCtx.drawImage(sourceCanvas, 0, 0);
|
||
|
||
const logo = await getMonthChartExportLogo();
|
||
if (logo) {
|
||
const padding = Math.round(exportCanvas.width * 0.024);
|
||
const targetWidth = Math.max(90, Math.min(320, Math.round(exportCanvas.width * 0.14 * logoScale)));
|
||
const targetHeight = Math.round(targetWidth * (logo.naturalHeight / Math.max(1, logo.naturalWidth)));
|
||
const drawX = logoPosition === 'bottom-right'
|
||
? exportCanvas.width - padding - targetWidth
|
||
: padding;
|
||
const drawY = logoPosition === 'bottom-left' || logoPosition === 'bottom-right'
|
||
? exportCanvas.height - padding - targetHeight
|
||
: padding;
|
||
exportCtx.drawImage(logo, drawX, drawY, targetWidth, targetHeight);
|
||
}
|
||
|
||
const caption = monthChartExportCaption();
|
||
if (showCaption && caption !== '') {
|
||
const padding = Math.round(exportCanvas.width * 0.024);
|
||
const fontSize = Math.max(18, Math.round(exportCanvas.width * 0.015));
|
||
exportCtx.save();
|
||
exportCtx.font = '600 ' + fontSize + 'px Rajdhani, sans-serif';
|
||
exportCtx.textAlign = 'right';
|
||
exportCtx.textBaseline = 'bottom';
|
||
exportCtx.fillStyle = 'rgba(235, 242, 255, 0.96)';
|
||
exportCtx.fillText(caption, exportCanvas.width - padding, exportCanvas.height - padding);
|
||
exportCtx.restore();
|
||
}
|
||
|
||
return exportCanvas;
|
||
}
|
||
|
||
function triggerCanvasDownload(sourceCanvas, filename) {
|
||
const link = document.createElement('a');
|
||
link.href = sourceCanvas.toDataURL('image/png');
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
}
|
||
|
||
function monthChartExportFilename(viewMode) {
|
||
if (!MONTH_CHART_CONFIG) {
|
||
return 'Himmelsanblick_' + viewMode + '.png';
|
||
}
|
||
|
||
const month = String(MONTH_CHART_CONFIG.monthNumber || MONTH_CHART_CONFIG.month || '').padStart(2, '0');
|
||
const year = String(MONTH_CHART_CONFIG.year || '');
|
||
const hour = String(Number(MONTH_CHART_CONFIG.monthSkyHour ?? 0)).padStart(2, '0');
|
||
const minute = String(Number(MONTH_CHART_CONFIG.monthSkyMinute ?? 0)).padStart(2, '0');
|
||
const label = viewMode === 'sued' ? 'Himmelsanblick_Sued' : 'Himmelsanblick_Polar';
|
||
return year + '_' + month + '_' + label + '_' + hour + '_' + minute + '.png';
|
||
}
|
||
|
||
function monthChartExportCaption() {
|
||
if (!MONTH_CHART_CONFIG) {
|
||
return '';
|
||
}
|
||
|
||
const hour = Number(MONTH_CHART_CONFIG.monthSkyHour ?? NaN);
|
||
const minute = Number(MONTH_CHART_CONFIG.monthSkyMinute ?? NaN);
|
||
if (!Number.isFinite(hour) || !Number.isFinite(minute)) {
|
||
return '';
|
||
}
|
||
|
||
const parts = String(MONTH_CHART_CONFIG.localTimeLabel || '').split(' ');
|
||
const date = parts[0] || '';
|
||
const hh = String(hour).padStart(2, '0');
|
||
const mm = String(minute).padStart(2, '0');
|
||
return date !== ''
|
||
? 'Sternhimmel am ' + date + ' um ' + hh + ':' + mm + ' Uhr'
|
||
: 'Sternhimmel um ' + hh + ':' + mm + ' Uhr';
|
||
}
|
||
|
||
function formatGermanDate(date) {
|
||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||
const year = String(date.getUTCFullYear());
|
||
return day + '.' + month + '.' + year;
|
||
}
|
||
|
||
function rebuildWeekOptions() {
|
||
const weekYearSelect = document.getElementById('weekYearSelect');
|
||
const weekSelect = document.getElementById('weekSelect');
|
||
|
||
if (!weekYearSelect || !weekSelect) {
|
||
return;
|
||
}
|
||
|
||
const selectedYear = Number(weekYearSelect.value);
|
||
if (!Number.isInteger(selectedYear) || selectedYear < 1) {
|
||
return;
|
||
}
|
||
|
||
const previousWeek = Number(weekSelect.value) || 1;
|
||
const totalWeeks = isoWeekCount(selectedYear);
|
||
const nextWeek = Math.min(Math.max(previousWeek, 1), totalWeeks);
|
||
|
||
weekSelect.innerHTML = '';
|
||
|
||
for (let week = 1; week <= totalWeeks; week++) {
|
||
const option = document.createElement('option');
|
||
option.value = String(week);
|
||
option.textContent = 'KW ' + String(week).padStart(2, '0') + ' (' + formatGermanDate(isoWeekMonday(selectedYear, week)) + ')';
|
||
if (week === nextWeek) {
|
||
option.selected = true;
|
||
}
|
||
weekSelect.appendChild(option);
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
const weekYearSelect = document.getElementById('weekYearSelect');
|
||
if (!weekYearSelect) {
|
||
return;
|
||
}
|
||
|
||
rebuildWeekOptions();
|
||
weekYearSelect.addEventListener('change', rebuildWeekOptions);
|
||
});
|
||
|
||
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 insertForm = document.getElementById('observationTipInsertForm');
|
||
const insertButton = document.getElementById('observationTipInsertButton');
|
||
let insertStatus = document.getElementById('observationTipInsertStatus');
|
||
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 (insertForm && insertButton) {
|
||
insertForm.addEventListener('submit', async function (event) {
|
||
event.preventDefault();
|
||
const originalLabel = insertButton.textContent;
|
||
insertButton.classList.remove('is-success');
|
||
insertButton.disabled = true;
|
||
insertButton.textContent = 'Trage ein...';
|
||
|
||
try {
|
||
const response = await fetch(insertForm.action, {
|
||
method: 'POST',
|
||
body: new FormData(insertForm),
|
||
headers: {
|
||
'X-Requested-With': 'fetch'
|
||
}
|
||
});
|
||
|
||
const payload = await response.json();
|
||
if (!insertStatus) {
|
||
insertStatus = document.createElement('p');
|
||
insertStatus.id = 'observationTipInsertStatus';
|
||
insertStatus.setAttribute('role', 'status');
|
||
insertStatus.setAttribute('aria-live', 'polite');
|
||
insertStatus.className = 'monatskarte-insert-status';
|
||
const footer = insertForm.closest('.monatskarte-card')?.querySelector('.monatskarte-footer');
|
||
if (footer) {
|
||
footer.insertBefore(insertStatus, footer.firstChild);
|
||
}
|
||
}
|
||
|
||
if (insertStatus) {
|
||
insertStatus.hidden = false;
|
||
insertStatus.className = 'monatskarte-insert-status ' + (payload && payload.success ? 'is-success' : 'is-error');
|
||
insertStatus.textContent = payload && payload.message ? payload.message : 'Unbekannte Rückmeldung vom Server.';
|
||
}
|
||
|
||
if (payload && payload.success) {
|
||
insertButton.classList.add('is-success');
|
||
insertButton.textContent = 'Eingetragen';
|
||
window.setTimeout(function () {
|
||
insertButton.classList.remove('is-success');
|
||
insertButton.textContent = originalLabel;
|
||
}, 1800);
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
if (!insertStatus) {
|
||
insertStatus = document.createElement('p');
|
||
insertStatus.id = 'observationTipInsertStatus';
|
||
insertStatus.setAttribute('role', 'status');
|
||
insertStatus.setAttribute('aria-live', 'polite');
|
||
insertStatus.className = 'monatskarte-insert-status is-error';
|
||
const footer = insertForm.closest('.monatskarte-card')?.querySelector('.monatskarte-footer');
|
||
if (footer) {
|
||
footer.insertBefore(insertStatus, footer.firstChild);
|
||
}
|
||
}
|
||
|
||
if (insertStatus) {
|
||
insertStatus.hidden = false;
|
||
insertStatus.className = 'monatskarte-insert-status is-error';
|
||
insertStatus.textContent = 'Eintragen fehlgeschlagen. Bitte erneut versuchen.';
|
||
}
|
||
} finally {
|
||
insertButton.disabled = false;
|
||
if (!insertButton.classList.contains('is-success')) {
|
||
insertButton.textContent = originalLabel;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
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 downloadButton = document.getElementById('downloadMonatsphasenPng');
|
||
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();
|
||
let phaseViewportWidth = 1200;
|
||
let phaseViewportHeight = 700;
|
||
let phaseRenderDpr = Math.max(1, window.devicePixelRatio || 1);
|
||
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 renderPhaseCanvas(cssWidth, cssHeight, dpr, shouldUpdateImage) {
|
||
phaseViewportWidth = cssWidth;
|
||
phaseViewportHeight = cssHeight;
|
||
phaseRenderDpr = dpr;
|
||
canvas.width = Math.floor(cssWidth * dpr);
|
||
canvas.height = Math.floor(cssHeight * dpr);
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
draw();
|
||
|
||
if (shouldUpdateImage) {
|
||
imageEl.src = canvas.toDataURL('image/png');
|
||
}
|
||
}
|
||
|
||
function resizeCanvas() {
|
||
const cssWidth = Math.max(320, Math.floor(imageEl.clientWidth || 773));
|
||
const cssHeight = Math.round(cssWidth * (700 / 773));
|
||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||
renderPhaseCanvas(cssWidth, cssHeight, dpr, true);
|
||
}
|
||
|
||
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 = phaseViewportWidth;
|
||
const height = phaseViewportHeight;
|
||
const layoutScale = width / 773;
|
||
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 ' + (24 * layoutScale) + 'px Rajdhani, sans-serif';
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'top';
|
||
ctx.fillText('Mondphasen im ' + monthName + ' ' + year, 42 * layoutScale, 27 * layoutScale);
|
||
|
||
const leftPad = 33 * layoutScale;
|
||
const rightPad = 33 * layoutScale;
|
||
const usableWidth = width - leftPad - rightPad;
|
||
const daysPerRow = 9;
|
||
const rowCenters = [108, 260, 411, 563].map(function (value) { return value * layoutScale; });
|
||
const eventTextY = [174, 326, 477, 629].map(function (value) { return value * layoutScale; });
|
||
const eventDateY = [200, 351, 503, 655].map(function (value) { return value * layoutScale; });
|
||
|
||
ctx.fillStyle = 'rgba(255, 255, 255, 0.72)';
|
||
ctx.font = '500 ' + (17 * layoutScale) + 'px \"Share Tech Mono\", monospace';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'top';
|
||
|
||
for (let day = 1; day <= totalDays; day++) {
|
||
const row = Math.floor((day - 1) / daysPerRow);
|
||
const rowStartDay = row * daysPerRow + 1;
|
||
const dayInRow = day - rowStartDay + 1;
|
||
const spacing = usableWidth / daysPerRow;
|
||
const radius = Math.max(23 * layoutScale, Math.min(33 * layoutScale, spacing * 0.52));
|
||
const x = leftPad + spacing * (dayInRow - 0.5);
|
||
const centerY = rowCenters[row];
|
||
drawMoonDisc(x, centerY, radius, phaseInfoForDay(year, month, day));
|
||
ctx.fillText(String(day), x, centerY + radius + (12 * layoutScale));
|
||
}
|
||
|
||
ctx.font = '600 ' + (21 * layoutScale) + 'px 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 row = Math.floor((day - 1) / daysPerRow);
|
||
const rowStartDay = row * daysPerRow + 1;
|
||
const dayInRow = day - rowStartDay + 1;
|
||
const x = leftPad + (usableWidth / daysPerRow) * (dayInRow - 0.5);
|
||
ctx.fillText(String(event.label || ''), x, eventTextY[row]);
|
||
ctx.fillStyle = 'rgba(182, 196, 224, 0.94)';
|
||
ctx.fillText(day + '.' + month + '.', x, eventDateY[row]);
|
||
ctx.fillStyle = 'rgba(225, 232, 246, 0.94)';
|
||
}
|
||
|
||
statusEl.textContent = 'Mondphasenleiste für ' + monthName + ' ' + year;
|
||
}
|
||
|
||
function monthPhaseExportFilename() {
|
||
if (!MONTH_CHART_CONFIG) {
|
||
return 'Himmelsanblick_mondphasen.png';
|
||
}
|
||
|
||
const year = String(MONTH_CHART_CONFIG.year || '');
|
||
const month = String(MONTH_CHART_CONFIG.monthNumber || MONTH_CHART_CONFIG.month || '').padStart(2, '0');
|
||
return year + '_' + month + '_Mondphasen.png';
|
||
}
|
||
|
||
if (downloadButton) {
|
||
downloadButton.addEventListener('click', function () {
|
||
statusEl.textContent = 'Mondphasen-PNG wird erstellt...';
|
||
downloadButton.disabled = true;
|
||
|
||
composeExportCanvas(canvas, 'bottom-right', { showCaption: false, logoScale: 0.6 })
|
||
.then(function (exportCanvas) {
|
||
triggerCanvasDownload(exportCanvas || canvas, monthPhaseExportFilename());
|
||
})
|
||
.finally(function () {
|
||
draw();
|
||
imageEl.src = canvas.toDataURL('image/png');
|
||
downloadButton.disabled = false;
|
||
});
|
||
});
|
||
}
|
||
|
||
phaseImage.onload = resizeCanvas;
|
||
phaseImage.onerror = function () {
|
||
statusEl.textContent = 'Mondgrafik konnte nicht geladen werden.';
|
||
};
|
||
|
||
window.addEventListener('resize', resizeCanvas);
|
||
|
||
if (phaseImage.complete) {
|
||
resizeCanvas();
|
||
}
|
||
})();
|
||
|
||
(function () {
|
||
const downloadButton = document.getElementById('downloadMonatskartePng');
|
||
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;
|
||
let viewportWidth = 900;
|
||
let viewportHeight = 900;
|
||
let renderScale = 1;
|
||
let exportRenderMode = false;
|
||
const projCenterAzDeg = 180;
|
||
const projCenterAltDeg = 45;
|
||
|
||
function uiScale() {
|
||
return (viewportWidth / 900) * 1.22;
|
||
}
|
||
|
||
function scalePx(value) {
|
||
return value * uiScale();
|
||
}
|
||
|
||
function scaleFont(sizePx, weight = '600', family = 'Rajdhani, sans-serif') {
|
||
return weight + ' ' + scalePx(sizePx) + 'px ' + family;
|
||
}
|
||
|
||
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 <= viewportWidth &&
|
||
box.y + box.height <= viewportHeight;
|
||
}
|
||
|
||
return {
|
||
reset() {
|
||
occupiedBoxes.length = 0;
|
||
},
|
||
drawText(options) {
|
||
const {
|
||
text,
|
||
x,
|
||
y,
|
||
font = scaleFont(12, '400', 'Georgia, serif'),
|
||
color = 'rgba(232, 239, 255, 0.82)',
|
||
align = 'left',
|
||
baseline = 'middle',
|
||
padding = scalePx(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 + scalePx(6), y: y - scalePx(1), align: 'left', baseline: 'middle' },
|
||
{ x: x - radius - scalePx(6), y: y - scalePx(1), align: 'right', baseline: 'middle' },
|
||
{ x, y: y - radius - scalePx(7), align: 'center', baseline: 'bottom' },
|
||
{ x, y: y + radius + scalePx(7), align: 'center', baseline: 'top' },
|
||
{ x: x + radius + scalePx(6), y: y - radius - scalePx(5), align: 'left', baseline: 'bottom' },
|
||
{ x: x - radius - scalePx(6), y: y - radius - scalePx(5), align: 'right', baseline: 'bottom' },
|
||
{ x: x + radius + scalePx(6), y: y + radius + scalePx(5), align: 'left', baseline: 'top' },
|
||
{ x: x - radius - scalePx(6), y: y + radius + scalePx(5), align: 'right', baseline: 'top' },
|
||
];
|
||
|
||
function drawLabelBackdrop(drawX, drawY, align, baseline) {
|
||
ctx.save();
|
||
ctx.font = scaleFont(11);
|
||
const metrics = ctx.measureText(text);
|
||
const textWidth = metrics.width;
|
||
const ascent = metrics.actualBoundingBoxAscent || 8;
|
||
const descent = metrics.actualBoundingBoxDescent || 4;
|
||
const padX = scalePx(5);
|
||
const padY = scalePx(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 = scalePx(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: scaleFont(11),
|
||
color: 'rgba(246, 235, 214, 0.84)',
|
||
align: attempt.align,
|
||
baseline: attempt.baseline,
|
||
padding: scalePx(2),
|
||
})) {
|
||
drawLabelBackdrop(attempt.x, attempt.y, attempt.align, attempt.baseline);
|
||
ctx.save();
|
||
ctx.font = scaleFont(11);
|
||
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 + scalePx(6), y: y - scalePx(1), align: 'left', baseline: 'middle' },
|
||
{ x: x - radius - scalePx(6), y: y - scalePx(1), align: 'right', baseline: 'middle' },
|
||
{ x, y: y - radius - scalePx(7), align: 'center', baseline: 'bottom' },
|
||
{ x, y: y + radius + scalePx(7), align: 'center', baseline: 'top' },
|
||
{ x: x + radius + scalePx(6), y: y - radius - scalePx(5), align: 'left', baseline: 'bottom' },
|
||
{ x: x - radius - scalePx(6), y: y - radius - scalePx(5), align: 'right', baseline: 'bottom' },
|
||
{ x: x + radius + scalePx(6), y: y + radius + scalePx(5), align: 'left', baseline: 'top' },
|
||
{ x: x - radius - scalePx(6), y: y + radius + scalePx(5), align: 'right', baseline: 'top' },
|
||
];
|
||
|
||
function drawLabelBackdrop(drawX, drawY, align, baseline) {
|
||
ctx.save();
|
||
ctx.font = scaleFont(11);
|
||
const metrics = ctx.measureText(text);
|
||
const textWidth = metrics.width;
|
||
const ascent = metrics.actualBoundingBoxAscent || 8;
|
||
const descent = metrics.actualBoundingBoxDescent || 4;
|
||
const padX = scalePx(5);
|
||
const padY = scalePx(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 = scalePx(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: scaleFont(11),
|
||
color: 'rgba(246, 235, 214, 0.84)',
|
||
align: attempt.align,
|
||
baseline: attempt.baseline,
|
||
padding: scalePx(2),
|
||
})) {
|
||
drawLabelBackdrop(attempt.x, attempt.y, attempt.align, attempt.baseline);
|
||
ctx.save();
|
||
ctx.font = scaleFont(11);
|
||
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);
|
||
viewportWidth = cssSize;
|
||
viewportHeight = cssSize;
|
||
renderScale = ratio;
|
||
canvas.width = Math.round(viewportWidth * renderScale);
|
||
canvas.height = Math.round(viewportHeight * renderScale);
|
||
canvas.style.width = cssSize + 'px';
|
||
canvas.style.height = cssSize + 'px';
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(renderScale, renderScale);
|
||
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(scalePx(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 = scalePx(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 = scalePx(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 möglichst 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', scalePx(18), scaleFont(13), 0.78);
|
||
drawDirectionLabel(90, 'Westen', scalePx(22), scaleFont(13), 0.78);
|
||
drawDirectionLabel(180, 'Süden', scalePx(18), scaleFont(13), 0.78);
|
||
drawDirectionLabel(270, 'Osten', scalePx(22), scaleFont(13), 0.78);
|
||
|
||
drawDirectionLabel(45, 'Nordosten', scalePx(18), scaleFont(11), 0.64);
|
||
drawDirectionLabel(315, 'Nordwesten', scalePx(18), scaleFont(11), 0.64);
|
||
drawDirectionLabel(135, 'Südosten', scalePx(18), scaleFont(11), 0.64);
|
||
drawDirectionLabel(225, 'Südwesten', scalePx(18), scaleFont(11), 0.64);
|
||
|
||
ctx.fillStyle = 'rgba(214, 226, 255, 0.62)';
|
||
ctx.font = scaleFont(11);
|
||
ctx.fillText('60°', cx, cy - radius * 0.66 - scalePx(6));
|
||
ctx.fillText('30°', cx, cy - radius * 0.33 - scalePx(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 = scalePx(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 = scalePx(0.95);
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0].x + scalePx(0.18), points[0].y - scalePx(0.12));
|
||
for (let i = 1; i < points.length; i++) {
|
||
const jitterX = (i % 2 === 0 ? scalePx(0.22) : -scalePx(0.12));
|
||
const jitterY = (i % 2 === 0 ? -scalePx(0.14) : scalePx(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: scaleFont(11),
|
||
color: 'rgba(198, 214, 255, 0.54)',
|
||
align: 'center',
|
||
baseline: 'middle',
|
||
padding: scalePx(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 = scalePx(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 = scalePx(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(scalePx(3.8), Math.min(scalePx(8.6), scalePx(6.7 - mag * 0.72)))
|
||
: scalePx(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 = scalePx(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 - scalePx(0.3), 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
ctx.strokeStyle = 'rgba(116, 84, 54, 0.78)';
|
||
ctx.lineWidth = Math.max(scalePx(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 = scalePx(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(scalePx(8), Math.min(scalePx(28), scalePx(18) / pos.rH)) : scalePx(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 = scalePx(2.2);
|
||
ctx.lineCap = 'round';
|
||
ctx.stroke();
|
||
|
||
const comaR = scalePx(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(scalePx(3.4), Math.min(scalePx(6.8), scalePx(3.2 + Math.max(0, 6.5 - Number(obj.mag)) * 0.5)));
|
||
ctx.strokeStyle = style.stroke;
|
||
ctx.fillStyle = style.fill;
|
||
ctx.lineWidth = scalePx(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([scalePx(2), scalePx(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 + scalePx(5),
|
||
y: pt.y,
|
||
font: scaleFont(11),
|
||
color: 'rgba(210, 224, 255, 0.78)',
|
||
align: 'left',
|
||
baseline: 'middle',
|
||
padding: scalePx(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) * (exportRenderMode ? 2.0 : 1);
|
||
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 = scalePx(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 + scalePx(5),
|
||
y: pt.y - scalePx(1),
|
||
font: scaleFont(11),
|
||
color: 'rgba(232, 239, 255, 0.82)',
|
||
align: 'left',
|
||
baseline: 'middle',
|
||
padding: scalePx(2),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
const width = viewportWidth;
|
||
const height = viewportHeight;
|
||
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;
|
||
}
|
||
|
||
if (downloadButton) {
|
||
downloadButton.addEventListener('click', async function () {
|
||
const previous = {
|
||
width: canvas.width,
|
||
height: canvas.height,
|
||
styleWidth: canvas.style.width,
|
||
styleHeight: canvas.style.height,
|
||
viewportWidth,
|
||
viewportHeight,
|
||
renderScale,
|
||
};
|
||
|
||
try {
|
||
statusEl.textContent = 'PNG wird in 2000 px neu gerendert...';
|
||
downloadButton.disabled = true;
|
||
|
||
exportRenderMode = true;
|
||
viewportWidth = MONTH_CHART_EXPORT_WIDTH;
|
||
viewportHeight = MONTH_CHART_EXPORT_WIDTH;
|
||
renderScale = 1;
|
||
canvas.width = viewportWidth;
|
||
canvas.height = viewportHeight;
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
labelPlacer = createLabelPlacer(ctx);
|
||
draw();
|
||
|
||
const exportCanvas = await composeExportCanvas(canvas, 'bottom-left');
|
||
if (!exportCanvas) {
|
||
throw new Error('Export-Canvas konnte nicht erzeugt werden.');
|
||
}
|
||
|
||
triggerCanvasDownload(exportCanvas, monthChartExportFilename('polar'));
|
||
} catch (error) {
|
||
statusEl.textContent = 'PNG-Download konnte nicht erstellt werden.';
|
||
} finally {
|
||
exportRenderMode = false;
|
||
viewportWidth = previous.viewportWidth;
|
||
viewportHeight = previous.viewportHeight;
|
||
renderScale = previous.renderScale;
|
||
canvas.width = previous.width;
|
||
canvas.height = previous.height;
|
||
canvas.style.width = previous.styleWidth;
|
||
canvas.style.height = previous.styleHeight;
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(renderScale, renderScale);
|
||
labelPlacer = createLabelPlacer(ctx);
|
||
draw();
|
||
downloadButton.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
resizeCanvas();
|
||
window.addEventListener('resize', resizeCanvas);
|
||
})();
|
||
|
||
(function () {
|
||
const downloadButton = document.getElementById('downloadMonatskarteSouthPng');
|
||
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;
|
||
let viewportWidth = 1200;
|
||
let viewportHeight = 720;
|
||
let renderScale = 1;
|
||
let exportRenderMode = false;
|
||
const projCenterAzDeg = 180;
|
||
const projCenterAltDeg = 45;
|
||
|
||
function uiScale() {
|
||
return (viewportWidth / 1100) * 1.22;
|
||
}
|
||
|
||
function scalePx(value) {
|
||
return value * uiScale();
|
||
}
|
||
|
||
function scaleFont(sizePx, weight = '600', family = 'Rajdhani, sans-serif') {
|
||
return weight + ' ' + scalePx(sizePx) + 'px ' + family;
|
||
}
|
||
|
||
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 <= viewportWidth &&
|
||
box.y + box.height <= viewportHeight;
|
||
}
|
||
|
||
return {
|
||
reset() {
|
||
occupiedBoxes.length = 0;
|
||
},
|
||
drawText(options) {
|
||
const {
|
||
text,
|
||
x,
|
||
y,
|
||
font = scaleFont(11),
|
||
color = 'rgba(232, 239, 255, 0.82)',
|
||
align = 'left',
|
||
baseline = 'middle',
|
||
padding = scalePx(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 + scalePx(6), y: y - scalePx(1), align: 'left', baseline: 'middle' },
|
||
{ x: x - radius - scalePx(6), y: y - scalePx(1), align: 'right', baseline: 'middle' },
|
||
{ x, y: y - radius - scalePx(7), align: 'center', baseline: 'bottom' },
|
||
{ x, y: y + radius + scalePx(7), align: 'center', baseline: 'top' },
|
||
{ x: x + radius + scalePx(6), y: y - radius - scalePx(5), align: 'left', baseline: 'bottom' },
|
||
{ x: x - radius - scalePx(6), y: y - radius - scalePx(5), align: 'right', baseline: 'bottom' },
|
||
{ x: x + radius + scalePx(6), y: y + radius + scalePx(5), align: 'left', baseline: 'top' },
|
||
{ x: x - radius - scalePx(6), y: y + radius + scalePx(5), align: 'right', baseline: 'top' },
|
||
];
|
||
|
||
for (const attempt of attempts) {
|
||
if (labelPlacer.drawText({
|
||
text,
|
||
x: attempt.x,
|
||
y: attempt.y,
|
||
font: scaleFont(11),
|
||
color: 'rgba(246, 235, 214, 0.84)',
|
||
align: attempt.align,
|
||
baseline: attempt.baseline,
|
||
padding: scalePx(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);
|
||
viewportWidth = cssWidth;
|
||
viewportHeight = cssHeight;
|
||
renderScale = ratio;
|
||
canvas.width = Math.round(viewportWidth * renderScale);
|
||
canvas.height = Math.round(viewportHeight * renderScale);
|
||
canvas.style.width = cssWidth + 'px';
|
||
canvas.style.height = cssHeight + 'px';
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(renderScale, renderScale);
|
||
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 = scalePx(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 = scalePx(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(scalePx(3.8), Math.min(scalePx(8.6), scalePx(6.7 - mag * 0.72)))
|
||
: scalePx(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 = scalePx(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 - scalePx(0.3), 0, Math.PI * 2);
|
||
ctx.clip();
|
||
|
||
ctx.strokeStyle = 'rgba(116, 84, 54, 0.78)';
|
||
ctx.lineWidth = Math.max(scalePx(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 = scalePx(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(scalePx(8), Math.min(scalePx(28), scalePx(18) / pos.rH)) : scalePx(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 = scalePx(2.2);
|
||
ctx.lineCap = 'round';
|
||
ctx.stroke();
|
||
|
||
const comaR = scalePx(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 = scalePx(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)', scalePx(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)', scalePx(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 = scaleFont(12);
|
||
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 ? scalePx(22) : scalePx(18));
|
||
}
|
||
|
||
const zenitPt = southProject(88, 180);
|
||
if (zenitPt) {
|
||
ctx.fillStyle = 'rgba(214, 226, 255, 0.72)';
|
||
ctx.font = scaleFont(12);
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'bottom';
|
||
ctx.fillText('Zenit', zenitPt.x, zenitPt.y - scalePx(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 = scaleFont(11);
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(`${alt}°`, pt.x + scalePx(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 = scalePx(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 = scalePx(0.95);
|
||
ctx.beginPath();
|
||
ctx.moveTo(points[0].x + scalePx(0.18), points[0].y - scalePx(0.12));
|
||
for (let i = 1; i < points.length; i++) {
|
||
const jitterX = (i % 2 === 0 ? scalePx(0.22) : -scalePx(0.12));
|
||
const jitterY = (i % 2 === 0 ? -scalePx(0.14) : scalePx(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: scaleFont(11),
|
||
color: 'rgba(198, 214, 255, 0.54)',
|
||
align: 'center',
|
||
baseline: 'middle',
|
||
padding: scalePx(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(scalePx(3.4), Math.min(scalePx(6.8), scalePx(3.2 + Math.max(0, 6.5 - Number(obj.mag)) * 0.5)));
|
||
ctx.strokeStyle = style.stroke;
|
||
ctx.fillStyle = style.fill;
|
||
ctx.lineWidth = scalePx(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([scalePx(2), scalePx(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 + scalePx(5),
|
||
y: pt.y,
|
||
font: scaleFont(11),
|
||
color: 'rgba(210, 224, 255, 0.78)',
|
||
align: 'left',
|
||
baseline: 'middle',
|
||
padding: scalePx(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) * (exportRenderMode ? 2.0 : 1);
|
||
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 = scalePx(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 + scalePx(5),
|
||
y: pt.y - scalePx(1),
|
||
font: scaleFont(11),
|
||
color: 'rgba(232, 239, 255, 0.82)',
|
||
align: 'left',
|
||
baseline: 'middle',
|
||
padding: scalePx(2),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
const width = viewportWidth;
|
||
const height = viewportHeight;
|
||
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;
|
||
}
|
||
|
||
if (downloadButton) {
|
||
downloadButton.addEventListener('click', async function () {
|
||
const previous = {
|
||
width: canvas.width,
|
||
height: canvas.height,
|
||
styleWidth: canvas.style.width,
|
||
styleHeight: canvas.style.height,
|
||
viewportWidth,
|
||
viewportHeight,
|
||
renderScale,
|
||
};
|
||
|
||
try {
|
||
statusEl.textContent = 'PNG wird in 2000 px neu gerendert...';
|
||
downloadButton.disabled = true;
|
||
|
||
exportRenderMode = true;
|
||
viewportWidth = MONTH_CHART_EXPORT_WIDTH;
|
||
viewportHeight = Math.round(MONTH_CHART_EXPORT_WIDTH * 0.6);
|
||
renderScale = 1;
|
||
canvas.width = viewportWidth;
|
||
canvas.height = viewportHeight;
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
labelPlacer = createLabelPlacer(ctx);
|
||
draw();
|
||
|
||
const exportCanvas = await composeExportCanvas(canvas, 'bottom-left');
|
||
if (!exportCanvas) {
|
||
throw new Error('Export-Canvas konnte nicht erzeugt werden.');
|
||
}
|
||
|
||
triggerCanvasDownload(exportCanvas, monthChartExportFilename('sued'));
|
||
} catch (error) {
|
||
statusEl.textContent = 'PNG-Download konnte nicht erstellt werden.';
|
||
} finally {
|
||
exportRenderMode = false;
|
||
viewportWidth = previous.viewportWidth;
|
||
viewportHeight = previous.viewportHeight;
|
||
renderScale = previous.renderScale;
|
||
canvas.width = previous.width;
|
||
canvas.height = previous.height;
|
||
canvas.style.width = previous.styleWidth;
|
||
canvas.style.height = previous.styleHeight;
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.scale(renderScale, renderScale);
|
||
labelPlacer = createLabelPlacer(ctx);
|
||
draw();
|
||
downloadButton.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
|
||
resizeCanvas();
|
||
window.addEventListener('resize', resizeCanvas);
|
||
})();
|
||
|
||
(function () {
|
||
const downloadButton = document.getElementById('downloadMonatsueberblickZip');
|
||
const copyButton = document.getElementById('copyMonatsueberblickHtml');
|
||
const saveToJoomlaButton = document.getElementById('saveMonatsueberblickToJoomla');
|
||
const statusEl = document.getElementById('monatsueberblickZipStatus');
|
||
const overviewEl = document.getElementById('monatsueberblick');
|
||
const polarCanvas = document.getElementById('monatskarteCanvas');
|
||
const southCanvas = document.getElementById('monatskarteSouthCanvas');
|
||
const phaseImage = document.getElementById('monatsphasenImage');
|
||
const htmlImageMaxWidth = 900;
|
||
|
||
if (!downloadButton || !copyButton || !statusEl || !overviewEl || !polarCanvas || !southCanvas || !phaseImage || !MONTH_CHART_CONFIG) {
|
||
return;
|
||
}
|
||
|
||
function canvasToPngBlob(sourceCanvas) {
|
||
return new Promise(function (resolve, reject) {
|
||
sourceCanvas.toBlob(function (blob) {
|
||
if (blob) {
|
||
resolve(blob);
|
||
} else {
|
||
reject(new Error('PNG konnte nicht erzeugt werden.'));
|
||
}
|
||
}, 'image/png');
|
||
});
|
||
}
|
||
|
||
async function exportCanvasAsPng(sourceCanvas, logoPosition, options) {
|
||
const exportCanvas = await composeExportCanvas(sourceCanvas, logoPosition || 'bottom-left', options);
|
||
return canvasToPngBlob(exportCanvas || sourceCanvas);
|
||
}
|
||
|
||
async function exportPhaseImageAsPng() {
|
||
if (!phaseImage.complete || !phaseImage.naturalWidth || !phaseImage.naturalHeight) {
|
||
throw new Error('Die Mondphasengrafik ist noch nicht bereit.');
|
||
}
|
||
|
||
const sourceCanvas = document.createElement('canvas');
|
||
sourceCanvas.width = phaseImage.naturalWidth;
|
||
sourceCanvas.height = phaseImage.naturalHeight;
|
||
const sourceCtx = sourceCanvas.getContext('2d');
|
||
if (!sourceCtx) {
|
||
throw new Error('Mondphasengrafik konnte nicht vorbereitet werden.');
|
||
}
|
||
|
||
sourceCtx.drawImage(phaseImage, 0, 0);
|
||
return exportCanvasAsPng(sourceCanvas, 'bottom-right', { showCaption: false, logoScale: 0.6 });
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function blobToDataUrl(blob) {
|
||
return new Promise(function (resolve, reject) {
|
||
const reader = new FileReader();
|
||
reader.onload = function () { resolve(String(reader.result)); };
|
||
reader.onerror = function () { reject(new Error('Bild konnte nicht eingebettet werden.')); };
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
async function scaleImageBlob(blob, maxWidth) {
|
||
const sourceUrl = window.URL.createObjectURL(blob);
|
||
const image = new Image();
|
||
|
||
try {
|
||
await new Promise(function (resolve, reject) {
|
||
image.onload = resolve;
|
||
image.onerror = function () { reject(new Error('Bild konnte nicht skaliert werden.')); };
|
||
image.src = sourceUrl;
|
||
});
|
||
|
||
if (image.naturalWidth <= maxWidth) {
|
||
return blob;
|
||
}
|
||
|
||
const scaledCanvas = document.createElement('canvas');
|
||
scaledCanvas.width = maxWidth;
|
||
scaledCanvas.height = Math.round(image.naturalHeight * (maxWidth / image.naturalWidth));
|
||
const scaledContext = scaledCanvas.getContext('2d');
|
||
if (!scaledContext) {
|
||
throw new Error('Bild konnte nicht skaliert werden.');
|
||
}
|
||
|
||
scaledContext.drawImage(image, 0, 0, scaledCanvas.width, scaledCanvas.height);
|
||
return canvasToPngBlob(scaledCanvas);
|
||
} finally {
|
||
window.URL.revokeObjectURL(sourceUrl);
|
||
}
|
||
}
|
||
|
||
async function overviewHtml(images) {
|
||
const monthLabel = (overviewEl.querySelector('.monatskarte-header p')?.textContent || '').trim();
|
||
const narrative = (overviewEl.querySelector('.monatsnarrativ-text')?.textContent || '').trim();
|
||
const escapedNarrative = escapeHtml(narrative).replace(/\r?\n/g, '<br>\n');
|
||
const imageMarkup = await Promise.all(images.map(async function (image) {
|
||
const scaledImage = await scaleImageBlob(image.content, htmlImageMaxWidth);
|
||
const imageDataUrl = await blobToDataUrl(scaledImage);
|
||
return '<h2>' + escapeHtml(image.title) + '</h2>\n'
|
||
+ '<p><img src="' + imageDataUrl + '" alt="' + escapeHtml(image.title) + '" style="max-width: 100%; height: auto;"></p>\n';
|
||
}));
|
||
|
||
return '<h3>' + escapeHtml(monthLabel) + '</h3>\n'
|
||
+ '<p>' + escapedNarrative + '</p>\n'
|
||
+ imageMarkup.join('')
|
||
+ eventTableHtml()
|
||
+ '<p>Alle Zeiten gelten für MEZ/MESZ.</p>\n'
|
||
+ '\n';
|
||
}
|
||
|
||
function eventTableHtml() {
|
||
const rows = (Array.isArray(MONTH_EVENT_LIST) ? MONTH_EVENT_LIST : [])
|
||
.map(function (event) {
|
||
return '<tr><td style="padding: 6px;">' + escapeHtml(event.event || '') + '</td><td style="padding: 6px; text-align: center;">'
|
||
+ escapeHtml(event.date || '') + '</td><td style="padding: 6px; text-align: center;">'
|
||
+ escapeHtml(event.time || '') + '</td></tr>';
|
||
});
|
||
|
||
if (rows.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
return '<h2>Ereignisse</h2>\n'
|
||
+ '<table style="border-collapse: collapse;">\n'
|
||
+ '<thead><tr><th style="padding: 6px; text-align: center;">Ereignis</th><th style="padding: 6px; text-align: center;">Datum</th><th style="padding: 6px; text-align: center;">Uhrzeit</th></tr></thead>\n'
|
||
+ '<tbody>\n' + rows.join('\n') + '\n</tbody>\n'
|
||
+ '</table>\n';
|
||
}
|
||
|
||
function crc32(bytes) {
|
||
let crc = 0xffffffff;
|
||
for (let index = 0; index < bytes.length; index++) {
|
||
crc ^= bytes[index];
|
||
for (let bit = 0; bit < 8; bit++) {
|
||
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
||
}
|
||
}
|
||
return (crc ^ 0xffffffff) >>> 0;
|
||
}
|
||
|
||
function zipHeader(size) {
|
||
const bytes = new Uint8Array(size);
|
||
return { bytes: bytes, view: new DataView(bytes.buffer) };
|
||
}
|
||
|
||
async function createZip(entries) {
|
||
const encoder = new TextEncoder();
|
||
const localChunks = [];
|
||
const centralChunks = [];
|
||
const now = new Date();
|
||
const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2);
|
||
const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
|
||
let offset = 0;
|
||
|
||
for (const entry of entries) {
|
||
const nameBytes = encoder.encode(entry.name);
|
||
const content = entry.content instanceof Blob
|
||
? new Uint8Array(await entry.content.arrayBuffer())
|
||
: encoder.encode(entry.content);
|
||
const checksum = crc32(content);
|
||
const local = zipHeader(30);
|
||
local.view.setUint32(0, 0x04034b50, true);
|
||
local.view.setUint16(4, 20, true);
|
||
local.view.setUint16(6, 0x0800, true);
|
||
local.view.setUint16(8, 0, true);
|
||
local.view.setUint16(10, dosTime, true);
|
||
local.view.setUint16(12, dosDate, true);
|
||
local.view.setUint32(14, checksum, true);
|
||
local.view.setUint32(18, content.length, true);
|
||
local.view.setUint32(22, content.length, true);
|
||
local.view.setUint16(26, nameBytes.length, true);
|
||
local.view.setUint16(28, 0, true);
|
||
localChunks.push(local.bytes, nameBytes, content);
|
||
|
||
const central = zipHeader(46);
|
||
central.view.setUint32(0, 0x02014b50, true);
|
||
central.view.setUint16(4, 20, true);
|
||
central.view.setUint16(6, 20, true);
|
||
central.view.setUint16(8, 0x0800, true);
|
||
central.view.setUint16(10, 0, true);
|
||
central.view.setUint16(12, dosTime, true);
|
||
central.view.setUint16(14, dosDate, true);
|
||
central.view.setUint32(16, checksum, true);
|
||
central.view.setUint32(20, content.length, true);
|
||
central.view.setUint32(24, content.length, true);
|
||
central.view.setUint16(28, nameBytes.length, true);
|
||
central.view.setUint16(30, 0, true);
|
||
central.view.setUint16(32, 0, true);
|
||
central.view.setUint16(34, 0, true);
|
||
central.view.setUint16(36, 0, true);
|
||
central.view.setUint32(38, 0, true);
|
||
central.view.setUint32(42, offset, true);
|
||
centralChunks.push(central.bytes, nameBytes);
|
||
|
||
offset += local.bytes.length + nameBytes.length + content.length;
|
||
}
|
||
|
||
const centralSize = centralChunks.reduce(function (size, chunk) { return size + chunk.length; }, 0);
|
||
const end = zipHeader(22);
|
||
end.view.setUint32(0, 0x06054b50, true);
|
||
end.view.setUint16(8, entries.length, true);
|
||
end.view.setUint16(10, entries.length, true);
|
||
end.view.setUint32(12, centralSize, true);
|
||
end.view.setUint32(16, offset, true);
|
||
end.view.setUint16(20, 0, true);
|
||
|
||
return new Blob([...localChunks, ...centralChunks, end.bytes], { type: 'application/zip' });
|
||
}
|
||
|
||
function downloadBlob(blob, filename) {
|
||
const objectUrl = window.URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = objectUrl;
|
||
link.download = filename;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
window.setTimeout(function () {
|
||
window.URL.revokeObjectURL(objectUrl);
|
||
}, 0);
|
||
}
|
||
|
||
async function createOverviewImages() {
|
||
const exports = await Promise.all([
|
||
exportCanvasAsPng(polarCanvas),
|
||
exportCanvasAsPng(southCanvas),
|
||
exportPhaseImageAsPng(),
|
||
]);
|
||
return [
|
||
{ title: 'Zenitprojektion', content: exports[0] },
|
||
{ title: 'Südhimmel', content: exports[1] },
|
||
{ title: 'Mondphasen', content: exports[2] },
|
||
];
|
||
}
|
||
|
||
async function copyTextToClipboard(text) {
|
||
if (navigator.clipboard && window.isSecureContext) {
|
||
await navigator.clipboard.writeText(text);
|
||
return;
|
||
}
|
||
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = text;
|
||
textarea.setAttribute('readonly', '');
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.opacity = '0';
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
const copied = document.execCommand('copy');
|
||
document.body.removeChild(textarea);
|
||
if (!copied) {
|
||
throw new Error('Zwischenablage nicht verfügbar.');
|
||
}
|
||
}
|
||
|
||
downloadButton.addEventListener('click', async function () {
|
||
const year = String(MONTH_CHART_CONFIG.year || '');
|
||
const month = String(MONTH_CHART_CONFIG.monthNumber || MONTH_CHART_CONFIG.month || '').padStart(2, '0');
|
||
|
||
try {
|
||
statusEl.textContent = 'Monatspaket wird erstellt...';
|
||
downloadButton.disabled = true;
|
||
|
||
const images = await createOverviewImages();
|
||
const zip = await createZip([
|
||
{ name: monthChartExportFilename('polar'), content: images[0].content },
|
||
{ name: monthChartExportFilename('sued'), content: images[1].content },
|
||
{ name: year + '_' + month + '_Mondphasen.png', content: images[2].content },
|
||
{ name: year + '_' + month + '_Monat_im_Ueberblick.html', content: await overviewHtml(images) },
|
||
]);
|
||
|
||
downloadBlob(zip, year + '_' + month + '_Monatspaket.zip');
|
||
statusEl.textContent = 'Monatspaket wurde heruntergeladen.';
|
||
} catch (error) {
|
||
statusEl.textContent = 'Monatspaket konnte nicht erstellt werden.';
|
||
} finally {
|
||
downloadButton.disabled = false;
|
||
}
|
||
});
|
||
|
||
copyButton.addEventListener('click', async function () {
|
||
try {
|
||
statusEl.textContent = 'HTML für die Zwischenablage wird erstellt...';
|
||
copyButton.disabled = true;
|
||
const html = await overviewHtml(await createOverviewImages());
|
||
await copyTextToClipboard(html);
|
||
statusEl.textContent = 'HTML wurde in die Zwischenablage kopiert.';
|
||
} catch (error) {
|
||
statusEl.textContent = 'HTML konnte nicht in die Zwischenablage kopiert werden.';
|
||
} finally {
|
||
copyButton.disabled = false;
|
||
}
|
||
});
|
||
|
||
if (saveToJoomlaButton) {
|
||
saveToJoomlaButton.addEventListener('click', async function () {
|
||
const year = Number(MONTH_CHART_CONFIG.year || 0);
|
||
const month = Number(MONTH_CHART_CONFIG.monthNumber || MONTH_CHART_CONFIG.month || 0);
|
||
if (!Number.isInteger(year) || !Number.isInteger(month)) {
|
||
statusEl.textContent = 'Monat für den Joomla-Export konnte nicht bestimmt werden.';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
statusEl.textContent = 'Monatsvorschau wird für Joomla erstellt...';
|
||
saveToJoomlaButton.disabled = true;
|
||
const formData = new FormData();
|
||
formData.append('joomla_monthly_preview_token', JOOMLA_MONTHLY_PREVIEW_TOKEN);
|
||
formData.append('year', String(year));
|
||
formData.append('month', String(month));
|
||
formData.append('html', await overviewHtml(await createOverviewImages()));
|
||
|
||
const response = await fetch(JOOMLA_MONTHLY_PREVIEW_ENDPOINT, {
|
||
method: 'POST',
|
||
body: formData,
|
||
headers: { 'X-Requested-With': 'fetch' },
|
||
});
|
||
const payload = await response.json();
|
||
statusEl.textContent = payload && payload.message
|
||
? payload.message
|
||
: 'Die Monatsvorschau konnte nicht in Joomla gespeichert werden.';
|
||
} catch (error) {
|
||
statusEl.textContent = 'Die Monatsvorschau konnte nicht in Joomla gespeichert werden.';
|
||
} finally {
|
||
saveToJoomlaButton.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
})();
|
||
</script>
|
||
|
||
<?php require __DIR__ . '/footer.php'; ?>
|