Files
skyview.astronomiemuseum.de/public/mondplanetenbedeckungen.php
T
EskimueandClaude Sonnet 5 71c8e832ba Detailkarte der Mond-Planeten-Bedeckungen: echte Mondtextur statt Flaechenfuellung
Verwendet dieselbe Mondgrafik (moon4calendar.png) wie Mondphasenansicht und
Mondjahr, korrekt zur Himmelsorientierung gedreht, mit Fallback auf die
alte Flachdarstellung solange die Textur noch nicht geladen ist.

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

1345 lines
51 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 MOON_TEXTURE = new Image();
MOON_TEXTURE.src = 'images/moons/moon4calendar.png';
MOON_TEXTURE.onload = function () {
if (STATE.selectedEventIndex !== null) {
renderOccultationMap(STATE.selectedEventIndex);
}
};
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 drawMoonWithTerminatorFlat(ctx, radiusPx, phaseFraction, cosPhase, isInverted) {
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.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();
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();
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();
}
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 textureReady = MOON_TEXTURE.complete && MOON_TEXTURE.naturalWidth > 0;
const moonStroke = isInverted ? 'rgba(70, 78, 96, 0.75)' : 'rgba(245,248,255,0.55)';
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(brightSideAngleRad);
if (!textureReady) {
drawMoonWithTerminatorFlat(ctx, radiusPx, phaseFraction, cosPhase, isInverted);
ctx.restore();
return;
}
// Dunkle Mondscheibe als Basis (Neumondseite), darüber wird die
// Textur nur im beleuchteten Bereich eingeblendet.
ctx.save();
ctx.beginPath();
ctx.arc(0, 0, radiusPx, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = isInverted ? 'rgba(210, 214, 224, 0.94)' : 'rgba(8, 10, 16, 0.94)';
ctx.fill();
if (phaseFraction > 0.001) {
ctx.save();
if (phaseFraction < 0.999) {
ctx.beginPath();
for (let i = 0; i <= 72; i++) {
const t = -radiusPx + 2 * radiusPx * i / 72;
const xr = Math.sqrt(Math.max(0, radiusPx * radiusPx - t * t));
const xt = -cosPhase * xr;
i === 0 ? ctx.moveTo(xt, t) : ctx.lineTo(xt, t);
}
for (let i = 72; i >= 0; i--) {
const t = -radiusPx + 2 * radiusPx * i / 72;
const xr = Math.sqrt(Math.max(0, radiusPx * radiusPx - t * t));
ctx.lineTo(xr, t);
}
ctx.closePath();
ctx.clip();
}
ctx.globalAlpha = isInverted ? 0.5 : 0.96;
// Die Textur liegt um 180 Grad gedreht vor (Sueden oben, Ost/West
// vertauscht); hier um die Bildmitte drehen, damit sie zur
// Himmelsdarstellung mit Norden oben passt.
ctx.scale(-1, -1);
ctx.drawImage(MOON_TEXTURE, -radiusPx, -radiusPx, radiusPx * 2, radiusPx * 2);
ctx.globalAlpha = 1;
ctx.restore();
}
ctx.restore();
ctx.restore();
ctx.beginPath();
ctx.arc(centerX, centerY, radiusPx, 0, Math.PI * 2);
ctx.lineWidth = 1.5;
ctx.strokeStyle = moonStroke;
ctx.stroke();
}
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'; ?>