377 lines
13 KiB
PHP
377 lines
13 KiB
PHP
<?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'; ?>
|