diff --git a/public/monatsvorhersage.php b/public/monatsvorhersage.php index 37082fe..57d24cc 100644 --- a/public/monatsvorhersage.php +++ b/public/monatsvorhersage.php @@ -14,6 +14,7 @@ $selectedMonth = isset($_GET['month']) ? max(1, min(12, (int) $_GET['month'])) : $selectedWeek = isset($_GET['week']) ? max(1, min(53, (int) $_GET['week'])) : (int) date('W'); $selectedWeekYear = isset($_GET['week_year']) ? (int) $_GET['week_year'] : $currentYear; $shouldRunForecast = isset($_GET['run']) && $_GET['run'] === '1'; +$includeComets = isset($_GET['include_comets']) && $_GET['include_comets'] === '1'; if (!isset($_SESSION['observation_tip_insert_token']) || !is_string($_SESSION['observation_tip_insert_token'])) { $_SESSION['observation_tip_insert_token'] = bin2hex(random_bytes(16)); } @@ -45,10 +46,10 @@ $observationTipInsertToken = $_SESSION['observation_tip_insert_token']; // - Zusammengeführte Monatsereignisliste für Übersicht, SQL-Export und iCal // ------------------------------------------------------------------------- -// Grenzwerte fuer Sternkarte und Objektanzeige +// Grenzwerte für Sternkarte und Objektanzeige // ------------------------------------------------------------------------- // Maximale Helligkeit (Magnitude) der Sterne, die aus der Datenbank geladen -// werden. Hoehere Werte laden mehr, aber auch schwaechere Sterne. +// werden. Höhere Werte laden mehr, aber auch schwächere Sterne. $starDatabaseMagnitudeLimit = 6.0; // Maximale Helligkeit der Sterne, die auf der Monats-Sternkarte sichtbar @@ -67,95 +68,95 @@ $messierDatabaseMagnitudeLimit = 2.0; $messierDisplayMagnitudeLimit = 1.0; // ------------------------------------------------------------------------- -// Grenzwerte fuer Monatsereignisse +// Grenzwerte für Monatsereignisse // ------------------------------------------------------------------------- -// Maximaler Winkelabstand fuer Begegnungen zwischen Mond und Planeten. +// Maximaler Winkelabstand für Begegnungen zwischen Mond und Planeten. $moonPlanetMaxSeparationDeg = 1.5; -// Maximaler Winkelabstand fuer Begegnungen zwischen Mond und ausgewaehlten +// Maximaler Winkelabstand für Begegnungen zwischen Mond und ausgewählten // Sternhaufen wie Plejaden oder Praesepe. $moonDeepSkyMaxSeparationDeg = 1.0; -// Maximaler Winkelabstand fuer Begegnungen zwischen hellen Planeten und -// ausgewaehlten hellen Sternen wie Regulus oder Spica. +// Maximaler Winkelabstand für Begegnungen zwischen hellen Planeten und +// ausgewählten hellen Sternen wie Regulus oder Spica. $planetBrightStarMaxSeparationDeg = 1.0; -// Maximaler Winkelabstand fuer Planet-Planet-Konjunktionen. +// Maximaler Winkelabstand für Planet-Planet-Konjunktionen. $planetConjunctionMaxSeparationDeg = 1.0; -// Nur Sterne bis zu dieser Helligkeit werden fuer Mondbedeckungen -// beruecksichtigt. +// Nur Sterne bis zu dieser Helligkeit werden für Mondbedeckungen +// berücksichtigt. $moonOccultationMaxMag = 2.0; // ------------------------------------------------------------------------- -// Schwellen fuer "Gute Satelliten-Ueberfluege" +// Schwellen für "Gute Satelliten-Überflüge" // ------------------------------------------------------------------------- -// Es wird nur der Favoriten-Satellit des angemeldeten Users geprueft. +// Es wird nur der Favoriten-Satellit des angemeldeten Users geprüft. // -// Ein Ueberflug zaehlt als guter Beobachtungstermin nur dann, wenn er am -// Hoehepunkt mindestens diese Hoehe ueber dem Horizont erreicht. +// Ein Überflug zählt als guter Beobachtungstermin nur dann, wenn er am +// Höhepunkt mindestens diese Höhe über dem Horizont erreicht. $favoriteSatelliteBestPassMinAltitudeDeg = 80.0; -// Gleichzeitig muss die Sonne zum Maximum des Ueberflugs mindestens so weit +// Gleichzeitig muss die Sonne zum Maximum des Überflugs mindestens so weit // unter dem Horizont stehen. -12 Grad entspricht dem Beginn/Ende der -// nautischen Daemmerung und sorgt fuer ausreichend dunklen Himmel. +// nautischen Dämmerung und sorgt für 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 +// Optional können wir die Liste auf die erste Nachthälfte begrenzen. +// "Erste Nachthälfte" ist hier bewusst pragmatisch als lokaler Abend vor +// Mitternacht definiert. Überflüge nach 00:00 Ortszeit werden damit // ausgefiltert. $favoriteSatelliteBestPassOnlyFirstNightHalf = true; -// Optional koennen wir nur besonders helle Ueberfluege anzeigen. +// Optional können wir nur besonders helle Überflüge 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. +// dass nur Überflüge mit Peak-Helligkeit von 0 mag oder heller bleiben. $favoriteSatelliteBestPassMaxPeakMagnitude = 0.0; // ------------------------------------------------------------------------- -// Schwellen fuer "Gute Merkur-Sichtbarkeit" +// Schwellen für "Gute Merkur-Sichtbarkeit" // ------------------------------------------------------------------------- -// Gute Merkur-Sichtbarkeit wird nicht nur ueber Elongation definiert, sondern -// ueber echte Beobachtbarkeit in der Daemmerung. -// Geprueft wird pro Tag: +// Gute Merkur-Sichtbarkeit wird nicht nur über Elongation definiert, sondern +// über echte Beobachtbarkeit in der Dämmerung. +// Geprüft wird pro Tag: // morgens eine feste Zahl Minuten VOR Sonnenaufgang, // abends eine feste Zahl Minuten NACH Sonnenuntergang. $mercuryGoodVisibilitySampleOffsetMinutes = 45; -// Merkur muss zum Pruefzeitpunkt mindestens diese Hoehe ueber dem Horizont +// Merkur muss zum Prüfzeitpunkt mindestens diese Höhe über dem Horizont // erreichen, damit der Tag als gut sichtbar gilt. $mercuryGoodVisibilityMinAltitudeDeg = 7.0; // Gleichzeitig muss die Sonne ausreichend tief unter dem Horizont stehen. -// So vermeiden wir Treffer, bei denen Merkur zwar formal sichtbar waere, aber -// noch zu stark in der hellen Daemmerung untergeht. +// So vermeiden wir Treffer, bei denen Merkur zwar formal sichtbar wäre, aber +// noch zu stark in der hellen Dämmerung untergeht. $mercuryGoodVisibilityMaxSunAltitudeDeg = -3.0; // Erst ab dieser Anzahl aufeinanderfolgender guter Tage erzeugen wir ein -// Monatsereignis. Einzelne Ausreisser-Tage werden dadurch herausgefiltert. +// Monatsereignis. Einzelne Ausreißer-Tage werden dadurch herausgefiltert. $mercuryGoodVisibilityMinConsecutiveDays = 3; // ------------------------------------------------------------------------- -// Schwellen fuer "Planetenparade" +// Schwellen für "Planetenparade" // ------------------------------------------------------------------------- -// Geprueft wird morgens eine feste Zahl Minuten VOR Sonnenaufgang und abends +// Geprüft 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. +// Ein Planet zählt für die Parade nur dann mit, wenn er zum Prüfzeitpunkt +// mindestens diese Höhe über dem Horizont erreicht. $planetParadeMinAltitudeDeg = 7.0; // Gleichzeitig muss die Sonne bereits tief genug unter dem Horizont stehen, // damit die Parade nicht nur rechnerisch, sondern auch praktisch sichtbar ist. $planetParadeMaxSunAltitudeDeg = -4.0; -// Die beteiligten Planeten 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. +// Die beteiligten Planeten müssen innerhalb dieses Azimutbogens liegen. +// So vermeiden wir Treffer, bei denen die Planeten zu weit über den Himmel +// verteilt sind und nicht mehr als zusammenhängende Parade wirken. $planetParadeMaxAzimuthSpanDeg = 120.0; -// Mindestens so viele helle Planeten muessen gleichzeitig sichtbar sein, +// Mindestens so viele helle Planeten müssen gleichzeitig sichtbar sein, // damit wir von einer Planetenparade sprechen. $planetParadeMinPlanetCount = 4; @@ -164,10 +165,10 @@ $planetParadeMinPlanetCount = 4; $planetParadeMinConsecutiveDays = 2; // ------------------------------------------------------------------------- -// Schwellen fuer ausgewaehlte Kometen +// Schwellen für ausgewählte Kometen // ------------------------------------------------------------------------- // Gute Kometensichtbarkeit wird morgens vor Sonnenaufgang und abends nach -// Sonnenuntergang geprueft. +// Sonnenuntergang geprüft. $favoriteCometVisibilitySampleOffsetMinutes = 60; // Der Komet sollte ausreichend hoch stehen, damit die Beobachtung praktisch @@ -177,7 +178,7 @@ $favoriteCometVisibilityMinAltitudeDeg = 10.0; // Die Sonne muss tief genug unter dem Horizont stehen. $favoriteCometVisibilityMaxSunAltitudeDeg = -8.0; -// Optional koennen wir eine grobe Helligkeitsschwelle setzen, damit nur +// Optional können wir eine grobe Helligkeitsschwelle setzen, damit nur // realistisch interessante Fenster erscheinen. $favoriteCometVisibilityMaxMagnitude = 10.0; @@ -273,6 +274,7 @@ $planetDataJson = '[]'; $moonDataJson = 'null'; $monthChartCometsJson = '[]'; $moonPhaseEventsJson = '[]'; +$monthEventListJson = '[]'; $starDataErrorJson = 'null'; $observationTipCalendarIcsJson = '""'; $observationTipCalendarFilenameJson = json_encode( @@ -1023,7 +1025,7 @@ if (!function_exists('monthForecastBuildNarrativeText')) { } if ($moonSentences !== []) { - $paragraphs[] = implode(' ', $moonSentences); + $paragraphs[] = implode("\n", $moonSentences); } } @@ -1311,42 +1313,45 @@ if (!function_exists('monthForecastBuildNarrativeText')) { $paragraphs[] = $eclipseIntro . ' In diesem Monat stechen ' . $describeEvents($eclipseEvents) . ' hervor.'; } - $meteorEvents = $findEvents( + $meteorCandidates = array_values(array_filter( $filteredEvents, static fn (array $event): bool => (string) ($event['type'] ?? '') === 'meteor_shower_peak' - && ( - (int) ($event['zhr_min_estimate'] ?? 0) >= 20 - || (int) ($event['zhr_max_estimate'] ?? 0) >= 30 - ), - 2 - ); + && max( + (int) ($event['zhr_min_estimate'] ?? 0), + (int) ($event['zhr_max_estimate'] ?? 0) + ) >= 10 + )); + usort($meteorCandidates, static function (array $left, array $right): int { + $leftRate = max((int) ($left['zhr_min_estimate'] ?? 0), (int) ($left['zhr_max_estimate'] ?? 0)); + $rightRate = max((int) ($right['zhr_min_estimate'] ?? 0), (int) ($right['zhr_max_estimate'] ?? 0)); + return $rightRate <=> $leftRate; + }); + + // Der stärkste Strom wird immer genannt. Ist er kein bekannter Jahresstrom, + // ergänzen wir einen solchen, damit etwa die Leoniden nicht untergehen. + $meteorEvents = $meteorCandidates === [] ? [] : [$meteorCandidates[0]]; + $establishedMeteorCodes = ['QUA', 'LYR', 'ETA', 'SDA', 'PER', 'DRA', 'ORI', 'NTA', 'STA', 'LEO', 'GEM', 'URS']; + $strongestCode = $meteorEvents === [] + ? '' + : strtoupper(trim((string) ($meteorEvents[0]['meteor_shower_code'] ?? ''))); + if ($meteorEvents !== [] && !in_array($strongestCode, $establishedMeteorCodes, true)) { + foreach (array_slice($meteorCandidates, 1) as $meteorCandidate) { + $candidateCode = strtoupper(trim((string) ($meteorCandidate['meteor_shower_code'] ?? ''))); + if (in_array($candidateCode, $establishedMeteorCodes, true)) { + $meteorEvents[] = $meteorCandidate; + break; + } + } + } + if ($meteorEvents !== []) { $meteorIntro = $pickRandomText([ 'Freunde von Meteoren sollten zudem die Aktivität der Meteorströme im Blick behalten.', 'Auch die Meteorströme verdienen in diesem Monat Aufmerksamkeit.', 'Wer nach Sternschnuppen Ausschau halten möchte, sollte auch die Meteorströme beachten.', - 'Ein Blick auf die Meteorstroeme kann sich in diesem Monat ebenfalls lohnen.', - ], 'Freunde von Meteoren sollten zudem die Aktivitaet der Meteorstroeme im Blick behalten.'); + 'Ein Blick auf die Meteorströme kann sich in diesem Monat ebenfalls lohnen.', + ], 'Freunde von Meteoren sollten zudem die Aktivität der Meteorströme im Blick behalten.'); $paragraphs[] = $meteorIntro . ' Erwähnenswert sind besonders ' . $describeEvents($meteorEvents) . '.'; - } else { - $meteorEvents = $findEvents( - $filteredEvents, - static fn (array $event): bool => (string) ($event['type'] ?? '') === 'meteor_shower_peak' - && ( - (int) ($event['zhr_min_estimate'] ?? 0) >= 10 - || (int) ($event['zhr_max_estimate'] ?? 0) >= 20 - ), - 2 - ); - if ($meteorEvents !== []) { - $meteorModerateIntro = $pickRandomText([ - 'Bei den Meteorströmen lohnt in diesem Monat vor allem ein Blick auf', - 'Unter den Meteorströmen verdienen in diesem Monat besonders', - 'Auch bei den Meteorströmen fallen in diesem Monat vor allem', - 'Wer Meteorströme verfolgen möchte, sollte in diesem Monat vor allem auf', - ], 'Bei den Meteorstroemen lohnt in diesem Monat vor allem ein Blick auf'); - $paragraphs[] = $meteorModerateIntro . ' ' . $describeEvents($meteorEvents) . '.'; - } } if (count($paragraphs) === 1 && $filteredEvents !== []) { @@ -1719,7 +1724,7 @@ if (!empty($favoriteSatellitePayload)) { } } -if (!empty($favoriteCometPayload)) { +if ($includeComets && !empty($favoriteCometPayload)) { $tempFavoriteCometFile = tempnam(sys_get_temp_dir(), 'month_comets_'); if ($tempFavoriteCometFile !== false) { try { @@ -2679,6 +2684,7 @@ foreach ($meteorShowerPeaks as $meteorShowerPeak) { 'local_iso' => $peakDateTime->format(DateTimeInterface::ATOM), 'zhr_min_estimate' => $zhrMinEstimate, 'zhr_max_estimate' => $zhrMaxEstimate, + 'meteor_shower_code' => $showerCode, 'type' => 'meteor_shower_peak', ]; @@ -2743,6 +2749,7 @@ usort( ); $monthNarrativeText = monthForecastBuildNarrativeText($monthEventList, $selectedMonth, $months[$selectedMonth], $selectedYear); +$monthEventListJson = json_encode($monthEventList, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]'; $observationTipInsertSql = buildObservationTipInsertSql($monthEventList, new DateTimeZone($chartTimezone)); $observationTipCalendarIcs = buildObservationTipCalendarIcs($monthEventList, new DateTimeZone($chartTimezone)); @@ -2795,7 +2802,7 @@ $monthChartComets = array_map( 'G' => array_key_exists('slope_parameter_g', $comet) && $comet['slope_parameter_g'] !== null ? (float) $comet['slope_parameter_g'] : null, ]; }, - $favoriteCometPayload + $includeComets ? $favoriteCometPayload : [] ); $monthChartCometsJson = json_encode($monthChartComets, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $moonPhaseEventsJson = json_encode($moonPhaseEvents, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); @@ -2843,6 +2850,11 @@ $observationTipCalendarFilenameJson = json_encode(sprintf('astrotools-monatserei + +
@@ -2878,16 +2890,26 @@ $observationTipCalendarFilenameJson = json_encode(sprintf('astrotools-monatserei
- +
-
+
+ + +

+
+ +
-

Monat Im Ueberblick

+

Monat im Überblick

@@ -2961,7 +2983,7 @@ $observationTipCalendarFilenameJson = json_encode(sprintf('astrotools-monatserei
- Mondphasen des Monats in einer Zeile + Mondphasen des Monats in bis zu vier Zeilen
@@ -3023,7 +3045,7 @@ $observationTipCalendarFilenameJson = json_encode(sprintf('astrotools-monatserei -

Fuer diesen Monat wurden keine Ereignisse gefunden.

+

Für diesen Monat wurden keine Ereignisse gefunden.

@@ -3036,7 +3058,7 @@ $observationTipCalendarFilenameJson = json_encode(sprintf('astrotools-monatserei
@@ -3057,6 +3079,19 @@ $observationTipCalendarFilenameJson = json_encode(sprintf('astrotools-monatserei margin: 0; } +.monatszip-export { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + padding: 0 20px; +} + +.monatszip-export p { + margin: 0; + color: rgba(222, 232, 255, 0.86); +} + .monatskarte-insert-status { margin: 0 0 14px; padding: 10px 12px; @@ -3179,6 +3214,22 @@ $observationTipCalendarFilenameJson = json_encode(sprintf('astrotools-monatserei font: inherit; } +.monatsvorhersage-checkbox { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 10px; + color: rgba(235, 242, 255, 0.95); + font-weight: 600; + cursor: pointer; +} + +.monatsvorhersage-checkbox input { + width: 18px; + height: 18px; + accent-color: #4a9eff; +} + .monatsvorhersage-actions { grid-column: 1 / -1; } @@ -3294,8 +3345,7 @@ $observationTipCalendarFilenameJson = json_encode(sprintf('astrotools-monatserei #monatsphasenImage { display: block; - width: min(100%, 1200px); - min-width: 920px; + width: min(100%, 773px); height: auto; margin: 0 auto; border-radius: 20px; @@ -3335,6 +3385,7 @@ const MONTH_CHART_PLANETS = ; const MONTH_CHART_MOON = ; const MONTH_CHART_COMETS = ; const MONTH_PHASE_EVENTS = ; +const MONTH_EVENT_LIST = ; const MONTH_CHART_ERROR = ; const OBSERVATION_TIP_ICAL = ; const OBSERVATION_TIP_ICAL_FILENAME = ; @@ -3439,8 +3490,10 @@ async function composeExportCanvas(sourceCanvas, logoPosition, options) { const padding = Math.round(exportCanvas.width * 0.024); const targetWidth = Math.max(90, Math.min(320, Math.round(exportCanvas.width * 0.14 * logoScale))); const targetHeight = Math.round(targetWidth * (logo.naturalHeight / Math.max(1, logo.naturalWidth))); - const drawX = padding; - const drawY = logoPosition === 'bottom-left' + const drawX = logoPosition === 'bottom-right' + ? exportCanvas.width - padding - targetWidth + : padding; + const drawY = logoPosition === 'bottom-left' || logoPosition === 'bottom-right' ? exportCanvas.height - padding - targetHeight : padding; exportCtx.drawImage(logo, drawX, drawY, targetWidth, targetHeight); @@ -3480,8 +3533,8 @@ function monthChartExportFilename(viewMode) { const year = String(MONTH_CHART_CONFIG.year || ''); const hour = String(Number(MONTH_CHART_CONFIG.monthSkyHour ?? 0)).padStart(2, '0'); const minute = String(Number(MONTH_CHART_CONFIG.monthSkyMinute ?? 0)).padStart(2, '0'); - const safeMode = viewMode === 'sued' ? 'sued' : 'polar'; - return 'Himmelsanblick_' + safeMode + '_' + year + '_' + month + '_' + hour + '-' + minute + '.png'; + const label = viewMode === 'sued' ? 'Himmelsanblick_Sued' : 'Himmelsanblick_Polar'; + return year + '_' + month + '_' + label + '_' + hour + '_' + minute + '.png'; } function monthChartExportCaption() { @@ -3708,7 +3761,7 @@ function monthChartCometRaDec(comet, date) { if (insertStatus) { insertStatus.hidden = false; insertStatus.className = 'monatskarte-insert-status ' + (payload && payload.success ? 'is-success' : 'is-error'); - insertStatus.textContent = payload && payload.message ? payload.message : 'Unbekannte Rueckmeldung vom Server.'; + insertStatus.textContent = payload && payload.message ? payload.message : 'Unbekannte Rückmeldung vom Server.'; } if (payload && payload.success) { @@ -3807,7 +3860,7 @@ function monthChartCometRaDec(comet, date) { const ctx = canvas.getContext('2d'); const phaseImage = new Image(); let phaseViewportWidth = 1200; - let phaseViewportHeight = 220; + let phaseViewportHeight = 700; let phaseRenderDpr = Math.max(1, window.devicePixelRatio || 1); phaseImage.src = ; @@ -3848,8 +3901,8 @@ function monthChartCometRaDec(comet, date) { } function resizeCanvas() { - const cssWidth = Math.max(920, Math.floor(imageEl.clientWidth || 1200)); - const cssHeight = 220; + const cssWidth = Math.max(320, Math.floor(imageEl.clientWidth || 773)); + const cssHeight = Math.round(cssWidth * (700 / 773)); const dpr = Math.max(1, window.devicePixelRatio || 1); renderPhaseCanvas(cssWidth, cssHeight, dpr, true); } @@ -3924,6 +3977,7 @@ function monthChartCometRaDec(comet, date) { const width = phaseViewportWidth; const height = phaseViewportHeight; + const layoutScale = width / 773; const year = Number(MONTH_CHART_CONFIG.year); const month = Number(MONTH_CHART_CONFIG.month); const monthName = String(MONTH_CHART_CONFIG.monthName || ''); @@ -3938,30 +3992,37 @@ function monthChartCometRaDec(comet, date) { ctx.fillRect(0, 0, width, height); ctx.fillStyle = 'rgba(240, 215, 132, 0.96)'; - ctx.font = '600 16px Rajdhani, sans-serif'; + ctx.font = '600 ' + (24 * layoutScale) + 'px Rajdhani, sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; - ctx.fillText('Mondphasen im ' + monthName + ' ' + year, 28, 18); + ctx.fillText('Mondphasen im ' + monthName + ' ' + year, 42 * layoutScale, 27 * layoutScale); - const leftPad = 22; - const rightPad = 22; + const leftPad = 33 * layoutScale; + const rightPad = 33 * layoutScale; const usableWidth = width - leftPad - rightPad; - const spacing = usableWidth / totalDays; - const radius = Math.max(11, Math.min(18, spacing * 0.42)); - const centerY = 82; + const daysPerRow = 9; + const rowCenters = [108, 260, 411, 563].map(function (value) { return value * layoutScale; }); + const eventTextY = [174, 326, 477, 629].map(function (value) { return value * layoutScale; }); + const eventDateY = [200, 351, 503, 655].map(function (value) { return value * layoutScale; }); ctx.fillStyle = 'rgba(255, 255, 255, 0.72)'; - ctx.font = '500 10px \"Share Tech Mono\", monospace'; + ctx.font = '500 ' + (17 * layoutScale) + 'px \"Share Tech Mono\", monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; for (let day = 1; day <= totalDays; day++) { - const x = leftPad + spacing * (day - 0.5); + const row = Math.floor((day - 1) / daysPerRow); + const rowStartDay = row * daysPerRow + 1; + const dayInRow = day - rowStartDay + 1; + const spacing = usableWidth / daysPerRow; + const radius = Math.max(23 * layoutScale, Math.min(33 * layoutScale, spacing * 0.52)); + const x = leftPad + spacing * (dayInRow - 0.5); + const centerY = rowCenters[row]; drawMoonDisc(x, centerY, radius, phaseInfoForDay(year, month, day)); - ctx.fillText(String(day), x, centerY + radius + 8); + ctx.fillText(String(day), x, centerY + radius + (12 * layoutScale)); } - ctx.font = '600 12px Rajdhani, sans-serif'; + ctx.font = '600 ' + (21 * layoutScale) + 'px Rajdhani, sans-serif'; ctx.fillStyle = 'rgba(225, 232, 246, 0.94)'; for (const event of Array.isArray(MONTH_PHASE_EVENTS) ? MONTH_PHASE_EVENTS : []) { @@ -3975,10 +4036,13 @@ function monthChartCometRaDec(comet, date) { continue; } - const x = leftPad + spacing * (day - 0.5); - ctx.fillText(String(event.label || ''), x, 158); + const row = Math.floor((day - 1) / daysPerRow); + const rowStartDay = row * daysPerRow + 1; + const dayInRow = day - rowStartDay + 1; + const x = leftPad + (usableWidth / daysPerRow) * (dayInRow - 0.5); + ctx.fillText(String(event.label || ''), x, eventTextY[row]); ctx.fillStyle = 'rgba(182, 196, 224, 0.94)'; - ctx.fillText(day + '.' + month + '.', x, 175); + ctx.fillText(day + '.' + month + '.', x, eventDateY[row]); ctx.fillStyle = 'rgba(225, 232, 246, 0.94)'; } @@ -3992,31 +4056,23 @@ function monthChartCometRaDec(comet, date) { const year = String(MONTH_CHART_CONFIG.year || ''); const month = String(MONTH_CHART_CONFIG.monthNumber || MONTH_CHART_CONFIG.month || '').padStart(2, '0'); - return 'Himmelsanblick_mondphasen_' + year + '_' + month + '.png'; + return year + '_' + month + '_Mondphasen.png'; } if (downloadButton) { downloadButton.addEventListener('click', function () { - const previous = { - width: phaseViewportWidth, - height: phaseViewportHeight, - dpr: phaseRenderDpr, - }; + statusEl.textContent = 'Mondphasen-PNG wird erstellt...'; + downloadButton.disabled = true; - try { - statusEl.textContent = 'Mondphasen-PNG wird in 2000 px neu gerendert...'; - downloadButton.disabled = true; - - const exportWidth = MONTH_CHART_EXPORT_WIDTH; - const exportHeight = Math.round(exportWidth * (220 / 1200)); - renderPhaseCanvas(exportWidth, exportHeight, 1, false); - composeExportCanvas(canvas, 'bottom-left', { showCaption: false, logoScale: 0.6 }).then(function (exportCanvas) { + composeExportCanvas(canvas, 'bottom-right', { showCaption: false, logoScale: 0.6 }) + .then(function (exportCanvas) { triggerCanvasDownload(exportCanvas || canvas, monthPhaseExportFilename()); + }) + .finally(function () { + draw(); + imageEl.src = canvas.toDataURL('image/png'); + downloadButton.disabled = false; }); - } finally { - renderPhaseCanvas(previous.width, previous.height, previous.dpr, true); - downloadButton.disabled = false; - } }); } @@ -4496,7 +4552,7 @@ function monthChartCometRaDec(comet, date) { const y = cy - Math.cos(angleRad) * (radius + offset); let rotation = angleRad; - // Text tangential zum Horizontkreis ausrichten, dabei moeglichst gut lesbar halten. + // Text tangential zum Horizontkreis ausrichten, dabei möglichst gut lesbar halten. if (rotation > Math.PI / 2 && rotation < Math.PI * 1.5) { rotation -= Math.PI; } @@ -6100,6 +6156,306 @@ function monthChartCometRaDec(comet, date) { resizeCanvas(); window.addEventListener('resize', resizeCanvas); })(); + +(function () { + const downloadButton = document.getElementById('downloadMonatsueberblickZip'); + const copyButton = document.getElementById('copyMonatsueberblickHtml'); + const statusEl = document.getElementById('monatsueberblickZipStatus'); + const overviewEl = document.getElementById('monatsueberblick'); + const polarCanvas = document.getElementById('monatskarteCanvas'); + const southCanvas = document.getElementById('monatskarteSouthCanvas'); + const phaseImage = document.getElementById('monatsphasenImage'); + const htmlImageMaxWidth = 900; + + if (!downloadButton || !copyButton || !statusEl || !overviewEl || !polarCanvas || !southCanvas || !phaseImage || !MONTH_CHART_CONFIG) { + return; + } + + function canvasToPngBlob(sourceCanvas) { + return new Promise(function (resolve, reject) { + sourceCanvas.toBlob(function (blob) { + if (blob) { + resolve(blob); + } else { + reject(new Error('PNG konnte nicht erzeugt werden.')); + } + }, 'image/png'); + }); + } + + async function exportCanvasAsPng(sourceCanvas, logoPosition, options) { + const exportCanvas = await composeExportCanvas(sourceCanvas, logoPosition || 'bottom-left', options); + return canvasToPngBlob(exportCanvas || sourceCanvas); + } + + async function exportPhaseImageAsPng() { + if (!phaseImage.complete || !phaseImage.naturalWidth || !phaseImage.naturalHeight) { + throw new Error('Die Mondphasengrafik ist noch nicht bereit.'); + } + + const sourceCanvas = document.createElement('canvas'); + sourceCanvas.width = phaseImage.naturalWidth; + sourceCanvas.height = phaseImage.naturalHeight; + const sourceCtx = sourceCanvas.getContext('2d'); + if (!sourceCtx) { + throw new Error('Mondphasengrafik konnte nicht vorbereitet werden.'); + } + + sourceCtx.drawImage(phaseImage, 0, 0); + return exportCanvasAsPng(sourceCanvas, 'bottom-right', { showCaption: false, logoScale: 0.6 }); + } + + function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function blobToDataUrl(blob) { + return new Promise(function (resolve, reject) { + const reader = new FileReader(); + reader.onload = function () { resolve(String(reader.result)); }; + reader.onerror = function () { reject(new Error('Bild konnte nicht eingebettet werden.')); }; + reader.readAsDataURL(blob); + }); + } + + async function scaleImageBlob(blob, maxWidth) { + const sourceUrl = window.URL.createObjectURL(blob); + const image = new Image(); + + try { + await new Promise(function (resolve, reject) { + image.onload = resolve; + image.onerror = function () { reject(new Error('Bild konnte nicht skaliert werden.')); }; + image.src = sourceUrl; + }); + + if (image.naturalWidth <= maxWidth) { + return blob; + } + + const scaledCanvas = document.createElement('canvas'); + scaledCanvas.width = maxWidth; + scaledCanvas.height = Math.round(image.naturalHeight * (maxWidth / image.naturalWidth)); + const scaledContext = scaledCanvas.getContext('2d'); + if (!scaledContext) { + throw new Error('Bild konnte nicht skaliert werden.'); + } + + scaledContext.drawImage(image, 0, 0, scaledCanvas.width, scaledCanvas.height); + return canvasToPngBlob(scaledCanvas); + } finally { + window.URL.revokeObjectURL(sourceUrl); + } + } + + async function overviewHtml(images) { + const monthLabel = (overviewEl.querySelector('.monatskarte-header p')?.textContent || '').trim(); + const narrative = (overviewEl.querySelector('.monatsnarrativ-text')?.textContent || '').trim(); + const escapedNarrative = escapeHtml(narrative).replace(/\r?\n/g, '
\n'); + const imageMarkup = await Promise.all(images.map(async function (image) { + const scaledImage = await scaleImageBlob(image.content, htmlImageMaxWidth); + const imageDataUrl = await blobToDataUrl(scaledImage); + return '

' + escapeHtml(image.title) + '

\n' + + '

' + escapeHtml(image.title) + '

\n'; + })); + + return '

' + escapeHtml(monthLabel) + '

\n' + + '

' + escapedNarrative + '

\n' + + imageMarkup.join('') + + eventTableHtml() + + '

Alle Zeiten gelten für MEZ/MESZ.

\n' + + '\n'; + } + + function eventTableHtml() { + const rows = (Array.isArray(MONTH_EVENT_LIST) ? MONTH_EVENT_LIST : []) + .map(function (event) { + return '' + escapeHtml(event.event || '') + '' + + escapeHtml(event.date || '') + '' + + escapeHtml(event.time || '') + ''; + }); + + if (rows.length === 0) { + return ''; + } + + return '

Ereignisse

\n' + + '\n' + + '\n' + + '\n' + rows.join('\n') + '\n\n' + + '
EreignisDatumUhrzeit
\n'; + } + + function crc32(bytes) { + let crc = 0xffffffff; + for (let index = 0; index < bytes.length; index++) { + crc ^= bytes[index]; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + return (crc ^ 0xffffffff) >>> 0; + } + + function zipHeader(size) { + const bytes = new Uint8Array(size); + return { bytes: bytes, view: new DataView(bytes.buffer) }; + } + + async function createZip(entries) { + const encoder = new TextEncoder(); + const localChunks = []; + const centralChunks = []; + const now = new Date(); + const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2); + const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate(); + let offset = 0; + + for (const entry of entries) { + const nameBytes = encoder.encode(entry.name); + const content = entry.content instanceof Blob + ? new Uint8Array(await entry.content.arrayBuffer()) + : encoder.encode(entry.content); + const checksum = crc32(content); + const local = zipHeader(30); + local.view.setUint32(0, 0x04034b50, true); + local.view.setUint16(4, 20, true); + local.view.setUint16(6, 0x0800, true); + local.view.setUint16(8, 0, true); + local.view.setUint16(10, dosTime, true); + local.view.setUint16(12, dosDate, true); + local.view.setUint32(14, checksum, true); + local.view.setUint32(18, content.length, true); + local.view.setUint32(22, content.length, true); + local.view.setUint16(26, nameBytes.length, true); + local.view.setUint16(28, 0, true); + localChunks.push(local.bytes, nameBytes, content); + + const central = zipHeader(46); + central.view.setUint32(0, 0x02014b50, true); + central.view.setUint16(4, 20, true); + central.view.setUint16(6, 20, true); + central.view.setUint16(8, 0x0800, true); + central.view.setUint16(10, 0, true); + central.view.setUint16(12, dosTime, true); + central.view.setUint16(14, dosDate, true); + central.view.setUint32(16, checksum, true); + central.view.setUint32(20, content.length, true); + central.view.setUint32(24, content.length, true); + central.view.setUint16(28, nameBytes.length, true); + central.view.setUint16(30, 0, true); + central.view.setUint16(32, 0, true); + central.view.setUint16(34, 0, true); + central.view.setUint16(36, 0, true); + central.view.setUint32(38, 0, true); + central.view.setUint32(42, offset, true); + centralChunks.push(central.bytes, nameBytes); + + offset += local.bytes.length + nameBytes.length + content.length; + } + + const centralSize = centralChunks.reduce(function (size, chunk) { return size + chunk.length; }, 0); + const end = zipHeader(22); + end.view.setUint32(0, 0x06054b50, true); + end.view.setUint16(8, entries.length, true); + end.view.setUint16(10, entries.length, true); + end.view.setUint32(12, centralSize, true); + end.view.setUint32(16, offset, true); + end.view.setUint16(20, 0, true); + + return new Blob([...localChunks, ...centralChunks, end.bytes], { type: 'application/zip' }); + } + + function downloadBlob(blob, filename) { + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.setTimeout(function () { + window.URL.revokeObjectURL(objectUrl); + }, 0); + } + + async function createOverviewImages() { + const exports = await Promise.all([ + exportCanvasAsPng(polarCanvas), + exportCanvasAsPng(southCanvas), + exportPhaseImageAsPng(), + ]); + return [ + { title: 'Zenitprojektion', content: exports[0] }, + { title: 'Südhimmel', content: exports[1] }, + { title: 'Mondphasen', content: exports[2] }, + ]; + } + + async function copyTextToClipboard(text) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + const copied = document.execCommand('copy'); + document.body.removeChild(textarea); + if (!copied) { + throw new Error('Zwischenablage nicht verfügbar.'); + } + } + + downloadButton.addEventListener('click', async function () { + const year = String(MONTH_CHART_CONFIG.year || ''); + const month = String(MONTH_CHART_CONFIG.monthNumber || MONTH_CHART_CONFIG.month || '').padStart(2, '0'); + + try { + statusEl.textContent = 'Monatspaket wird erstellt...'; + downloadButton.disabled = true; + + const images = await createOverviewImages(); + const zip = await createZip([ + { name: monthChartExportFilename('polar'), content: images[0].content }, + { name: monthChartExportFilename('sued'), content: images[1].content }, + { name: year + '_' + month + '_Mondphasen.png', content: images[2].content }, + { name: year + '_' + month + '_Monat_im_Ueberblick.html', content: await overviewHtml(images) }, + ]); + + downloadBlob(zip, year + '_' + month + '_Monatspaket.zip'); + statusEl.textContent = 'Monatspaket wurde heruntergeladen.'; + } catch (error) { + statusEl.textContent = 'Monatspaket konnte nicht erstellt werden.'; + } finally { + downloadButton.disabled = false; + } + }); + + copyButton.addEventListener('click', async function () { + try { + statusEl.textContent = 'HTML für die Zwischenablage wird erstellt...'; + copyButton.disabled = true; + const html = await overviewHtml(await createOverviewImages()); + await copyTextToClipboard(html); + statusEl.textContent = 'HTML wurde in die Zwischenablage kopiert.'; + } catch (error) { + statusEl.textContent = 'HTML konnte nicht in die Zwischenablage kopiert werden.'; + } finally { + copyButton.disabled = false; + } + }); +})();