4388 lines
156 KiB
PHP
4388 lines
156 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
session_start();
|
|
|
|
$loggedIn = isset($_SESSION['user_id']);
|
|
$pageTitle = 'Monatsvorhersage - AstroTools';
|
|
$bodyClass = 'admin-page monatsvorhersage-page';
|
|
$publicBasePath = './';
|
|
|
|
$currentYear = (int) date('Y');
|
|
$selectedYear = isset($_GET['year']) ? (int) $_GET['year'] : $currentYear;
|
|
$selectedMonth = isset($_GET['month']) ? max(1, min(12, (int) $_GET['month'])) : (int) date('n');
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Bereits berechnete Bausteine der Monatsvorschau
|
|
// -------------------------------------------------------------------------
|
|
// Stand heute erzeugt diese Seite bereits:
|
|
// - Monats-Sternkarten für Nordhimmel und Südhimmel
|
|
// - Mond- und Planetenkontext für die feste Monatszeit
|
|
// - Mondphasen des Monats
|
|
// - Goldener-Henkel-Termine
|
|
// - Jahreszeitenwechsel und Zeitumstellungen
|
|
// - Sonnen- und Mondfinsternisse
|
|
// - Perigäum/Apogäum des Mondes sowie Perihel/Aphel der Sonne
|
|
// - Größte Elongationen von Merkur/Venus und größte Venushelligkeit
|
|
// - Ereignisse äußerer Planeten und deren Stillstände
|
|
// - Jupitermonde auf nur einer Planetenseite
|
|
// - Annäherungen Mond-Planet, Mond-Deep-Sky und Planet-heller-Stern
|
|
// - Planetenkonjunktionen
|
|
// - Gute Merkursichtbarkeit
|
|
// - Planetenparaden
|
|
// - Sternbildwechsel der Planeten
|
|
// - Sternbedeckungen durch den Mond
|
|
// - Meteorstrom-Maxima
|
|
// - Gute Überflüge des Favoriten-Satelliten
|
|
// - Zusammengeführte Monatsereignisliste für Übersicht, SQL-Export und iCal
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Grenzwerte fuer Sternkarte und Objektanzeige
|
|
// -------------------------------------------------------------------------
|
|
// Maximale Helligkeit (Magnitude) der Sterne, die aus der Datenbank geladen
|
|
// werden. Hoehere Werte laden mehr, aber auch schwaechere Sterne.
|
|
$starDatabaseMagnitudeLimit = 6.0;
|
|
|
|
// Maximale Helligkeit der Sterne, die auf der Monats-Sternkarte sichtbar
|
|
// gezeichnet werden sollen.
|
|
$starDisplayMagnitudeLimit = 4.4;
|
|
|
|
// Sterne bis zu dieser Helligkeit erhalten auf der Sternkarte einen Namen.
|
|
$starLabelMagnitudeLimit = 2.0;
|
|
|
|
// Maximale Helligkeit der Messier-Objekte, die aus der Datenbank geladen
|
|
// werden.
|
|
$messierDatabaseMagnitudeLimit = 2.0;
|
|
|
|
// Messier-Objekte bis zu dieser Helligkeit werden auf der Monatskarte
|
|
// eingeblendet.
|
|
$messierDisplayMagnitudeLimit = 1.0;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Grenzwerte fuer Monatsereignisse
|
|
// -------------------------------------------------------------------------
|
|
// Maximaler Winkelabstand fuer Begegnungen zwischen Mond und Planeten.
|
|
$moonPlanetMaxSeparationDeg = 1.5;
|
|
|
|
// Maximaler Winkelabstand fuer Begegnungen zwischen Mond und ausgewaehlten
|
|
// Sternhaufen wie Plejaden oder Praesepe.
|
|
$moonDeepSkyMaxSeparationDeg = 1.0;
|
|
|
|
// Maximaler Winkelabstand fuer Begegnungen zwischen hellen Planeten und
|
|
// ausgewaehlten hellen Sternen wie Regulus oder Spica.
|
|
$planetBrightStarMaxSeparationDeg = 1.0;
|
|
|
|
// Maximaler Winkelabstand fuer Planet-Planet-Konjunktionen.
|
|
$planetConjunctionMaxSeparationDeg = 1.0;
|
|
|
|
// Nur Sterne bis zu dieser Helligkeit werden fuer Mondbedeckungen
|
|
// beruecksichtigt.
|
|
$moonOccultationMaxMag = 2.0;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Schwellen fuer "Gute Satelliten-Ueberfluege"
|
|
// -------------------------------------------------------------------------
|
|
// Es wird nur der Favoriten-Satellit des angemeldeten Users geprueft.
|
|
//
|
|
// Ein Ueberflug zaehlt als guter Beobachtungstermin nur dann, wenn er am
|
|
// Hoehepunkt mindestens diese Hoehe ueber dem Horizont erreicht.
|
|
$favoriteSatelliteBestPassMinAltitudeDeg = 80.0;
|
|
|
|
// Gleichzeitig muss die Sonne zum Maximum des Ueberflugs mindestens so weit
|
|
// unter dem Horizont stehen. -12 Grad entspricht dem Beginn/Ende der
|
|
// nautischen Daemmerung und sorgt fuer ausreichend dunklen Himmel.
|
|
$favoriteSatelliteBestPassMaxSunAltitudeDeg = -14.0;
|
|
|
|
// Optional koennen wir die Liste auf die erste Nachthaelfte begrenzen.
|
|
// "Erste Nachthaelfte" ist hier bewusst pragmatisch als lokaler Abend vor
|
|
// Mitternacht definiert. Ueberfluege nach 00:00 Ortszeit werden damit
|
|
// ausgefiltert.
|
|
$favoriteSatelliteBestPassOnlyFirstNightHalf = true;
|
|
|
|
// Optional koennen wir nur besonders helle Ueberfluege anzeigen.
|
|
// Bei Magnituden gilt: kleinere Werte sind heller. 0.0 mag bedeutet also,
|
|
// dass nur Ueberfluege mit Peak-Helligkeit von 0 mag oder heller bleiben.
|
|
$favoriteSatelliteBestPassMaxPeakMagnitude = 0.0;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Schwellen fuer "Gute Merkur-Sichtbarkeit"
|
|
// -------------------------------------------------------------------------
|
|
// Gute Merkur-Sichtbarkeit wird nicht nur ueber Elongation definiert, sondern
|
|
// ueber echte Beobachtbarkeit in der Daemmerung.
|
|
// Geprueft wird pro Tag:
|
|
// morgens eine feste Zahl Minuten VOR Sonnenaufgang,
|
|
// abends eine feste Zahl Minuten NACH Sonnenuntergang.
|
|
$mercuryGoodVisibilitySampleOffsetMinutes = 45;
|
|
|
|
// Merkur muss zum Pruefzeitpunkt mindestens diese Hoehe ueber dem Horizont
|
|
// erreichen, damit der Tag als gut sichtbar gilt.
|
|
$mercuryGoodVisibilityMinAltitudeDeg = 7.0;
|
|
|
|
// Gleichzeitig muss die Sonne ausreichend tief unter dem Horizont stehen.
|
|
// So vermeiden wir Treffer, bei denen Merkur zwar formal sichtbar waere, aber
|
|
// noch zu stark in der hellen Daemmerung untergeht.
|
|
$mercuryGoodVisibilityMaxSunAltitudeDeg = -3.0;
|
|
|
|
// Erst ab dieser Anzahl aufeinanderfolgender guter Tage erzeugen wir ein
|
|
// Monatsereignis. Einzelne Ausreisser-Tage werden dadurch herausgefiltert.
|
|
$mercuryGoodVisibilityMinConsecutiveDays = 3;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Schwellen fuer "Planetenparade"
|
|
// -------------------------------------------------------------------------
|
|
// Geprueft wird morgens eine feste Zahl Minuten VOR Sonnenaufgang und abends
|
|
// eine feste Zahl Minuten NACH Sonnenuntergang.
|
|
$planetParadeSampleOffsetMinutes = 45;
|
|
|
|
// Ein Planet zaehlt fuer die Parade nur dann mit, wenn er zum Pruefzeitpunkt
|
|
// mindestens diese Hoehe ueber dem Horizont erreicht.
|
|
$planetParadeMinAltitudeDeg = 7.0;
|
|
|
|
// Gleichzeitig muss die Sonne bereits tief genug unter dem Horizont stehen,
|
|
// damit die Parade nicht nur rechnerisch, sondern auch praktisch sichtbar ist.
|
|
$planetParadeMaxSunAltitudeDeg = -4.0;
|
|
|
|
// Die beteiligten Planeten muessen innerhalb dieses Azimutbogens liegen.
|
|
// So vermeiden wir Treffer, bei denen die Planeten zu weit ueber den Himmel
|
|
// verteilt sind und nicht mehr als zusammenhaengende Parade wirken.
|
|
$planetParadeMaxAzimuthSpanDeg = 120.0;
|
|
|
|
// Mindestens so viele helle Planeten muessen gleichzeitig sichtbar sein,
|
|
// damit wir von einer Planetenparade sprechen.
|
|
$planetParadeMinPlanetCount = 4;
|
|
|
|
// Erst ab dieser Anzahl aufeinanderfolgender Tage erzeugen wir ein Ereignis.
|
|
// Das filtert kurzlebige Ein-Tages-Treffer heraus.
|
|
$planetParadeMinConsecutiveDays = 2;
|
|
|
|
$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";
|
|
}
|
|
}
|
|
|
|
if (!function_exists('monthForecastBuildNarrativeText')) {
|
|
function monthForecastBuildNarrativeText(array $monthEventList, int $monthNumber, string $monthName, int $year): string
|
|
{
|
|
$filteredEvents = array_values(array_filter(
|
|
$monthEventList,
|
|
static fn (array $item): bool => (string) ($item['type'] ?? '') !== 'favorite_satellite_pass'
|
|
));
|
|
|
|
$skyIntroByMonth = [
|
|
1 => 'Der Sternenhimmel im Januar wird noch von den klassischen Wintersternbildern beherrscht. Orion, Stier, Fuhrmann, Zwillinge und der Grosse Hund praegen den Abendhimmel, waehrend im Osten bereits erste Fruehlingsboten hoeher steigen.',
|
|
2 => 'Der Februar zeigt den Winterhimmel in seiner vielleicht schoensten Form. Orion, Stier, Zwillinge, Fuhrmann und Grosser Hund stehen abends eindrucksvoll ueber dem Horizont, spaeter in der Nacht ruesten sich bereits Loewe und andere Fruehlingssternbilder fuer ihren Auftritt.',
|
|
3 => 'Der Sternenhimmel im Maerz bildet den Uebergang von den Winter- zu den Fruehlingssternbildern. Abends stehen Orion, Zwillinge, Fuhrmann und Stier noch markant am Himmel, waehrend gegen Mitternacht Loewe, Jungfrau und Bootes immer mehr Aufmerksamkeit auf sich ziehen.',
|
|
4 => 'Im April dominiert der Fruehlingshimmel den Abend. Loewe, Jungfrau und Bootes stehen guenstig, waehrend die Wintersternbilder nun rasch in den Westen abwandern und am spaeteren Abend bereits Platz fuer den kommenden Sommerhimmel machen.',
|
|
5 => 'Der Mai zeigt den Fruehlingshimmel in voller Pracht. Loewe, Jungfrau, Bootes und die ausgedehnte Wasserschlange praegen die Nacht, waehrend sich im Osten schon die ersten Sternbilder des Sommers bemerkbar machen.',
|
|
6 => 'Im Juni werden die Naechte kurz, doch gerade in der spaeteren Dunkelheit treten bereits die Sommersternbilder hervor. Schwan, Leier und Adler gewinnen an Hoehe, waehrend der Fruehlingshimmel langsam nach Westen rueckt.',
|
|
7 => 'Der Juli gehoert dem Sommerhimmel. Das Sommerdreieck aus Wega, Deneb und Atair dominiert den Blick nach oben, daneben laden Schuetze, Skorpion und die reichen Sternfelder der Milchstrasse zu ausgedehnten Beobachtungen ein.',
|
|
8 => 'Im August zeigt sich der Sommerhimmel von seiner eindrucksvollsten Seite. Das Sommerdreieck steht hoch, die Milchstrasse zieht sich markant ueber den Himmel, und in der zweiten Nachthaelfte machen sich bereits erste Herbststernbilder bemerkbar.',
|
|
9 => 'Der September vermittelt zwischen Sommer- und Herbsthimmel. Das Sommerdreieck bleibt ein Blickfang, doch Pegasus, Andromeda und die herbstlichen Sternbilder steigen nun immer besser am Abendhimmel auf.',
|
|
10 => 'Im Oktober uebernimmt der Herbsthimmel die Hauptrolle. Pegasus, Andromeda, Perseus und Dreieck stehen guenstig, waehrend sich gegen Morgen schon die ersten Vorboten des Winterhimmels zeigen.',
|
|
11 => 'Der November bringt lange dunkle Naechte und einen Himmel zwischen Herbst und Winter. Pegasus und Andromeda stehen noch gut, waehrend Stier, Fuhrmann und Orion im Laufe der Nacht immer eindrucksvoller werden.',
|
|
12 => 'Im Dezember kehrt der Winterhimmel eindrucksvoll zurueck. Orion, Stier, Zwillinge, Fuhrmann und der Grosse Hund dominieren den Abend und machen den Monat zu einer besonders attraktiven Zeit fuer Sternbeobachtungen.',
|
|
];
|
|
|
|
$paragraphs = [];
|
|
$paragraphs[] = $skyIntroByMonth[$monthNumber] ?? ($monthName . ' ' . $year . ' bietet einen abwechslungsreichen Himmel zwischen den jahreszeitlichen Sternbildern.');
|
|
|
|
$findEvents = static function (array $events, callable $filter, int $limit = 3): array {
|
|
$matches = [];
|
|
foreach ($events as $event) {
|
|
if ($filter($event)) {
|
|
$matches[] = $event;
|
|
if (count($matches) >= $limit) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return $matches;
|
|
};
|
|
|
|
$describeEvents = static function (array $events): string {
|
|
$parts = [];
|
|
foreach ($events as $event) {
|
|
$label = trim((string) ($event['event'] ?? ''));
|
|
$date = trim((string) ($event['date'] ?? ''));
|
|
if ($label === '' || $date === '') {
|
|
continue;
|
|
}
|
|
$parts[] = $label . ' am ' . $date;
|
|
}
|
|
return implode(', ', $parts);
|
|
};
|
|
|
|
$moonEvents = $findEvents(
|
|
$filteredEvents,
|
|
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['moon_phase', 'golden_handle', 'moon_planet_approach', 'moon_deep_sky_approach', 'moon_occultation', 'moon_apsis'], true),
|
|
4
|
|
);
|
|
if ($moonEvents !== []) {
|
|
$paragraphs[] = 'Auch der Mond liefert im Laufe des Monats einige markante Beobachtungsmomente. Besonders auffaellig sind ' . $describeEvents($moonEvents) . '.';
|
|
}
|
|
|
|
$planetEvents = $findEvents(
|
|
$filteredEvents,
|
|
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['inner_planet_elongation', 'venus_peak_magnitude', 'outer_planet_event', 'outer_planet_station', 'planet_conjunction', 'planet_constellation_change', 'planet_parade', 'mercury_good_visibility'], true),
|
|
5
|
|
);
|
|
if ($planetEvents !== []) {
|
|
$paragraphs[] = 'Bei den Planeten lohnt sich vor allem der Blick auf ' . $describeEvents($planetEvents) . '. Diese Termine geben dem Monat seinen charakteristischen planetaren Akzent.';
|
|
}
|
|
|
|
$eclipseEvents = $findEvents(
|
|
$filteredEvents,
|
|
static fn (array $event): bool => in_array((string) ($event['type'] ?? ''), ['eclipse', 'lunar_eclipse', 'solar_eclipse'], true),
|
|
3
|
|
);
|
|
if ($eclipseEvents !== []) {
|
|
$paragraphs[] = 'Besondere Aufmerksamkeit verdienen ausserdem die Finsternistermine. In diesem Monat stechen ' . $describeEvents($eclipseEvents) . ' hervor.';
|
|
}
|
|
|
|
$meteorEvents = $findEvents(
|
|
$filteredEvents,
|
|
static fn (array $event): bool => (string) ($event['type'] ?? '') === 'meteor_shower_peak',
|
|
2
|
|
);
|
|
if ($meteorEvents !== []) {
|
|
$paragraphs[] = 'Freunde von Meteoren sollten zudem die Aktivitaet der Meteorstroeme im Blick behalten. Erwähnenswert sind besonders ' . $describeEvents($meteorEvents) . '.';
|
|
}
|
|
|
|
if (count($paragraphs) === 1 && $filteredEvents !== []) {
|
|
$highlights = array_slice($filteredEvents, 0, 4);
|
|
$paragraphs[] = 'Zu den auffaelligen Terminen des Monats gehoeren ' . $describeEvents($highlights) . '.';
|
|
}
|
|
|
|
$paragraphs[] = 'Die chronologische Ereignisliste darunter dient als kompakte Terminuebersicht; automatische ISS- und Favoriten-Satellitenueberfluege bleiben in diesem Beschreibungstext bewusst unberuecksichtigt.';
|
|
|
|
return implode("\n\n", $paragraphs);
|
|
}
|
|
}
|
|
|
|
try {
|
|
$dbConfig = require __DIR__ . '/../config/database.php';
|
|
$dsn = sprintf(
|
|
'mysql:host=%s;dbname=%s;charset=%s',
|
|
$dbConfig['host'],
|
|
$dbConfig['dbname'],
|
|
$dbConfig['charset'] ?? 'utf8mb4'
|
|
);
|
|
$pdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
]);
|
|
|
|
$starStmt = $pdo->query(
|
|
"SELECT hip, proper, bayer, con, spect, mag, ci, rarad, decrad
|
|
FROM star_hipparcos
|
|
WHERE mag IS NOT NULL
|
|
AND rarad IS NOT NULL
|
|
AND decrad IS NOT NULL
|
|
AND mag <= " . (float) $starDatabaseMagnitudeLimit . "
|
|
ORDER BY mag ASC"
|
|
);
|
|
|
|
while ($row = $starStmt->fetch()) {
|
|
$starData[] = [
|
|
'hip' => $row['hip'] !== null ? (int) $row['hip'] : null,
|
|
'proper' => trim((string) ($row['proper'] ?? '')),
|
|
'bayer' => trim((string) ($row['bayer'] ?? '')),
|
|
'con' => trim((string) ($row['con'] ?? '')),
|
|
'spect' => trim((string) ($row['spect'] ?? '')),
|
|
'ra' => (float) $row['rarad'],
|
|
'dec' => (float) $row['decrad'],
|
|
'mag' => (float) $row['mag'],
|
|
'ci' => $row['ci'] !== null ? (float) $row['ci'] : null,
|
|
];
|
|
}
|
|
|
|
$constellationIndex = [];
|
|
$ensureConstellation = static function (string $id) use (&$constellationIndex): void {
|
|
if (!isset($constellationIndex[$id])) {
|
|
$constellationIndex[$id] = [
|
|
'id' => $id,
|
|
'lines' => [],
|
|
'label_positions' => [],
|
|
'common_name' => [
|
|
'german' => null,
|
|
'native' => null,
|
|
'byname' => null,
|
|
],
|
|
];
|
|
}
|
|
};
|
|
|
|
$nameStmt = $pdo->query(
|
|
"SELECT constellation_id, german_name, native_name, byname
|
|
FROM constellation_names
|
|
ORDER BY constellation_id"
|
|
);
|
|
foreach ($nameStmt as $row) {
|
|
$constellationId = (string) $row['constellation_id'];
|
|
$ensureConstellation($constellationId);
|
|
$constellationIndex[$constellationId]['common_name'] = [
|
|
'german' => $row['german_name'] !== null ? (string) $row['german_name'] : null,
|
|
'native' => $row['native_name'] !== null ? (string) $row['native_name'] : null,
|
|
'byname' => $row['byname'] !== null ? (string) $row['byname'] : null,
|
|
];
|
|
}
|
|
|
|
$labelStmt = $pdo->query(
|
|
"SELECT constellation_id, position_index, ra_hours, dec_degrees
|
|
FROM constellation_label_positions
|
|
ORDER BY constellation_id, position_index"
|
|
);
|
|
foreach ($labelStmt as $row) {
|
|
$constellationId = (string) $row['constellation_id'];
|
|
$ensureConstellation($constellationId);
|
|
$constellationIndex[$constellationId]['label_positions'][] = [
|
|
(float) $row['ra_hours'],
|
|
(float) $row['dec_degrees'],
|
|
];
|
|
}
|
|
|
|
$lineStmt = $pdo->query(
|
|
"SELECT cl.constellation_id, cl.line_index, clp.point_index, clp.hip_number
|
|
FROM constellation_lines cl
|
|
INNER JOIN constellation_line_points clp ON clp.line_id = cl.id
|
|
ORDER BY cl.constellation_id, cl.line_index, clp.point_index"
|
|
);
|
|
|
|
foreach ($lineStmt as $row) {
|
|
$constellationId = (string) $row['constellation_id'];
|
|
$lineIndex = (int) $row['line_index'];
|
|
|
|
$ensureConstellation($constellationId);
|
|
|
|
$constellationIndex[$constellationId]['lines'][$lineIndex] ??= [];
|
|
$constellationIndex[$constellationId]['lines'][$lineIndex][] = (int) $row['hip_number'];
|
|
}
|
|
|
|
foreach ($constellationIndex as &$constellation) {
|
|
if ($constellation['lines']) {
|
|
ksort($constellation['lines']);
|
|
$constellation['lines'] = array_values($constellation['lines']);
|
|
}
|
|
}
|
|
unset($constellation);
|
|
|
|
$constellationLines = array_values($constellationIndex);
|
|
|
|
$lineStarStmt = $pdo->query(
|
|
"SELECT DISTINCT sh.hip, sh.proper, sh.bayer, sh.con, sh.spect, sh.mag, sh.ci, sh.rarad, sh.decrad
|
|
FROM constellation_line_points clp
|
|
INNER JOIN star_hipparcos sh ON sh.hip = clp.hip_number
|
|
WHERE sh.rarad IS NOT NULL
|
|
AND sh.decrad IS NOT NULL"
|
|
);
|
|
|
|
while ($row = $lineStarStmt->fetch()) {
|
|
$lineStarData[] = [
|
|
'hip' => $row['hip'] !== null ? (int) $row['hip'] : null,
|
|
'proper' => trim((string) ($row['proper'] ?? '')),
|
|
'bayer' => trim((string) ($row['bayer'] ?? '')),
|
|
'con' => trim((string) ($row['con'] ?? '')),
|
|
'spect' => trim((string) ($row['spect'] ?? '')),
|
|
'ra' => (float) $row['rarad'],
|
|
'dec' => (float) $row['decrad'],
|
|
'mag' => $row['mag'] !== null ? (float) $row['mag'] : 99.0,
|
|
'ci' => $row['ci'] !== null ? (float) $row['ci'] : null,
|
|
];
|
|
}
|
|
|
|
$messierStmt = $pdo->query(
|
|
"SELECT messier, name, object_type_abrev, ra_deg, dec_deg, v_mag, major_axis
|
|
FROM dso_objects
|
|
WHERE messier IS NOT NULL
|
|
AND messier <> ''
|
|
AND ra_deg IS NOT NULL
|
|
AND dec_deg IS NOT NULL
|
|
AND v_mag IS NOT NULL
|
|
AND v_mag <= " . (float) $messierDatabaseMagnitudeLimit . "
|
|
ORDER BY v_mag ASC, CAST(REGEXP_REPLACE(messier, '[^0-9]', '') AS UNSIGNED)"
|
|
);
|
|
|
|
while ($row = $messierStmt->fetch()) {
|
|
if (!preg_match('/(\d+)/', (string) $row['messier'], $match)) {
|
|
continue;
|
|
}
|
|
|
|
$messierData[] = [
|
|
'm' => (int) $match[1],
|
|
'name' => trim((string) ($row['name'] ?? '')),
|
|
'type' => trim((string) ($row['object_type_abrev'] ?? 'As')),
|
|
'ra' => ((float) $row['ra_deg']) / 15.0,
|
|
'dec' => (float) $row['dec_deg'],
|
|
'mag' => (float) $row['v_mag'],
|
|
'size' => $row['major_axis'] !== null ? (float) $row['major_axis'] : 8.0,
|
|
];
|
|
}
|
|
|
|
$meteorStmt = $pdo->query(
|
|
"SELECT designation, shower_code, radiant_delta_deg, peak_sol, zhr, zhr_variable
|
|
FROM meteorshowers
|
|
WHERE peak_sol IS NOT NULL
|
|
ORDER BY peak_sol ASC, designation ASC"
|
|
);
|
|
|
|
while ($row = $meteorStmt->fetch()) {
|
|
$meteorShowerPeaks[] = [
|
|
'designation' => trim((string) ($row['designation'] ?? '')),
|
|
'shower_code' => trim((string) ($row['shower_code'] ?? '')),
|
|
'radiant_delta_deg' => $row['radiant_delta_deg'] !== null ? (float) $row['radiant_delta_deg'] : null,
|
|
'peak_sol' => (float) $row['peak_sol'],
|
|
'zhr' => $row['zhr'] !== null ? (int) $row['zhr'] : null,
|
|
'zhr_variable' => trim((string) ($row['zhr_variable'] ?? '')),
|
|
];
|
|
}
|
|
|
|
if ($loggedIn && isset($_SESSION['user_id'])) {
|
|
$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;
|
|
}
|
|
|
|
$phaseLabel = trim((string) ($phaseEvent['label'] ?? ''));
|
|
if ($phaseLabel === 'Erstes Viertel' || $phaseLabel === 'Letztes Viertel') {
|
|
$phaseLabel = 'Mond ' . $phaseLabel;
|
|
}
|
|
|
|
$monthEventList[] = [
|
|
'event' => $phaseLabel,
|
|
'date' => (string) ($phaseEvent['local_date'] ?? ''),
|
|
'time' => (string) ($phaseEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($phaseEvent['local_iso'] ?? ''),
|
|
'type' => 'moon_phase',
|
|
];
|
|
}
|
|
|
|
foreach ($goldenHandleEvents as $goldenHandleEvent) {
|
|
if (!is_array($goldenHandleEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$peakDate = (string) ($goldenHandleEvent['peak_local_date'] ?? '');
|
|
$peakTime = (string) ($goldenHandleEvent['peak_local_time'] ?? '');
|
|
$goldenHandleLabel = 'Goldener Henkel am Mond';
|
|
|
|
$monthEventList[] = [
|
|
'event' => $goldenHandleLabel,
|
|
'date' => $peakDate !== '' ? $peakDate : (string) ($goldenHandleEvent['local_date'] ?? ''),
|
|
'time' => $peakTime !== '' ? $peakTime : (string) ($goldenHandleEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($goldenHandleEvent['peak_local_iso'] ?? $goldenHandleEvent['local_iso'] ?? ''),
|
|
'type' => 'golden_handle',
|
|
];
|
|
}
|
|
|
|
foreach ($seasonChanges as $seasonChange) {
|
|
if (!is_array($seasonChange)) {
|
|
continue;
|
|
}
|
|
|
|
$monthEventList[] = [
|
|
'event' => (string) ($seasonChange['label'] ?? 'Jahreszeitenwechsel'),
|
|
'date' => (string) ($seasonChange['local_date'] ?? ''),
|
|
'time' => (string) ($seasonChange['local_time'] ?? ''),
|
|
'local_iso' => (string) ($seasonChange['local_iso'] ?? ''),
|
|
'type' => 'season_change',
|
|
];
|
|
}
|
|
|
|
foreach ($timeChanges as $timeChange) {
|
|
if (!is_array($timeChange)) {
|
|
continue;
|
|
}
|
|
|
|
$timeChangeKind = (string) ($timeChange['kind'] ?? '');
|
|
$timeChangeLabel = $timeChangeKind === 'dst_start'
|
|
? 'Beginn der Sommerzeit (Uhren von 02:00 auf 03:00 Uhr vorstellen)'
|
|
: ($timeChangeKind === 'dst_end'
|
|
? 'Beginn der Normalzeit (Uhren von 03:00 auf 02:00 Uhr 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;
|
|
}
|
|
|
|
$eventLabel = trim((string) ($jupiterMoonSideEvent['label'] ?? 'Alle 4 Jupitermonde auf einer Seite von Jupiter'));
|
|
|
|
$monthEventList[] = [
|
|
'event' => $eventLabel,
|
|
'date' => (string) ($jupiterMoonSideEvent['local_date'] ?? ''),
|
|
'time' => (string) ($jupiterMoonSideEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($jupiterMoonSideEvent['local_iso'] ?? ''),
|
|
'end_local_iso' => (string) ($jupiterMoonSideEvent['end_local_iso'] ?? ''),
|
|
'duration_minutes' => isset($jupiterMoonSideEvent['duration_minutes']) ? (int) $jupiterMoonSideEvent['duration_minutes'] : null,
|
|
'type' => 'jupiter_moons_one_side',
|
|
];
|
|
}
|
|
|
|
foreach ($moonPlanetApproaches as $approachEvent) {
|
|
if (!is_array($approachEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$distanceText = isset($approachEvent['separation_deg'])
|
|
? number_format((float) $approachEvent['separation_deg'], 2, ',', '') . ' Grad'
|
|
: '';
|
|
|
|
$monthEventList[] = [
|
|
'event' => trim((string) ($approachEvent['label'] ?? 'Mond nahe Planet') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
|
'date' => (string) ($approachEvent['local_date'] ?? ''),
|
|
'time' => (string) ($approachEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
|
|
'type' => 'moon_planet',
|
|
];
|
|
}
|
|
|
|
foreach ($moonDeepSkyApproaches as $approachEvent) {
|
|
if (!is_array($approachEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$distanceText = isset($approachEvent['separation_deg'])
|
|
? number_format((float) ($approachEvent['separation_deg']), 2, ',', '') . ' Grad'
|
|
: '';
|
|
|
|
$monthEventList[] = [
|
|
'event' => trim((string) ($approachEvent['label'] ?? 'Mond nahe Sternhaufen') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
|
'date' => (string) ($approachEvent['local_date'] ?? ''),
|
|
'time' => (string) ($approachEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
|
|
'type' => 'moon_deep_sky',
|
|
];
|
|
}
|
|
|
|
foreach ($planetBrightStarApproaches as $approachEvent) {
|
|
if (!is_array($approachEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$distanceText = isset($approachEvent['separation_deg'])
|
|
? number_format((float) ($approachEvent['separation_deg']), 2, ',', '') . ' Grad'
|
|
: '';
|
|
|
|
$monthEventList[] = [
|
|
'event' => trim((string) ($approachEvent['label'] ?? 'Planet nahe hellem Stern') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
|
'date' => (string) ($approachEvent['local_date'] ?? ''),
|
|
'time' => (string) ($approachEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($approachEvent['local_iso'] ?? ''),
|
|
'type' => 'planet_bright_star',
|
|
];
|
|
}
|
|
|
|
foreach ($planetConjunctions as $conjunctionEvent) {
|
|
if (!is_array($conjunctionEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$distanceText = isset($conjunctionEvent['separation_deg'])
|
|
? number_format((float) $conjunctionEvent['separation_deg'], 2, ',', '') . ' Grad'
|
|
: '';
|
|
|
|
$monthEventList[] = [
|
|
'event' => trim((string) ($conjunctionEvent['label'] ?? 'Planetenkonjunktion') . ($distanceText !== '' ? ' (' . $distanceText . ')' : '')),
|
|
'date' => (string) ($conjunctionEvent['local_date'] ?? ''),
|
|
'time' => (string) ($conjunctionEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($conjunctionEvent['local_iso'] ?? ''),
|
|
'type' => 'planet_conjunction',
|
|
];
|
|
}
|
|
|
|
foreach ($mercuryGoodVisibilityEvents as $visibilityEvent) {
|
|
if (!is_array($visibilityEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$monthEventList[] = [
|
|
'event' => (string) ($visibilityEvent['label'] ?? 'Gute Merkur-Sichtbarkeit'),
|
|
'date' => (string) ($visibilityEvent['local_date'] ?? ''),
|
|
'time' => (string) ($visibilityEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($visibilityEvent['local_iso'] ?? ''),
|
|
'end_local_iso' => (string) ($visibilityEvent['end_local_iso'] ?? ''),
|
|
'duration_minutes' => isset($visibilityEvent['duration_minutes']) ? (int) $visibilityEvent['duration_minutes'] : null,
|
|
'type' => 'mercury_good_visibility',
|
|
];
|
|
}
|
|
|
|
foreach ($planetParadeEvents as $paradeEvent) {
|
|
if (!is_array($paradeEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$monthEventList[] = [
|
|
'event' => (string) ($paradeEvent['label'] ?? 'Planetenparade'),
|
|
'date' => (string) ($paradeEvent['local_date'] ?? ''),
|
|
'time' => (string) ($paradeEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($paradeEvent['local_iso'] ?? ''),
|
|
'end_local_iso' => (string) ($paradeEvent['end_local_iso'] ?? ''),
|
|
'duration_minutes' => isset($paradeEvent['duration_minutes']) ? (int) $paradeEvent['duration_minutes'] : null,
|
|
'type' => 'planet_parade',
|
|
];
|
|
}
|
|
|
|
foreach ($planetConstellationChangeEvents as $changeEvent) {
|
|
if (!is_array($changeEvent)) {
|
|
continue;
|
|
}
|
|
|
|
$planetLabel = trim((string) ($changeEvent['planet_label'] ?? 'Planet'));
|
|
$fromConstellationName = trim((string) ($changeEvent['from_constellation_name'] ?? ''));
|
|
$toConstellationName = trim((string) ($changeEvent['to_constellation_name'] ?? ''));
|
|
$changeLabel = (string) ($changeEvent['label'] ?? 'Planet wechselt Sternbild');
|
|
if ($fromConstellationName !== '' && $toConstellationName !== '') {
|
|
$changeLabel = sprintf(
|
|
'%s wechselt vom Sternbild %s in das Sternbild %s',
|
|
$planetLabel,
|
|
$fromConstellationName,
|
|
$toConstellationName
|
|
);
|
|
}
|
|
|
|
$monthEventList[] = [
|
|
'event' => $changeLabel,
|
|
'date' => (string) ($changeEvent['local_date'] ?? ''),
|
|
'time' => (string) ($changeEvent['local_time'] ?? ''),
|
|
'local_iso' => (string) ($changeEvent['local_iso'] ?? ''),
|
|
'type' => 'planet_constellation_change',
|
|
];
|
|
}
|
|
|
|
foreach ($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'] ?? ''))
|
|
);
|
|
|
|
$monthNarrativeText = monthForecastBuildNarrativeText($monthEventList, $selectedMonth, $months[$selectedMonth], $selectedYear);
|
|
|
|
$observationTipInsertSql = buildObservationTipInsertSql($monthEventList, new DateTimeZone($chartTimezone));
|
|
$observationTipCalendarIcs = buildObservationTipCalendarIcs($monthEventList, new DateTimeZone($chartTimezone));
|
|
|
|
$chartConfig = [
|
|
'monthName' => $months[$selectedMonth],
|
|
'year' => $selectedYear,
|
|
'month' => $selectedMonth,
|
|
'location' => [
|
|
'name' => $chartLocationName,
|
|
'latitude' => $chartLatitude,
|
|
'longitude' => $chartLongitude,
|
|
'elevation' => $chartElevation,
|
|
'timezone' => $chartTimezone,
|
|
],
|
|
'localTimeLabel' => $chartLocalTime->format('d.m.Y H:i') . ' ' . $chartTimezone,
|
|
'utcTimeIso' => $chartUtcTime->format(DateTimeInterface::ATOM),
|
|
'monthSkyHour' => $monthTime['hour'],
|
|
'monthSkyMinute' => $monthTime['minute'],
|
|
'starLimit' => $starDisplayMagnitudeLimit,
|
|
'starLabelLimit' => $starLabelMagnitudeLimit,
|
|
'messierLimit' => $messierDisplayMagnitudeLimit,
|
|
];
|
|
|
|
$chartConfigJson = json_encode($chartConfig, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$starDataJson = json_encode($starData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$lineStarDataJson = json_encode($lineStarData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$constellationLinesJson = json_encode($constellationLines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$messierDataJson = json_encode($messierData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$planetDataJson = json_encode($planetData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$moonDataJson = json_encode($moonData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$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="monatsnarrativ-card">
|
|
<div class="monatskarte-header">
|
|
<div>
|
|
<h3>Monat Im Ueberblick</h3>
|
|
<p><?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></p>
|
|
</div>
|
|
</div>
|
|
<div class="monatskarte-footer">
|
|
<p class="monatsnarrativ-text"><?= htmlspecialchars($monthNarrativeText, ENT_QUOTES, 'UTF-8') ?></p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="monatskarte-card">
|
|
<div class="monatskarte-header">
|
|
<div>
|
|
<h3>Zenitprojektion <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
|
|
<p id="monatskarteMeta">
|
|
<?= htmlspecialchars($chartConfig['localTimeLabel'], ENT_QUOTES, 'UTF-8') ?>,
|
|
<?= htmlspecialchars($chartLocationName, ENT_QUOTES, 'UTF-8') ?>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="monatskarte-stage">
|
|
<canvas id="monatskarteCanvas" width="900" height="900" aria-label="Sternkarte in Zenitprojektion"></canvas>
|
|
</div>
|
|
|
|
<div class="monatskarte-footer">
|
|
<p id="monatskarteStatus">Bereite Sternkarte vor...</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="monatskarte-card">
|
|
<div class="monatskarte-header">
|
|
<div>
|
|
<h3>Südhimmel <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
|
|
<p id="monatskarteSouthMeta">
|
|
<?= htmlspecialchars($chartConfig['localTimeLabel'], ENT_QUOTES, 'UTF-8') ?>,
|
|
<?= htmlspecialchars($chartLocationName, ENT_QUOTES, 'UTF-8') ?>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="monatskarte-stage monatskarte-stage-south">
|
|
<canvas id="monatskarteSouthCanvas" width="1200" height="720" aria-label="Südhimmel-Projektion"></canvas>
|
|
</div>
|
|
|
|
<div class="monatskarte-footer">
|
|
<p id="monatskarteSouthStatus">Bereite Südhimmelkarte vor...</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="monatskarte-card">
|
|
<div class="monatskarte-header">
|
|
</div>
|
|
|
|
<div class="monatskarte-stage monatsphase-stage">
|
|
<div class="monatsphase-strip-wrap">
|
|
<img id="monatsphasenImage" alt="Mondphasen des Monats in einer Zeile">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="monatskarte-footer">
|
|
<p id="monatsphasenStatus">Bereite Mondphasenleiste vor...</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="monatskarte-card">
|
|
<div class="monatskarte-header">
|
|
<div>
|
|
<h3>Ereignisse <?= htmlspecialchars($months[$selectedMonth] . ' ' . (string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h3>
|
|
<p>Chronologische Monatsliste 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,
|
|
.monatsnarrativ-card,
|
|
.monatskarte-card {
|
|
border: 1px solid rgba(201, 168, 76, 0.14);
|
|
border-radius: calc(var(--radius) + 4px);
|
|
background:
|
|
radial-gradient(circle at top left, rgba(74, 158, 255, 0.1), transparent 34%),
|
|
linear-gradient(180deg, rgba(10, 15, 30, 0.96) 0%, rgba(7, 11, 22, 0.98) 100%);
|
|
}
|
|
|
|
.monatsvorhersage-form {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 16px;
|
|
padding: 22px 20px;
|
|
}
|
|
|
|
.monatsvorhersage-form-header,
|
|
.monatskarte-header,
|
|
.monatskarte-footer {
|
|
padding: 22px 20px;
|
|
}
|
|
|
|
.monatsvorhersage-form-header {
|
|
grid-column: 1 / -1;
|
|
padding: 0;
|
|
}
|
|
|
|
.monatsvorhersage-form-header h3,
|
|
.monatskarte-header h3 {
|
|
margin: 0 0 8px;
|
|
}
|
|
|
|
.monatsvorhersage-form-header p,
|
|
.monatskarte-header p,
|
|
.monatskarte-footer p {
|
|
margin: 0;
|
|
color: rgba(222, 232, 255, 0.86);
|
|
line-height: 1.7;
|
|
}
|
|
|
|
.monatsvorhersage-field {
|
|
display: grid;
|
|
gap: 8px;
|
|
}
|
|
|
|
.monatsvorhersage-field span {
|
|
color: rgba(235, 242, 255, 0.95);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.monatsvorhersage-field select {
|
|
width: 100%;
|
|
min-height: 46px;
|
|
padding: 10px 12px;
|
|
border: 1px solid rgba(108, 145, 220, 0.28);
|
|
border-radius: 12px;
|
|
background: rgba(8, 14, 28, 0.92);
|
|
color: rgba(235, 242, 255, 0.95);
|
|
font: inherit;
|
|
}
|
|
|
|
.monatsvorhersage-actions {
|
|
grid-column: 1 / -1;
|
|
}
|
|
|
|
.monatsvorhersage-actions button {
|
|
min-height: 44px;
|
|
padding: 10px 16px;
|
|
border: 1px solid rgba(150, 190, 255, 0.78);
|
|
border-radius: 999px;
|
|
background: rgba(38, 66, 122, 0.92);
|
|
color: rgba(245, 248, 255, 0.96);
|
|
font: inherit;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.monatskarte-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 16px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.monatskarte-export {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.monatskarte-export-button {
|
|
min-height: 40px;
|
|
padding: 8px 14px;
|
|
border: 1px solid rgba(150, 190, 255, 0.72);
|
|
border-radius: 999px;
|
|
background: rgba(24, 43, 83, 0.92);
|
|
color: rgba(245, 248, 255, 0.96);
|
|
font: inherit;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.monatskarte-sql-export {
|
|
display: grid;
|
|
gap: 12px;
|
|
margin-bottom: 18px;
|
|
padding: 16px;
|
|
border: 1px solid rgba(108, 145, 220, 0.22);
|
|
border-radius: 16px;
|
|
background: rgba(6, 11, 22, 0.72);
|
|
}
|
|
|
|
.monatskarte-sql-label {
|
|
color: rgba(235, 242, 255, 0.95);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.monatskarte-sql-export textarea {
|
|
width: 100%;
|
|
min-height: 220px;
|
|
padding: 12px 14px;
|
|
border: 1px solid rgba(108, 145, 220, 0.28);
|
|
border-radius: 12px;
|
|
background: rgba(3, 7, 14, 0.96);
|
|
color: rgba(233, 239, 250, 0.94);
|
|
font: 13px/1.5 Consolas, Monaco, monospace;
|
|
resize: vertical;
|
|
}
|
|
|
|
.monatskarte-sql-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
|
|
.monatsnarrativ-text {
|
|
font-size: 1.02rem;
|
|
white-space: pre-line;
|
|
}
|
|
|
|
.monatskarte-stage {
|
|
padding: 0 20px 20px;
|
|
}
|
|
|
|
.monatskarte-stage canvas {
|
|
display: block;
|
|
width: min(100%, 900px);
|
|
aspect-ratio: 1;
|
|
margin: 0 auto;
|
|
border-radius: 20px;
|
|
background: #010203;
|
|
box-shadow: inset 0 0 0 1px rgba(140, 165, 220, 0.12);
|
|
}
|
|
|
|
.monatskarte-stage-south canvas {
|
|
width: min(100%, 1100px);
|
|
aspect-ratio: 5 / 3;
|
|
}
|
|
|
|
.monatsphase-stage {
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.monatsphase-strip-wrap {
|
|
width: 100%;
|
|
overflow-x: auto;
|
|
padding-bottom: 4px;
|
|
}
|
|
|
|
#monatsphasenImage {
|
|
display: block;
|
|
width: min(100%, 1200px);
|
|
min-width: 920px;
|
|
height: auto;
|
|
margin: 0 auto;
|
|
border-radius: 20px;
|
|
background:
|
|
radial-gradient(circle at 50% 0%, rgba(92, 122, 186, 0.16), transparent 42%),
|
|
linear-gradient(180deg, rgba(3, 6, 12, 0.98) 0%, rgba(5, 9, 16, 0.99) 100%);
|
|
box-shadow: inset 0 0 0 1px rgba(140, 165, 220, 0.12);
|
|
}
|
|
|
|
.monatskarte-footer {
|
|
padding-top: 0;
|
|
}
|
|
|
|
@media (max-width: 820px) {
|
|
.monatsvorhersage-form {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.monatskarte-header {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script src="<?= htmlspecialchars($publicBasePath . 'js/astronomy.browser.min.js', ENT_QUOTES, 'UTF-8') ?>"></script>
|
|
<script>
|
|
const MONTH_CHART_CONFIG = <?= $chartConfigJson ?: 'null' ?>;
|
|
const MONTH_CHART_STARS = <?= $starDataJson ?: '[]' ?>;
|
|
const MONTH_CHART_LINE_STARS = <?= $lineStarDataJson ?: '[]' ?>;
|
|
const MONTH_CHART_CONSTELLATIONS = <?= $constellationLinesJson ?: '[]' ?>;
|
|
const MONTH_CHART_MESSIER = <?= $messierDataJson ?: '[]' ?>;
|
|
const MONTH_CHART_PLANETS = <?= $planetDataJson ?: '[]' ?>;
|
|
const MONTH_CHART_MOON = <?= $moonDataJson ?: 'null' ?>;
|
|
const MONTH_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'; ?>
|