diff --git a/public/monatsvorhersage_old.php b/public/monatsvorhersage_old.php index 26571b0..1015edf 100644 --- a/public/monatsvorhersage_old.php +++ b/public/monatsvorhersage_old.php @@ -12,10 +12,12 @@ $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'); $starDatabaseMagnitudeLimit = 6.0; -$starDisplayMagnitudeLimit = 4.6; +$starDisplayMagnitudeLimit = 4.4; $starLabelMagnitudeLimit = 2.0; $messierDatabaseMagnitudeLimit = 4.0; -$messierDisplayMagnitudeLimit = 4.0; +$messierDisplayMagnitudeLimit = 1.0; +$moonPlanetMaxSeparationDeg = 1.5; +$moonOccultationMaxMag = 2.0; $years = range($currentYear - 2, $currentYear + 5); $months = [ @@ -51,8 +53,33 @@ $starData = []; $lineStarData = []; $constellationLines = []; $messierData = []; +$planetData = []; +$moonData = null; +$moonPhaseEvents = []; +$moonPlanetApproaches = []; +$moonOccultations = []; +$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'; + } +} + try { $dbConfig = require __DIR__ . '/../config/database.php'; $dsn = sprintf( @@ -230,6 +257,221 @@ $chartLocalTime = new DateTimeImmutable( ); $chartUtcTime = $chartLocalTime->setTimezone(new DateTimeZone('UTC')); +$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; +} + +$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'] : []; +} + +$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'] : []; +} + +$moonOccultationStars = []; +try { + if (isset($pdo)) { + $stmtOccultStars = $pdo->prepare( + "SELECT hip, proper, bayer, con, mag, rarad, decrad, dist + FROM star_hipparcos + WHERE rarad IS NOT NULL + AND decrad IS NOT NULL + AND mag IS NOT NULL + AND mag <= :max_mag + ORDER BY mag ASC, proper ASC, bayer ASC, hip ASC" + ); + $stmtOccultStars->execute([':max_mag' => $moonOccultationMaxMag]); + + foreach ($stmtOccultStars->fetchAll() as $row) { + $moonOccultationStars[] = [ + 'hip' => isset($row['hip']) ? (int) $row['hip'] : 0, + 'label' => buildMonthOccultationStarLabel($row), + 'constellation' => trim((string) ($row['con'] ?? '')), + 'mag' => isset($row['mag']) ? (float) $row['mag'] : 99.0, + 'ra' => isset($row['rarad']) ? (float) $row['rarad'] * (12 / M_PI) : 0.0, + 'dec' => isset($row['decrad']) ? (float) $row['decrad'] * (180 / M_PI) : 0.0, + 'distLy' => isset($row['dist']) && $row['dist'] !== null && (float) $row['dist'] > 0.0 + ? (float) $row['dist'] * 3.26156 + : 1000.0, + ]; + } + } +} catch (Throwable $e) { +} + +if ($moonOccultationStars) { + $tempOccultationFile = tempnam(sys_get_temp_dir(), 'month_occult_'); + if ($tempOccultationFile !== false) { + try { + file_put_contents($tempOccultationFile, json_encode($moonOccultationStars, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)); + $moonOccultOutput = []; + $moonOccultExitCode = 0; + $moonOccultArgs = [ + $pythonScriptPath, + 'moon_star_occultations_for_month', + (string) $chartLatitude, + (string) $chartLongitude, + (string) $chartElevation, + (string) $selectedYear, + (string) $selectedMonth, + $chartTimezone, + $tempOccultationFile, + ]; + $moonOccultCommand = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $moonOccultArgs)) . ' 2>&1'; + exec($moonOccultCommand, $moonOccultOutput, $moonOccultExitCode); + $moonOccultRaw = trim(implode("\n", $moonOccultOutput)); + $moonOccultDecoded = json_decode($moonOccultRaw, true); + if (is_array($moonOccultDecoded) && ($moonOccultDecoded['ok'] ?? false)) { + $moonOccultations = is_array($moonOccultDecoded['results'] ?? null) ? $moonOccultDecoded['results'] : []; + } + } catch (Throwable $e) { + } finally { + if (is_file($tempOccultationFile)) { + @unlink($tempOccultationFile); + } + } + } +} + +foreach ($moonPhaseEvents as $phaseEvent) { + if (!is_array($phaseEvent)) { + continue; + } + + $monthEventList[] = [ + 'event' => (string) ($phaseEvent['label'] ?? ''), + 'date' => (string) ($phaseEvent['local_date'] ?? ''), + 'time' => (string) ($phaseEvent['local_time'] ?? ''), + 'local_iso' => (string) ($phaseEvent['local_iso'] ?? ''), + 'type' => 'moon_phase', + ]; +} + +foreach ($moonPlanetApproaches as $approachEvent) { + if (!is_array($approachEvent)) { + continue; + } + + $distanceText = isset($approachEvent['separation_deg']) + ? number_format((float) $approachEvent['separation_deg'], 2, ',', '') . '°' + : ''; + + $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 ($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, ',', '') : ''; + $monthEventList[] = [ + 'event' => 'Mond bedeckt ' . $starLabel . ($starMag !== '' ? ' (mag ' . $starMag . ')' : ''), + 'date' => (string) ($maximum['local_date'] ?? ''), + 'time' => (string) ($maximum['local_time'] ?? ''), + 'local_iso' => (string) ($maximum['local_iso'] ?? ''), + 'type' => 'moon_occultation', + ]; +} + +usort( + $monthEventList, + static fn (array $a, array $b): int => strcmp((string) ($a['local_iso'] ?? ''), (string) ($b['local_iso'] ?? '')) +); + $chartConfig = [ 'monthName' => $months[$selectedMonth], 'year' => $selectedYear, @@ -253,6 +495,8 @@ $starDataJson = json_encode($starData, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_S $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); $starDataErrorJson = json_encode($starDataError, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); ?> @@ -330,6 +574,90 @@ $starDataErrorJson = json_encode($starDataError, JSON_UNESCAPED_UNICODE | JSON_U

Bereite Südhimmelkarte vor...

+ +
+
+
+

Mondphasen

+

Chronologisch für

+
+
+ + +
+ +
+
+
+

Mond nahe Planeten

+

Maximalabstand °

+
+
+ + +
+ +
+
+
+

Sternbedeckungen

+

Bis Grenzgröße

+
+
+ + +
@@ -471,6 +799,8 @@ const MONTH_CHART_STARS = ; const MONTH_CHART_LINE_STARS = ; const MONTH_CHART_CONSTELLATIONS = ; const MONTH_CHART_MESSIER = ; +const MONTH_CHART_PLANETS = ; +const MONTH_CHART_MOON = ; const MONTH_CHART_ERROR = ; (function () { @@ -636,6 +966,162 @@ const MONTH_CHART_ERROR = ; }; } + 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); @@ -933,6 +1419,168 @@ const MONTH_CHART_ERROR = ; 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)) { @@ -1099,6 +1747,8 @@ const MONTH_CHART_ERROR = ; 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; @@ -1248,6 +1898,36 @@ const MONTH_CHART_ERROR = ; }; } + 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); @@ -1414,6 +2094,158 @@ const MONTH_CHART_ERROR = ; 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 }; @@ -1734,6 +2566,8 @@ const MONTH_CHART_ERROR = ; 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; } diff --git a/public/py/api.py b/public/py/api.py index c8eff1d..bcd331f 100644 --- a/public/py/api.py +++ b/public/py/api.py @@ -384,6 +384,289 @@ def action_month_sky_context(args: list[str]) -> dict: } +def action_moon_phases_for_month(args: list[str]) -> dict: + if len(args) != 3: + fail( + "Aktion moon_phases_for_month erwartet 3 Argumente: year month timezone", + extra={"argv": args}, + ) + + try: + year = int(args[0]) + month = int(args[1]) + except ValueError as exc: + fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args}) + + if month < 1 or month > 12: + fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month}) + + timezone_name = args[2] + try: + tz = ZoneInfo(timezone_name) + except Exception as exc: + fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)}) + + local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz) + if month == 12: + local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz) + else: + local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz) + + search_start = dt_to_time(local_start.astimezone(timezone.utc) - timedelta(days=3)) + search_limit_days = ((local_end - local_start).total_seconds() / 86400.0) + 10.0 + + phase_defs = [ + (0.0, "Neumond"), + (90.0, "Erstes Viertel"), + (180.0, "Vollmond"), + (270.0, "Letztes Viertel"), + ] + + phases = [] + for target_lon, label in phase_defs: + probe = search_start + found = None + + for _ in range(3): + result = astronomy.SearchMoonPhase(target_lon, probe, search_limit_days) + if result is None: + break + + dt_utc = time_to_datetime(result) + local_dt = dt_utc.astimezone(tz) + if local_dt.year == year and local_dt.month == month: + found = { + "label": label, + "utc_iso": dt_utc.isoformat().replace("+00:00", "Z"), + "local_iso": local_dt.isoformat(), + "local_date": local_dt.strftime("%d.%m.%Y"), + "local_time": local_dt.strftime("%H:%M"), + "sort_iso": local_dt.isoformat(), + } + break + + probe = dt_to_time(dt_utc + timedelta(days=1)) + + if found is not None: + phases.append(found) + + phases.sort(key=lambda item: item["sort_iso"]) + for item in phases: + item.pop("sort_iso", None) + + return { + "ok": True, + "action": "moon_phases_for_month", + "selected": { + "year": year, + "month": month, + "timezone": timezone_name, + }, + "phases": phases, + } + + +def moon_planet_separation_deg( + body: astronomy.Body, + observer: astronomy.Observer, + dt_utc: datetime, +) -> float: + time_value = dt_to_time(dt_utc) + moon_eq = astronomy.Equator(astronomy.Body.Moon, time_value, observer, True, True) + body_eq = astronomy.Equator(body, time_value, observer, True, True) + return spherical_separation_deg(float(moon_eq.ra), float(moon_eq.dec), float(body_eq.ra), float(body_eq.dec)) + + +def refine_minimum_separation( + body: astronomy.Body, + observer: astronomy.Observer, + left_utc: datetime, + right_utc: datetime, +) -> tuple[datetime, float]: + left = left_utc + right = right_utc + + for _ in range(32): + span = (right - left) / 3 + m1 = left + span + m2 = right - span + f1 = moon_planet_separation_deg(body, observer, m1) + f2 = moon_planet_separation_deg(body, observer, m2) + if f1 <= f2: + right = m2 + else: + left = m1 + + best = left + (right - left) / 2 + return best, moon_planet_separation_deg(body, observer, best) + + +def action_moon_planet_approaches(args: list[str]) -> dict: + if len(args) != 7: + fail( + "Aktion moon_planet_approaches erwartet 7 Argumente: latitude longitude elevation year month timezone max_sep_deg", + extra={"argv": args}, + ) + + latitude = parse_float(args[0], "Latitude") + longitude = parse_float(args[1], "Longitude") + elevation = parse_float(args[2], "Elevation") + + try: + year = int(args[3]) + month = int(args[4]) + except ValueError as exc: + fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args}) + + if month < 1 or month > 12: + fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month}) + + timezone_name = args[5] + max_sep_deg = parse_float(args[6], "Maximalabstand") + + try: + tz = ZoneInfo(timezone_name) + except Exception as exc: + fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)}) + + observer = astronomy.Observer(latitude, longitude, elevation) + local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz) + if month == 12: + local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz) + else: + local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz) + + utc_start = local_start.astimezone(timezone.utc) + utc_end = local_end.astimezone(timezone.utc) + scan_step = timedelta(hours=1) + coarse_threshold = max_sep_deg + 1.0 + + planet_defs = [ + ("Merkur", astronomy.Body.Mercury, "mercury"), + ("Venus", astronomy.Body.Venus, "venus"), + ("Mars", astronomy.Body.Mars, "mars"), + ("Jupiter", astronomy.Body.Jupiter, "jupiter"), + ("Saturn", astronomy.Body.Saturn, "saturn"), + ("Uranus", astronomy.Body.Uranus, "uranus"), + ("Neptun", astronomy.Body.Neptune, "neptune"), + ] + + approaches = [] + for label, body, key in planet_defs: + samples: list[tuple[datetime, float]] = [] + current = utc_start + while current <= utc_end: + samples.append((current, moon_planet_separation_deg(body, observer, current))) + current += scan_step + if samples[-1][0] < utc_end: + samples.append((utc_end, moon_planet_separation_deg(body, observer, utc_end))) + + seen_ranges: list[tuple[datetime, datetime]] = [] + for index in range(1, len(samples) - 1): + prev_t, prev_sep = samples[index - 1] + curr_t, curr_sep = samples[index] + next_t, next_sep = samples[index + 1] + + if curr_sep > coarse_threshold: + continue + if curr_sep > prev_sep or curr_sep > next_sep: + continue + + left = max(utc_start, curr_t - scan_step) + right = min(utc_end, curr_t + scan_step) + + if any(not (right <= seen_left or left >= seen_right) for seen_left, seen_right in seen_ranges): + continue + + min_time_utc, min_sep = refine_minimum_separation(body, observer, left, right) + local_dt = min_time_utc.astimezone(tz) + if local_dt.year != year or local_dt.month != month: + continue + if min_sep > max_sep_deg: + continue + + seen_ranges.append((left, right)) + approaches.append({ + "planet_key": key, + "planet_label": label, + "label": f"Mond nahe {label}", + "separation_deg": float(min_sep), + "utc_iso": min_time_utc.isoformat().replace("+00:00", "Z"), + "local_iso": local_dt.isoformat(), + "local_date": local_dt.strftime("%d.%m.%Y"), + "local_time": local_dt.strftime("%H:%M"), + }) + + approaches.sort(key=lambda item: item["local_iso"]) + + return { + "ok": True, + "action": "moon_planet_approaches", + "selected": { + "year": year, + "month": month, + "timezone": timezone_name, + "max_separation_deg": max_sep_deg, + }, + "approaches": approaches, + } + + +def action_moon_star_occultations_for_month(args: list[str]) -> dict: + if len(args) != 7: + fail( + "Aktion moon_star_occultations_for_month erwartet 7 Argumente: latitude longitude elevation year month timezone star_file", + extra={"argv": args}, + ) + + latitude = parse_float(args[0], "Latitude") + longitude = parse_float(args[1], "Longitude") + elevation = parse_float(args[2], "Elevation") + + try: + year = int(args[3]) + month = int(args[4]) + except ValueError as exc: + fail("Jahr oder Monat sind ungueltig.", extra={"details": str(exc), "argv": args}) + + if month < 1 or month > 12: + fail("Monat muss zwischen 1 und 12 liegen.", extra={"month": month}) + + timezone_name = args[5] + star_file_path = args[6] + if not star_file_path: + fail("Sterndatei fehlt.") + + try: + tz = ZoneInfo(timezone_name) + except Exception as exc: + fail("Zeitzone ist ungueltig oder auf dem Server nicht verfuegbar.", extra={"details": str(exc)}) + + observer = astronomy.Observer(latitude, longitude, elevation) + star_rows = load_star_file(star_file_path) + + local_start = datetime(year, month, 1, 0, 0, 0, tzinfo=tz) + if month == 12: + local_end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=tz) + else: + local_end = datetime(year, month + 1, 1, 0, 0, 0, tzinfo=tz) + + results, debug_profile = compute_star_occultation(star_rows, observer, local_start, local_end, tz) + + return { + "ok": True, + "action": "moon_star_occultations_for_month", + "selected": { + "year": year, + "month": month, + "timezone": timezone_name, + }, + "star_count": len(star_rows), + "debug": debug_profile, + "results": results, + } + + def classify_visibility(sun_alt_deg: float) -> dict: if sun_alt_deg > -5: return {"key": "day", "label": "Tag"} @@ -1385,7 +1668,7 @@ def action_satellite_passes(args: list[str]) -> dict: def main() -> None: if len(sys.argv) < 2: - fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["sun_moon_rise_set", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context"]}) + fail("Es wurde keine Aktion uebergeben.", extra={"available_actions": ["sun_moon_rise_set", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "moon_planet_approaches", "moon_star_occultations_for_month"]}) action = sys.argv[1] args = sys.argv[2:] @@ -1420,7 +1703,22 @@ def main() -> None: print(json.dumps(result, ensure_ascii=False)) return - fail("Unbekannte Aktion.", extra={"action": action, "available_actions": ["sun_moon_rise_set", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context"]}) + if action == "moon_phases_for_month": + result = action_moon_phases_for_month(args) + print(json.dumps(result, ensure_ascii=False)) + return + + if action == "moon_planet_approaches": + result = action_moon_planet_approaches(args) + print(json.dumps(result, ensure_ascii=False)) + return + + if action == "moon_star_occultations_for_month": + result = action_moon_star_occultations_for_month(args) + print(json.dumps(result, ensure_ascii=False)) + return + + fail("Unbekannte Aktion.", extra={"action": action, "available_actions": ["sun_moon_rise_set", "moon_star_occultations", "moon_phase_details", "satellite_passes", "planet_rise_set", "month_sky_context", "moon_phases_for_month", "moon_planet_approaches", "moon_star_occultations_for_month"]}) if __name__ == "__main__":