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>
1345 lines
51 KiB
PHP
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, '.', '') ?>° / <?= number_format((float)$pageLocation['longitude'], 4, '.', '') ?>°</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
|
|
– 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function formatTimeBlock(timeData) {
|
|
if (!timeData || !timeData.local_time) {
|
|
return '—';
|
|
}
|
|
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 '—';
|
|
}
|
|
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 '—';
|
|
}
|
|
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)}°</strong></div>
|
|
<div class="occult-detail-row"><span>Planetenhöhe</span><strong>${Number(event.planet_alt_deg || 0).toFixed(1)}°</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'; ?>
|