Mondjahresübersicht eingebaut
This commit is contained in:
@@ -131,6 +131,7 @@ function manageablePublicPages(): array
|
||||
'bedeckung.php' => 'Bedeckungsphänomene',
|
||||
'sternbedeckungen.php' => 'Mond-Stern-Bedeckungen',
|
||||
'monatsvorhersage.php' => 'Monatsvorhersage',
|
||||
'moonyear.php' => 'Mondphasen-Jahresübersicht',
|
||||
'wochenvorhersage.php' => 'Wochenvorhersage',
|
||||
'satellitenhimmel.php' => 'Satellitenhimmel',
|
||||
];
|
||||
|
||||
@@ -141,6 +141,7 @@ $menuGroups = [
|
||||
'title' => 'Himmel & Planeten',
|
||||
'links' => [
|
||||
['href' => 'moonphase.php', 'label' => 'Mondphase'],
|
||||
['href' => 'moonyear.php', 'label' => 'Mondphasen-Jahresübersicht'],
|
||||
['href' => 'planetensichtbarkeit.php', 'label' => 'Planetensichtbarkeit'],
|
||||
['href' => 'comets.php', 'label' => 'Kometen'],
|
||||
['href' => 'astronomical_conversions.php', 'label' => 'Astronomical Conversions'],
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
session_start();
|
||||
|
||||
$currentYear = (int) date('Y');
|
||||
$selectedYear = isset($_GET['year']) ? max(1900, min(2100, (int) $_GET['year'])) : $currentYear;
|
||||
|
||||
$pageTitle = 'Mondphasen-Jahresübersicht ' . $selectedYear . ' - AstroTools';
|
||||
$bodyClass = 'moonyear-page';
|
||||
$publicBasePath = './';
|
||||
|
||||
require __DIR__ . '/header.php';
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Zeitzone aus dem Benutzer-Standort; Fallback auf Europe/Berlin
|
||||
// -------------------------------------------------------------------------
|
||||
$chartTimezone = 'Europe/Berlin';
|
||||
if (!empty($currentLocation['timezone'])) {
|
||||
$chartTimezone = (string) $currentLocation['timezone'];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Python: Mondphasen für alle 12 Monate abrufen
|
||||
// -------------------------------------------------------------------------
|
||||
$pythonExecutable = PHP_OS_FAMILY === 'Windows' ? 'python' : '/usr/bin/python';
|
||||
$pythonScriptPath = __DIR__ . '/py/api.py';
|
||||
|
||||
$allPhaseEvents = [];
|
||||
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
$args = [
|
||||
$pythonScriptPath,
|
||||
'moon_phases_for_month',
|
||||
(string) $selectedYear,
|
||||
(string) $m,
|
||||
$chartTimezone,
|
||||
];
|
||||
$cmd = $pythonExecutable . ' ' . implode(' ', array_map('escapeshellarg', $args)) . ' 2>&1';
|
||||
exec($cmd, $output, $exitCode);
|
||||
$raw = trim(implode("\n", $output));
|
||||
$decoded = json_decode($raw, true);
|
||||
if (is_array($decoded) && ($decoded['ok'] ?? false) && is_array($decoded['phases'] ?? null)) {
|
||||
foreach ($decoded['phases'] as $phase) {
|
||||
// local_date format: "DD.MM.YYYY"
|
||||
$parts = explode('.', (string) ($phase['local_date'] ?? ''));
|
||||
if (count($parts) === 3) {
|
||||
$day = (int) $parts[0];
|
||||
$month = (int) $parts[1];
|
||||
if ($month === $m && $day >= 1 && $day <= 31) {
|
||||
$allPhaseEvents[] = [
|
||||
'month' => $month,
|
||||
'day' => $day,
|
||||
'label' => (string) ($phase['label'] ?? ''),
|
||||
'time' => (string) ($phase['local_time'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
<style>
|
||||
.moonyear-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.moonyear-controls label {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.moonyear-controls input[type="number"] {
|
||||
width: 100px;
|
||||
}
|
||||
.moonyear-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
#moonyearImage {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
#moonyearStatus {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.moonyear-legend {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
<h2>Mondphasen <?= htmlspecialchars((string) $selectedYear, ENT_QUOTES, 'UTF-8') ?></h2>
|
||||
|
||||
<form method="get" class="moonyear-controls">
|
||||
<label for="yearInput">Jahr:</label>
|
||||
<input type="number" id="yearInput" name="year"
|
||||
value="<?= htmlspecialchars((string) $selectedYear, ENT_QUOTES, 'UTF-8') ?>"
|
||||
min="1900" max="2100">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Anzeigen</button>
|
||||
<?php if ($selectedYear !== $currentYear): ?>
|
||||
<a href="moonyear.php?year=<?= $currentYear ?>" class="btn btn-secondary btn-sm">Aktuelles Jahr</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
|
||||
<div class="moonyear-wrap">
|
||||
<img id="moonyearImage" alt="Mondphasen <?= htmlspecialchars((string) $selectedYear, ENT_QUOTES, 'UTF-8') ?>">
|
||||
</div>
|
||||
<p id="moonyearStatus">Zeichne Mondphasen-Jahresübersicht…</p>
|
||||
|
||||
<div class="moonyear-legend">
|
||||
Neumond · Erstes Viertel · Vollmond · Letztes Viertel
|
||||
— Zeitzone: <?= htmlspecialchars($chartTimezone, ENT_QUOTES, 'UTF-8') ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="<?= htmlspecialchars($publicBasePath . 'js/astronomy.browser.min.js', ENT_QUOTES, 'UTF-8') ?>"></script>
|
||||
<script>
|
||||
(function () {
|
||||
const YEAR = <?= json_encode($selectedYear) ?>;
|
||||
const imageEl = document.getElementById('moonyearImage');
|
||||
const statusEl = document.getElementById('moonyearStatus');
|
||||
|
||||
// Phase events from Python: [{month, day, label, time}, ...]
|
||||
// label = "Neumond" | "Erstes Viertel" | "Vollmond" | "Letztes Viertel"
|
||||
const PHASE_EVENTS = <?= json_encode($allPhaseEvents, JSON_UNESCAPED_UNICODE) ?>;
|
||||
|
||||
if (!imageEl || !statusEl) return;
|
||||
|
||||
if (!window.Astronomy) {
|
||||
statusEl.textContent = 'Astronomy Engine konnte nicht geladen werden.';
|
||||
return;
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||
];
|
||||
|
||||
// Short label + color per phase
|
||||
const PHASE_LABEL_MAP = {
|
||||
'Neumond': { short: 'NM', color: 'rgba(180,180,180,0.80)' },
|
||||
'Erstes Viertel': { short: 'EV', color: 'rgba(201,168,76,0.90)' },
|
||||
'Vollmond': { short: 'VM', color: 'rgba(240,217,132,1.00)' },
|
||||
'Letztes Viertel': { short: 'LV', color: 'rgba(201,168,76,0.90)' },
|
||||
};
|
||||
|
||||
function daysInMonth(year, month) {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
function clamp01(v) { return Math.max(0, Math.min(1, v)); }
|
||||
|
||||
function phaseInfoForDay(year, month, day) {
|
||||
const d = new Date(year, month - 1, day, 12, 0, 0);
|
||||
const angle = ((Astronomy.MoonPhase(d) % 360) + 360) % 360;
|
||||
return {
|
||||
phaseAngleDeg: angle,
|
||||
illumination: clamp01((1 - Math.cos(angle * Math.PI / 180)) / 2),
|
||||
waxing: angle <= 180,
|
||||
};
|
||||
}
|
||||
|
||||
// Build fast lookup: "month-day" → {short, color, time}
|
||||
const eventMap = {};
|
||||
for (const e of PHASE_EVENTS) {
|
||||
const key = e.month + '-' + e.day;
|
||||
const meta = PHASE_LABEL_MAP[e.label] || { short: '?', color: 'rgba(255,255,255,0.7)' };
|
||||
eventMap[key] = { short: meta.short, color: meta.color, time: e.time };
|
||||
}
|
||||
|
||||
const phaseImage = new Image();
|
||||
phaseImage.src = <?= json_encode($publicBasePath . 'images/moons/moon4geocron.png', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||
|
||||
function drawMoonDisc(ctx, 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;
|
||||
if (i === 0) ctx.moveTo(x + xt, y + t);
|
||||
else ctx.lineTo(x + xt, y + t);
|
||||
}
|
||||
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));
|
||||
ctx.lineTo(x + (phaseInfo.waxing ? xr : -xr), y + t);
|
||||
}
|
||||
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 || !phaseImage.naturalWidth) return;
|
||||
|
||||
const dpr = Math.max(1, window.devicePixelRatio || 1);
|
||||
|
||||
// Layout (CSS pixels)
|
||||
const labelW = 92;
|
||||
const dayW = 30;
|
||||
const rowH = 46;
|
||||
const headerH = 26;
|
||||
const padTop = 10;
|
||||
const padBot = 20;
|
||||
const padRight = 10;
|
||||
const radius = 11;
|
||||
|
||||
const cssW = labelW + 31 * dayW + padRight;
|
||||
const cssH = padTop + headerH + 12 * rowH + padBot;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(cssW * dpr);
|
||||
canvas.height = Math.round(cssH * dpr);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
// Background
|
||||
const bg = ctx.createLinearGradient(0, 0, 0, cssH);
|
||||
bg.addColorStop(0, '#02050a');
|
||||
bg.addColorStop(1, '#04080f');
|
||||
ctx.fillStyle = bg;
|
||||
ctx.fillRect(0, 0, cssW, cssH);
|
||||
|
||||
// Day-number header
|
||||
ctx.fillStyle = 'rgba(201, 168, 76, 0.65)';
|
||||
ctx.font = '500 9px "Share Tech Mono", monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
const headerY = padTop + headerH / 2;
|
||||
for (let d = 1; d <= 31; d++) {
|
||||
ctx.fillText(String(d), labelW + (d - 1) * dayW + dayW / 2, headerY);
|
||||
}
|
||||
|
||||
// Month rows
|
||||
for (let m = 1; m <= 12; m++) {
|
||||
const rowTop = padTop + headerH + (m - 1) * rowH;
|
||||
const centerY = rowTop + rowH / 2;
|
||||
const days = daysInMonth(YEAR, m);
|
||||
|
||||
// Alternating row tint
|
||||
if (m % 2 === 0) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.018)';
|
||||
ctx.fillRect(0, rowTop, cssW, rowH);
|
||||
}
|
||||
|
||||
// Month name
|
||||
ctx.fillStyle = 'rgba(240, 217, 132, 0.85)';
|
||||
ctx.font = '500 11px Rajdhani, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(MONTH_NAMES[m - 1], labelW - 8, centerY - 5);
|
||||
|
||||
// Moon discs & phase labels
|
||||
for (let d = 1; d <= 31; d++) {
|
||||
const x = labelW + (d - 1) * dayW + dayW / 2;
|
||||
|
||||
if (d > days) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.05)';
|
||||
ctx.fillRect(x - 7, centerY - 0.5, 14, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const info = phaseInfoForDay(YEAR, m, d);
|
||||
drawMoonDisc(ctx, x, centerY - 5, radius, info);
|
||||
|
||||
// Phase event label from Python data
|
||||
const key = m + '-' + d;
|
||||
if (eventMap[key]) {
|
||||
const ev = eventMap[key];
|
||||
ctx.fillStyle = ev.color;
|
||||
ctx.font = '600 7.5px "Share Tech Mono", monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(ev.short, x, centerY - 5 + radius + 2);
|
||||
// Time below label
|
||||
if (ev.time) {
|
||||
ctx.fillStyle = 'rgba(180,180,180,0.55)';
|
||||
ctx.font = '400 6.5px "Share Tech Mono", monospace';
|
||||
ctx.fillText(ev.time, x, centerY - 5 + radius + 11);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Row separator
|
||||
ctx.strokeStyle = 'rgba(201,168,76,0.07)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, rowTop + rowH);
|
||||
ctx.lineTo(cssW, rowTop + rowH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Vertical separator after label column
|
||||
ctx.strokeStyle = 'rgba(201,168,76,0.15)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(labelW, padTop);
|
||||
ctx.lineTo(labelW, cssH - padBot);
|
||||
ctx.stroke();
|
||||
|
||||
// Footer
|
||||
ctx.fillStyle = 'rgba(240, 215, 132, 0.40)';
|
||||
ctx.font = '400 8px "Share Tech Mono", monospace';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText('NM=Neumond EV=Erstes Viertel VM=Vollmond LV=Letztes Viertel', cssW - padRight, cssH - 4);
|
||||
|
||||
imageEl.src = canvas.toDataURL('image/png');
|
||||
statusEl.textContent = 'Mondphasen ' + YEAR + ' — Zeitzone: <?= htmlspecialchars($chartTimezone, ENT_QUOTES, 'UTF-8') ?>';
|
||||
}
|
||||
|
||||
phaseImage.onload = draw;
|
||||
phaseImage.onerror = function () {
|
||||
statusEl.textContent = 'Mondgrafik konnte nicht geladen werden.';
|
||||
};
|
||||
if (phaseImage.complete && phaseImage.naturalWidth) draw();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<?php require __DIR__ . '/footer.php'; ?>
|
||||
Reference in New Issue
Block a user