Files
skyview.astronomiemuseum.de/public/sternbedeckungen.php
T
2026-03-31 09:44:27 +02:00

1470 lines
56 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-Stern-Bedeckungen - AstroTools';
$bodyClass = 'occult-page';
$headerIntroPre = '';
$headerIntroTitle = '';
$headerIntroSub = '';
function h(?string $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function buildStarLabel(array $row): string
{
$proper = trim((string)($row['proper'] ?? ''));
if ($proper !== '') {
return $proper;
}
$bf = trim((string)($row['bf'] ?? ''));
if ($bf !== '') {
return $bf;
}
$hip = isset($row['hip']) ? (int)$row['hip'] : 0;
if ($hip > 0) {
return 'HIP ' . $hip;
}
return 'Unbenannter Stern';
}
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 ausgefuehrt 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 gueltiges JSON.' : 'Python lieferte keine Ausgabe zurueck.';
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);
}
$todayLocal = (new DateTimeImmutable('now', $timezone))->format('Y-m-d');
$selectedDate = isset($_POST['date']) && preg_match('/^\d{4}-\d{2}-\d{2}$/', (string)$_POST['date'])
? (string)$_POST['date']
: $todayLocal;
$selectedMag = isset($_POST['mag']) ? (float)$_POST['mag'] : 3.0;
$selectedMag = max(1.0, min(6.0, $selectedMag));
$selectedMag = round($selectedMag, 1);
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) {
$detailMapStars[] = [
'hip' => isset($row['hip']) ? (int)$row['hip'] : 0,
'label' => buildStarLabel($row),
'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 {
$stmtStars = $pdo->prepare(
"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` <= :max_mag
ORDER BY `mag` ASC, `proper` ASC, `bf` ASC, `hip` ASC"
);
$stmtStars->execute([':max_mag' => $selectedMag]);
$stars = [];
foreach ($stmtStars->fetchAll() as $row) {
$stars[] = [
'hip' => isset($row['hip']) ? (int)$row['hip'] : 0,
'label' => buildStarLabel($row),
'constellation' => trim((string)($row['con'] ?? '')),
'mag' => isset($row['mag']) ? (float)$row['mag'] : 99.0,
'ra' => isset($row['ra']) ? (float)$row['ra'] : 0.0,
'dec' => isset($row['dec']) ? (float)$row['dec'] : 0.0,
'distLy' => isset($row['dist']) && $row['dist'] !== null && (float)$row['dist'] > 0.0
? (float)$row['dist'] * 3.26156
: 1000.0,
];
}
$starCount = count($stars);
$tempFile = tempnam(sys_get_temp_dir(), 'occult_stars_');
if ($tempFile === false) {
throw new RuntimeException('Temporäre Sterndatei konnte nicht angelegt werden.');
}
try {
file_put_contents($tempFile, json_encode($stars, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$pythonResult = runPythonApi(
__DIR__ . '/py/api.py',
'moon_star_occultations',
[
(string)$pageLocation['latitude'],
(string)$pageLocation['longitude'],
(string)$pageLocation['elevation'],
$selectedDate,
$timezoneName,
$tempFile,
]
);
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,
'star_count' => $starCount,
'results' => $pythonResult['data']['results'] ?? [],
'selected_date' => $selectedDate,
'selected_mag' => $selectedMag,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
} finally {
if (is_file($tempFile)) {
@unlink($tempFile);
}
}
} 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 waehle 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="occultPrevDay">Tag -</button>
<label for="occultDatePicker" class="occult-select-label">Datum</label>
<input type="date" id="occultDatePicker" class="occult-date-input" name="date" value="<?= h($selectedDate) ?>">
<button type="button" class="btn btn-secondary btn-sm" id="occultTodayBtn">Heute</button>
<button type="button" class="btn btn-secondary btn-sm" id="occultNextDay">Tag +</button>
<label for="occultMagSelect" class="occult-select-label">bis</label>
<select id="occultMagSelect" class="satpass-select occult-select" name="mag">
<?php foreach ([2.0, 2.5, 3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0] as $magOption): ?>
<option value="<?= h(number_format($magOption, 1, '.', '')) ?>"<?= abs($selectedMag - $magOption) < 0.01 ? ' selected' : '' ?>>
<?= h(number_format($magOption, 1, ',', '')) ?> mag
</option>
<?php endforeach; ?>
</select>
<input type="hidden" name="calculate" value="1">
<button type="submit" class="btn btn-primary btn-sm">Berechnen</button>
<button type="button" class="btn btn-secondary btn-sm" id="occultWeekBtn">7 Tage</button>
<button type="button" class="btn btn-secondary btn-sm" id="occultMonthBtn">Monat</button>
</form>
<span class="hint">Die Berechnung laeuft im Hintergrund. Hoehere Grenzgroessen brauchen deutlich laenger.</span>
</div>
</div>
<div class="occult-status" id="occultSubmitStatus" hidden style="display:none;">
<span class="occult-spinner" aria-hidden="true" style="opacity:1"></span>
<span>Berechnung laeuft... Die serverseitige Python-Auswertung kann je nach Grenzgroesse etwas dauern.</span>
</div>
</section>
<div id="occultResults">
<section class="card occult-card">
<h2>Bereit zur Berechnung</h2>
<p class="hint">
Die Seite bleibt jetzt waehrend der Berechnung sichtbar. Waehle Datum und Grenzgroesse und starte die
Auswertung mit dem Button.
</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">Kartengroesse</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" aria-hidden="true" style="opacity:1"></span>
<span class="occult-submit-text">Die serverseitige Python-Auswertung wird im Hintergrund ausgefuehrt. Die Seite bleibt dabei sichtbar.</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 class="occult-submit-progress-note">Geschaetzter Fortschritt waehrend der laufenden Serverberechnung.</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 prevDayBtn = document.getElementById('occultPrevDay');
const nextDayBtn = document.getElementById('occultNextDay');
const todayBtn = document.getElementById('occultTodayBtn');
const weekBtn = document.getElementById('occultWeekBtn');
const monthBtn = document.getElementById('occultMonthBtn');
const datePicker = document.getElementById('occultDatePicker');
const magSelect = document.getElementById('occultMagSelect');
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 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) {
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 shiftDateString(dateString, days) {
const date = new Date(`${dateString}T12:00:00Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
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 getTodayDateString() {
const parts = getTzParts(new Date(), SITE_LOCATION.timezone);
return `${String(parts.year).padStart(4, '0')}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')}`;
}
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 === '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;
const dateValues = rows
.map((row) => row._dateString || '')
.filter((value) => !!value)
.sort();
const rangeStart = payload.range_start || (dateValues.length > 0 ? dateValues[0] : (payload.selected_date || ''));
const rangeEnd = payload.range_end || (dateValues.length > 0 ? dateValues[dateValues.length - 1] : (payload.selected_date || ''));
let coveredRangeLabel = 'kein Zeitraum';
if (rangeStart && rangeEnd) {
coveredRangeLabel = rangeStart === rangeEnd
? formatDateLabel(rangeStart)
: `${formatDateLabel(rangeStart)} bis ${formatDateLabel(rangeEnd)}`;
}
const hasDateColumn = rows.some((row) => !!row._dateString);
let summaryHtml = `
<section class="card occult-card">
<div class="occult-summary">
<div class="occult-stat">
<span class="occult-label">Gepruefte Sterne</span>
<strong>${escapeHtml(payload.star_count ?? 0)}</strong>
</div>
<div class="occult-stat">
<span class="occult-label">Zeitraum</span>
<strong>${escapeHtml(coveredRangeLabel)}</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) {
const magSelect = form.querySelector('select[name="mag"]');
const currentMag = magSelect ? magSelect.value.replace('.', ',') : '';
tableRows = `
<tr>
<td colspan="9" class="occult-empty">
Fuer dieses Datum wurden keine Mond-Stern-Bedeckungen heller als ${escapeHtml(currentMag)} mag gefunden.
</td>
</tr>
`;
} else {
tableRows = rows.map((row, index) => {
const star = row.star || {};
const starName = star.constellation
? `${escapeHtml(star.label || 'Unbenannter Stern')} (${escapeHtml(star.constellation)})`
: escapeHtml(star.label || 'Unbenannter Stern');
const visibility = row.visibility || {};
const dateCell = hasDateColumn ? `<td class="occult-week-date">${escapeHtml(formatDateLabel(row._dateString || payload.selected_date || ''))}</td>` : '';
return `
<tr class="occult-row" data-event-index="${index}">
${dateCell}
<td>${starName}</td>
<td>${escapeHtml(Number(star.mag || 99).toFixed(2).replace('.', ','))}</td>
<td>${formatTimeBlock(row.ingress)}</td>
<td>${formatTimeBlock(row.maximum)}</td>
<td>${formatTimeBlock(row.egress)}</td>
<td>${formatDuration(Number(row.duration_seconds))}</td>
<td><span class="occult-visibility ${visibilityClass(visibility.key)}">${escapeHtml(visibility.label || 'Tag')}</span></td>
<td>${escapeHtml(Number(row.limb_distance_arcmin || 0).toFixed(2).replace('.', ','))}'</td>
<td>${escapeHtml(Number(row.moon_alt_deg || 0).toFixed(1).replace('.', ','))}&deg;</td>
</tr>
`;
}).join('');
}
results.innerHTML = `
${summaryHtml}
<section class="card occult-card">
<div class="occult-table-wrap">
<table class="data-table occult-table">
<thead>
<tr>
${hasDateColumn ? '<th>Datum</th>' : ''}
<th>Stern</th>
<th>mag</th>
<th>Eintritt</th>
<th>Maximum</th>
<th>Austritt</th>
<th>Dauer</th>
<th>Sichtbarkeit</th>
<th>Randabstand</th>
<th>Mondhoehe</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 += 4;
} else if (current < 45) {
current += 2;
} else if (current < 70) {
current += 1.2;
} else if (current < 88) {
current += 0.5;
} else if (current < 94) {
current += 0.2;
}
setEstimatedProgress(current);
}, 300);
}
function stopEstimatedProgress(success) {
if (progressTimer) {
window.clearInterval(progressTimer);
progressTimer = null;
}
setEstimatedProgress(success ? 100 : 0);
}
function setRangeProgress(done, total) {
if (!total || total <= 0) {
return;
}
setEstimatedProgress((done / total) * 100);
}
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, isTarget) {
const brightness = starBrightnessFactor(mag);
const baseRadius = 0.7 + brightness * 2.2;
return isTarget ? baseRadius + 0.8 : baseRadius;
}
function drawStarSymbol(ctx, x, y, mag, isTarget) {
const brightness = starBrightnessFactor(mag);
const coreRadius = magnitudeToRadius(mag, isTarget);
const glowRadius = coreRadius * (isTarget ? 3.6 : 2.6);
const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius);
const isInverted = STATE.invertMap;
if (isTarget) {
if (isInverted) {
glow.addColorStop(0, 'rgba(143, 98, 12, 0.72)');
glow.addColorStop(0.28, 'rgba(201, 152, 48, 0.38)');
glow.addColorStop(1, 'rgba(201, 152, 48, 0)');
} else {
glow.addColorStop(0, 'rgba(255, 236, 170, 0.95)');
glow.addColorStop(0.28, 'rgba(255, 214, 111, 0.68)');
glow.addColorStop(1, 'rgba(255, 214, 111, 0)');
}
} else {
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
? (isTarget ? '#7f5b05' : '#253246')
: (isTarget ? '#ffe8b0' : '#f5f8ff');
ctx.fill();
if (isTarget) {
ctx.beginPath();
ctx.arc(x, y, coreRadius + 2.2, 0, Math.PI * 2);
ctx.strokeStyle = isInverted ? 'rgba(153, 112, 22, 0.85)' : 'rgba(255, 214, 111, 0.85)';
ctx.lineWidth = 1;
ctx.stroke();
}
}
function getStarLabelMetrics(ctx, label, isTarget) {
ctx.save();
ctx.font = isTarget ? '12px Segoe UI' : '11px Segoe UI';
const text = String(label);
const textWidth = ctx.measureText(text).width;
ctx.restore();
return {
text,
textWidth,
padX: 4,
boxH: isTarget ? 18 : 16,
font: isTarget ? '12px Segoe UI' : '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 },
{ boxX: x - boxW / 2, boxY: y - 14 - boxH / 2, boxW, boxH },
{ boxX: x - boxW / 2, boxY: y + 16, boxW, boxH },
];
}
function drawStarLabel(ctx, x, y, label, isTarget, placement) {
if (!label) {
return null;
}
const metrics = getStarLabelMetrics(ctx, label, isTarget);
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
? (isTarget ? 'rgba(255, 247, 222, 0.9)' : 'rgba(255, 255, 255, 0.82)')
: (isTarget ? 'rgba(10, 14, 24, 0.82)' : 'rgba(0, 0, 0, 0.58)');
ctx.fillRect(box.boxX, box.boxY - box.boxH / 2, box.boxW, box.boxH);
ctx.fillStyle = isInverted
? (isTarget ? '#6d4e00' : 'rgba(26, 34, 52, 0.9)')
: (isTarget ? '#f0d990' : '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, event) {
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,
isTarget: Number(star.hip) === Number(event.star && event.star.hip)
});
});
}
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;
}
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 moonRadiusDeg = Math.asin(Math.max(-1, Math.min(1, 1737.4 / (Number(moonEq.dist || 1) * Astronomy.KM_PER_AU)))) * Astronomy.RAD2DEG;
const moonTerminatorInfo = getMoonTerminatorInfo(time, moonEq);
const mapStars = getStarsForDetailMap(time, event);
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,
isTarget: item.isTarget
});
}
});
nearbyStars.forEach((starItem) => {
const x = centerX - starItem.offset.xDeg * scale;
const y = centerY - starItem.offset.yDeg * scale;
drawStarSymbol(ctx, x, y, starItem.star.mag, starItem.isTarget);
if (starItem.isTarget || 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,
isTarget: starItem.isTarget,
mag: Number(starItem.star.mag)
});
}
});
drawMoonWithTerminator(ctx, centerX, centerY, Math.max(6, moonRadiusDeg * scale), moonTerminatorInfo);
const placedLabelBoxes = [];
labelItems
.sort((left, right) => {
if (left.isTarget !== right.isTarget) {
return left.isTarget ? -1 : 1;
}
return left.mag - right.mag;
})
.forEach((item) => {
if (!item.label) {
return;
}
const metrics = getStarLabelMetrics(ctx, item.label, item.isTarget);
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, item.isTarget, chosenPlacement);
if (labelBox) {
placedLabelBoxes.push(labelBox);
}
});
detailTitleEl.textContent = `${event.star.label || 'Stern'} - Beginn der Bedeckung`;
detailMetaEl.textContent = `${TIME_FMT.format(time)} Uhr, Feld ${STATE.mapSizeDeg} x ${STATE.mapSizeDeg} Grad, Mond im Zentrum`;
detailInfoEl.innerHTML = `
<div class="occult-detail-row"><span>Zeit</span><strong>${TIME_FMT.format(time)} Uhr</strong></div>
<div class="occult-detail-row"><span>Stern</span><strong>${escapeHtml(event.star.label || '')}</strong></div>
<div class="occult-detail-row"><span>Magnitude</span><strong>${Number(event.star.mag || 99).toFixed(2)}</strong></div>
<div class="occult-detail-row"><span>Phase</span><strong>${(moonTerminatorInfo.phaseFraction * 100).toFixed(1)} %</strong></div>
<div class="occult-detail-row"><span>Sterne im Feld</span><strong>${nearbyStars.length}</strong></div>
<div class="occult-detail-row"><span>Mondradius</span><strong>${(moonRadiusDeg * 60).toFixed(2)}'</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 fetchOccultationsForDate(dateString) {
const formData = new FormData(form);
formData.set('ajax', '1');
formData.set('date', dateString);
formData.set('mag', magSelect.value);
const response = await fetch(window.location.href, {
method: 'POST',
body: formData,
headers: { 'X-Requested-With': 'fetch' }
});
return response.json();
}
async function runRange(mode) {
const baseDate = datePicker.value || '<?= h($selectedDate) ?>';
const dayStrings = [];
if (mode === 'week') {
for (let i = 0; i < 7; i += 1) dayStrings.push(shiftDateString(baseDate, i));
} else if (mode === 'month') {
const date = new Date(`${baseDate}T12:00:00Z`);
const year = date.getUTCFullYear();
const month = date.getUTCMonth();
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
for (let d = 1; d <= daysInMonth; d += 1) {
dayStrings.push(new Date(Date.UTC(year, month, d, 12, 0, 0)).toISOString().slice(0, 10));
}
} else {
dayStrings.push(baseDate);
}
if (isSubmitting) {
return;
}
isSubmitting = true;
status.hidden = false;
status.style.display = 'inline-flex';
overlay.hidden = false;
overlay.classList.add('is-visible');
const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = 'Berechnet...';
}
if (weekBtn) weekBtn.disabled = true;
if (monthBtn) monthBtn.disabled = true;
const allResults = [];
let totalStarCount = 0;
try {
for (let i = 0; i < dayStrings.length; i += 1) {
setRangeProgress(i, dayStrings.length);
const payload = await fetchOccultationsForDate(dayStrings[i]);
if (!payload || !payload.ok) {
stopEstimatedProgress(false);
renderError(payload && payload.error ? payload.error : 'Unbekannter Fehler', payload && payload.debug ? payload.debug : '');
return;
}
totalStarCount = payload.star_count || totalStarCount;
(payload.results || []).forEach((row) => {
allResults.push({ ...row, _dateString: dayStrings[i] });
});
}
setRangeProgress(dayStrings.length, dayStrings.length);
renderSuccess({
star_count: totalStarCount,
results: allResults,
selected_date: baseDate,
selected_mag: Number(magSelect.value || 3.0),
range_start: dayStrings[0] || baseDate,
range_end: dayStrings[dayStrings.length - 1] || baseDate
});
} catch (error) {
renderError('Die Berechnung konnte nicht abgeschlossen werden.', error && error.message ? error.message : '');
} finally {
isSubmitting = false;
window.setTimeout(function () {
status.hidden = true;
status.style.display = 'none';
overlay.hidden = true;
overlay.classList.remove('is-visible');
setEstimatedProgress(0);
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = 'Berechnen';
}
if (weekBtn) weekBtn.disabled = false;
if (monthBtn) monthBtn.disabled = false;
}, 220);
}
}
form.addEventListener('submit', function (event) {
if (isSubmitting) {
event.preventDefault();
return;
}
event.preventDefault();
isSubmitting = true;
status.hidden = false;
status.style.display = 'inline-flex';
overlay.hidden = false;
overlay.classList.add('is-visible');
startEstimatedProgress();
const submitButton = form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
submitButton.textContent = 'Berechnet...';
}
fetchOccultationsForDate(datePicker.value || '<?= h($selectedDate) ?>')
.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.style.display = 'none';
overlay.hidden = true;
overlay.classList.remove('is-visible');
setEstimatedProgress(0);
if (submitButton) {
submitButton.disabled = false;
submitButton.textContent = 'Berechnen';
}
}, 220);
});
});
if (prevDayBtn && datePicker) {
prevDayBtn.addEventListener('click', function () {
datePicker.value = shiftDateString(datePicker.value || '<?= h($selectedDate) ?>', -1);
});
}
if (nextDayBtn && datePicker) {
nextDayBtn.addEventListener('click', function () {
datePicker.value = shiftDateString(datePicker.value || '<?= h($selectedDate) ?>', 1);
});
}
if (todayBtn && datePicker) {
todayBtn.addEventListener('click', function () {
datePicker.value = getTodayDateString();
});
}
if (weekBtn) {
weekBtn.addEventListener('click', function () {
runRange('week');
});
}
if (monthBtn) {
monthBtn.addEventListener('click', function () {
runRange('month');
});
}
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'; ?>