Files
skyview.astronomiemuseum.de/public/monatsvorhersage_old.php
T

4230 lines
147 KiB
PHP

<?php
declare(strict_types=1);
session_start();
$loggedIn = isset($_SESSION['user_id']);
$pageTitle = 'Monatsvorhersage - AstroTools';
$bodyClass = 'admin-page monatsvorhersage-page';
$publicBasePath = './';
$currentYear = (int) date('Y');
$selectedYear = isset($_GET['year']) ? (int) $_GET['year'] : $currentYear;
$selectedMonth = isset($_GET['month']) ? max(1, min(12, (int) $_GET['month'])) : (int) date('n');
// -------------------------------------------------------------------------
// Grenzwerte fuer Sternkarte und Objektanzeige
// -------------------------------------------------------------------------
// Maximale Helligkeit (Magnitude) der Sterne, die aus der Datenbank geladen
// werden. Hoehere Werte laden mehr, aber auch schwaechere Sterne.
$starDatabaseMagnitudeLimit = 6.0;
// Maximale Helligkeit der Sterne, die auf der Monats-Sternkarte sichtbar
// gezeichnet werden sollen.
$starDisplayMagnitudeLimit = 4.4;
// Sterne bis zu dieser Helligkeit erhalten auf der Sternkarte einen Namen.
$starLabelMagnitudeLimit = 2.0;
// Maximale Helligkeit der Messier-Objekte, die aus der Datenbank geladen
// werden.
$messierDatabaseMagnitudeLimit = 2.0;
// Messier-Objekte bis zu dieser Helligkeit werden auf der Monatskarte
// eingeblendet.
$messierDisplayMagnitudeLimit = 1.0;
// -------------------------------------------------------------------------
// Grenzwerte fuer Monatsereignisse
// -------------------------------------------------------------------------
// Maximaler Winkelabstand fuer Begegnungen zwischen Mond und Planeten.
$moonPlanetMaxSeparationDeg = 1.5;
// Maximaler Winkelabstand fuer Begegnungen zwischen Mond und ausgewaehlten
// Sternhaufen wie Plejaden oder Praesepe.
$moonDeepSkyMaxSeparationDeg = 1.0;
// Maximaler Winkelabstand fuer Begegnungen zwischen hellen Planeten und
// ausgewaehlten hellen Sternen wie Regulus oder Spica.
$planetBrightStarMaxSeparationDeg = 1.0;
// Maximaler Winkelabstand fuer Planet-Planet-Konjunktionen.
$planetConjunctionMaxSeparationDeg = 1.0;
// Nur Sterne bis zu dieser Helligkeit werden fuer Mondbedeckungen
// beruecksichtigt.
$moonOccultationMaxMag = 2.0;
// -------------------------------------------------------------------------
// Schwellen fuer "Gute Satelliten-Ueberfluege"
// -------------------------------------------------------------------------
// Es wird nur der Favoriten-Satellit des angemeldeten Users geprueft.
//
// Ein Ueberflug zaehlt als guter Beobachtungstermin nur dann, wenn er am
// Hoehepunkt mindestens diese Hoehe ueber dem Horizont erreicht.
$favoriteSatelliteBestPassMinAltitudeDeg = 80.0;
// Gleichzeitig muss die Sonne zum Maximum des Ueberflugs mindestens so weit
// unter dem Horizont stehen. -12 Grad entspricht dem Beginn/Ende der
// nautischen Daemmerung und sorgt fuer ausreichend dunklen Himmel.
$favoriteSatelliteBestPassMaxSunAltitudeDeg = -14.0;
// Optional koennen wir die Liste auf die erste Nachthaelfte begrenzen.
// "Erste Nachthaelfte" ist hier bewusst pragmatisch als lokaler Abend vor
// Mitternacht definiert. Ueberfluege nach 00:00 Ortszeit werden damit
// ausgefiltert.
$favoriteSatelliteBestPassOnlyFirstNightHalf = true;
// Optional koennen wir nur besonders helle Ueberfluege anzeigen.
// Bei Magnituden gilt: kleinere Werte sind heller. 0.0 mag bedeutet also,
// dass nur Ueberfluege mit Peak-Helligkeit von 0 mag oder heller bleiben.
$favoriteSatelliteBestPassMaxPeakMagnitude = 0.0;
// -------------------------------------------------------------------------
// Schwellen fuer "Gute Merkur-Sichtbarkeit"
// -------------------------------------------------------------------------
// Gute Merkur-Sichtbarkeit wird nicht nur ueber Elongation definiert, sondern
// ueber echte Beobachtbarkeit in der Daemmerung.
// Geprueft wird pro Tag:
// morgens eine feste Zahl Minuten VOR Sonnenaufgang,
// abends eine feste Zahl Minuten NACH Sonnenuntergang.
$mercuryGoodVisibilitySampleOffsetMinutes = 45;
// Merkur muss zum Pruefzeitpunkt mindestens diese Hoehe ueber dem Horizont
// erreichen, damit der Tag als gut sichtbar gilt.
$mercuryGoodVisibilityMinAltitudeDeg = 7.0;
// Gleichzeitig muss die Sonne ausreichend tief unter dem Horizont stehen.
// So vermeiden wir Treffer, bei denen Merkur zwar formal sichtbar waere, aber
// noch zu stark in der hellen Daemmerung untergeht.
$mercuryGoodVisibilityMaxSunAltitudeDeg = -3.0;
// Erst ab dieser Anzahl aufeinanderfolgender guter Tage erzeugen wir ein
// Monatsereignis. Einzelne Ausreisser-Tage werden dadurch herausgefiltert.
$mercuryGoodVisibilityMinConsecutiveDays = 3;
// -------------------------------------------------------------------------
// Schwellen fuer "Planetenparade"
// -------------------------------------------------------------------------
// Geprueft wird morgens eine feste Zahl Minuten VOR Sonnenaufgang und abends
// eine feste Zahl Minuten NACH Sonnenuntergang.
$planetParadeSampleOffsetMinutes = 45;
// Ein Planet zaehlt fuer die Parade nur dann mit, wenn er zum Pruefzeitpunkt
// mindestens diese Hoehe ueber dem Horizont erreicht.
$planetParadeMinAltitudeDeg = 7.0;
// Gleichzeitig muss die Sonne bereits tief genug unter dem Horizont stehen,
// damit die Parade nicht nur rechnerisch, sondern auch praktisch sichtbar ist.
$planetParadeMaxSunAltitudeDeg = -4.0;
// Die beteiligten Planeten muessen innerhalb dieses Azimutbogens liegen.
// So vermeiden wir Treffer, bei denen die Planeten zu weit ueber den Himmel
// verteilt sind und nicht mehr als zusammenhaengende Parade wirken.
$planetParadeMaxAzimuthSpanDeg = 120.0;
// Mindestens so viele helle Planeten muessen gleichzeitig sichtbar sein,
// damit wir von einer Planetenparade sprechen.
$planetParadeMinPlanetCount = 4;
// Erst ab dieser Anzahl aufeinanderfolgender Tage erzeugen wir ein Ereignis.
// Das filtert kurzlebige Ein-Tages-Treffer heraus.
$planetParadeMinConsecutiveDays = 2;
$years = range($currentYear - 2, $currentYear + 5);
$months = [
1 => 'Januar',
2 => 'Februar',
3 => 'März',
4 => 'April',
5 => 'Mai',
6 => 'Juni',
7 => 'Juli',
8 => 'August',
9 => 'September',
10 => 'Oktober',
11 => 'November',
12 => 'Dezember',
];
$monthSkyTimes = [
1 => ['hour' => 22, 'minute' => 0],
2 => ['hour' => 22, 'minute' => 0],
3 => ['hour' => 22, 'minute' => 0],
4 => ['hour' => 23, 'minute' => 0],
5 => ['hour' => 23, 'minute' => 0],
6 => ['hour' => 23, 'minute' => 30],
7 => ['hour' => 23, 'minute' => 30],
8 => ['hour' => 23, 'minute' => 0],
9 => ['hour' => 23, 'minute' => 0],
10 => ['hour' => 23, 'minute' => 0],
11 => ['hour' => 22, 'minute' => 0],
12 => ['hour' => 22, 'minute' => 0],
];
$starData = [];
$lineStarData = [];
$constellationLines = [];
$messierData = [];
$planetData = [];
$moonData = null;
$moonPhaseEvents = [];
$goldenHandleEvents = [];
$moonPlanetApproaches = [];
$moonDeepSkyApproaches = [];
$planetBrightStarApproaches = [];
$mercuryGoodVisibilityEvents = [];
$planetParadeEvents = [];
$planetConstellationChangeEvents = [];
$planetConjunctions = [];
$eclipseEvents = [];
$moonApsisEvents = [];
$sunApsisEvents = [];
$innerPlanetElongationEvents = [];
$venusPeakMagnitudeEvents = [];
$outerPlanetEvents = [];
$outerPlanetStationEvents = [];
$jupiterMoonSideEvents = [];
$moonOccultations = [];
$meteorShowerPeaks = [];
$favoriteSatellitePasses = [];
$favoriteSatelliteName = null;
$favoriteSatellitePayload = [];
$seasonChanges = [];
$timeChanges = [];
$monthEventList = [];
$starDataError = null;
if (!function_exists('buildMonthOccultationStarLabel')) {
function buildMonthOccultationStarLabel(array $row): string
{
$proper = trim((string) ($row['proper'] ?? ''));
if ($proper !== '') {
return $proper;
}
$bayer = trim((string) ($row['bayer'] ?? ''));
$con = trim((string) ($row['con'] ?? ''));
if ($bayer !== '') {
return $con !== '' ? $bayer . ' ' . $con : $bayer;
}
$hip = isset($row['hip']) ? (int) $row['hip'] : 0;
return $hip > 0 ? 'HIP ' . $hip : 'Unbenannter Stern';
}
}
if (!function_exists('normalizeMonthForecastDegree')) {
function normalizeMonthForecastDegree(float $value): float
{
$normalized = fmod($value, 360.0);
if ($normalized < 0.0) {
$normalized += 360.0;
}
return $normalized;
}
}
if (!function_exists('monthForecastSolarLongitude')) {
function monthForecastSolarLongitude(DateTimeImmutable $dateTime): float
{
$julianDay = ((float) $dateTime->format('U')) / 86400.0 + 2440587.5;
$daysSinceJ2000 = $julianDay - 2451545.0;
$meanLongitude = normalizeMonthForecastDegree(280.46 + 0.9856474 * $daysSinceJ2000);
$meanAnomalyRad = deg2rad(normalizeMonthForecastDegree(357.528 + 0.9856003 * $daysSinceJ2000));
return normalizeMonthForecastDegree(
$meanLongitude
+ 1.915 * sin($meanAnomalyRad)
+ 0.020 * sin(2.0 * $meanAnomalyRad)
);
}
}
if (!function_exists('monthForecastForwardDegreeDistance')) {
function monthForecastForwardDegreeDistance(float $from, float $to): float
{
return normalizeMonthForecastDegree($to - $from);
}
}
if (!function_exists('monthForecastRadiantIsVisible')) {
function monthForecastRadiantIsVisible(float $latitudeDeg, ?float $declinationDeg): bool
{
if ($declinationDeg === null) {
return false;
}
return abs($latitudeDeg - $declinationDeg) < 90.0;
}
}
if (!function_exists('monthForecastTleChecksum')) {
function monthForecastTleChecksum(string $line): int
{
$sum = 0;
for ($i = 0; $i < 68; $i++) {
$c = $line[$i] ?? ' ';
if (ctype_digit($c)) {
$sum += (int) $c;
} elseif ($c === '-') {
$sum += 1;
}
}
return $sum % 10;
}
}
if (!function_exists('monthForecastTleExp')) {
function monthForecastTleExp(float $value): string
{
if (abs($value) < 1e-99) {
return ' 00000-0';
}
$sign = $value >= 0 ? '+' : '-';
$abs = abs($value);
$exp = (int) floor(log10($abs)) + 1;
$mantissa = (int) round($abs / pow(10, $exp) * 1e5);
if ($mantissa >= 100000) {
$mantissa = 10000;
$exp++;
}
$expSign = $exp >= 0 ? '+' : '-';
return sprintf('%s%05d%s%d', $sign, $mantissa, $expSign, abs($exp));
}
}
if (!function_exists('monthForecastTleMmDot')) {
function monthForecastTleMmDot(float $value): string
{
$sign = $value >= 0 ? ' ' : '-';
return sprintf('%s.%08d', $sign, (int) round(abs($value) * 1e8));
}
}
if (!function_exists('monthForecastTleEpoch')) {
function monthForecastTleEpoch(string $epochStr): string
{
$dt = new DateTimeImmutable($epochStr, new DateTimeZone('UTC'));
$yy = (int) $dt->format('Y') % 100;
$jan1 = new DateTimeImmutable($dt->format('Y') . '-01-01 00:00:00', new DateTimeZone('UTC'));
$secs = (float) $dt->format('U') - (float) $jan1->format('U');
$day = $secs / 86400.0 + 1.0;
return sprintf('%02d%012.8f', $yy, $day);
}
}
if (!function_exists('monthForecastTleIntlDesig')) {
function monthForecastTleIntlDesig(?string $objectId): string
{
if (!$objectId) {
return str_repeat(' ', 8);
}
if (preg_match('/^(\d{4})-(\d+)([A-Z]*)$/i', trim($objectId), $match)) {
$yy = substr($match[1], 2, 2);
$launch = str_pad($match[2], 3, '0', STR_PAD_LEFT);
$piece = str_pad(strtoupper($match[3]), 3, ' ', STR_PAD_RIGHT);
return substr(str_pad($yy . $launch . $piece, 8, ' '), 0, 8);
}
return str_pad(substr($objectId, 0, 8), 8);
}
}
if (!function_exists('monthForecastBuildTleLine1')) {
function monthForecastBuildTleLine1(array $sat, array $tle): string
{
$norad = (int) ($sat['norad_cat_id'] ?? 0);
$class = in_array($sat['classification_type'] ?? '', ['U', 'C', 'S'], true)
? (string) $sat['classification_type']
: 'U';
$line = sprintf(
'1 %05d%1s %-8s %14s %10s %8s %8s %1d %4d',
$norad,
$class,
monthForecastTleIntlDesig($sat['object_id'] ?? null),
monthForecastTleEpoch((string) ($tle['epoch'] ?? '')),
monthForecastTleMmDot((float) ($tle['mean_motion_dot'] ?? 0)),
monthForecastTleExp((float) ($tle['mean_motion_ddot'] ?? 0)),
monthForecastTleExp((float) ($tle['bstar'] ?? 0)),
(int) ($tle['ephemeris_type'] ?? 0),
(int) ($tle['element_set_no'] ?? 0)
);
$line = str_pad($line, 68);
return $line . monthForecastTleChecksum($line);
}
}
if (!function_exists('monthForecastBuildTleLine2')) {
function monthForecastBuildTleLine2(array $sat, array $tle): string
{
$norad = (int) ($sat['norad_cat_id'] ?? 0);
$ecc = (float) ($tle['eccentricity'] ?? 0);
$eccStr = substr(sprintf('%.7f', $ecc), 2);
$line = sprintf(
'2 %05d %8.4f %8.4f %7s %8.4f %8.4f %11.8f%5d',
$norad,
(float) ($tle['inclination'] ?? 0),
(float) ($tle['ra_of_asc_node'] ?? 0),
$eccStr,
(float) ($tle['arg_of_pericenter'] ?? 0),
(float) ($tle['mean_anomaly'] ?? 0),
(float) ($tle['mean_motion'] ?? 0),
(int) ($tle['rev_at_epoch'] ?? 0)
);
$line = str_pad($line, 68);
return $line . monthForecastTleChecksum($line);
}
}
if (!function_exists('findMonthForecastSolarLongitudeCrossing')) {
function findMonthForecastSolarLongitudeCrossing(
DateTimeImmutable $rangeStartLocal,
DateTimeImmutable $rangeEndLocal,
float $targetLongitude,
DateTimeZone $displayTimezone
): ?DateTimeImmutable {
$utc = new DateTimeZone('UTC');
$rangeStartUtc = $rangeStartLocal->setTimezone($utc);
$rangeEndUtc = $rangeEndLocal->setTimezone($utc);
$startLongitude = monthForecastSolarLongitude($rangeStartUtc);
$endLongitude = monthForecastSolarLongitude($rangeEndUtc);
$targetDelta = monthForecastForwardDegreeDistance($startLongitude, $targetLongitude);
$rangeDelta = monthForecastForwardDegreeDistance($startLongitude, $endLongitude);
if ($targetDelta > $rangeDelta + 0.0001) {
return null;
}
$low = (int) $rangeStartUtc->format('U');
$high = (int) $rangeEndUtc->format('U');
for ($i = 0; $i < 40; $i++) {
$mid = (int) floor(($low + $high) / 2);
$midLongitude = monthForecastSolarLongitude(new DateTimeImmutable('@' . $mid));
$midDelta = monthForecastForwardDegreeDistance($startLongitude, $midLongitude);
if ($midDelta < $targetDelta) {
$low = $mid;
} else {
$high = $mid;
}
}
return (new DateTimeImmutable('@' . $high))->setTimezone($displayTimezone);
}
}
if (!function_exists('buildObservationTipInsertSql')) {
function buildObservationTipInsertSql(array $events, DateTimeZone $timezone): string
{
$rows = [];
foreach ($events as $event) {
if (!is_array($event)) {
continue;
}
$tip = trim((string) ($event['event'] ?? ''));
if ($tip === '') {
continue;
}
$moment = null;
$localIso = trim((string) ($event['local_iso'] ?? ''));
if ($localIso !== '') {
try {
$moment = new DateTimeImmutable($localIso);
} catch (Throwable $e) {
$moment = null;
}
}
if (!$moment instanceof DateTimeImmutable) {
$date = trim((string) ($event['date'] ?? ''));
$time = trim((string) ($event['time'] ?? ''));
if ($date !== '' && $time !== '') {
$moment = DateTimeImmutable::createFromFormat('d.m.Y H:i', $date . ' ' . $time, $timezone) ?: null;
}
}
if (!$moment instanceof DateTimeImmutable) {
continue;
}
$rows[] = sprintf(
"('%s', '%s')",
$moment->setTimezone($timezone)->format('Y-m-d H:i:s'),
str_replace("'", "''", $tip)
);
}
if ($rows === []) {
return '';
}
return "INSERT INTO `astro_sonobs_observationtips` (`moment`, `tip`)\nVALUES\n "
. implode(",\n ", $rows)
. ";\n";
}
}
if (!function_exists('escapeIcalText')) {
function escapeIcalText(string $value): string
{
return str_replace(
["\\", ";", ",", "\r\n", "\n", "\r"],
["\\\\", "\\;", "\\,", "\\n", "\\n", "\\n"],
$value
);
}
}
if (!function_exists('foldIcalLine')) {
function foldIcalLine(string $line): string
{
$chunks = preg_split('/(.{1,73})/u', $line, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
if ($chunks === false || $chunks === []) {
return $line;
}
$firstChunk = array_shift($chunks);
return $firstChunk . ($chunks ? "\r\n " . implode("\r\n ", $chunks) : '');
}
}
if (!function_exists('buildObservationTipCalendarIcs')) {
function buildObservationTipCalendarIcs(array $events, DateTimeZone $timezone): string
{
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//AstroTools//Monatsvorhersage//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'X-WR-CALNAME:AstroTools Monatsereignisse',
];
$createdUtc = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->format('Ymd\THis\Z');
foreach ($events as $index => $event) {
if (!is_array($event)) {
continue;
}
$summary = trim((string) ($event['event'] ?? ''));
if ($summary === '') {
continue;
}
$moment = null;
$localIso = trim((string) ($event['local_iso'] ?? ''));
if ($localIso !== '') {
try {
$moment = new DateTimeImmutable($localIso);
} catch (Throwable $e) {
$moment = null;
}
}
if (!$moment instanceof DateTimeImmutable) {
$date = trim((string) ($event['date'] ?? ''));
$time = trim((string) ($event['time'] ?? ''));
if ($date !== '' && $time !== '') {
$moment = DateTimeImmutable::createFromFormat('d.m.Y H:i', $date . ' ' . $time, $timezone) ?: null;
}
}
if (!$moment instanceof DateTimeImmutable) {
continue;
}
$startUtc = $moment->setTimezone(new DateTimeZone('UTC'));
$endMoment = null;
$endLocalIso = trim((string) ($event['end_local_iso'] ?? ''));
if ($endLocalIso !== '') {
try {
$endMoment = new DateTimeImmutable($endLocalIso);
} catch (Throwable $e) {
$endMoment = null;
}
}
if (!$endMoment instanceof DateTimeImmutable) {
$durationMinutes = isset($event['duration_minutes']) ? max(1, (int) $event['duration_minutes']) : 15;
$endMoment = $moment->modify('+' . $durationMinutes . ' minutes');
}
$endUtc = $endMoment->setTimezone(new DateTimeZone('UTC'));
$uidBase = sha1($startUtc->format(DateTimeInterface::ATOM) . '|' . $summary . '|' . (string) $index);
$eventLines = [
'BEGIN:VEVENT',
'UID:' . $uidBase . '@astrotools.local',
'DTSTAMP:' . $createdUtc,
'DTSTART:' . $startUtc->format('Ymd\THis\Z'),
'DTEND:' . $endUtc->format('Ymd\THis\Z'),
'SUMMARY:' . escapeIcalText($summary),
'DESCRIPTION:' . escapeIcalText($summary),
'END:VEVENT',
];
foreach ($eventLines as $eventLine) {
$lines[] = foldIcalLine($eventLine);
}
}
$lines[] = 'END:VCALENDAR';
return implode("\r\n", $lines) . "\r\n";
}
}
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'])) {
$favoriteSatelliteStmt = $pdo->prepare(
"SELECT s.id, s.norad_cat_id, s.object_name, s.object_id, s.classification_type,
s.std_mag, s.rcs_size, s.rcs,
t.epoch, t.mean_motion, t.eccentricity, t.inclination,
t.ra_of_asc_node, t.arg_of_pericenter, t.mean_anomaly,
t.ephemeris_type, t.element_set_no, t.rev_at_epoch,
t.bstar, t.mean_motion_dot, t.mean_motion_ddot
FROM app_user_satellites us
INNER JOIN sat_satellites s ON s.id = us.satellite_id
INNER JOIN sat_tle_current t ON t.satellite_id = s.id
WHERE us.user_id = :user_id
AND us.is_favorite = 1
AND t.epoch IS NOT NULL
AND t.mean_motion IS NOT NULL
ORDER BY s.object_name ASC
LIMIT 1"
);
$favoriteSatelliteStmt->execute([
':user_id' => (int) $_SESSION['user_id'],
]);
$favoriteSatelliteRow = $favoriteSatelliteStmt->fetch();
if (is_array($favoriteSatelliteRow)) {
$favoriteSatelliteName = trim((string) ($favoriteSatelliteRow['object_name'] ?? ''));
$favoriteSatellitePayload = [[
'id' => (int) ($favoriteSatelliteRow['id'] ?? 0),
'norad_cat_id' => (int) ($favoriteSatelliteRow['norad_cat_id'] ?? 0),
'object_name' => $favoriteSatelliteName,
'is_favorite' => true,
'std_mag' => $favoriteSatelliteRow['std_mag'] !== null ? (float) $favoriteSatelliteRow['std_mag'] : null,
'rcs_size' => $favoriteSatelliteRow['rcs_size'] !== null ? (string) $favoriteSatelliteRow['rcs_size'] : null,
'rcs' => $favoriteSatelliteRow['rcs'] !== null && (float) $favoriteSatelliteRow['rcs'] > 0 ? (float) $favoriteSatelliteRow['rcs'] : null,
'tle_line1' => monthForecastBuildTleLine1($favoriteSatelliteRow, $favoriteSatelliteRow),
'tle_line2' => monthForecastBuildTleLine2($favoriteSatelliteRow, $favoriteSatelliteRow),
]];
}
}
} catch (Throwable $e) {
$starDataError = $e->getMessage();
}
require __DIR__ . '/header.php';
$chartLocationName = (string) (($currentLocation['name'] ?? null) ?: 'Sternwarte Sonneberg');
$chartLatitude = isset($currentLocation['latitude']) ? (float) $currentLocation['latitude'] : 50.3547;
$chartLongitude = isset($currentLocation['longitude']) ? (float) $currentLocation['longitude'] : 11.1900;
$chartElevation = isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float) $currentLocation['elevation'] : 640.0;
$chartTimezone = (string) (($currentLocation['timezone'] ?? null) ?: 'Europe/Berlin');
$monthTime = $monthSkyTimes[$selectedMonth];
$chartLocalTime = new DateTimeImmutable(
sprintf('%04d-%02d-15 %02d:%02d:00', $selectedYear, $selectedMonth, $monthTime['hour'], $monthTime['minute']),
new DateTimeZone($chartTimezone)
);
$chartUtcTime = $chartLocalTime->setTimezone(new DateTimeZone('UTC'));
$monthStartLocal = new DateTimeImmutable(
sprintf('%04d-%02d-01 00:00:00', $selectedYear, $selectedMonth),
new DateTimeZone($chartTimezone)
);
$monthEndLocal = $monthStartLocal->modify('first day of next month');
$pythonExecutable = PHP_OS_FAMILY === 'Windows' ? 'python' : '/usr/bin/python';
$pythonScriptPath = __DIR__ . '/py/api.py';
$pythonOutput = [];
$pythonExitCode = 0;
$planetErrorText = null;
$pythonArgs = [
$pythonScriptPath,
'month_sky_context',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
(string) $selectedYear,
(string) $selectedMonth,
(string) $monthTime['hour'],
(string) $monthTime['minute'],
$chartTimezone,
];
$pythonCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $pythonArgs)) . ' 2>&1';
exec($pythonCommand, $pythonOutput, $pythonExitCode);
$pythonRaw = trim(implode("\n", $pythonOutput));
$pythonDecoded = json_decode($pythonRaw, true);
if (is_array($pythonDecoded) && ($pythonDecoded['ok'] ?? false)) {
if (is_array($pythonDecoded['bodies']['moon'] ?? null)) {
$moon = $pythonDecoded['bodies']['moon'];
$moonData = [
'label' => (string) ($moon['label'] ?? 'Mond'),
'az' => isset($moon['azimuth_deg']) ? (float) $moon['azimuth_deg'] : null,
'alt' => isset($moon['altitude_deg']) ? (float) $moon['altitude_deg'] : null,
'visible' => !empty($moon['visible']),
'phaseFraction' => isset($moon['phase_fraction']) ? (float) $moon['phase_fraction'] : null,
'waxing' => isset($moon['waxing']) ? (bool) $moon['waxing'] : null,
];
}
foreach (($pythonDecoded['bodies']['planets'] ?? []) as $planet) {
if (!is_array($planet)) {
continue;
}
$planetData[] = [
'key' => (string) ($planet['key'] ?? ''),
'label' => (string) ($planet['label'] ?? ''),
'az' => isset($planet['azimuth_deg']) ? (float) $planet['azimuth_deg'] : null,
'alt' => isset($planet['altitude_deg']) ? (float) $planet['altitude_deg'] : null,
'mag' => isset($planet['magnitude']) ? (float) $planet['magnitude'] : null,
'visible' => !empty($planet['visible']),
];
}
} elseif ($pythonRaw !== '') {
$planetErrorText = $pythonRaw;
}
if (!empty($favoriteSatellitePayload)) {
$favoriteSatellitePassOutput = [];
$favoriteSatellitePassArgs = [
$pythonScriptPath,
'satellite_passes',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
$chartTimezone,
$monthStartLocal->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'),
$monthEndLocal->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z'),
base64_encode((string) json_encode($favoriteSatellitePayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)),
];
$favoriteSatellitePassCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $favoriteSatellitePassArgs)) . ' 2>&1';
exec($favoriteSatellitePassCommand, $favoriteSatellitePassOutput);
$favoriteSatellitePassRaw = trim(implode("\n", $favoriteSatellitePassOutput));
$favoriteSatellitePassDecoded = json_decode($favoriteSatellitePassRaw, true);
if (is_array($favoriteSatellitePassDecoded) && ($favoriteSatellitePassDecoded['ok'] ?? false)) {
$favoriteSatelliteResults = is_array($favoriteSatellitePassDecoded['results'] ?? null)
? $favoriteSatellitePassDecoded['results']
: [];
$favoriteSatellitePasses = is_array($favoriteSatelliteResults[0]['passes'] ?? null)
? $favoriteSatelliteResults[0]['passes']
: [];
}
}
$moonPhaseOutput = [];
$moonPhaseExitCode = 0;
$moonPhaseArgs = [
$pythonScriptPath,
'moon_phases_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$moonPhaseCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonPhaseArgs)) . ' 2>&1';
exec($moonPhaseCommand, $moonPhaseOutput, $moonPhaseExitCode);
$moonPhaseRaw = trim(implode("\n", $moonPhaseOutput));
$moonPhaseDecoded = json_decode($moonPhaseRaw, true);
if (is_array($moonPhaseDecoded) && ($moonPhaseDecoded['ok'] ?? false)) {
$moonPhaseEvents = is_array($moonPhaseDecoded['phases'] ?? null) ? $moonPhaseDecoded['phases'] : [];
}
$goldenHandleOutput = [];
$goldenHandleExitCode = 0;
$goldenHandleArgs = [
$pythonScriptPath,
'golden_handle_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$goldenHandleCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $goldenHandleArgs)) . ' 2>&1';
exec($goldenHandleCommand, $goldenHandleOutput, $goldenHandleExitCode);
$goldenHandleRaw = trim(implode("\n", $goldenHandleOutput));
$goldenHandleDecoded = json_decode($goldenHandleRaw, true);
if (is_array($goldenHandleDecoded) && ($goldenHandleDecoded['ok'] ?? false)) {
$goldenHandleEvents = is_array($goldenHandleDecoded['events'] ?? null) ? $goldenHandleDecoded['events'] : [];
}
$seasonOutput = [];
$seasonExitCode = 0;
$seasonArgs = [
$pythonScriptPath,
'season_changes_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$seasonCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $seasonArgs)) . ' 2>&1';
exec($seasonCommand, $seasonOutput, $seasonExitCode);
$seasonRaw = trim(implode("\n", $seasonOutput));
$seasonDecoded = json_decode($seasonRaw, true);
if (is_array($seasonDecoded) && ($seasonDecoded['ok'] ?? false)) {
$seasonChanges = is_array($seasonDecoded['changes'] ?? null) ? $seasonDecoded['changes'] : [];
}
$timeChangeOutput = [];
$timeChangeExitCode = 0;
$timeChangeArgs = [
$pythonScriptPath,
'time_changes_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$timeChangeCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $timeChangeArgs)) . ' 2>&1';
exec($timeChangeCommand, $timeChangeOutput, $timeChangeExitCode);
$timeChangeRaw = trim(implode("\n", $timeChangeOutput));
$timeChangeDecoded = json_decode($timeChangeRaw, true);
if (is_array($timeChangeDecoded) && ($timeChangeDecoded['ok'] ?? false)) {
$timeChanges = is_array($timeChangeDecoded['events'] ?? null) ? $timeChangeDecoded['events'] : [];
}
$eclipseOutput = [];
$eclipseExitCode = 0;
$eclipseArgs = [
$pythonScriptPath,
'eclipses_for_month',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$eclipseCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $eclipseArgs)) . ' 2>&1';
exec($eclipseCommand, $eclipseOutput, $eclipseExitCode);
$eclipseRaw = trim(implode("\n", $eclipseOutput));
$eclipseDecoded = json_decode($eclipseRaw, true);
if (is_array($eclipseDecoded) && ($eclipseDecoded['ok'] ?? false)) {
$eclipseEvents = is_array($eclipseDecoded['events'] ?? null) ? $eclipseDecoded['events'] : [];
}
$moonApsisOutput = [];
$moonApsisExitCode = 0;
$moonApsisArgs = [
$pythonScriptPath,
'moon_apsides_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$moonApsisCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonApsisArgs)) . ' 2>&1';
exec($moonApsisCommand, $moonApsisOutput, $moonApsisExitCode);
$moonApsisRaw = trim(implode("\n", $moonApsisOutput));
$moonApsisDecoded = json_decode($moonApsisRaw, true);
if (is_array($moonApsisDecoded) && ($moonApsisDecoded['ok'] ?? false)) {
$moonApsisEvents = is_array($moonApsisDecoded['events'] ?? null) ? $moonApsisDecoded['events'] : [];
}
$sunApsisOutput = [];
$sunApsisExitCode = 0;
$sunApsisArgs = [
$pythonScriptPath,
'sun_apsides_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$sunApsisCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $sunApsisArgs)) . ' 2>&1';
exec($sunApsisCommand, $sunApsisOutput, $sunApsisExitCode);
$sunApsisRaw = trim(implode("\n", $sunApsisOutput));
$sunApsisDecoded = json_decode($sunApsisRaw, true);
if (is_array($sunApsisDecoded) && ($sunApsisDecoded['ok'] ?? false)) {
$sunApsisEvents = is_array($sunApsisDecoded['events'] ?? null) ? $sunApsisDecoded['events'] : [];
}
$elongationOutput = [];
$elongationExitCode = 0;
$elongationArgs = [
$pythonScriptPath,
'inner_planet_elongations_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$elongationCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $elongationArgs)) . ' 2>&1';
exec($elongationCommand, $elongationOutput, $elongationExitCode);
$elongationRaw = trim(implode("\n", $elongationOutput));
$elongationDecoded = json_decode($elongationRaw, true);
if (is_array($elongationDecoded) && ($elongationDecoded['ok'] ?? false)) {
$innerPlanetElongationEvents = is_array($elongationDecoded['events'] ?? null) ? $elongationDecoded['events'] : [];
}
$venusPeakMagnitudeOutput = [];
$venusPeakMagnitudeExitCode = 0;
$venusPeakMagnitudeArgs = [
$pythonScriptPath,
'venus_peak_magnitude_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$venusPeakMagnitudeCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $venusPeakMagnitudeArgs)) . ' 2>&1';
exec($venusPeakMagnitudeCommand, $venusPeakMagnitudeOutput, $venusPeakMagnitudeExitCode);
$venusPeakMagnitudeRaw = trim(implode("\n", $venusPeakMagnitudeOutput));
$venusPeakMagnitudeDecoded = json_decode($venusPeakMagnitudeRaw, true);
if (is_array($venusPeakMagnitudeDecoded) && ($venusPeakMagnitudeDecoded['ok'] ?? false)) {
$venusPeakMagnitudeEvents = is_array($venusPeakMagnitudeDecoded['events'] ?? null) ? $venusPeakMagnitudeDecoded['events'] : [];
}
$outerPlanetOutput = [];
$outerPlanetExitCode = 0;
$outerPlanetArgs = [
$pythonScriptPath,
'outer_planet_events_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$outerPlanetCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $outerPlanetArgs)) . ' 2>&1';
exec($outerPlanetCommand, $outerPlanetOutput, $outerPlanetExitCode);
$outerPlanetRaw = trim(implode("\n", $outerPlanetOutput));
$outerPlanetDecoded = json_decode($outerPlanetRaw, true);
if (is_array($outerPlanetDecoded) && ($outerPlanetDecoded['ok'] ?? false)) {
$outerPlanetEvents = is_array($outerPlanetDecoded['events'] ?? null) ? $outerPlanetDecoded['events'] : [];
}
$outerPlanetStationOutput = [];
$outerPlanetStationExitCode = 0;
$outerPlanetStationArgs = [
$pythonScriptPath,
'outer_planet_stations_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$outerPlanetStationCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $outerPlanetStationArgs)) . ' 2>&1';
exec($outerPlanetStationCommand, $outerPlanetStationOutput, $outerPlanetStationExitCode);
$outerPlanetStationRaw = trim(implode("\n", $outerPlanetStationOutput));
$outerPlanetStationDecoded = json_decode($outerPlanetStationRaw, true);
if (is_array($outerPlanetStationDecoded) && ($outerPlanetStationDecoded['ok'] ?? false)) {
$outerPlanetStationEvents = is_array($outerPlanetStationDecoded['events'] ?? null) ? $outerPlanetStationDecoded['events'] : [];
}
$jupiterMoonSideOutput = [];
$jupiterMoonSideExitCode = 0;
$jupiterMoonSideArgs = [
$pythonScriptPath,
'jupiter_moons_one_side_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$jupiterMoonSideCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $jupiterMoonSideArgs)) . ' 2>&1';
exec($jupiterMoonSideCommand, $jupiterMoonSideOutput, $jupiterMoonSideExitCode);
$jupiterMoonSideRaw = trim(implode("\n", $jupiterMoonSideOutput));
$jupiterMoonSideDecoded = json_decode($jupiterMoonSideRaw, true);
if (is_array($jupiterMoonSideDecoded) && ($jupiterMoonSideDecoded['ok'] ?? false)) {
$jupiterMoonSideEvents = is_array($jupiterMoonSideDecoded['events'] ?? null) ? $jupiterMoonSideDecoded['events'] : [];
}
$moonPlanetOutput = [];
$moonPlanetExitCode = 0;
$moonPlanetArgs = [
$pythonScriptPath,
'moon_planet_approaches',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
(string) $moonPlanetMaxSeparationDeg,
];
$moonPlanetCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonPlanetArgs)) . ' 2>&1';
exec($moonPlanetCommand, $moonPlanetOutput, $moonPlanetExitCode);
$moonPlanetRaw = trim(implode("\n", $moonPlanetOutput));
$moonPlanetDecoded = json_decode($moonPlanetRaw, true);
if (is_array($moonPlanetDecoded) && ($moonPlanetDecoded['ok'] ?? false)) {
$moonPlanetApproaches = is_array($moonPlanetDecoded['approaches'] ?? null) ? $moonPlanetDecoded['approaches'] : [];
}
$moonDeepSkyOutput = [];
$moonDeepSkyExitCode = 0;
$moonDeepSkyArgs = [
$pythonScriptPath,
'moon_deep_sky_approaches_for_month',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
(string) $moonDeepSkyMaxSeparationDeg,
];
$moonDeepSkyCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonDeepSkyArgs)) . ' 2>&1';
exec($moonDeepSkyCommand, $moonDeepSkyOutput, $moonDeepSkyExitCode);
$moonDeepSkyRaw = trim(implode("\n", $moonDeepSkyOutput));
$moonDeepSkyDecoded = json_decode($moonDeepSkyRaw, true);
if (is_array($moonDeepSkyDecoded) && ($moonDeepSkyDecoded['ok'] ?? false)) {
$moonDeepSkyApproaches = is_array($moonDeepSkyDecoded['approaches'] ?? null) ? $moonDeepSkyDecoded['approaches'] : [];
}
$planetBrightStarOutput = [];
$planetBrightStarExitCode = 0;
$planetBrightStarArgs = [
$pythonScriptPath,
'planet_bright_star_approaches_for_month',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
(string) $planetBrightStarMaxSeparationDeg,
];
$planetBrightStarCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $planetBrightStarArgs)) . ' 2>&1';
exec($planetBrightStarCommand, $planetBrightStarOutput, $planetBrightStarExitCode);
$planetBrightStarRaw = trim(implode("\n", $planetBrightStarOutput));
$planetBrightStarDecoded = json_decode($planetBrightStarRaw, true);
if (is_array($planetBrightStarDecoded) && ($planetBrightStarDecoded['ok'] ?? false)) {
$planetBrightStarApproaches = is_array($planetBrightStarDecoded['approaches'] ?? null) ? $planetBrightStarDecoded['approaches'] : [];
}
$planetConjunctionOutput = [];
$planetConjunctionExitCode = 0;
$planetConjunctionArgs = [
$pythonScriptPath,
'planet_conjunctions_for_month',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
(string) $planetConjunctionMaxSeparationDeg,
];
$planetConjunctionCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $planetConjunctionArgs)) . ' 2>&1';
exec($planetConjunctionCommand, $planetConjunctionOutput, $planetConjunctionExitCode);
$planetConjunctionRaw = trim(implode("\n", $planetConjunctionOutput));
$planetConjunctionDecoded = json_decode($planetConjunctionRaw, true);
if (is_array($planetConjunctionDecoded) && ($planetConjunctionDecoded['ok'] ?? false)) {
$planetConjunctions = is_array($planetConjunctionDecoded['conjunctions'] ?? null) ? $planetConjunctionDecoded['conjunctions'] : [];
}
$mercuryGoodVisibilityOutput = [];
$mercuryGoodVisibilityExitCode = 0;
$mercuryGoodVisibilityArgs = [
$pythonScriptPath,
'mercury_good_visibility_for_month',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
(string) $mercuryGoodVisibilitySampleOffsetMinutes,
(string) $mercuryGoodVisibilityMinAltitudeDeg,
(string) $mercuryGoodVisibilityMaxSunAltitudeDeg,
(string) $mercuryGoodVisibilityMinConsecutiveDays,
];
$mercuryGoodVisibilityCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $mercuryGoodVisibilityArgs)) . ' 2>&1';
exec($mercuryGoodVisibilityCommand, $mercuryGoodVisibilityOutput, $mercuryGoodVisibilityExitCode);
$mercuryGoodVisibilityRaw = trim(implode("\n", $mercuryGoodVisibilityOutput));
$mercuryGoodVisibilityDecoded = json_decode($mercuryGoodVisibilityRaw, true);
if (is_array($mercuryGoodVisibilityDecoded) && ($mercuryGoodVisibilityDecoded['ok'] ?? false)) {
$mercuryGoodVisibilityEvents = is_array($mercuryGoodVisibilityDecoded['events'] ?? null) ? $mercuryGoodVisibilityDecoded['events'] : [];
}
$planetParadeOutput = [];
$planetParadeExitCode = 0;
$planetParadeArgs = [
$pythonScriptPath,
'planet_parades_for_month',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
(string) $planetParadeSampleOffsetMinutes,
(string) $planetParadeMinAltitudeDeg,
(string) $planetParadeMaxSunAltitudeDeg,
(string) $planetParadeMaxAzimuthSpanDeg,
(string) $planetParadeMinPlanetCount,
(string) $planetParadeMinConsecutiveDays,
];
$planetParadeCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $planetParadeArgs)) . ' 2>&1';
exec($planetParadeCommand, $planetParadeOutput, $planetParadeExitCode);
$planetParadeRaw = trim(implode("\n", $planetParadeOutput));
$planetParadeDecoded = json_decode($planetParadeRaw, true);
if (is_array($planetParadeDecoded) && ($planetParadeDecoded['ok'] ?? false)) {
$planetParadeEvents = is_array($planetParadeDecoded['events'] ?? null) ? $planetParadeDecoded['events'] : [];
}
$planetConstellationChangeOutput = [];
$planetConstellationChangeExitCode = 0;
$planetConstellationChangeArgs = [
$pythonScriptPath,
'planet_constellation_changes_for_month',
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
];
$planetConstellationChangeCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $planetConstellationChangeArgs)) . ' 2>&1';
exec($planetConstellationChangeCommand, $planetConstellationChangeOutput, $planetConstellationChangeExitCode);
$planetConstellationChangeRaw = trim(implode("\n", $planetConstellationChangeOutput));
$planetConstellationChangeDecoded = json_decode($planetConstellationChangeRaw, true);
if (is_array($planetConstellationChangeDecoded) && ($planetConstellationChangeDecoded['ok'] ?? false)) {
$planetConstellationChangeEvents = is_array($planetConstellationChangeDecoded['events'] ?? null) ? $planetConstellationChangeDecoded['events'] : [];
}
$moonOccultationStars = [];
try {
if (isset($pdo)) {
$stmtOccultStars = $pdo->prepare(
"SELECT hip, proper, bayer, con, mag, rarad, decrad, dist
FROM star_hipparcos
WHERE rarad IS NOT NULL
AND decrad IS NOT NULL
AND mag IS NOT NULL
AND mag <= :max_mag
ORDER BY mag ASC, proper ASC, bayer ASC, hip ASC"
);
$stmtOccultStars->execute([':max_mag' => $moonOccultationMaxMag]);
foreach ($stmtOccultStars->fetchAll() as $row) {
$moonOccultationStars[] = [
'hip' => isset($row['hip']) ? (int) $row['hip'] : 0,
'label' => buildMonthOccultationStarLabel($row),
'constellation' => trim((string) ($row['con'] ?? '')),
'mag' => isset($row['mag']) ? (float) $row['mag'] : 99.0,
'ra' => isset($row['rarad']) ? (float) $row['rarad'] * (12 / M_PI) : 0.0,
'dec' => isset($row['decrad']) ? (float) $row['decrad'] * (180 / M_PI) : 0.0,
'distLy' => isset($row['dist']) && $row['dist'] !== null && (float) $row['dist'] > 0.0
? (float) $row['dist'] * 3.26156
: 1000.0,
];
}
}
} catch (Throwable $e) {
}
if ($moonOccultationStars) {
$tempOccultationFile = tempnam(sys_get_temp_dir(), 'month_occult_');
if ($tempOccultationFile !== false) {
try {
file_put_contents($tempOccultationFile, json_encode($moonOccultationStars, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$moonOccultOutput = [];
$moonOccultExitCode = 0;
$moonOccultArgs = [
$pythonScriptPath,
'moon_star_occultations_for_month',
(string) $chartLatitude,
(string) $chartLongitude,
(string) $chartElevation,
(string) $selectedYear,
(string) $selectedMonth,
$chartTimezone,
$tempOccultationFile,
];
$moonOccultCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonOccultArgs)) . ' 2>&1';
exec($moonOccultCommand, $moonOccultOutput, $moonOccultExitCode);
$moonOccultRaw = trim(implode("\n", $moonOccultOutput));
$moonOccultDecoded = json_decode($moonOccultRaw, true);
if (is_array($moonOccultDecoded) && ($moonOccultDecoded['ok'] ?? false)) {
$moonOccultations = is_array($moonOccultDecoded['results'] ?? null) ? $moonOccultDecoded['results'] : [];
}
} catch (Throwable $e) {
} finally {
if (is_file($tempOccultationFile)) {
@unlink($tempOccultationFile);
}
}
}
}
foreach ($moonPhaseEvents as $phaseEvent) {
if (!is_array($phaseEvent)) {
continue;
}
$monthEventList[] = [
'event' => (string) ($phaseEvent['label'] ?? ''),
'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 zurueckstellen)'
: 'Zeitumstellung');
$timeChangeTime = $timeChangeKind === 'dst_start'
? '02:00'
: ($timeChangeKind === 'dst_end' ? '03:00' : (string) ($timeChange['local_time'] ?? ''));
$monthEventList[] = [
'event' => $timeChangeLabel,
'date' => (string) ($timeChange['local_date'] ?? ''),
'time' => $timeChangeTime,
'local_iso' => (string) ($timeChange['local_iso'] ?? ''),
'type' => 'time_change',
];
}
foreach ($eclipseEvents as $eclipseEvent) {
if (!is_array($eclipseEvent)) {
continue;
}
$monthEventList[] = [
'event' => (string) ($eclipseEvent['label'] ?? 'Finsternis'),
'date' => (string) ($eclipseEvent['local_date'] ?? ''),
'time' => (string) ($eclipseEvent['local_time'] ?? ''),
'local_iso' => (string) ($eclipseEvent['local_iso'] ?? ''),
'type' => (string) ($eclipseEvent['category'] ?? 'eclipse'),
];
}
foreach ($moonApsisEvents as $moonApsisEvent) {
if (!is_array($moonApsisEvent)) {
continue;
}
$moonApsisKind = (string) ($moonApsisEvent['kind'] ?? '');
$distanceText = isset($moonApsisEvent['distance_km'])
? number_format((float) $moonApsisEvent['distance_km'], 0, ',', '.') . ' km'
: '';
$moonApsisLabel = $moonApsisKind === 'perigee'
? 'Mond in Erdnähe (Perigäum)'
: ($moonApsisKind === 'apogee' ? 'Mond in Erdferne (Apogäum)' : (string) ($moonApsisEvent['label'] ?? 'Mondabstand'));
$monthEventList[] = [
'event' => trim($moonApsisLabel . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
'date' => (string) ($moonApsisEvent['local_date'] ?? ''),
'time' => (string) ($moonApsisEvent['local_time'] ?? ''),
'local_iso' => (string) ($moonApsisEvent['local_iso'] ?? ''),
'type' => 'moon_apsis',
];
}
foreach ($sunApsisEvents as $sunApsisEvent) {
if (!is_array($sunApsisEvent)) {
continue;
}
$sunApsisKind = (string) ($sunApsisEvent['kind'] ?? '');
$distanceText = isset($sunApsisEvent['distance_km'])
? number_format((float) $sunApsisEvent['distance_km'], 0, ',', '.') . ' km'
: '';
$sunApsisLabel = $sunApsisKind === 'perihelion'
? 'Erde erreicht sonnennächsten Punkt (Perihel)'
: ($sunApsisKind === 'aphelion' ? 'Erde erreicht sonnenfernsten Punkt (Aphel)' : (string) ($sunApsisEvent['label'] ?? 'Sonnenabstand'));
$monthEventList[] = [
'event' => trim($sunApsisLabel . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
'date' => (string) ($sunApsisEvent['local_date'] ?? ''),
'time' => (string) ($sunApsisEvent['local_time'] ?? ''),
'local_iso' => (string) ($sunApsisEvent['local_iso'] ?? ''),
'type' => 'sun_apsis',
];
}
foreach ($innerPlanetElongationEvents as $elongationEvent) {
if (!is_array($elongationEvent)) {
continue;
}
$elongationPlanet = (string) ($elongationEvent['planet_label'] ?? 'Planet');
$elongationKind = (string) ($elongationEvent['kind'] ?? '');
$elongationText = isset($elongationEvent['elongation_deg'])
? number_format((float) $elongationEvent['elongation_deg'], 2, ',', '') . ' Grad'
: '';
$elongationLabel = $elongationKind === 'western'
? $elongationPlanet . ' größte westliche Elongation'
: ($elongationKind === 'eastern'
? $elongationPlanet . ' größte östliche Elongation'
: (string) ($elongationEvent['label'] ?? 'Elongation'));
$monthEventList[] = [
'event' => trim($elongationLabel . ($elongationText !== '' ? ' (' . $elongationText . ')' : '')),
'date' => (string) ($elongationEvent['local_date'] ?? ''),
'time' => (string) ($elongationEvent['local_time'] ?? ''),
'local_iso' => (string) ($elongationEvent['local_iso'] ?? ''),
'type' => 'planet_elongation',
];
}
foreach ($venusPeakMagnitudeEvents as $venusPeakMagnitudeEvent) {
if (!is_array($venusPeakMagnitudeEvent)) {
continue;
}
$magnitudeText = isset($venusPeakMagnitudeEvent['mag'])
? number_format((float) $venusPeakMagnitudeEvent['mag'], 1, ',', '') . ' mag'
: '';
$monthEventList[] = [
'event' => trim('Venus in größter Helligkeit' . ($magnitudeText !== '' ? ' (' . $magnitudeText . ')' : '')),
'date' => (string) ($venusPeakMagnitudeEvent['local_date'] ?? ''),
'time' => (string) ($venusPeakMagnitudeEvent['local_time'] ?? ''),
'local_iso' => (string) ($venusPeakMagnitudeEvent['local_iso'] ?? ''),
'type' => 'venus_peak_magnitude',
];
}
foreach ($outerPlanetEvents as $outerPlanetEvent) {
if (!is_array($outerPlanetEvent)) {
continue;
}
$monthEventList[] = [
'event' => (string) ($outerPlanetEvent['label'] ?? 'Planetenereignis'),
'date' => (string) ($outerPlanetEvent['local_date'] ?? ''),
'time' => (string) ($outerPlanetEvent['local_time'] ?? ''),
'local_iso' => (string) ($outerPlanetEvent['local_iso'] ?? ''),
'type' => 'outer_planet_event',
];
}
foreach ($outerPlanetStationEvents as $outerPlanetStationEvent) {
if (!is_array($outerPlanetStationEvent)) {
continue;
}
$stationPlanet = (string) ($outerPlanetStationEvent['planet_label'] ?? 'Planet');
$stationKind = (string) ($outerPlanetStationEvent['kind'] ?? '');
$stationLabel = $stationKind === 'retrograde'
? $stationPlanet . ' stationär, dann rückläufig'
: ($stationKind === 'prograde'
? $stationPlanet . ' stationär, dann rechtläufig'
: (string) ($outerPlanetStationEvent['label'] ?? 'Stationärer Punkt'));
$monthEventList[] = [
'event' => $stationLabel,
'date' => (string) ($outerPlanetStationEvent['local_date'] ?? ''),
'time' => (string) ($outerPlanetStationEvent['local_time'] ?? ''),
'local_iso' => (string) ($outerPlanetStationEvent['local_iso'] ?? ''),
'type' => 'outer_planet_station',
];
}
foreach ($jupiterMoonSideEvents as $jupiterMoonSideEvent) {
if (!is_array($jupiterMoonSideEvent)) {
continue;
}
$durationLabel = trim((string) ($jupiterMoonSideEvent['duration_label'] ?? ''));
$eventLabel = trim((string) ($jupiterMoonSideEvent['label'] ?? 'Alle 4 Jupitermonde auf einer Seite von Jupiter'));
if ($durationLabel !== '') {
$eventLabel .= ' (' . $durationLabel . ')';
}
$monthEventList[] = [
'event' => $eventLabel,
'date' => (string) ($jupiterMoonSideEvent['local_date'] ?? ''),
'time' => (string) ($jupiterMoonSideEvent['local_time'] ?? ''),
'local_iso' => (string) ($jupiterMoonSideEvent['local_iso'] ?? ''),
'end_local_iso' => (string) ($jupiterMoonSideEvent['end_local_iso'] ?? ''),
'duration_minutes' => isset($jupiterMoonSideEvent['duration_minutes']) ? (int) $jupiterMoonSideEvent['duration_minutes'] : null,
'type' => 'jupiter_moons_one_side',
];
}
foreach ($moonPlanetApproaches as $approachEvent) {
if (!is_array($approachEvent)) {
continue;
}
$distanceText = isset($approachEvent['separation_deg'])
? number_format((float) $approachEvent['separation_deg'], 2, ',', '') . ' Grad'
: '';
$monthEventList[] = [
'event' => trim((string) ($approachEvent['label'] ?? 'Mond nahe Planet') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
'date' => (string) ($approachEvent['local_date'] ?? ''),
'time' => (string) ($approachEvent['local_time'] ?? ''),
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
'type' => 'moon_planet',
];
}
foreach ($moonDeepSkyApproaches as $approachEvent) {
if (!is_array($approachEvent)) {
continue;
}
$distanceText = isset($approachEvent['separation_deg'])
? number_format((float) ($approachEvent['separation_deg']), 2, ',', '') . ' Grad'
: '';
$monthEventList[] = [
'event' => trim((string) ($approachEvent['label'] ?? 'Mond nahe Sternhaufen') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
'date' => (string) ($approachEvent['local_date'] ?? ''),
'time' => (string) ($approachEvent['local_time'] ?? ''),
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
'type' => 'moon_deep_sky',
];
}
foreach ($planetBrightStarApproaches as $approachEvent) {
if (!is_array($approachEvent)) {
continue;
}
$distanceText = isset($approachEvent['separation_deg'])
? number_format((float) ($approachEvent['separation_deg']), 2, ',', '') . ' Grad'
: '';
$monthEventList[] = [
'event' => trim((string) ($approachEvent['label'] ?? 'Planet nahe hellem Stern') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
'date' => (string) ($approachEvent['local_date'] ?? ''),
'time' => (string) ($approachEvent['local_time'] ?? ''),
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
'type' => 'planet_bright_star',
];
}
foreach ($planetConjunctions as $conjunctionEvent) {
if (!is_array($conjunctionEvent)) {
continue;
}
$distanceText = isset($conjunctionEvent['separation_deg'])
? number_format((float) $conjunctionEvent['separation_deg'], 2, ',', '') . ' Grad'
: '';
$monthEventList[] = [
'event' => trim((string) ($conjunctionEvent['label'] ?? 'Planetenkonjunktion') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
'date' => (string) ($conjunctionEvent['local_date'] ?? ''),
'time' => (string) ($conjunctionEvent['local_time'] ?? ''),
'local_iso' => (string) ($conjunctionEvent['local_iso'] ?? ''),
'type' => 'planet_conjunction',
];
}
foreach ($mercuryGoodVisibilityEvents as $visibilityEvent) {
if (!is_array($visibilityEvent)) {
continue;
}
$monthEventList[] = [
'event' => (string) ($visibilityEvent['label'] ?? 'Gute Merkur-Sichtbarkeit'),
'date' => (string) ($visibilityEvent['local_date'] ?? ''),
'time' => (string) ($visibilityEvent['local_time'] ?? ''),
'local_iso' => (string) ($visibilityEvent['local_iso'] ?? ''),
'end_local_iso' => (string) ($visibilityEvent['end_local_iso'] ?? ''),
'duration_minutes' => isset($visibilityEvent['duration_minutes']) ? (int) $visibilityEvent['duration_minutes'] : null,
'type' => 'mercury_good_visibility',
];
}
foreach ($planetParadeEvents as $paradeEvent) {
if (!is_array($paradeEvent)) {
continue;
}
$monthEventList[] = [
'event' => (string) ($paradeEvent['label'] ?? 'Planetenparade'),
'date' => (string) ($paradeEvent['local_date'] ?? ''),
'time' => (string) ($paradeEvent['local_time'] ?? ''),
'local_iso' => (string) ($paradeEvent['local_iso'] ?? ''),
'end_local_iso' => (string) ($paradeEvent['end_local_iso'] ?? ''),
'duration_minutes' => isset($paradeEvent['duration_minutes']) ? (int) $paradeEvent['duration_minutes'] : null,
'type' => 'planet_parade',
];
}
foreach ($planetConstellationChangeEvents as $changeEvent) {
if (!is_array($changeEvent)) {
continue;
}
$monthEventList[] = [
'event' => (string) ($changeEvent['label'] ?? 'Planet wechselt Sternbild'),
'date' => (string) ($changeEvent['local_date'] ?? ''),
'time' => (string) ($changeEvent['local_time'] ?? ''),
'local_iso' => (string) ($changeEvent['local_iso'] ?? ''),
'type' => 'planet_constellation_change',
];
}
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'] ?? ''));
$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),
'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 Ueberflug ' . ($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'] ?? ''))
);
$observationTipInsertSql = buildObservationTipInsertSql($monthEventList, new DateTimeZone($chartTimezone));
$observationTipCalendarIcs = buildObservationTipCalendarIcs($monthEventList, new DateTimeZone($chartTimezone));
$chartConfig = [
'monthName' => $months[$selectedMonth],
'year' => $selectedYear,
'month' => $selectedMonth,
'location' => [
'name' => $chartLocationName,
'latitude' => $chartLatitude,
'longitude' => $chartLongitude,
'elevation' => $chartElevation,
'timezone' => $chartTimezone,
],
'localTimeLabel' => $chartLocalTime->format('d.m.Y H:i') . ' ' . $chartTimezone,
'utcTimeIso' => $chartUtcTime->format(DateTimeInterface::ATOM),
'monthSkyHour' => $monthTime['hour'],
'monthSkyMinute' => $monthTime['minute'],
'starLimit' => $starDisplayMagnitudeLimit,
'starLabelLimit' => $starLabelMagnitudeLimit,
'messierLimit' => $messierDisplayMagnitudeLimit,
];
$chartConfigJson = json_encode($chartConfig, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$starDataJson = json_encode($starData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$lineStarDataJson = json_encode($lineStarData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$constellationLinesJson = json_encode($constellationLines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$messierDataJson = json_encode($messierData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$planetDataJson = json_encode($planetData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$moonDataJson = json_encode($moonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$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 fuer den 15. des gewählten Monats mit der festen Monatsuhrzeit erzeugt.</p>
</div>
<label class="monatsvorhersage-field">
<span>Jahr</span>
<select name="year">
<?php foreach ($years as $year): ?>
<option value="<?= htmlspecialchars((string) $year, ENT_QUOTES, 'UTF-8') ?>" <?= $year === $selectedYear ? 'selected' : '' ?>>
<?= htmlspecialchars((string) $year, ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label class="monatsvorhersage-field">
<span>Monat</span>
<select name="month">
<?php foreach ($months as $monthNumber => $monthLabel): ?>
<option value="<?= htmlspecialchars((string) $monthNumber, ENT_QUOTES, 'UTF-8') ?>" <?= $monthNumber === $selectedMonth ? 'selected' : '' ?>>
<?= htmlspecialchars($monthLabel, ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
</label>
<div class="monatsvorhersage-actions">
<button type="submit">Sternkarte laden</button>
</div>
</form>
<section class="monatskarte-card">
<div class="monatskarte-header">
<div>
<h3>Zenitprojektion <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
<p id="monatskarteMeta">
<?= htmlspecialchars($chartConfig['localTimeLabel'], ENT_QUOTES, 'UTF-8') ?>,
<?= htmlspecialchars($chartLocationName, ENT_QUOTES, 'UTF-8') ?>
</p>
</div>
</div>
<div class="monatskarte-stage">
<canvas id="monatskarteCanvas" width="900" height="900" aria-label="Sternkarte in Zenitprojektion"></canvas>
</div>
<div class="monatskarte-footer">
<p id="monatskarteStatus">Bereite Sternkarte vor...</p>
</div>
</section>
<section class="monatskarte-card">
<div class="monatskarte-header">
<div>
<h3>Südhimmel <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
<p id="monatskarteSouthMeta">
<?= htmlspecialchars($chartConfig['localTimeLabel'], ENT_QUOTES, 'UTF-8') ?>,
<?= htmlspecialchars($chartLocationName, ENT_QUOTES, 'UTF-8') ?>
</p>
</div>
</div>
<div class="monatskarte-stage monatskarte-stage-south">
<canvas id="monatskarteSouthCanvas" width="1200" height="720" aria-label="Südhimmel-Projektion"></canvas>
</div>
<div class="monatskarte-footer">
<p id="monatskarteSouthStatus">Bereite Südhimmelkarte vor...</p>
</div>
</section>
<section class="monatskarte-card">
<div class="monatskarte-header">
</div>
<div class="monatskarte-stage monatsphase-stage">
<div class="monatsphase-strip-wrap">
<img id="monatsphasenImage" alt="Mondphasen des Monats in einer Zeile">
</div>
</div>
<div class="monatskarte-footer">
<p id="monatsphasenStatus">Bereite Mondphasenleiste vor...</p>
</div>
</section>
<section class="monatskarte-card">
<div class="monatskarte-header">
<div>
<h3>Ereignisse <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
<p>Chronologische Monatsliste fuer <?= htmlspecialchars($chartTimezone, ENT_QUOTES, 'UTF-8') ?></p>
</div>
<div class="monatskarte-export">
<?php if ($observationTipInsertSql !== ''): ?>
<button type="button" class="monatskarte-export-button" id="toggleObservationTipSql">
SQL-Insert anzeigen
</button>
<?php endif; ?>
<?php if ($observationTipCalendarIcs !== ''): ?>
<button type="button" class="monatskarte-export-button" id="downloadObservationTipIcal">
iCal herunterladen
</button>
<?php endif; ?>
</div>
</div>
<div class="monatskarte-footer">
<?php if ($observationTipInsertSql !== ''): ?>
<div class="monatskarte-sql-export" id="observationTipSqlPanel" hidden>
<label class="monatskarte-sql-label" for="observationTipSqlOutput">SQL fuer `astro_sonobs_observationtips`</label>
<textarea id="observationTipSqlOutput" readonly><?= htmlspecialchars($observationTipInsertSql, ENT_QUOTES, 'UTF-8') ?></textarea>
<div class="monatskarte-sql-actions">
<button type="button" class="monatskarte-export-button" id="copyObservationTipSql">SQL kopieren</button>
</div>
</div>
<?php endif; ?>
<?php if ($monthEventList): ?>
<div class="riseset-grid" style="grid-template-columns: 1.5fr 1fr 1fr; row-gap: 10px;">
<span class="riseset-label riseset-dim">Ereignis</span>
<span class="riseset-label riseset-dim">Datum</span>
<span class="riseset-label riseset-dim">Uhrzeit</span>
<?php foreach ($monthEventList as $eventItem): ?>
<span class="riseset-val"><?= htmlspecialchars((string) ($eventItem['event'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<span class="riseset-val"><?= htmlspecialchars((string) ($eventItem['date'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<span class="riseset-val"><?= htmlspecialchars((string) ($eventItem['time'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span>
<?php endforeach; ?>
</div>
<?php else: ?>
<p>Fuer diesen Monat wurden keine Ereignisse gefunden.</p>
<?php endif; ?>
</div>
</section>
</div>
</section>
<style>
.monatsvorhersage-page main > .container {
max-width: min(1280px, calc(100% - 28px));
}
.monatsvorhersage-card {
padding: 18px;
}
.monatsvorhersage-shell {
display: grid;
gap: 18px;
}
.monatsvorhersage-form,
.monatskarte-card {
border: 1px solid rgba(201, 168, 76, 0.14);
border-radius: calc(var(--radius) + 4px);
background:
radial-gradient(circle at top left, rgba(74, 158, 255, 0.1), transparent 34%),
linear-gradient(180deg, rgba(10, 15, 30, 0.96) 0%, rgba(7, 11, 22, 0.98) 100%);
}
.monatsvorhersage-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 22px 20px;
}
.monatsvorhersage-form-header,
.monatskarte-header,
.monatskarte-footer {
padding: 22px 20px;
}
.monatsvorhersage-form-header {
grid-column: 1 / -1;
padding: 0;
}
.monatsvorhersage-form-header h3,
.monatskarte-header h3 {
margin: 0 0 8px;
}
.monatsvorhersage-form-header p,
.monatskarte-header p,
.monatskarte-footer p {
margin: 0;
color: rgba(222, 232, 255, 0.86);
line-height: 1.7;
}
.monatsvorhersage-field {
display: grid;
gap: 8px;
}
.monatsvorhersage-field span {
color: rgba(235, 242, 255, 0.95);
font-weight: 600;
}
.monatsvorhersage-field select {
width: 100%;
min-height: 46px;
padding: 10px 12px;
border: 1px solid rgba(108, 145, 220, 0.28);
border-radius: 12px;
background: rgba(8, 14, 28, 0.92);
color: rgba(235, 242, 255, 0.95);
font: inherit;
}
.monatsvorhersage-actions {
grid-column: 1 / -1;
}
.monatsvorhersage-actions button {
min-height: 44px;
padding: 10px 16px;
border: 1px solid rgba(150, 190, 255, 0.78);
border-radius: 999px;
background: rgba(38, 66, 122, 0.92);
color: rgba(245, 248, 255, 0.96);
font: inherit;
cursor: pointer;
}
.monatskarte-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.monatskarte-export {
display: flex;
align-items: center;
}
.monatskarte-export-button {
min-height: 40px;
padding: 8px 14px;
border: 1px solid rgba(150, 190, 255, 0.72);
border-radius: 999px;
background: rgba(24, 43, 83, 0.92);
color: rgba(245, 248, 255, 0.96);
font: inherit;
cursor: pointer;
}
.monatskarte-sql-export {
display: grid;
gap: 12px;
margin-bottom: 18px;
padding: 16px;
border: 1px solid rgba(108, 145, 220, 0.22);
border-radius: 16px;
background: rgba(6, 11, 22, 0.72);
}
.monatskarte-sql-label {
color: rgba(235, 242, 255, 0.95);
font-weight: 600;
}
.monatskarte-sql-export textarea {
width: 100%;
min-height: 220px;
padding: 12px 14px;
border: 1px solid rgba(108, 145, 220, 0.28);
border-radius: 12px;
background: rgba(3, 7, 14, 0.96);
color: rgba(233, 239, 250, 0.94);
font: 13px/1.5 Consolas, Monaco, monospace;
resize: vertical;
}
.monatskarte-sql-actions {
display: flex;
justify-content: flex-end;
}
.monatskarte-stage {
padding: 0 20px 20px;
}
.monatskarte-stage canvas {
display: block;
width: min(100%, 900px);
aspect-ratio: 1;
margin: 0 auto;
border-radius: 20px;
background: #010203;
box-shadow: inset 0 0 0 1px rgba(140, 165, 220, 0.12);
}
.monatskarte-stage-south canvas {
width: min(100%, 1100px);
aspect-ratio: 5 / 3;
}
.monatsphase-stage {
padding-top: 8px;
}
.monatsphase-strip-wrap {
width: 100%;
overflow-x: auto;
padding-bottom: 4px;
}
#monatsphasenImage {
display: block;
width: min(100%, 1200px);
min-width: 920px;
height: auto;
margin: 0 auto;
border-radius: 20px;
background:
radial-gradient(circle at 50% 0%, rgba(92, 122, 186, 0.16), transparent 42%),
linear-gradient(180deg, rgba(3, 6, 12, 0.98) 0%, rgba(5, 9, 16, 0.99) 100%);
box-shadow: inset 0 0 0 1px rgba(140, 165, 220, 0.12);
}
.monatskarte-footer {
padding-top: 0;
}
@media (max-width: 820px) {
.monatsvorhersage-form {
grid-template-columns: 1fr;
}
.monatskarte-header {
flex-direction: column;
}
}
</style>
<script src="<?= htmlspecialchars($publicBasePath . 'js/astronomy.browser.min.js', ENT_QUOTES, 'UTF-8') ?>"></script>
<script>
const MONTH_CHART_CONFIG = <?= $chartConfigJson ?: 'null' ?>;
const MONTH_CHART_STARS = <?= $starDataJson ?: '[]' ?>;
const MONTH_CHART_LINE_STARS = <?= $lineStarDataJson ?: '[]' ?>;
const MONTH_CHART_CONSTELLATIONS = <?= $constellationLinesJson ?: '[]' ?>;
const MONTH_CHART_MESSIER = <?= $messierDataJson ?: '[]' ?>;
const MONTH_CHART_PLANETS = <?= $planetDataJson ?: '[]' ?>;
const MONTH_CHART_MOON = <?= $moonDataJson ?: 'null' ?>;
const MONTH_PHASE_EVENTS = <?= $moonPhaseEventsJson ?: '[]' ?>;
const MONTH_CHART_ERROR = <?= $starDataErrorJson ?: 'null' ?>;
const OBSERVATION_TIP_ICAL = <?= $observationTipCalendarIcsJson ?: '""' ?>;
const OBSERVATION_TIP_ICAL_FILENAME = <?= $observationTipCalendarFilenameJson ?: '"astrotools-monatsereignisse.ics"' ?>;
(function () {
const toggleButton = document.getElementById('toggleObservationTipSql');
const copyButton = document.getElementById('copyObservationTipSql');
const icalButton = document.getElementById('downloadObservationTipIcal');
const panel = document.getElementById('observationTipSqlPanel');
const output = document.getElementById('observationTipSqlOutput');
if (toggleButton && panel) {
toggleButton.addEventListener('click', function () {
const isHidden = panel.hasAttribute('hidden');
if (isHidden) {
panel.removeAttribute('hidden');
toggleButton.textContent = 'SQL-Insert ausblenden';
} else {
panel.setAttribute('hidden', '');
toggleButton.textContent = 'SQL-Insert anzeigen';
}
});
}
if (copyButton && output && navigator.clipboard) {
copyButton.addEventListener('click', async function () {
try {
await navigator.clipboard.writeText(output.value);
copyButton.textContent = 'SQL kopiert';
window.setTimeout(function () {
copyButton.textContent = 'SQL kopieren';
}, 1800);
} catch (error) {
copyButton.textContent = 'Kopieren fehlgeschlagen';
window.setTimeout(function () {
copyButton.textContent = 'SQL kopieren';
}, 1800);
}
});
}
if (icalButton && OBSERVATION_TIP_ICAL) {
icalButton.addEventListener('click', function () {
const blob = new Blob([OBSERVATION_TIP_ICAL], { type: 'text/calendar;charset=utf-8' });
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = OBSERVATION_TIP_ICAL_FILENAME || 'astrotools-monatsereignisse.ics';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.setTimeout(function () {
window.URL.revokeObjectURL(objectUrl);
}, 0);
});
}
})();
(function () {
const imageEl = document.getElementById('monatsphasenImage');
const statusEl = document.getElementById('monatsphasenStatus');
if (!imageEl || !statusEl || !window.Astronomy || !MONTH_CHART_CONFIG) {
return;
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const phaseImage = new Image();
phaseImage.src = <?= json_encode($publicBasePath . 'images/moons/moon4geocron.png', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function daysInMonth(year, month) {
return new Date(year, month, 0).getDate();
}
function phaseInfoForDay(year, month, day) {
const monthHour = Number(MONTH_CHART_CONFIG?.monthSkyHour ?? 12);
const monthMinute = Number(MONTH_CHART_CONFIG?.monthSkyMinute ?? 0);
const localPhaseTime = new Date(year, month - 1, day, monthHour, monthMinute, 0);
const phaseAngle = Number(Astronomy.MoonPhase(localPhaseTime));
const normalizedPhase = ((phaseAngle % 360) + 360) % 360;
return {
phaseAngleDeg: normalizedPhase,
illumination: clamp01((1 - Math.cos(normalizedPhase * Math.PI / 180)) / 2),
waxing: normalizedPhase <= 180,
};
}
function resizeCanvas() {
const cssWidth = Math.max(920, Math.floor(imageEl.clientWidth || 1200));
const cssHeight = 220;
const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.floor(cssWidth * dpr);
canvas.height = Math.floor(cssHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
draw();
}
function drawMoonDisc(x, y, radius, phaseInfo) {
const phaseRad = phaseInfo.phaseAngleDeg * Math.PI / 180;
const cosPhase = Math.cos(phaseRad);
ctx.save();
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = 'rgba(8, 10, 16, 0.96)';
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 0.96;
ctx.drawImage(phaseImage, x - radius, y - radius, radius * 2, radius * 2);
ctx.globalAlpha = 1;
ctx.fillStyle = 'rgba(8, 10, 16, 0.96)';
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.clip();
if (phaseInfo.illumination >= 0.999) {
ctx.drawImage(phaseImage, x - radius, y - radius, radius * 2, radius * 2);
} else if (phaseInfo.illumination > 0.001) {
ctx.beginPath();
for (let i = 0; i <= 72; i++) {
const t = -radius + (2 * radius * i / 72);
const xr = Math.sqrt(Math.max(0, radius * radius - t * t));
const xt = (phaseInfo.waxing ? cosPhase : -cosPhase) * xr;
const px = x + xt;
const py = y + t;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
for (let i = 72; i >= 0; i--) {
const t = -radius + (2 * radius * i / 72);
const xr = Math.sqrt(Math.max(0, radius * radius - t * t));
const px = x + (phaseInfo.waxing ? xr : -xr);
const py = y + t;
ctx.lineTo(px, py);
}
ctx.closePath();
ctx.clip();
ctx.drawImage(phaseImage, x - radius, y - radius, radius * 2, radius * 2);
}
ctx.restore();
ctx.restore();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.16)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.stroke();
}
function draw() {
if (!phaseImage.complete) {
return;
}
const width = canvas.width / Math.max(1, window.devicePixelRatio || 1);
const height = canvas.height / Math.max(1, window.devicePixelRatio || 1);
const year = Number(MONTH_CHART_CONFIG.year);
const month = Number(MONTH_CHART_CONFIG.month);
const monthName = String(MONTH_CHART_CONFIG.monthName || '');
const totalDays = daysInMonth(year, month);
ctx.clearRect(0, 0, width, height);
const bg = ctx.createLinearGradient(0, 0, 0, height);
bg.addColorStop(0, '#02050a');
bg.addColorStop(1, '#04080f');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = 'rgba(240, 215, 132, 0.96)';
ctx.font = '600 16px Rajdhani, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText('Mondphasen im ' + monthName + ' ' + year, 28, 18);
const leftPad = 22;
const rightPad = 22;
const usableWidth = width - leftPad - rightPad;
const spacing = usableWidth / totalDays;
const radius = Math.max(11, Math.min(18, spacing * 0.42));
const centerY = 82;
ctx.fillStyle = 'rgba(255, 255, 255, 0.72)';
ctx.font = '500 10px \"Share Tech Mono\", monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
for (let day = 1; day <= totalDays; day++) {
const x = leftPad + spacing * (day - 0.5);
drawMoonDisc(x, centerY, radius, phaseInfoForDay(year, month, day));
ctx.fillText(String(day), x, centerY + radius + 8);
}
ctx.font = '600 12px Rajdhani, sans-serif';
ctx.fillStyle = 'rgba(225, 232, 246, 0.94)';
for (const event of Array.isArray(MONTH_PHASE_EVENTS) ? MONTH_PHASE_EVENTS : []) {
const parts = String(event.local_date || '').split('.');
if (parts.length < 3) {
continue;
}
const day = Number(parts[0]);
if (!Number.isFinite(day) || day < 1 || day > totalDays) {
continue;
}
const x = leftPad + spacing * (day - 0.5);
ctx.fillText(String(event.label || ''), x, 158);
ctx.fillStyle = 'rgba(182, 196, 224, 0.94)';
ctx.fillText(day + '.' + month + '.', x, 175);
ctx.fillStyle = 'rgba(225, 232, 246, 0.94)';
}
imageEl.src = canvas.toDataURL('image/png');
statusEl.textContent = 'Mondphasenleiste fuer ' + monthName + ' ' + year;
}
phaseImage.onload = resizeCanvas;
phaseImage.onerror = function () {
statusEl.textContent = 'Mondgrafik konnte nicht geladen werden.';
};
window.addEventListener('resize', resizeCanvas);
if (phaseImage.complete) {
resizeCanvas();
}
})();
(function () {
const canvas = document.getElementById('monatskarteCanvas');
const statusEl = document.getElementById('monatskarteStatus');
if (!canvas || !statusEl) {
return;
}
if (!window.Astronomy) {
statusEl.textContent = 'Astronomy Engine konnte nicht geladen werden.';
return;
}
if (!MONTH_CHART_CONFIG) {
statusEl.textContent = 'Die Monatskonfiguration konnte nicht geladen werden.';
return;
}
if (MONTH_CHART_ERROR) {
statusEl.textContent = 'Stern-Datenbank konnte nicht geladen werden: ' + MONTH_CHART_ERROR;
return;
}
const ctx = canvas.getContext('2d');
const date = new Date(MONTH_CHART_CONFIG.utcTimeIso);
const observer = new Astronomy.Observer(
Number(MONTH_CHART_CONFIG.location.latitude),
Number(MONTH_CHART_CONFIG.location.longitude),
Number(MONTH_CHART_CONFIG.location.elevation || 0)
);
const starByHip = new Map();
const constellationHipSet = new Set();
const displayStarMap = new Map();
let labelPlacer = null;
let projectionLayout = null;
const projCenterAzDeg = 180;
const projCenterAltDeg = 45;
for (const star of MONTH_CHART_LINE_STARS) {
if (Number.isFinite(Number(star.hip))) {
starByHip.set(Number(star.hip), star);
}
}
for (const star of MONTH_CHART_STARS) {
if (Number.isFinite(Number(star.hip))) {
displayStarMap.set(Number(star.hip), star);
} else {
displayStarMap.set(`star-${displayStarMap.size}`, star);
}
}
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
if (!Array.isArray(constellation.lines)) {
continue;
}
for (const segment of constellation.lines) {
if (!Array.isArray(segment)) {
continue;
}
for (const hip of segment) {
if (Number.isFinite(Number(hip))) {
constellationHipSet.add(Number(hip));
}
}
}
}
for (const hip of constellationHipSet) {
if (!displayStarMap.has(hip) && starByHip.has(hip)) {
displayStarMap.set(hip, starByHip.get(hip));
}
}
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function createLabelPlacer(context) {
const occupiedBoxes = [];
function overlaps(box) {
return occupiedBoxes.some((other) =>
box.x < other.x + other.width &&
box.x + box.width > other.x &&
box.y < other.y + other.height &&
box.y + box.height > other.y
);
}
function insideStage(box) {
return box.x >= 0 &&
box.y >= 0 &&
box.x + box.width <= canvas.clientWidth &&
box.y + box.height <= canvas.clientHeight;
}
return {
reset() {
occupiedBoxes.length = 0;
},
drawText(options) {
const {
text,
x,
y,
font = '12px Georgia, serif',
color = 'rgba(232, 239, 255, 0.82)',
align = 'left',
baseline = 'middle',
padding = 2,
} = options;
if (!text) {
return false;
}
context.save();
context.font = font;
context.textAlign = align;
context.textBaseline = baseline;
const metrics = context.measureText(text);
const textWidth = metrics.width;
const ascent = metrics.actualBoundingBoxAscent || 8;
const descent = metrics.actualBoundingBoxDescent || 4;
let boxX = x;
if (align === 'center') {
boxX = x - textWidth / 2;
} else if (align === 'right') {
boxX = x - textWidth;
}
let boxY = y - ascent;
if (baseline === 'middle') {
boxY = y - (ascent + descent) / 2;
} else if (baseline === 'bottom' || baseline === 'alphabetic') {
boxY = y - ascent - descent;
}
const box = {
x: boxX - padding,
y: boxY - padding,
width: textWidth + padding * 2,
height: ascent + descent + padding * 2,
};
if (!insideStage(box) || overlaps(box)) {
context.restore();
return false;
}
occupiedBoxes.push(box);
context.fillStyle = color;
context.fillText(text, x, y);
context.restore();
return true;
},
};
}
function placePlanetLabel(text, x, y, radius) {
const attempts = [
{ x: x + radius + 6, y: y - 1, align: 'left', baseline: 'middle' },
{ x: x - radius - 6, y: y - 1, align: 'right', baseline: 'middle' },
{ x, y: y - radius - 7, align: 'center', baseline: 'bottom' },
{ x, y: y + radius + 7, align: 'center', baseline: 'top' },
{ x: x + radius + 6, y: y - radius - 5, align: 'left', baseline: 'bottom' },
{ x: x - radius - 6, y: y - radius - 5, align: 'right', baseline: 'bottom' },
{ x: x + radius + 6, y: y + radius + 5, align: 'left', baseline: 'top' },
{ x: x - radius - 6, y: y + radius + 5, align: 'right', baseline: 'top' },
];
function drawLabelBackdrop(drawX, drawY, align, baseline) {
ctx.save();
ctx.font = '600 11px Rajdhani, sans-serif';
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
const ascent = metrics.actualBoundingBoxAscent || 8;
const descent = metrics.actualBoundingBoxDescent || 4;
const padX = 5;
const padY = 3;
let boxX = drawX;
if (align === 'center') boxX = drawX - textWidth / 2;
else if (align === 'right') boxX = drawX - textWidth;
let boxY = drawY - ascent;
if (baseline === 'middle') boxY = drawY - (ascent + descent) / 2;
else if (baseline === 'bottom' || baseline === 'alphabetic') boxY = drawY - ascent - descent;
const rectX = boxX - padX;
const rectY = boxY - padY;
const rectW = textWidth + padX * 2;
const rectH = ascent + descent + padY * 2;
const radiusPx = 6;
ctx.fillStyle = 'rgba(3, 6, 12, 0.52)';
ctx.beginPath();
ctx.moveTo(rectX + radiusPx, rectY);
ctx.lineTo(rectX + rectW - radiusPx, rectY);
ctx.quadraticCurveTo(rectX + rectW, rectY, rectX + rectW, rectY + radiusPx);
ctx.lineTo(rectX + rectW, rectY + rectH - radiusPx);
ctx.quadraticCurveTo(rectX + rectW, rectY + rectH, rectX + rectW - radiusPx, rectY + rectH);
ctx.lineTo(rectX + radiusPx, rectY + rectH);
ctx.quadraticCurveTo(rectX, rectY + rectH, rectX, rectY + rectH - radiusPx);
ctx.lineTo(rectX, rectY + radiusPx);
ctx.quadraticCurveTo(rectX, rectY, rectX + radiusPx, rectY);
ctx.closePath();
ctx.fill();
ctx.restore();
}
for (const attempt of attempts) {
if (labelPlacer.drawText({
text,
x: attempt.x,
y: attempt.y,
font: '600 11px Rajdhani, sans-serif',
color: 'rgba(246, 235, 214, 0.84)',
align: attempt.align,
baseline: attempt.baseline,
padding: 2,
})) {
drawLabelBackdrop(attempt.x, attempt.y, attempt.align, attempt.baseline);
ctx.save();
ctx.font = '600 11px Rajdhani, sans-serif';
ctx.textAlign = attempt.align;
ctx.textBaseline = attempt.baseline;
ctx.fillStyle = 'rgba(246, 235, 214, 0.84)';
ctx.fillText(text, attempt.x, attempt.y);
ctx.restore();
return true;
}
}
return false;
}
function placePlanetLabel(text, x, y, radius) {
const attempts = [
{ x: x + radius + 6, y: y - 1, align: 'left', baseline: 'middle' },
{ x: x - radius - 6, y: y - 1, align: 'right', baseline: 'middle' },
{ x, y: y - radius - 7, align: 'center', baseline: 'bottom' },
{ x, y: y + radius + 7, align: 'center', baseline: 'top' },
{ x: x + radius + 6, y: y - radius - 5, align: 'left', baseline: 'bottom' },
{ x: x - radius - 6, y: y - radius - 5, align: 'right', baseline: 'bottom' },
{ x: x + radius + 6, y: y + radius + 5, align: 'left', baseline: 'top' },
{ x: x - radius - 6, y: y + radius + 5, align: 'right', baseline: 'top' },
];
function drawLabelBackdrop(drawX, drawY, align, baseline) {
ctx.save();
ctx.font = '600 11px Rajdhani, sans-serif';
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
const ascent = metrics.actualBoundingBoxAscent || 8;
const descent = metrics.actualBoundingBoxDescent || 4;
const padX = 5;
const padY = 3;
let boxX = drawX;
if (align === 'center') boxX = drawX - textWidth / 2;
else if (align === 'right') boxX = drawX - textWidth;
let boxY = drawY - ascent;
if (baseline === 'middle') boxY = drawY - (ascent + descent) / 2;
else if (baseline === 'bottom' || baseline === 'alphabetic') boxY = drawY - ascent - descent;
const rectX = boxX - padX;
const rectY = boxY - padY;
const rectW = textWidth + padX * 2;
const rectH = ascent + descent + padY * 2;
const radiusPx = 6;
ctx.fillStyle = 'rgba(3, 6, 12, 0.52)';
ctx.beginPath();
ctx.moveTo(rectX + radiusPx, rectY);
ctx.lineTo(rectX + rectW - radiusPx, rectY);
ctx.quadraticCurveTo(rectX + rectW, rectY, rectX + rectW, rectY + radiusPx);
ctx.lineTo(rectX + rectW, rectY + rectH - radiusPx);
ctx.quadraticCurveTo(rectX + rectW, rectY + rectH, rectX + rectW - radiusPx, rectY + rectH);
ctx.lineTo(rectX + radiusPx, rectY + rectH);
ctx.quadraticCurveTo(rectX, rectY + rectH, rectX, rectY + rectH - radiusPx);
ctx.lineTo(rectX, rectY + radiusPx);
ctx.quadraticCurveTo(rectX, rectY, rectX + radiusPx, rectY);
ctx.closePath();
ctx.fill();
ctx.restore();
}
for (const attempt of attempts) {
if (labelPlacer.drawText({
text,
x: attempt.x,
y: attempt.y,
font: '600 11px Rajdhani, sans-serif',
color: 'rgba(246, 235, 214, 0.84)',
align: attempt.align,
baseline: attempt.baseline,
padding: 2,
})) {
drawLabelBackdrop(attempt.x, attempt.y, attempt.align, attempt.baseline);
ctx.save();
ctx.font = '600 11px Rajdhani, sans-serif';
ctx.textAlign = attempt.align;
ctx.textBaseline = attempt.baseline;
ctx.fillStyle = 'rgba(246, 235, 214, 0.84)';
ctx.fillText(text, attempt.x, attempt.y);
ctx.restore();
return true;
}
}
return false;
}
function resizeCanvas() {
const cssSize = Math.max(320, Math.min(canvas.parentElement.clientWidth, 900));
const ratio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(cssSize * ratio);
canvas.height = Math.round(cssSize * ratio);
canvas.style.width = cssSize + 'px';
canvas.style.height = cssSize + 'px';
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(ratio, ratio);
labelPlacer = createLabelPlacer(ctx);
draw();
}
function project(altDeg, azDeg, radius, cx, cy) {
if (!Number.isFinite(altDeg) || !Number.isFinite(azDeg) || altDeg < 0) {
return null;
}
const rho = (90 - altDeg) / 90;
const azRad = azDeg * Math.PI / 180;
return {
x: cx - rho * radius * Math.sin(azRad),
y: cy - rho * radius * Math.cos(azRad),
};
}
function starHorizon(star) {
const raHours = star.ra * (12 / Math.PI);
const decDeg = star.dec * (180 / Math.PI);
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
}
function starColor(star) {
if (typeof star.ci === 'number') {
if (star.ci < -0.1) return '#b9d4ff';
if (star.ci < 0.3) return '#dbe7ff';
if (star.ci < 0.7) return '#fff6e0';
if (star.ci < 1.1) return '#ffe0b5';
return '#ffc98a';
}
const spect = String(star.spect || '').trim().toUpperCase();
if (spect.startsWith('O') || spect.startsWith('B')) return '#b9d4ff';
if (spect.startsWith('A')) return '#dbe7ff';
if (spect.startsWith('F') || spect.startsWith('G')) return '#fff6e0';
if (spect.startsWith('K')) return '#ffd4a0';
if (spect.startsWith('M')) return '#ffbd85';
return '#f4f7ff';
}
function getStarLabel(star) {
const proper = String(star.proper || '').trim();
if (proper !== '') {
return proper;
}
const bayer = String(star.bayer || '').trim();
const con = String(star.con || '').trim();
if (bayer !== '') {
return con !== '' ? `${bayer} ${con}` : bayer;
}
return '';
}
function drawSkyBackground(cx, cy, radius) {
const baseGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
baseGrad.addColorStop(0, '#010203');
baseGrad.addColorStop(0.45, '#02050b');
baseGrad.addColorStop(0.78, '#06101d');
baseGrad.addColorStop(1, '#0d1f38');
ctx.fillStyle = baseGrad;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
const zenithDarkening = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius * 0.92);
zenithDarkening.addColorStop(0, 'rgba(0, 0, 0, 0.44)');
zenithDarkening.addColorStop(0.42, 'rgba(0, 0, 0, 0.2)');
zenithDarkening.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = zenithDarkening;
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.92, 0, Math.PI * 2);
ctx.fill();
const horizonGlow = ctx.createRadialGradient(cx, cy, radius * 0.74, cx, cy, radius * 1.04);
horizonGlow.addColorStop(0, 'rgba(0, 0, 0, 0)');
horizonGlow.addColorStop(0.72, 'rgba(78, 126, 214, 0.025)');
horizonGlow.addColorStop(0.9, 'rgba(118, 170, 255, 0.09)');
horizonGlow.addColorStop(1, 'rgba(168, 208, 255, 0.14)');
ctx.strokeStyle = horizonGlow;
ctx.lineWidth = Math.max(8, radius * 0.035);
ctx.beginPath();
ctx.arc(cx, cy, radius * 0.97, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = 'rgba(183, 205, 255, 0.28)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
}
function drawGuides(cx, cy, radius) {
ctx.strokeStyle = 'rgba(140, 165, 220, 0.18)';
ctx.lineWidth = 1;
for (const frac of [0.33, 0.66]) {
ctx.beginPath();
ctx.arc(cx, cy, radius * frac, 0, Math.PI * 2);
ctx.stroke();
}
ctx.beginPath();
ctx.moveTo(cx, cy - radius);
ctx.lineTo(cx, cy + radius);
ctx.moveTo(cx - radius, cy);
ctx.lineTo(cx + radius, cy);
ctx.stroke();
function drawDirectionLabel(angleDeg, label, offset, font, alpha) {
const angleRad = angleDeg * Math.PI / 180;
const x = cx + Math.sin(angleRad) * (radius + offset);
const y = cy - Math.cos(angleRad) * (radius + offset);
let rotation = angleRad;
// Text tangential zum Horizontkreis ausrichten, dabei moeglichst gut lesbar halten.
if (rotation > Math.PI / 2 && rotation < Math.PI * 1.5) {
rotation -= Math.PI;
}
ctx.save();
ctx.translate(x, y);
ctx.rotate(rotation);
ctx.font = font;
ctx.fillStyle = `rgba(214, 226, 255, ${alpha})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, 0, 0);
ctx.restore();
}
drawDirectionLabel(0, 'Norden', 18, '600 13px Rajdhani, sans-serif', 0.78);
drawDirectionLabel(90, 'Westen', 22, '600 13px Rajdhani, sans-serif', 0.78);
drawDirectionLabel(180, 'Süden', 18, '600 13px Rajdhani, sans-serif', 0.78);
drawDirectionLabel(270, 'Osten', 22, '600 13px Rajdhani, sans-serif', 0.78);
drawDirectionLabel(45, 'Nordosten', 18, '600 11px Rajdhani, sans-serif', 0.64);
drawDirectionLabel(315, 'Nordwesten', 18, '600 11px Rajdhani, sans-serif', 0.64);
drawDirectionLabel(135, 'Südosten', 18, '600 11px Rajdhani, sans-serif', 0.64);
drawDirectionLabel(225, 'Südwesten', 18, '600 11px Rajdhani, sans-serif', 0.64);
ctx.fillStyle = 'rgba(214, 226, 255, 0.62)';
ctx.font = '600 11px Rajdhani, sans-serif';
ctx.fillText('60°', cx, cy - radius * 0.66 - 6);
ctx.fillText('30°', cx, cy - radius * 0.33 - 6);
ctx.fillText('Zenit', cx, cy);
}
function drawConstellations(cx, cy, radius) {
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
if (!Array.isArray(constellation.lines)) {
continue;
}
for (const segment of constellation.lines) {
if (!Array.isArray(segment) || segment.length < 2) {
continue;
}
const points = [];
for (const hip of segment) {
const star = starByHip.get(Number(hip));
if (!star) {
continue;
}
const hor = starHorizon(star);
if (!hor || hor.altitude < 0) {
continue;
}
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
if (!pt) {
continue;
}
points.push(pt);
}
if (points.length < 2) {
continue;
}
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(210, 214, 224, 0.16)';
ctx.lineWidth = 2.1;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
ctx.lineTo(points[i].x, points[i].y);
}
ctx.stroke();
ctx.strokeStyle = 'rgba(36, 40, 48, 0.34)';
ctx.lineWidth = 0.95;
ctx.beginPath();
ctx.moveTo(points[0].x + 0.18, points[0].y - 0.12);
for (let i = 1; i < points.length; i++) {
const jitterX = (i % 2 === 0 ? 0.22 : -0.12);
const jitterY = (i % 2 === 0 ? -0.14 : 0.2);
ctx.lineTo(points[i].x + jitterX, points[i].y + jitterY);
}
ctx.stroke();
ctx.restore();
}
}
}
function drawConstellationLabels(cx, cy, radius) {
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
const label = constellation.common_name?.german || constellation.common_name?.native || constellation.id;
if (!label) {
continue;
}
const labelPositions = Array.isArray(constellation.label_positions)
? constellation.label_positions
.filter((pos) => Array.isArray(pos) && pos.length >= 2)
.map((pos) => [Number(pos[0]), Number(pos[1])])
.filter(([raH, decD]) => Number.isFinite(raH) && Number.isFinite(decD))
: [];
for (const [raHours, decDeg] of labelPositions) {
const hor = equatorialHorizon(raHours, decDeg);
if (!hor || hor.altitude < 8) {
continue;
}
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
if (!pt) {
continue;
}
const placed = labelPlacer.drawText({
text: label,
x: pt.x,
y: pt.y,
font: '600 11px Rajdhani, sans-serif',
color: 'rgba(198, 214, 255, 0.54)',
align: 'center',
baseline: 'middle',
padding: 3,
});
if (placed) {
break;
}
}
}
}
function equatorialHorizon(raHours, decDeg) {
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
}
function getMessierStyle(type) {
switch (type) {
case 'Gc':
return { stroke: 'rgba(255, 180, 96, 0.92)', fill: 'rgba(255, 180, 96, 0.12)' };
case 'Oc':
return { stroke: 'rgba(255, 224, 150, 0.84)', fill: 'rgba(255, 224, 150, 0.08)' };
case 'Gx':
return { stroke: 'rgba(255, 140, 188, 0.88)', fill: 'rgba(255, 140, 188, 0.08)' };
case 'Pn':
return { stroke: 'rgba(156, 255, 186, 0.88)', fill: 'rgba(156, 255, 186, 0.08)' };
case 'Nb':
case 'Sc':
return { stroke: 'rgba(118, 204, 255, 0.88)', fill: 'rgba(118, 204, 255, 0.08)' };
default:
return { stroke: 'rgba(210, 218, 232, 0.74)', fill: 'rgba(210, 218, 232, 0.06)' };
}
}
function getMessierLabel(obj) {
const messierId = Number(obj.m);
if (!Number.isFinite(messierId)) {
return '';
}
const name = String(obj.name || '').trim();
return name !== '' ? `M${messierId} ${name}` : `M${messierId}`;
}
function getPlanetStyle(planet) {
switch (String(planet.key || '')) {
case 'mercury':
return { fill: '#c9b9a7', glow: 'rgba(201, 185, 167, 0.34)' };
case 'venus':
return { fill: '#f4ddb2', glow: 'rgba(244, 221, 178, 0.4)' };
case 'mars':
return { fill: '#d7724c', glow: 'rgba(215, 114, 76, 0.34)' };
case 'jupiter':
return { fill: '#d9b07e', glow: 'rgba(217, 176, 126, 0.36)' };
case 'saturn':
return { fill: '#d8ca8b', glow: 'rgba(216, 202, 139, 0.34)' };
case 'uranus':
return { fill: '#8fd3df', glow: 'rgba(143, 211, 223, 0.3)' };
case 'neptune':
return { fill: '#5a78d6', glow: 'rgba(90, 120, 214, 0.3)' };
default:
return { fill: '#d8dde8', glow: 'rgba(216, 221, 232, 0.28)' };
}
}
function drawMoon(cx, cy, radius) {
if (!MONTH_CHART_MOON) {
return;
}
const alt = Number(MONTH_CHART_MOON.alt);
const az = Number(MONTH_CHART_MOON.az);
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) {
return;
}
const pt = project(alt, az, radius, cx, cy);
if (!pt) {
return;
}
const phaseFraction = clamp01(Number(MONTH_CHART_MOON.phaseFraction ?? 0.5));
const waxing = Boolean(MONTH_CHART_MOON.waxing);
const moonRadius = 10.5;
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, moonRadius * 2.1);
glow.addColorStop(0, 'rgba(220, 226, 238, 0.28)');
glow.addColorStop(0.5, 'rgba(220, 226, 238, 0.08)');
glow.addColorStop(1, 'rgba(220, 226, 238, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(pt.x, pt.y, moonRadius * 2.1, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.translate(pt.x, pt.y);
ctx.beginPath();
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = '#515760';
ctx.beginPath();
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#d6d9df';
ctx.beginPath();
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
ctx.fill();
const phaseScale = 1 - 2 * phaseFraction;
ctx.fillStyle = '#515760';
ctx.beginPath();
ctx.ellipse((waxing ? -1 : 1) * moonRadius * phaseScale, 0, moonRadius, moonRadius, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pt.x, pt.y, moonRadius, 0, Math.PI * 2);
ctx.stroke();
}
function drawPlanets(cx, cy, radius) {
for (const planet of MONTH_CHART_PLANETS) {
const alt = Number(planet.alt);
const az = Number(planet.az);
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) {
continue;
}
const pt = project(alt, az, radius, cx, cy);
if (!pt) {
continue;
}
const style = getPlanetStyle(planet);
const mag = Number(planet.mag);
const baseRadius = Number.isFinite(mag)
? Math.max(3.8, Math.min(8.6, 6.7 - mag * 0.72))
: 5.4;
const glowRadius = baseRadius * 2.2;
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
glow.addColorStop(0, style.glow);
glow.addColorStop(0.45, style.glow.replace(/0\.\d+\)$/, '0.12)'));
glow.addColorStop(1, style.glow.replace(/0\.\d+\)$/, '0)'));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = style.fill;
ctx.beginPath();
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.26)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
ctx.stroke();
if (planet.key === 'jupiter') {
ctx.save();
ctx.translate(pt.x, pt.y);
ctx.beginPath();
ctx.arc(0, 0, baseRadius - 0.3, 0, Math.PI * 2);
ctx.clip();
ctx.strokeStyle = 'rgba(116, 84, 54, 0.78)';
ctx.lineWidth = Math.max(1, baseRadius * 0.34);
ctx.beginPath();
ctx.moveTo(-baseRadius * 0.95, -baseRadius * 0.34);
ctx.lineTo(baseRadius * 0.95, -baseRadius * 0.34);
ctx.moveTo(-baseRadius * 0.95, baseRadius * 0.2);
ctx.lineTo(baseRadius * 0.95, baseRadius * 0.2);
ctx.stroke();
ctx.fillStyle = 'rgba(194, 92, 68, 0.95)';
ctx.beginPath();
ctx.ellipse(baseRadius * 0.42, baseRadius * 0.06, baseRadius * 0.3, baseRadius * 0.17, -0.18, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
if (planet.key === 'saturn') {
ctx.save();
ctx.translate(pt.x, pt.y);
ctx.rotate(-0.35);
ctx.strokeStyle = 'rgba(232, 221, 170, 0.72)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.ellipse(0, 0, baseRadius * 1.7, baseRadius * 0.72, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
const label = String(planet.label || '').trim();
if (label !== '') {
placePlanetLabel(label, pt.x, pt.y, baseRadius);
}
}
}
function drawMessier(cx, cy, radius) {
for (const obj of MONTH_CHART_MESSIER) {
if (Number(obj.mag) > Number(MONTH_CHART_CONFIG.messierLimit)) {
continue;
}
const hor = equatorialHorizon(Number(obj.ra), Number(obj.dec));
if (!hor || hor.altitude < 0) {
continue;
}
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
if (!pt) {
continue;
}
const style = getMessierStyle(String(obj.type || 'As'));
const symbolRadius = Math.max(3.4, Math.min(6.8, 3.2 + Math.max(0, 6.5 - Number(obj.mag)) * 0.5));
ctx.strokeStyle = style.stroke;
ctx.fillStyle = style.fill;
ctx.lineWidth = 1;
if (obj.type === 'Gc') {
ctx.beginPath();
ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(pt.x - symbolRadius * 0.8, pt.y);
ctx.lineTo(pt.x + symbolRadius * 0.8, pt.y);
ctx.moveTo(pt.x, pt.y - symbolRadius * 0.8);
ctx.lineTo(pt.x, pt.y + symbolRadius * 0.8);
ctx.stroke();
} else if (obj.type === 'Oc') {
ctx.setLineDash([2, 2]);
ctx.beginPath();
ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
} else if (obj.type === 'Gx') {
ctx.save();
ctx.translate(pt.x, pt.y);
ctx.rotate(Math.PI / 5);
ctx.beginPath();
ctx.ellipse(0, 0, symbolRadius, symbolRadius * 0.58, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
} else if (obj.type === 'Nb' || obj.type === 'Sc') {
ctx.beginPath();
ctx.rect(pt.x - symbolRadius, pt.y - symbolRadius * 0.62, symbolRadius * 2, symbolRadius * 1.24);
ctx.fill();
ctx.stroke();
} else if (obj.type === 'Pn') {
ctx.beginPath();
ctx.arc(pt.x, pt.y, symbolRadius * 0.62, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else {
ctx.beginPath();
ctx.arc(pt.x, pt.y, symbolRadius * 0.68, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
const label = getMessierLabel(obj);
if (label !== '') {
labelPlacer.drawText({
text: label,
x: pt.x + symbolRadius + 5,
y: pt.y,
font: '600 11px Rajdhani, sans-serif',
color: 'rgba(210, 224, 255, 0.78)',
align: 'left',
baseline: 'middle',
padding: 2,
});
}
}
}
function drawStars(cx, cy, radius) {
for (const star of displayStarMap.values()) {
const hip = Number(star.hip);
const isConstellationStar = Number.isFinite(hip) && constellationHipSet.has(hip);
if (!isConstellationStar && Number(star.mag) > Number(MONTH_CHART_CONFIG.starLimit)) {
continue;
}
const hor = starHorizon(star);
if (!hor || hor.altitude < 0) {
continue;
}
const pt = project(hor.altitude, hor.azimuth, radius, cx, cy);
if (!pt) {
continue;
}
const magRange = Math.max(0.5, Number(MONTH_CHART_CONFIG.starLimit) + 1.6);
const effectiveMagnitude = isConstellationStar
? Math.min(Number(star.mag), Number(MONTH_CHART_CONFIG.starLimit) + 0.35)
: Number(star.mag);
const brightness = clamp01((Number(MONTH_CHART_CONFIG.starLimit) - effectiveMagnitude + 1.6) / magRange);
const starRadius = (isConstellationStar ? 0.85 : 0.65) + Math.pow(brightness, 1.9) * 5.2;
const glowRadius = starRadius * (effectiveMagnitude <= 1 ? 2.8 : 1.9);
const color = starColor(star);
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
glow.addColorStop(0, color + '66');
glow.addColorStop(0.35, color + '22');
glow.addColorStop(1, color + '00');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(pt.x, pt.y, starRadius, 0, Math.PI * 2);
ctx.fill();
if (star.mag <= 0.8) {
ctx.strokeStyle = color + '44';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pt.x - starRadius * 1.35, pt.y);
ctx.lineTo(pt.x + starRadius * 1.35, pt.y);
ctx.moveTo(pt.x, pt.y - starRadius * 1.35);
ctx.lineTo(pt.x, pt.y + starRadius * 1.35);
ctx.stroke();
}
if (Number(star.mag) <= Number(MONTH_CHART_CONFIG.starLabelLimit)) {
const label = getStarLabel(star);
if (label !== '') {
labelPlacer.drawText({
text: label,
x: pt.x + starRadius + 5,
y: pt.y - 1,
font: '600 11px Rajdhani, sans-serif',
color: 'rgba(232, 239, 255, 0.82)',
align: 'left',
baseline: 'middle',
padding: 2,
});
}
}
}
}
function draw() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
const cx = width / 2;
const cy = height / 2;
const radius = Math.min(width, height) * 0.44;
ctx.clearRect(0, 0, width, height);
labelPlacer.reset();
drawSkyBackground(cx, cy, radius);
drawGuides(cx, cy, radius);
drawConstellations(cx, cy, radius);
drawConstellationLabels(cx, cy, radius);
drawMessier(cx, cy, radius);
drawMoon(cx, cy, radius);
drawPlanets(cx, cy, radius);
drawStars(cx, cy, radius);
statusEl.textContent = 'Sternkarte fuer ' + MONTH_CHART_CONFIG.localTimeLabel;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
})();
(function () {
const canvas = document.getElementById('monatskarteSouthCanvas');
const statusEl = document.getElementById('monatskarteSouthStatus');
if (!canvas || !statusEl || !window.Astronomy || !MONTH_CHART_CONFIG || MONTH_CHART_ERROR) {
return;
}
const ctx = canvas.getContext('2d');
const date = new Date(MONTH_CHART_CONFIG.utcTimeIso);
const observer = new Astronomy.Observer(
Number(MONTH_CHART_CONFIG.location.latitude),
Number(MONTH_CHART_CONFIG.location.longitude),
Number(MONTH_CHART_CONFIG.location.elevation || 0)
);
const starByHip = new Map();
const constellationHipSet = new Set();
const displayStarMap = new Map();
let labelPlacer = null;
let projectionLayout = null;
const projCenterAzDeg = 180;
const projCenterAltDeg = 45;
for (const star of MONTH_CHART_LINE_STARS) {
if (Number.isFinite(Number(star.hip))) {
starByHip.set(Number(star.hip), star);
}
}
for (const star of MONTH_CHART_STARS) {
if (Number.isFinite(Number(star.hip))) {
displayStarMap.set(Number(star.hip), star);
} else {
displayStarMap.set(`south-star-${displayStarMap.size}`, star);
}
}
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
if (!Array.isArray(constellation.lines)) {
continue;
}
for (const segment of constellation.lines) {
if (!Array.isArray(segment)) {
continue;
}
for (const hip of segment) {
if (Number.isFinite(Number(hip))) {
constellationHipSet.add(Number(hip));
}
}
}
}
for (const hip of constellationHipSet) {
if (!displayStarMap.has(hip) && starByHip.has(hip)) {
displayStarMap.set(hip, starByHip.get(hip));
}
}
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function createLabelPlacer(context) {
const occupiedBoxes = [];
function overlaps(box) {
return occupiedBoxes.some((other) =>
box.x < other.x + other.width &&
box.x + box.width > other.x &&
box.y < other.y + other.height &&
box.y + box.height > other.y
);
}
function insideStage(box) {
return box.x >= 0 &&
box.y >= 0 &&
box.x + box.width <= canvas.clientWidth &&
box.y + box.height <= canvas.clientHeight;
}
return {
reset() {
occupiedBoxes.length = 0;
},
drawText(options) {
const {
text,
x,
y,
font = '600 11px Rajdhani, sans-serif',
color = 'rgba(232, 239, 255, 0.82)',
align = 'left',
baseline = 'middle',
padding = 2,
} = options;
if (!text) {
return false;
}
context.save();
context.font = font;
context.textAlign = align;
context.textBaseline = baseline;
const metrics = context.measureText(text);
const textWidth = metrics.width;
const ascent = metrics.actualBoundingBoxAscent || 8;
const descent = metrics.actualBoundingBoxDescent || 4;
let boxX = x;
if (align === 'center') boxX = x - textWidth / 2;
else if (align === 'right') boxX = x - textWidth;
let boxY = y - ascent;
if (baseline === 'middle') boxY = y - (ascent + descent) / 2;
else if (baseline === 'bottom' || baseline === 'alphabetic') boxY = y - ascent - descent;
const box = {
x: boxX - padding,
y: boxY - padding,
width: textWidth + padding * 2,
height: ascent + descent + padding * 2,
};
if (!insideStage(box) || overlaps(box)) {
context.restore();
return false;
}
occupiedBoxes.push(box);
context.fillStyle = color;
context.fillText(text, x, y);
context.restore();
return true;
},
};
}
function placePlanetLabel(text, x, y, radius) {
const attempts = [
{ x: x + radius + 6, y: y - 1, align: 'left', baseline: 'middle' },
{ x: x - radius - 6, y: y - 1, align: 'right', baseline: 'middle' },
{ x, y: y - radius - 7, align: 'center', baseline: 'bottom' },
{ x, y: y + radius + 7, align: 'center', baseline: 'top' },
{ x: x + radius + 6, y: y - radius - 5, align: 'left', baseline: 'bottom' },
{ x: x - radius - 6, y: y - radius - 5, align: 'right', baseline: 'bottom' },
{ x: x + radius + 6, y: y + radius + 5, align: 'left', baseline: 'top' },
{ x: x - radius - 6, y: y + radius + 5, align: 'right', baseline: 'top' },
];
for (const attempt of attempts) {
if (labelPlacer.drawText({
text,
x: attempt.x,
y: attempt.y,
font: '600 11px Rajdhani, sans-serif',
color: 'rgba(246, 235, 214, 0.84)',
align: attempt.align,
baseline: attempt.baseline,
padding: 2,
})) {
return true;
}
}
return false;
}
function resizeCanvas() {
const cssWidth = Math.max(420, Math.min(canvas.parentElement.clientWidth, 1100));
const cssHeight = Math.round(cssWidth * 0.6);
const ratio = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(cssWidth * ratio);
canvas.height = Math.round(cssHeight * ratio);
canvas.style.width = cssWidth + 'px';
canvas.style.height = cssHeight + 'px';
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(ratio, ratio);
labelPlacer = createLabelPlacer(ctx);
draw();
}
function vectorFromAltAz(altDeg, azDeg) {
const altRad = altDeg * Math.PI / 180;
const azRad = azDeg * Math.PI / 180;
const cosAlt = Math.cos(altRad);
return {
x: cosAlt * Math.sin(azRad),
y: cosAlt * Math.cos(azRad),
z: Math.sin(altRad),
};
}
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function cross(a, b) {
return {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x,
};
}
function normalize(v) {
const len = Math.hypot(v.x, v.y, v.z) || 1;
return { x: v.x / len, y: v.y / len, z: v.z / len };
}
const projectionCenter = vectorFromAltAz(projCenterAltDeg, projCenterAzDeg);
const basisX = normalize(cross({ x: 0, y: 0, z: 1 }, projectionCenter));
const basisY = normalize(cross(projectionCenter, basisX));
function rawSouthProjection(altDeg, azDeg) {
if (!Number.isFinite(altDeg) || !Number.isFinite(azDeg) || altDeg < 0) {
return null;
}
const p = vectorFromAltAz(altDeg, azDeg);
const denom = 1 + dot(projectionCenter, p);
if (denom <= 1e-6) {
return null;
}
const k = 2 / denom;
return {
u: k * dot(p, basisX),
v: k * dot(p, basisY),
};
}
function computeProjectionLayout(width, height) {
let minU = Infinity;
let maxU = -Infinity;
let minV = Infinity;
let maxV = -Infinity;
for (let az = 90; az <= 270; az += 2) {
for (let alt = 0; alt <= 90; alt += 2) {
const raw = rawSouthProjection(alt, az);
if (!raw) {
continue;
}
minU = Math.min(minU, raw.u);
maxU = Math.max(maxU, raw.u);
minV = Math.min(minV, raw.v);
maxV = Math.max(maxV, raw.v);
}
}
const marginX = width * 0.08;
const marginY = height * 0.08;
const usableWidth = width - marginX * 2;
const usableHeight = height - marginY * 2;
const scale = Math.min(
usableWidth / Math.max(0.001, maxU - minU),
usableHeight / Math.max(0.001, maxV - minV)
);
return {
scale,
offsetX: width / 2 - ((minU + maxU) * 0.5) * scale,
offsetY: height / 2 + ((minV + maxV) * 0.5) * scale,
};
}
function southProject(altDeg, azDeg) {
const raw = rawSouthProjection(altDeg, azDeg);
if (!raw || !projectionLayout) {
return null;
}
return {
x: projectionLayout.offsetX - raw.u * projectionLayout.scale,
y: projectionLayout.offsetY - raw.v * projectionLayout.scale,
};
}
function starHorizon(star) {
const raHours = star.ra * (12 / Math.PI);
const decDeg = star.dec * (180 / Math.PI);
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
}
function equatorialHorizon(raHours, decDeg) {
return Astronomy.Horizon(date, observer, raHours, decDeg, 'normal');
}
function starColor(star) {
if (typeof star.ci === 'number') {
if (star.ci < -0.1) return '#b9d4ff';
if (star.ci < 0.3) return '#dbe7ff';
if (star.ci < 0.7) return '#fff6e0';
if (star.ci < 1.1) return '#ffe0b5';
return '#ffc98a';
}
const spect = String(star.spect || '').trim().toUpperCase();
if (spect.startsWith('O') || spect.startsWith('B')) return '#b9d4ff';
if (spect.startsWith('A')) return '#dbe7ff';
if (spect.startsWith('F') || spect.startsWith('G')) return '#fff6e0';
if (spect.startsWith('K')) return '#ffd4a0';
if (spect.startsWith('M')) return '#ffbd85';
return '#f4f7ff';
}
function getStarLabel(star) {
const proper = String(star.proper || '').trim();
if (proper !== '') return proper;
const bayer = String(star.bayer || '').trim();
const con = String(star.con || '').trim();
if (bayer !== '') return con !== '' ? `${bayer} ${con}` : bayer;
return '';
}
function getMessierStyle(type) {
switch (type) {
case 'Gc': return { stroke: 'rgba(255, 180, 96, 0.92)', fill: 'rgba(255, 180, 96, 0.12)' };
case 'Oc': return { stroke: 'rgba(255, 224, 150, 0.84)', fill: 'rgba(255, 224, 150, 0.08)' };
case 'Gx': return { stroke: 'rgba(255, 140, 188, 0.88)', fill: 'rgba(255, 140, 188, 0.08)' };
case 'Pn': return { stroke: 'rgba(156, 255, 186, 0.88)', fill: 'rgba(156, 255, 186, 0.08)' };
case 'Nb':
case 'Sc': return { stroke: 'rgba(118, 204, 255, 0.88)', fill: 'rgba(118, 204, 255, 0.08)' };
default: return { stroke: 'rgba(210, 218, 232, 0.74)', fill: 'rgba(210, 218, 232, 0.06)' };
}
}
function getMessierLabel(obj) {
const messierId = Number(obj.m);
if (!Number.isFinite(messierId)) return '';
const name = String(obj.name || '').trim();
return name !== '' ? `M${messierId} ${name}` : `M${messierId}`;
}
function getPlanetStyle(planet) {
switch (String(planet.key || '')) {
case 'mercury':
return { fill: '#c9b9a7', glow: 'rgba(201, 185, 167, 0.34)' };
case 'venus':
return { fill: '#f4ddb2', glow: 'rgba(244, 221, 178, 0.4)' };
case 'mars':
return { fill: '#d7724c', glow: 'rgba(215, 114, 76, 0.34)' };
case 'jupiter':
return { fill: '#d9b07e', glow: 'rgba(217, 176, 126, 0.36)' };
case 'saturn':
return { fill: '#d8ca8b', glow: 'rgba(216, 202, 139, 0.34)' };
case 'uranus':
return { fill: '#8fd3df', glow: 'rgba(143, 211, 223, 0.3)' };
case 'neptune':
return { fill: '#5a78d6', glow: 'rgba(90, 120, 214, 0.3)' };
default:
return { fill: '#d8dde8', glow: 'rgba(216, 221, 232, 0.28)' };
}
}
function drawMoon(width, height) {
if (!MONTH_CHART_MOON) return;
const alt = Number(MONTH_CHART_MOON.alt);
const az = Number(MONTH_CHART_MOON.az);
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) return;
const pt = southProject(alt, az);
if (!pt) return;
const phaseFraction = clamp01(Number(MONTH_CHART_MOON.phaseFraction ?? 0.5));
const waxing = Boolean(MONTH_CHART_MOON.waxing);
const moonRadius = 10.5;
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, moonRadius * 2.1);
glow.addColorStop(0, 'rgba(220, 226, 238, 0.28)');
glow.addColorStop(0.5, 'rgba(220, 226, 238, 0.08)');
glow.addColorStop(1, 'rgba(220, 226, 238, 0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(pt.x, pt.y, moonRadius * 2.1, 0, Math.PI * 2);
ctx.fill();
ctx.save();
ctx.translate(pt.x, pt.y);
ctx.beginPath();
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = '#515760';
ctx.beginPath();
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#d6d9df';
ctx.beginPath();
ctx.arc(0, 0, moonRadius, 0, Math.PI * 2);
ctx.fill();
const phaseScale = 1 - 2 * phaseFraction;
ctx.fillStyle = '#515760';
ctx.beginPath();
ctx.ellipse((waxing ? -1 : 1) * moonRadius * phaseScale, 0, moonRadius, moonRadius, 0, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pt.x, pt.y, moonRadius, 0, Math.PI * 2);
ctx.stroke();
}
function drawPlanets(width, height) {
for (const planet of MONTH_CHART_PLANETS) {
const alt = Number(planet.alt);
const az = Number(planet.az);
if (!Number.isFinite(alt) || !Number.isFinite(az) || alt < 0) continue;
const pt = southProject(alt, az);
if (!pt) continue;
const style = getPlanetStyle(planet);
const mag = Number(planet.mag);
const baseRadius = Number.isFinite(mag)
? Math.max(3.8, Math.min(8.6, 6.7 - mag * 0.72))
: 5.4;
const glowRadius = baseRadius * 2.2;
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
glow.addColorStop(0, style.glow);
glow.addColorStop(0.45, style.glow.replace(/0\.\d+\)$/, '0.12)'));
glow.addColorStop(1, style.glow.replace(/0\.\d+\)$/, '0)'));
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = style.fill;
ctx.beginPath();
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.26)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pt.x, pt.y, baseRadius, 0, Math.PI * 2);
ctx.stroke();
if (planet.key === 'jupiter') {
ctx.save();
ctx.translate(pt.x, pt.y);
ctx.beginPath();
ctx.arc(0, 0, baseRadius - 0.3, 0, Math.PI * 2);
ctx.clip();
ctx.strokeStyle = 'rgba(116, 84, 54, 0.78)';
ctx.lineWidth = Math.max(1, baseRadius * 0.34);
ctx.beginPath();
ctx.moveTo(-baseRadius * 0.95, -baseRadius * 0.34);
ctx.lineTo(baseRadius * 0.95, -baseRadius * 0.34);
ctx.moveTo(-baseRadius * 0.95, baseRadius * 0.2);
ctx.lineTo(baseRadius * 0.95, baseRadius * 0.2);
ctx.stroke();
ctx.fillStyle = 'rgba(194, 92, 68, 0.95)';
ctx.beginPath();
ctx.ellipse(baseRadius * 0.42, baseRadius * 0.06, baseRadius * 0.3, baseRadius * 0.17, -0.18, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
if (planet.key === 'saturn') {
ctx.save();
ctx.translate(pt.x, pt.y);
ctx.rotate(-0.35);
ctx.strokeStyle = 'rgba(232, 221, 170, 0.72)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.ellipse(0, 0, baseRadius * 1.7, baseRadius * 0.72, 0, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
const label = String(planet.label || '').trim();
if (label !== '') {
placePlanetLabel(label, pt.x, pt.y, baseRadius);
}
}
}
function drawBackground(width, height) {
const zenitPt = southProject(88, 180) || { x: width / 2, y: height * 0.18 };
const horizonPt = southProject(0, 180) || { x: width / 2, y: height * 0.88 };
const baseRadius = Math.max(width, height) * 0.9;
const baseGrad = ctx.createRadialGradient(zenitPt.x, zenitPt.y, 0, zenitPt.x, zenitPt.y, baseRadius);
baseGrad.addColorStop(0, '#010203');
baseGrad.addColorStop(0.4, '#02050b');
baseGrad.addColorStop(0.74, '#06101d');
baseGrad.addColorStop(1, '#0d1f38');
ctx.fillStyle = baseGrad;
ctx.fillRect(0, 0, width, height);
const zenithDarkening = ctx.createRadialGradient(zenitPt.x, zenitPt.y, 0, zenitPt.x, zenitPt.y, baseRadius * 0.62);
zenithDarkening.addColorStop(0, 'rgba(0, 0, 0, 0.44)');
zenithDarkening.addColorStop(0.42, 'rgba(0, 0, 0, 0.2)');
zenithDarkening.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = zenithDarkening;
ctx.fillRect(0, 0, width, height);
const horizonGlow = ctx.createRadialGradient(
horizonPt.x,
horizonPt.y,
width * 0.14,
horizonPt.x,
horizonPt.y,
width * 0.62
);
horizonGlow.addColorStop(0, 'rgba(118, 170, 255, 0.11)');
horizonGlow.addColorStop(0.36, 'rgba(78, 126, 214, 0.055)');
horizonGlow.addColorStop(0.7, 'rgba(24, 46, 86, 0.02)');
horizonGlow.addColorStop(1, 'rgba(0, 0, 0, 0)');
ctx.fillStyle = horizonGlow;
ctx.fillRect(0, 0, width, height);
}
function strokeProjectedCurve(samples, strokeStyle, lineWidth) {
let penDown = false;
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lineWidth;
ctx.beginPath();
for (const sample of samples) {
const pt = southProject(sample.alt, sample.az);
if (!pt) {
penDown = false;
continue;
}
if (!penDown) {
ctx.moveTo(pt.x, pt.y);
penDown = true;
} else {
ctx.lineTo(pt.x, pt.y);
}
}
ctx.stroke();
}
function drawGuides(width, height) {
ctx.strokeStyle = 'rgba(140, 165, 220, 0.14)';
ctx.lineWidth = 1;
for (const alt of [0, 30, 60]) {
const samples = [];
for (let az = 90; az <= 270; az += 2) {
samples.push({ alt, az });
}
strokeProjectedCurve(samples, alt === 0 ? 'rgba(183, 205, 255, 0.18)' : 'rgba(140, 165, 220, 0.14)', 1);
}
for (const az of [90, 135, 180, 225, 270]) {
const samples = [];
for (let alt = 0; alt <= 90; alt += 2) {
samples.push({ alt, az });
}
strokeProjectedCurve(samples, 'rgba(140, 165, 220, 0.12)', 1);
}
function drawDirectionLabelOutside(azDeg, text, offset = 18) {
const horizonPt = southProject(0, azDeg);
const innerPt = southProject(4, azDeg);
const prevPt = southProject(0, azDeg - 2);
const nextPt = southProject(0, azDeg + 2);
if (!horizonPt || !innerPt || !prevPt || !nextPt) {
return;
}
let nx = horizonPt.x - innerPt.x;
let ny = horizonPt.y - innerPt.y;
const normalLen = Math.hypot(nx, ny) || 1;
nx /= normalLen;
ny /= normalLen;
let angle = Math.atan2(nextPt.y - prevPt.y, nextPt.x - prevPt.x);
if (angle > Math.PI / 2 || angle < -Math.PI / 2) {
angle += Math.PI;
}
ctx.save();
ctx.translate(horizonPt.x + nx * offset, horizonPt.y + ny * offset);
ctx.rotate(angle);
ctx.fillStyle = 'rgba(214, 226, 255, 0.72)';
ctx.font = '600 12px Rajdhani, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 0, 0);
ctx.restore();
}
const directionLabels = [
{ az: 90, text: 'Osten' },
{ az: 135, text: 'Südosten' },
{ az: 180, text: 'Süden' },
{ az: 225, text: 'Südwesten' },
{ az: 270, text: 'Westen' },
];
for (const label of directionLabels) {
drawDirectionLabelOutside(label.az, label.text, label.az === 180 ? 22 : 18);
}
const zenitPt = southProject(88, 180);
if (zenitPt) {
ctx.fillStyle = 'rgba(214, 226, 255, 0.72)';
ctx.font = '600 12px Rajdhani, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('Zenit', zenitPt.x, zenitPt.y - 8);
}
for (const alt of [30, 60]) {
const pt = southProject(alt, 182);
if (!pt) {
continue;
}
ctx.fillStyle = 'rgba(214, 226, 255, 0.58)';
ctx.font = '600 11px Rajdhani, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`${alt}°`, pt.x + 8, pt.y);
}
}
function drawConstellations(width, height) {
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
if (!Array.isArray(constellation.lines)) continue;
for (const segment of constellation.lines) {
if (!Array.isArray(segment) || segment.length < 2) continue;
const points = [];
for (const hip of segment) {
const star = starByHip.get(Number(hip));
if (!star) continue;
const hor = starHorizon(star);
const pt = southProject(hor.altitude, hor.azimuth);
if (pt) points.push(pt);
}
if (points.length < 2) continue;
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = 'rgba(210, 214, 224, 0.16)';
ctx.lineWidth = 2.1;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.stroke();
ctx.strokeStyle = 'rgba(36, 40, 48, 0.34)';
ctx.lineWidth = 0.95;
ctx.beginPath();
ctx.moveTo(points[0].x + 0.18, points[0].y - 0.12);
for (let i = 1; i < points.length; i++) {
const jitterX = (i % 2 === 0 ? 0.22 : -0.12);
const jitterY = (i % 2 === 0 ? -0.14 : 0.2);
ctx.lineTo(points[i].x + jitterX, points[i].y + jitterY);
}
ctx.stroke();
ctx.restore();
}
}
}
function drawConstellationLabels(width, height) {
for (const constellation of MONTH_CHART_CONSTELLATIONS) {
const label = constellation.common_name?.german || constellation.common_name?.native || constellation.id;
if (!label) continue;
const labelPositions = Array.isArray(constellation.label_positions)
? constellation.label_positions.filter((pos) => Array.isArray(pos) && pos.length >= 2)
: [];
for (const [raH, decD] of labelPositions) {
const hor = equatorialHorizon(Number(raH), Number(decD));
const pt = southProject(hor.altitude, hor.azimuth);
if (!pt || hor.altitude < 8) continue;
if (labelPlacer.drawText({
text: label,
x: pt.x,
y: pt.y,
font: '600 11px Rajdhani, sans-serif',
color: 'rgba(198, 214, 255, 0.54)',
align: 'center',
baseline: 'middle',
padding: 3,
})) break;
}
}
}
function drawMessier(width, height) {
for (const obj of MONTH_CHART_MESSIER) {
if (Number(obj.mag) > Number(MONTH_CHART_CONFIG.messierLimit)) continue;
const hor = equatorialHorizon(Number(obj.ra), Number(obj.dec));
const pt = southProject(hor.altitude, hor.azimuth);
if (!pt) continue;
const style = getMessierStyle(String(obj.type || 'As'));
const symbolRadius = Math.max(3.4, Math.min(6.8, 3.2 + Math.max(0, 6.5 - Number(obj.mag)) * 0.5));
ctx.strokeStyle = style.stroke;
ctx.fillStyle = style.fill;
ctx.lineWidth = 1;
if (obj.type === 'Gc') {
ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.moveTo(pt.x - symbolRadius * 0.8, pt.y); ctx.lineTo(pt.x + symbolRadius * 0.8, pt.y);
ctx.moveTo(pt.x, pt.y - symbolRadius * 0.8); ctx.lineTo(pt.x, pt.y + symbolRadius * 0.8);
ctx.stroke();
} else if (obj.type === 'Oc') {
ctx.setLineDash([2, 2]); ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);
} else if (obj.type === 'Gx') {
ctx.save(); ctx.translate(pt.x, pt.y); ctx.rotate(Math.PI / 5); ctx.beginPath(); ctx.ellipse(0, 0, symbolRadius, symbolRadius * 0.58, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.restore();
} else if (obj.type === 'Nb' || obj.type === 'Sc') {
ctx.beginPath(); ctx.rect(pt.x - symbolRadius, pt.y - symbolRadius * 0.62, symbolRadius * 2, symbolRadius * 1.24); ctx.fill(); ctx.stroke();
} else if (obj.type === 'Pn') {
ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius * 0.62, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
} else {
ctx.beginPath(); ctx.arc(pt.x, pt.y, symbolRadius * 0.68, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
const label = getMessierLabel(obj);
if (label) {
labelPlacer.drawText({
text: label,
x: pt.x + symbolRadius + 5,
y: pt.y,
font: '600 11px Rajdhani, sans-serif',
color: 'rgba(210, 224, 255, 0.78)',
align: 'left',
baseline: 'middle',
padding: 2,
});
}
}
}
function drawStars(width, height) {
for (const star of displayStarMap.values()) {
const hip = Number(star.hip);
const isConstellationStar = Number.isFinite(hip) && constellationHipSet.has(hip);
if (!isConstellationStar && Number(star.mag) > Number(MONTH_CHART_CONFIG.starLimit)) continue;
const hor = starHorizon(star);
const pt = southProject(hor.altitude, hor.azimuth);
if (!pt) continue;
const magRange = Math.max(0.5, Number(MONTH_CHART_CONFIG.starLimit) + 1.6);
const effectiveMagnitude = isConstellationStar
? Math.min(Number(star.mag), Number(MONTH_CHART_CONFIG.starLimit) + 0.35)
: Number(star.mag);
const brightness = clamp01((Number(MONTH_CHART_CONFIG.starLimit) - effectiveMagnitude + 1.6) / magRange);
const starRadius = (isConstellationStar ? 0.85 : 0.65) + Math.pow(brightness, 1.9) * 5.2;
const glowRadius = starRadius * (effectiveMagnitude <= 1 ? 2.8 : 1.9);
const color = starColor(star);
const glow = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, glowRadius);
glow.addColorStop(0, color + '66');
glow.addColorStop(0.35, color + '22');
glow.addColorStop(1, color + '00');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(pt.x, pt.y, glowRadius, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(pt.x, pt.y, starRadius, 0, Math.PI * 2); ctx.fill();
if (star.mag <= 0.8) {
ctx.strokeStyle = color + '44';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pt.x - starRadius * 1.35, pt.y); ctx.lineTo(pt.x + starRadius * 1.35, pt.y);
ctx.moveTo(pt.x, pt.y - starRadius * 1.35); ctx.lineTo(pt.x, pt.y + starRadius * 1.35);
ctx.stroke();
}
if (Number(star.mag) <= Number(MONTH_CHART_CONFIG.starLabelLimit)) {
const label = getStarLabel(star);
if (label) {
labelPlacer.drawText({
text: label,
x: pt.x + starRadius + 5,
y: pt.y - 1,
font: '600 11px Rajdhani, sans-serif',
color: 'rgba(232, 239, 255, 0.82)',
align: 'left',
baseline: 'middle',
padding: 2,
});
}
}
}
}
function draw() {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
projectionLayout = computeProjectionLayout(width, height);
ctx.clearRect(0, 0, width, height);
labelPlacer.reset();
drawBackground(width, height);
drawGuides(width, height);
drawConstellations(width, height);
drawConstellationLabels(width, height);
drawMessier(width, height);
drawMoon(width, height);
drawPlanets(width, height);
drawStars(width, height);
statusEl.textContent = 'Südhimmel für ' + MONTH_CHART_CONFIG.localTimeLabel;
}
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
})();
</script>
<?php require __DIR__ . '/footer.php'; ?>