Files
skyview.astronomiemuseum.de/public/mondplanetenbedeckungen.php
T
EskimueandClaude Sonnet 5 5652cc7e40 Neue Seite fuer Mond-Planeten-Bedeckungen mit Jahresberechnung hinzufuegen
Berechnet Bedeckungen der Planeten Merkur bis Neptun durch den Mond fuer
ein ganzes Jahr am Standardstandort, inklusive Detailkarte. Ereignisse
unter dem Horizont werden weiterhin angezeigt statt gefiltert, dafuer
mit eigenem Sichtbarkeits-Hinweis.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:35:36 +02:00

1285 lines
49 KiB
PHP

<?php
declare(strict_types=1);
ini_set('display_errors', '1');
error_reporting(E_ALL);
session_start();
$loggedIn = isset($_SESSION['user_id']);
$pageTitle = 'Mond-Planeten-Bedeckungen - AstroTools';
$bodyClass = 'occ-page';
$headerIntroPre = '';
$headerIntroTitle = '';
$headerIntroSub = '';
function h(?string $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function runPythonApi(string $scriptPath, string $action, array $arguments): array
{
$pythonCandidates = ['/usr/bin/python', '/usr/bin/python3', 'python3', 'python'];
$escapedArguments = [escapeshellarg($scriptPath), escapeshellarg($action)];
foreach ($arguments as $argument) {
$escapedArguments[] = escapeshellarg((string)$argument);
}
$lastError = 'Python konnte nicht ausgeführt werden.';
$debugOutput = [];
foreach ($pythonCandidates as $pythonBinary) {
$command = $pythonBinary . ' ' . implode(' ', $escapedArguments) . ' 2>&1';
$output = [];
$resultCode = 0;
exec($command, $output, $resultCode);
$joined = trim(implode("\n", $output));
$debugOutput = $output;
if ($resultCode !== 0) {
$lastError = $joined !== '' ? $joined : ('Fehlercode ' . $resultCode . ' bei ' . $pythonBinary);
continue;
}
$decoded = json_decode($joined, true);
if (!is_array($decoded)) {
$lastError = $joined !== '' ? 'Python-Ausgabe war kein gültiges JSON.' : 'Python lieferte keine Ausgabe zurück.';
continue;
}
if (!($decoded['ok'] ?? false)) {
return [
'ok' => false,
'error' => (string)($decoded['error'] ?? 'Unbekannter Python-Fehler'),
'debug' => $joined,
];
}
return ['ok' => true, 'data' => $decoded];
}
return [
'ok' => false,
'error' => $lastError,
'debug' => implode("\n", $debugOutput),
];
}
$defaultLocation = null;
$pdo = null;
$pageError = null;
$pageLocation = null;
$detailMapStars = [];
$constellationLines = [];
$detailMapCatalogLimit = 8.0;
$isAjax = isset($_POST['ajax']) && (string)$_POST['ajax'] === '1';
try {
$config = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$config['host'],
$config['dbname'],
$config['charset'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$config['user'],
$config['pass'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
if ($loggedIn && isset($_SESSION['user_id'])) {
$stmtLocation = $pdo->prepare(
'SELECT id, name, latitude, longitude, elevation, timezone
FROM app_user_locations
WHERE user_id = ? AND is_default = 1
LIMIT 1'
);
$stmtLocation->execute([(int)$_SESSION['user_id']]);
$defaultLocation = $stmtLocation->fetch() ?: null;
}
} catch (Throwable $exception) {
$pageError = $exception->getMessage();
}
if (!empty($defaultLocation) && is_array($defaultLocation)) {
$pageLocation = [
'name' => (string)($defaultLocation['name'] ?? ''),
'latitude' => isset($defaultLocation['latitude']) ? (float)$defaultLocation['latitude'] : null,
'longitude' => isset($defaultLocation['longitude']) ? (float)$defaultLocation['longitude'] : null,
'elevation' => isset($defaultLocation['elevation']) && $defaultLocation['elevation'] !== null ? (float)$defaultLocation['elevation'] : 0.0,
'timezone' => (string)($defaultLocation['timezone'] ?? 'Europe/Berlin'),
];
}
$timezoneName = $pageLocation['timezone'] ?? 'Europe/Berlin';
try {
$timezone = new DateTimeZone($timezoneName);
} catch (Throwable $exception) {
$timezoneName = 'Europe/Berlin';
$timezone = new DateTimeZone($timezoneName);
}
$todayYear = (int)(new DateTimeImmutable('now', $timezone))->format('Y');
$selectedYear = isset($_POST['year']) && preg_match('/^\d{4}$/', (string)$_POST['year'])
? (int)$_POST['year']
: $todayYear;
$selectedYear = max(1900, min(2100, $selectedYear));
if (!$isAjax && $pageError === null && $pdo instanceof PDO && $pageLocation !== null && $pageLocation['latitude'] !== null && $pageLocation['longitude'] !== null) {
try {
$stmtDetailMap = $pdo->query(
"SELECT `hip`, `proper`, `bf`, `con`, `mag`, `ra`, `dec`, `dist`
FROM `star_hipparcos`
WHERE `mag` IS NOT NULL
AND `ra` IS NOT NULL
AND `dec` IS NOT NULL
AND `mag` <= 8.0
ORDER BY `mag` ASC, `proper` ASC, `bf` ASC, `hip` ASC"
);
foreach ($stmtDetailMap->fetchAll() as $row) {
$proper = trim((string)($row['proper'] ?? ''));
$bf = trim((string)($row['bf'] ?? ''));
$hip = isset($row['hip']) ? (int)$row['hip'] : 0;
$label = $proper !== '' ? $proper : ($bf !== '' ? $bf : ($hip > 0 ? 'HIP ' . $hip : 'Unbenannter Stern'));
$detailMapStars[] = [
'hip' => $hip,
'label' => $label,
'constellation' => trim((string)($row['con'] ?? '')),
'mag' => isset($row['mag']) ? (float)$row['mag'] : null,
'ra' => isset($row['ra']) ? (float)$row['ra'] : null,
'dec' => isset($row['dec']) ? (float)$row['dec'] : null,
'distLy' => isset($row['dist']) && $row['dist'] !== null && (float)$row['dist'] > 0.0
? (float)$row['dist'] * 3.26156
: 1000.0,
];
}
$stmtLines = $pdo->query(
"SELECT
cl.`id` AS line_id,
clp.`point_index`,
sh.`ra` AS ra_hours,
sh.`dec` AS dec_deg
FROM `constellation_lines` cl
INNER JOIN `constellation_line_points` clp
ON clp.`line_id` = cl.`id`
INNER JOIN `star_hipparcos` sh
ON sh.`hip` = clp.`hip_number`
WHERE sh.`ra` IS NOT NULL
AND sh.`dec` IS NOT NULL
ORDER BY cl.`id`, clp.`point_index`"
);
foreach ($stmtLines->fetchAll() as $row) {
$lineId = (int)$row['line_id'];
$constellationLines[$lineId][] = [
'ra' => isset($row['ra_hours']) ? (float)$row['ra_hours'] : null,
'dec' => isset($row['dec_deg']) ? (float)$row['dec_deg'] : null,
];
}
} catch (Throwable $exception) {
$pageError = $exception->getMessage();
$detailMapStars = [];
$constellationLines = [];
}
}
if ($isAjax) {
header('Content-Type: application/json; charset=utf-8');
if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null) {
echo json_encode([
'ok' => false,
'error' => 'Kein Standard-Standort gesetzt. Bitte zuerst in den Einstellungen einen Standard-Standort hinterlegen.',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if ($pageError !== null || !($pdo instanceof PDO)) {
echo json_encode([
'ok' => false,
'error' => $pageError ?? 'Die Datenbank ist nicht verfuegbar.',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
try {
$pythonResult = runPythonApi(
__DIR__ . '/py/api.py',
'moon_planet_occultations_for_year',
[
(string)$pageLocation['latitude'],
(string)$pageLocation['longitude'],
(string)$pageLocation['elevation'],
(string)$selectedYear,
$timezoneName,
]
);
if (!$pythonResult['ok']) {
echo json_encode([
'ok' => false,
'error' => $pythonResult['error'] ?? 'Unbekannter Python-Fehler',
'debug' => $pythonResult['debug'] ?? null,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
echo json_encode([
'ok' => true,
'results' => $pythonResult['data']['results'] ?? [],
'selected_year' => $selectedYear,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
} catch (Throwable $exception) {
echo json_encode([
'ok' => false,
'error' => $exception->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
}
require __DIR__ . '/header.php';
if (!empty($currentLocation) && is_array($currentLocation)) {
$pageLocation = [
'name' => (string)($currentLocation['name'] ?? ''),
'latitude' => isset($currentLocation['latitude']) ? (float)$currentLocation['latitude'] : null,
'longitude' => isset($currentLocation['longitude']) ? (float)$currentLocation['longitude'] : null,
'elevation' => isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float)$currentLocation['elevation'] : 0.0,
'timezone' => (string)($currentLocation['timezone'] ?? 'Europe/Berlin'),
];
}
?>
<script src="js/astronomy.browser.min.js"></script>
<div class="occult-tool">
<?php if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null): ?>
<section class="card occult-card">
<h2>Kein Standard-Standort gesetzt</h2>
<p class="hint">
Fuer die Berechnung wird der Standard-Standort aus der Datenbank verwendet.
Bitte wähle in den <a href="settings.php">Einstellungen</a> einen Standard-Standort aus.
</p>
</section>
<?php elseif ($pageError !== null): ?>
<section class="card occult-card">
<h2>Datenbank nicht verfuegbar</h2>
<p class="hint">Die Sterndaten konnten nicht geladen werden.</p>
<p class="hint">Technischer Hinweis: <?= h($pageError) ?></p>
</section>
<?php else: ?>
<section class="card occult-card">
<div class="occult-head">
<div class="occult-head-item occult-head-location">
<span class="occult-label">Standort</span>
<strong><?= h($pageLocation['name']) ?></strong>
<span><?= number_format((float)$pageLocation['latitude'], 4, '.', '') ?>&deg; / <?= number_format((float)$pageLocation['longitude'], 4, '.', '') ?>&deg;</span>
<span><?= h($timezoneName) ?></span>
</div>
<div class="occult-head-item">
<span class="occult-label">Berechnung</span>
<form method="post" class="occult-date-picker-row" id="occultForm">
<button type="button" class="btn btn-secondary btn-sm" id="occultPrevYear">Jahr -</button>
<label for="occultYearInput" class="occult-select-label">Jahr</label>
<input type="number" id="occultYearInput" class="occult-date-input" name="year" min="1900" max="2100" value="<?= h((string)$selectedYear) ?>">
<button type="button" class="btn btn-secondary btn-sm" id="occultThisYearBtn">Aktuelles Jahr</button>
<button type="button" class="btn btn-secondary btn-sm" id="occultNextYear">Jahr +</button>
<input type="hidden" name="calculate" value="1">
<button type="submit" class="btn btn-primary btn-sm">Berechnen</button>
</form>
</div>
</div>
<div class="occult-status occult-status-hidden" id="occultSubmitStatus" hidden>
<span class="occult-spinner occult-spinner-visible" aria-hidden="true"></span>
<span>Berechnung laeuft... Ein ganzes Jahr für alle Planeten kann einige Sekunden dauern.</span>
</div>
</section>
<div id="occultResults">
<section class="card occult-card">
<h2>Bereit zur Berechnung</h2>
<p class="hint">
Wähle ein Jahr und starte die Auswertung mit dem Button.
Mond-Planeten-Bedeckungen sind an einem festen Standort selten
&ndash; es ist normal, wenn in manchen Jahren keine gefunden werden.
</p>
</section>
</div>
<section class="card occult-card occult-detail-card" id="occultDetailCard" hidden>
<div class="occult-detail-head">
<div>
<span class="occult-label">Detailansicht</span>
<strong id="occultDetailTitle">Bedeckungskarte</strong>
</div>
<div class="occult-detail-head-controls">
<div class="occult-detail-meta" id="occultDetailMeta">Feld 5 x 5 Grad, Mond im Zentrum</div>
<div class="occult-map-size-row">
<label for="occultMapSizeSelect" class="occult-select-label">Kartengrösse</label>
<select id="occultMapSizeSelect" class="satpass-select occult-select">
<option value="5" selected>5 x 5 Grad</option>
<option value="10">10 x 10 Grad</option>
<option value="15">15 x 15 Grad</option>
</select>
<button type="button" class="btn btn-secondary btn-sm" id="occultInvertBtn">Invertiert</button>
</div>
</div>
</div>
<div class="occult-detail-layout">
<canvas id="occultDetailCanvas" width="420" height="420"></canvas>
<div class="occult-detail-info" id="occultDetailInfo"></div>
</div>
</section>
<?php endif; ?>
</div>
<div class="occult-submit-overlay" id="occultSubmitOverlay" hidden>
<div class="occult-submit-panel">
<div class="occult-submit-title">Berechnung laeuft</div>
<div class="occult-submit-row">
<span class="occult-spinner occult-spinner-visible" aria-hidden="true"></span>
<span class="occult-submit-text">Berechnung ...</span>
</div>
<div class="occult-submit-progress">
<div class="occult-submit-progress-head">
<span>Fortschritt</span>
<span class="occult-submit-progress-value" id="occultSubmitProgressValue">0 %</span>
</div>
<div class="occult-submit-progress-bar" aria-hidden="true">
<div class="occult-submit-progress-fill" id="occultSubmitProgressFill"></div>
</div>
</div>
</div>
</div>
<script>
(function () {
const Astronomy = window.Astronomy;
const form = document.getElementById('occultForm');
const status = document.getElementById('occultSubmitStatus');
const overlay = document.getElementById('occultSubmitOverlay');
const results = document.getElementById('occultResults');
const progressValue = document.getElementById('occultSubmitProgressValue');
const progressFill = document.getElementById('occultSubmitProgressFill');
const prevYearBtn = document.getElementById('occultPrevYear');
const nextYearBtn = document.getElementById('occultNextYear');
const thisYearBtn = document.getElementById('occultThisYearBtn');
const yearInput = document.getElementById('occultYearInput');
const detailCardEl = document.getElementById('occultDetailCard');
const detailCanvasEl = document.getElementById('occultDetailCanvas');
const detailTitleEl = document.getElementById('occultDetailTitle');
const detailMetaEl = document.getElementById('occultDetailMeta');
const detailInfoEl = document.getElementById('occultDetailInfo');
const mapSizeSelectEl = document.getElementById('occultMapSizeSelect');
const invertBtn = document.getElementById('occultInvertBtn');
const SITE_LOCATION = <?= json_encode($pageLocation, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
const DETAIL_MAP_STARS = <?= json_encode($detailMapStars, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
const CONSTELLATION_LINES = <?= json_encode(array_values($constellationLines), JSON_UNESCAPED_SLASHES) ?>;
const DETAIL_MAP_CATALOG_LIMIT = <?= json_encode($detailMapCatalogLimit, JSON_UNESCAPED_SLASHES) ?>;
const DETAIL_MAP_LABEL_LIMIT = 5.0;
const STAR_BODIES = ['Star1', 'Star2', 'Star3', 'Star4', 'Star5', 'Star6', 'Star7', 'Star8'];
const PLANET_INFO = {
mercury: { body: 'Mercury', label: 'Merkur', radiusKm: 2439.7, color: '#b5b0a8' },
venus: { body: 'Venus', label: 'Venus', radiusKm: 6051.8, color: '#e8d9a0' },
mars: { body: 'Mars', label: 'Mars', radiusKm: 3396.2, color: '#c1663f' },
jupiter: { body: 'Jupiter', label: 'Jupiter', radiusKm: 71492.0, color: '#f0cf8d' },
saturn: { body: 'Saturn', label: 'Saturn', radiusKm: 60268.0, color: '#e0c9a6' },
uranus: { body: 'Uranus', label: 'Uranus', radiusKm: 25559.0, color: '#8fd9e8' },
neptune: { body: 'Neptune', label: 'Neptun', radiusKm: 24764.0, color: '#5f7fe0' }
};
const OBSERVER = new Astronomy.Observer(
Number(SITE_LOCATION.latitude),
Number(SITE_LOCATION.longitude),
Number(SITE_LOCATION.elevation || 0)
);
const TIME_FMT = new Intl.DateTimeFormat('de-DE', {
timeZone: SITE_LOCATION.timezone,
hour: '2-digit',
minute: '2-digit'
});
const STATE = {
results: [],
selectedEventIndex: null,
mapSizeDeg: mapSizeSelectEl ? Number(mapSizeSelectEl.value || 5) : 5,
invertMap: false
};
if (!form || !status || !overlay || !results || !progressValue || !progressFill) {
return;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatTimeBlock(timeData) {
if (!timeData || !timeData.local_time) {
return '&mdash;';
}
if (timeData.local_date) {
return `${escapeHtml(timeData.local_time)} Uhr (${escapeHtml(timeData.local_date)})`;
}
return `${escapeHtml(timeData.local_time)} Uhr`;
}
function formatDuration(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) {
return '&mdash;';
}
const minutes = Math.round(seconds / 60);
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
if (hours > 0) {
return `${hours} h ${String(rest).padStart(2, '0')} min`;
}
return `${rest} min`;
}
function formatDateLabel(dateString) {
if (!dateString) {
return '&mdash;';
}
const utcMid = new Date(`${dateString}T12:00:00Z`);
return new Intl.DateTimeFormat('de-DE', {
timeZone: SITE_LOCATION.timezone,
day: '2-digit',
month: '2-digit',
year: 'numeric'
}).format(utcMid);
}
function getTzParts(date, timeZone) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
}).formatToParts(date);
const result = {};
for (const part of parts) {
if (part.type !== 'literal') {
result[part.type] = Number(part.value);
}
}
return result;
}
function getTodayYear() {
const parts = getTzParts(new Date(), SITE_LOCATION.timezone);
return parts.year;
}
function toEventMillis(timeData) {
if (!timeData || !timeData.utc_iso) {
return null;
}
const millis = Date.parse(timeData.utc_iso);
return Number.isFinite(millis) ? millis : null;
}
function visibilityClass(key) {
if (key === 'below_horizon') return 'occult-visibility-below';
if (key === 'night') return 'occult-visibility-night';
if (key === 'twilight') return 'occult-visibility-twilight';
return 'occult-visibility-day';
}
function renderError(message, debug) {
results.innerHTML = `
<section class="card occult-card">
<h2>Berechnung fehlgeschlagen</h2>
<div class="msg-error">
${escapeHtml(message || 'Unbekannter Fehler')}
${debug ? `<pre>${escapeHtml(debug)}</pre>` : ''}
</div>
</section>
`;
}
function renderSuccess(payload) {
const rows = Array.isArray(payload.results) ? payload.results.map((row) => ({
...row,
ingressMs: toEventMillis(row.ingress),
maxMs: toEventMillis(row.maximum),
egressMs: toEventMillis(row.egress)
})) : [];
STATE.results = rows;
STATE.selectedEventIndex = null;
let summaryHtml = `
<section class="card occult-card">
<div class="occult-summary">
<div class="occult-stat">
<span class="occult-label">Jahr</span>
<strong>${escapeHtml(payload.selected_year ?? '')}</strong>
</div>
<div class="occult-stat">
<span class="occult-label">Gefundene Ereignisse</span>
<strong>${escapeHtml(rows.length)}</strong>
</div>
</div>
</section>
`;
let tableRows = '';
if (rows.length === 0) {
tableRows = `
<tr>
<td colspan="8" class="occult-empty">
Für dieses Jahr wurden am gewählten Standort keine Mond-Planeten-Bedeckungen gefunden.
</td>
</tr>
`;
} else {
tableRows = rows.map((row, index) => {
const planetInfo = PLANET_INFO[row.planet_key] || {};
const planetLabel = row.planet_label || planetInfo.label || 'Planet';
const visibility = row.visibility || {};
const dateString = row.maximum ? row.maximum.local_date : '';
const kind = row.is_total ? 'Total' : 'Partiell';
return `
<tr class="occult-row" data-event-index="${index}">
<td class="occult-week-date">${formatDateLabel(dateString)}</td>
<td>${escapeHtml(planetLabel)}</td>
<td>${formatTimeBlock(row.ingress)}</td>
<td>${formatTimeBlock(row.maximum)}</td>
<td>${formatTimeBlock(row.egress)}</td>
<td>${formatDuration(Number(row.duration_seconds))}</td>
<td>${escapeHtml(kind)}</td>
<td><span class="occult-visibility ${visibilityClass(visibility.key)}">${escapeHtml(visibility.label || 'Tag')}</span></td>
</tr>
`;
}).join('');
}
results.innerHTML = `
${summaryHtml}
<section class="card occult-card">
<div class="occult-table-wrap">
<table class="data-table occult-table">
<thead>
<tr>
<th>Datum</th>
<th>Planet</th>
<th>Eintritt</th>
<th>Maximum</th>
<th>Austritt</th>
<th>Dauer</th>
<th>Art</th>
<th>Sichtbarkeit</th>
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
</div>
</section>
`;
results.querySelectorAll('tr[data-event-index]').forEach((row) => {
row.addEventListener('click', function () {
renderOccultationMap(Number(row.dataset.eventIndex));
});
});
if (rows.length > 0) {
renderOccultationMap(0);
} else if (detailCardEl) {
detailCardEl.hidden = true;
}
}
function setEstimatedProgress(value) {
const safeValue = Math.max(0, Math.min(100, Math.round(value)));
progressValue.textContent = `${safeValue} %`;
progressFill.style.width = `${safeValue}%`;
}
let isSubmitting = false;
let progressTimer = null;
function startEstimatedProgress() {
if (progressTimer) {
window.clearInterval(progressTimer);
}
let current = 0;
setEstimatedProgress(current);
progressTimer = window.setInterval(function () {
if (current < 20) {
current += 3;
} else if (current < 45) {
current += 1.5;
} else if (current < 70) {
current += 0.8;
} else if (current < 88) {
current += 0.4;
} else if (current < 94) {
current += 0.15;
}
setEstimatedProgress(current);
}, 300);
}
function stopEstimatedProgress(success) {
if (progressTimer) {
window.clearInterval(progressTimer);
progressTimer = null;
}
setEstimatedProgress(success ? 100 : 0);
}
function angularOffsetDeg(raHours, decDeg, centerRaHours, centerDecDeg) {
let deltaRaDeg = (raHours - centerRaHours) * 15;
while (deltaRaDeg > 180) deltaRaDeg -= 360;
while (deltaRaDeg < -180) deltaRaDeg += 360;
return {
xDeg: deltaRaDeg * Math.cos(centerDecDeg * Math.PI / 180),
yDeg: decDeg - centerDecDeg,
};
}
function starBrightnessFactor(mag) {
const clamped = Math.max(-1.5, Math.min(8.0, Number(mag)));
return (8.5 - clamped) / 10.0;
}
function magnitudeToRadius(mag) {
const brightness = starBrightnessFactor(mag);
return 0.7 + brightness * 2.2;
}
function drawStarSymbol(ctx, x, y, mag) {
const brightness = starBrightnessFactor(mag);
const coreRadius = magnitudeToRadius(mag);
const glowRadius = coreRadius * 2.6;
const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius);
const isInverted = STATE.invertMap;
if (isInverted) {
const glowAlpha = 0.06 + brightness * 0.18;
glow.addColorStop(0, `rgba(42, 56, 82, ${Math.min(0.68, 0.18 + brightness * 0.38)})`);
glow.addColorStop(0.35, `rgba(85, 102, 136, ${glowAlpha})`);
glow.addColorStop(1, 'rgba(85, 102, 136, 0)');
} else {
const glowAlpha = 0.12 + brightness * 0.32;
glow.addColorStop(0, `rgba(232, 239, 255, ${Math.min(0.92, 0.30 + brightness * 0.55)})`);
glow.addColorStop(0.35, `rgba(190, 208, 255, ${glowAlpha})`);
glow.addColorStop(1, 'rgba(190, 208, 255, 0)');
}
ctx.beginPath();
ctx.arc(x, y, glowRadius, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, coreRadius, 0, Math.PI * 2);
ctx.fillStyle = isInverted ? '#253246' : '#f5f8ff';
ctx.fill();
}
function hexToRgb(hex) {
const clean = hex.replace('#', '');
const bigint = parseInt(clean, 16);
return {
r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255,
b: bigint & 255
};
}
function drawPlanetDisk(ctx, x, y, radiusPx, colorHex) {
const rgb = hexToRgb(colorHex);
const glowRadius = radiusPx * 3.2;
const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius);
glow.addColorStop(0, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.65)`);
glow.addColorStop(0.3, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.32)`);
glow.addColorStop(1, `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0)`);
ctx.beginPath();
ctx.arc(x, y, glowRadius, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, radiusPx, 0, Math.PI * 2);
ctx.fillStyle = colorHex;
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.stroke();
}
function getStarLabelMetrics(ctx, label) {
ctx.save();
ctx.font = '11px Segoe UI';
const text = String(label);
const textWidth = ctx.measureText(text).width;
ctx.restore();
return {
text,
textWidth,
padX: 4,
boxH: 16,
font: '11px Segoe UI',
};
}
function getLabelPlacementOptions(x, y, metrics) {
const boxW = metrics.textWidth + metrics.padX * 2;
const boxH = metrics.boxH;
return [
{ boxX: x + 8, boxY: y - 8, boxW, boxH },
{ boxX: x - boxW - 8, boxY: y - 8, boxW, boxH },
{ boxX: x + 8, boxY: y + 12, boxW, boxH },
{ boxX: x - boxW - 8, boxY: y + 12, boxW, boxH },
];
}
function drawStarLabel(ctx, x, y, label, placement) {
if (!label) {
return null;
}
const metrics = getStarLabelMetrics(ctx, label);
const box = placement || getLabelPlacementOptions(x, y, metrics)[0];
const isInverted = STATE.invertMap;
ctx.save();
ctx.font = metrics.font;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillStyle = isInverted ? 'rgba(255, 255, 255, 0.82)' : 'rgba(0, 0, 0, 0.58)';
ctx.fillRect(box.boxX, box.boxY - box.boxH / 2, box.boxW, box.boxH);
ctx.fillStyle = isInverted ? 'rgba(26, 34, 52, 0.9)' : 'rgba(221,227,245,0.82)';
ctx.fillText(metrics.text, box.boxX + metrics.padX, box.boxY);
ctx.restore();
return {
left: box.boxX,
top: box.boxY - box.boxH / 2,
right: box.boxX + box.boxW,
bottom: box.boxY + box.boxH / 2,
};
}
function labelBoxesOverlap(a, b, margin) {
const gap = Number.isFinite(margin) ? margin : 2;
return !(
a.right + gap < b.left ||
a.left > b.right + gap ||
a.bottom + gap < b.top ||
a.top > b.bottom + gap
);
}
function getMoonTerminatorInfo(time, moonEq) {
const sunEq = Astronomy.Equator('Sun', time, OBSERVER, true, true);
const illum = Astronomy.Illumination('Moon', time);
const offset = angularOffsetDeg(sunEq.ra, sunEq.dec, moonEq.ra, moonEq.dec);
const brightSideAngleRad = Math.atan2(-offset.yDeg, -offset.xDeg);
const phaseFraction = Math.max(0, Math.min(1, Number(illum.phase_fraction ?? 0.5)));
return {
phaseFraction,
brightSideAngleRad
};
}
function drawMoonWithTerminator(ctx, centerX, centerY, radiusPx, terminatorInfo) {
const phaseFraction = terminatorInfo.phaseFraction;
const brightSideAngleRad = terminatorInfo.brightSideAngleRad;
const cosPhase = Math.max(-1, Math.min(1, 2 * phaseFraction - 1));
const isInverted = STATE.invertMap;
const baseMoonFill = isInverted ? 'rgba(236, 239, 245, 0.98)' : 'rgba(24, 28, 36, 0.96)';
const litMoonFill = isInverted ? 'rgba(86, 94, 112, 0.88)' : 'rgba(228, 232, 240, 0.95)';
const moonStroke = isInverted ? 'rgba(70, 78, 96, 0.75)' : 'rgba(245,248,255,0.72)';
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(brightSideAngleRad);
ctx.beginPath();
ctx.arc(0, 0, radiusPx, 0, Math.PI * 2);
ctx.fillStyle = baseMoonFill;
ctx.fill();
if (phaseFraction <= 0.001) {
ctx.lineWidth = 2;
ctx.strokeStyle = isInverted ? 'rgba(70, 78, 96, 0.55)' : 'rgba(170,180,195,0.35)';
ctx.stroke();
ctx.restore();
return;
}
if (phaseFraction >= 0.999) {
ctx.beginPath();
ctx.arc(0, 0, radiusPx, 0, Math.PI * 2);
ctx.fillStyle = litMoonFill;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = moonStroke;
ctx.stroke();
ctx.restore();
return;
}
ctx.save();
ctx.beginPath();
ctx.arc(0, 0, radiusPx, 0, Math.PI * 2);
ctx.clip();
for (let y = -radiusPx; y <= radiusPx; y += 1) {
const chord = Math.sqrt(Math.max(0, radiusPx * radiusPx - y * y));
const xLeft = -chord;
const xRight = chord;
const xTerminator = -cosPhase * chord;
const litStart = Math.max(xLeft, xTerminator);
const litWidth = xRight - litStart;
if (litWidth <= 0) {
continue;
}
ctx.fillStyle = litMoonFill;
ctx.fillRect(litStart, y, litWidth, 1.2);
}
ctx.restore();
ctx.beginPath();
ctx.arc(0, 0, radiusPx, 0, Math.PI * 2);
ctx.lineWidth = 2;
ctx.strokeStyle = moonStroke;
ctx.stroke();
ctx.restore();
}
function chunkStars(stars, size) {
const chunks = [];
for (let index = 0; index < stars.length; index += size) {
chunks.push(stars.slice(index, index + size));
}
return chunks;
}
function defineChunk(chunk) {
STAR_BODIES.forEach((body, index) => {
const star = chunk[index];
if (star) {
Astronomy.DefineStar(body, Number(star.ra), Number(star.dec), Number(star.distLy || 1000));
return;
}
Astronomy.DefineStar(body, 0, 0, 1000);
});
}
function getStarsForDetailMap(time) {
const mapStars = DETAIL_MAP_STARS.filter((star) => Number(star.mag) <= DETAIL_MAP_CATALOG_LIMIT);
const chunks = chunkStars(mapStars, STAR_BODIES.length);
const stars = [];
for (const chunk of chunks) {
defineChunk(chunk);
chunk.forEach((star, index) => {
const starEq = Astronomy.Equator(STAR_BODIES[index], time, OBSERVER, true, true);
const starHor = Astronomy.Horizon(time, OBSERVER, starEq.ra, starEq.dec, 'normal');
if (Number(starHor.altitude || 0) <= 0) {
return;
}
stars.push({
star,
eq: starEq,
hor: starHor
});
});
}
return stars;
}
function clipSegmentToField(x1, y1, x2, y2, halfWidth, halfHeight) {
const INSIDE = 0;
const LEFT = 1;
const RIGHT = 2;
const BOTTOM = 4;
const TOP = 8;
function computeCode(x, y) {
let code = INSIDE;
if (x < -halfWidth) code |= LEFT;
else if (x > halfWidth) code |= RIGHT;
if (y < -halfHeight) code |= BOTTOM;
else if (y > halfHeight) code |= TOP;
return code;
}
let ax = x1;
let ay = y1;
let bx = x2;
let by = y2;
let codeA = computeCode(ax, ay);
let codeB = computeCode(bx, by);
while (true) {
if ((codeA | codeB) === 0) {
return { x1: ax, y1: ay, x2: bx, y2: by };
}
if ((codeA & codeB) !== 0) {
return null;
}
const outCode = codeA !== 0 ? codeA : codeB;
let x = 0;
let y = 0;
if (outCode & TOP) {
x = ax + (bx - ax) * (halfHeight - ay) / (by - ay);
y = halfHeight;
} else if (outCode & BOTTOM) {
x = ax + (bx - ax) * (-halfHeight - ay) / (by - ay);
y = -halfHeight;
} else if (outCode & RIGHT) {
y = ay + (by - ay) * (halfWidth - ax) / (bx - ax);
x = halfWidth;
} else if (outCode & LEFT) {
y = ay + (by - ay) * (-halfWidth - ax) / (bx - ax);
x = -halfWidth;
}
if (outCode === codeA) {
ax = x;
ay = y;
codeA = computeCode(ax, ay);
} else {
bx = x;
by = y;
codeB = computeCode(bx, by);
}
}
}
function getConstellationSegmentsForDetailMap(time, moonEq, fieldHalfDeg) {
const segments = [];
for (const line of CONSTELLATION_LINES) {
if (!Array.isArray(line) || line.length < 2) {
continue;
}
let prevPoint = null;
for (const point of line) {
if (!point || !Number.isFinite(point.ra) || !Number.isFinite(point.dec)) {
prevPoint = null;
continue;
}
const tempBody = 'Star1';
Astronomy.DefineStar(tempBody, Number(point.ra), Number(point.dec), 1000);
const eq = Astronomy.Equator(tempBody, time, OBSERVER, true, true);
const hor = Astronomy.Horizon(time, OBSERVER, eq.ra, eq.dec, 'normal');
const offset = angularOffsetDeg(eq.ra, eq.dec, moonEq.ra, moonEq.dec);
const currentPoint = {
xDeg: offset.xDeg,
yDeg: offset.yDeg,
visible: Number(hor.altitude || 0) > 0,
};
if (prevPoint && (prevPoint.visible || currentPoint.visible)) {
const clipped = clipSegmentToField(
prevPoint.xDeg,
prevPoint.yDeg,
currentPoint.xDeg,
currentPoint.yDeg,
fieldHalfDeg,
fieldHalfDeg
);
if (clipped) {
segments.push({
x1Deg: clipped.x1,
y1Deg: clipped.y1,
x2Deg: clipped.x2,
y2Deg: clipped.y2,
});
}
}
prevPoint = currentPoint;
}
}
return segments;
}
function renderOccultationMap(eventIndex) {
const event = STATE.results[eventIndex];
if (!event || !detailCanvasEl || !Astronomy) {
return;
}
const planetInfo = PLANET_INFO[event.planet_key] || { body: 'Venus', label: event.planet_label || 'Planet', radiusKm: 6051.8, color: '#e8d9a0' };
STATE.selectedEventIndex = eventIndex;
detailCardEl.hidden = false;
const ctx = detailCanvasEl.getContext('2d');
const width = detailCanvasEl.width;
const height = detailCanvasEl.height;
const padding = 28;
const plotSize = Math.min(width, height) - padding * 2;
const fieldHalfDeg = STATE.mapSizeDeg / 2;
const scale = plotSize / (fieldHalfDeg * 2);
const centerX = width / 2;
const centerY = height / 2;
const timeMs = event.ingressMs || event.maxMs;
const time = new Date(timeMs);
const moonEq = Astronomy.Equator('Moon', time, OBSERVER, true, true);
const planetEq = Astronomy.Equator(planetInfo.body, time, OBSERVER, true, true);
const moonRadiusDeg = Math.asin(Math.max(-1, Math.min(1, 1737.4 / (Number(moonEq.dist || 1) * Astronomy.KM_PER_AU)))) * Astronomy.RAD2DEG;
const planetRadiusDeg = Math.asin(Math.max(-1, Math.min(1, planetInfo.radiusKm / (Number(planetEq.dist || 1) * Astronomy.KM_PER_AU)))) * Astronomy.RAD2DEG;
const moonTerminatorInfo = getMoonTerminatorInfo(time, moonEq);
const mapStars = getStarsForDetailMap(time);
const constellationSegments = getConstellationSegmentsForDetailMap(time, moonEq, fieldHalfDeg);
const nearbyStars = [];
const labelItems = [];
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = STATE.invertMap ? '#f7f8fb' : '#000000';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = STATE.invertMap ? 'rgba(120, 145, 185, 0.36)' : 'rgba(95, 135, 185, 0.34)';
ctx.lineWidth = 1;
constellationSegments.forEach((segment) => {
ctx.beginPath();
ctx.moveTo(centerX - segment.x1Deg * scale, centerY - segment.y1Deg * scale);
ctx.lineTo(centerX - segment.x2Deg * scale, centerY - segment.y2Deg * scale);
ctx.stroke();
});
mapStars.forEach((item) => {
const offset = angularOffsetDeg(item.eq.ra, item.eq.dec, moonEq.ra, moonEq.dec);
if (Math.abs(offset.xDeg) <= fieldHalfDeg * 1.12 && Math.abs(offset.yDeg) <= fieldHalfDeg * 1.12) {
nearbyStars.push({ star: item.star, offset });
}
});
nearbyStars.forEach((starItem) => {
const x = centerX - starItem.offset.xDeg * scale;
const y = centerY - starItem.offset.yDeg * scale;
drawStarSymbol(ctx, x, y, starItem.star.mag);
if (Number(starItem.star.mag) <= DETAIL_MAP_LABEL_LIMIT) {
const label = starItem.star.label || (starItem.star.hip ? `HIP ${starItem.star.hip}` : '');
labelItems.push({ x, y, label, mag: Number(starItem.star.mag) });
}
});
const planetOffset = angularOffsetDeg(planetEq.ra, planetEq.dec, moonEq.ra, moonEq.dec);
const planetX = centerX - planetOffset.xDeg * scale;
const planetY = centerY - planetOffset.yDeg * scale;
const planetRadiusPx = Math.max(3, planetRadiusDeg * scale);
drawMoonWithTerminator(ctx, centerX, centerY, Math.max(6, moonRadiusDeg * scale), moonTerminatorInfo);
drawPlanetDisk(ctx, planetX, planetY, planetRadiusPx, planetInfo.color);
labelItems.push({ x: planetX, y: planetY, label: planetInfo.label, mag: -5 });
const placedLabelBoxes = [];
labelItems
.sort((left, right) => left.mag - right.mag)
.forEach((item) => {
if (!item.label) {
return;
}
const metrics = getStarLabelMetrics(ctx, item.label);
const placements = getLabelPlacementOptions(item.x, item.y, metrics);
let chosenPlacement = null;
for (const placement of placements) {
const candidateBox = {
left: placement.boxX,
top: placement.boxY - placement.boxH / 2,
right: placement.boxX + placement.boxW,
bottom: placement.boxY + placement.boxH / 2,
};
const collides = placedLabelBoxes.some((placed) => labelBoxesOverlap(candidateBox, placed));
if (!collides) {
chosenPlacement = placement;
break;
}
}
if (!chosenPlacement) {
chosenPlacement = placements[0];
}
const labelBox = drawStarLabel(ctx, item.x, item.y, item.label, chosenPlacement);
if (labelBox) {
placedLabelBoxes.push(labelBox);
}
});
detailTitleEl.textContent = `${planetInfo.label} - Beginn der Bedeckung`;
detailMetaEl.textContent = `${TIME_FMT.format(time)} Uhr, Feld ${STATE.mapSizeDeg} x ${STATE.mapSizeDeg} Grad, Mond im Zentrum`;
const belowHorizonNote = event.below_horizon
? `<p class="hint">Hinweis: Mond und/oder Planet stehen zu diesem Zeitpunkt am gewählten Standort unter dem Horizont und sind dort nicht beobachtbar.</p>`
: '';
detailInfoEl.innerHTML = `
${belowHorizonNote}
<div class="occult-detail-row"><span>Zeit</span><strong>${TIME_FMT.format(time)} Uhr</strong></div>
<div class="occult-detail-row"><span>Planet</span><strong>${escapeHtml(planetInfo.label)}</strong></div>
<div class="occult-detail-row"><span>Bedeckungsart</span><strong>${event.is_total ? 'Total' : 'Partiell'}</strong></div>
<div class="occult-detail-row"><span>Phase</span><strong>${(moonTerminatorInfo.phaseFraction * 100).toFixed(1)} %</strong></div>
<div class="occult-detail-row"><span>Mondhöhe</span><strong>${Number(event.moon_alt_deg || 0).toFixed(1)}&deg;</strong></div>
<div class="occult-detail-row"><span>Planetenhöhe</span><strong>${Number(event.planet_alt_deg || 0).toFixed(1)}&deg;</strong></div>
<div class="occult-detail-row"><span>Mondradius</span><strong>${(moonRadiusDeg * 60).toFixed(2)}'</strong></div>
<div class="occult-detail-row"><span>Planetenradius</span><strong>${(planetRadiusDeg * 3600).toFixed(1)}''</strong></div>
<div class="occult-detail-row"><span>Maximum</span><strong>${formatTimeBlock(event.maximum)}</strong></div>
`;
document.querySelectorAll('#occultResults tr[data-event-index]').forEach((row) => {
row.classList.toggle('occult-row-active', Number(row.dataset.eventIndex) === eventIndex);
});
detailCanvasEl.classList.toggle('occult-canvas-inverted', STATE.invertMap);
}
async function fetchOccultationsForYear(year) {
const formData = new FormData(form);
formData.set('ajax', '1');
formData.set('year', String(year));
const response = await fetch(window.location.href, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'fetch' }
});
return response.json();
}
function runSubmit(year) {
if (isSubmitting) {
return;
}
isSubmitting = true;
status.hidden = false;
status.classList.remove('occult-status-hidden');
overlay.hidden = false;
overlay.classList.add('is-visible');
startEstimatedProgress();
const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = 'Berechnet...';
}
fetchOccultationsForYear(year)
.then(function (payload) {
if (!payload || !payload.ok) {
stopEstimatedProgress(false);
renderError(payload && payload.error ? payload.error : 'Unbekannter Fehler', payload && payload.debug ? payload.debug : '');
return;
}
stopEstimatedProgress(true);
renderSuccess(payload);
})
.catch(function (error) {
stopEstimatedProgress(false);
renderError('Die Berechnung konnte nicht abgeschlossen werden.', error && error.message ? error.message : '');
})
.finally(function () {
isSubmitting = false;
window.setTimeout(function () {
status.hidden = true;
status.classList.add('occult-status-hidden');
overlay.hidden = true;
overlay.classList.remove('is-visible');
setEstimatedProgress(0);
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = 'Berechnen';
}
}, 220);
});
}
form.addEventListener('submit', function (event) {
event.preventDefault();
runSubmit(yearInput.value || '<?= h((string)$selectedYear) ?>');
});
if (prevYearBtn && yearInput) {
prevYearBtn.addEventListener('click', function () {
yearInput.value = String((parseInt(yearInput.value, 10) || getTodayYear()) - 1);
});
}
if (nextYearBtn && yearInput) {
nextYearBtn.addEventListener('click', function () {
yearInput.value = String((parseInt(yearInput.value, 10) || getTodayYear()) + 1);
});
}
if (thisYearBtn && yearInput) {
thisYearBtn.addEventListener('click', function () {
yearInput.value = String(getTodayYear());
});
}
if (mapSizeSelectEl) {
mapSizeSelectEl.addEventListener('change', function () {
STATE.mapSizeDeg = Number(mapSizeSelectEl.value || 5);
if (STATE.selectedEventIndex !== null) {
renderOccultationMap(STATE.selectedEventIndex);
}
});
}
if (invertBtn) {
invertBtn.addEventListener('click', function () {
STATE.invertMap = !STATE.invertMap;
invertBtn.classList.toggle('is-active', STATE.invertMap);
if (STATE.selectedEventIndex !== null) {
renderOccultationMap(STATE.selectedEventIndex);
} else if (detailCanvasEl) {
detailCanvasEl.classList.toggle('occult-canvas-inverted', STATE.invertMap);
}
});
}
})();
</script>
<?php require __DIR__ . '/footer.php'; ?>