1470 lines
56 KiB
PHP
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 = 'occ-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, '.', '') ?>° / <?= 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="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 occult-status-hidden" id="occultSubmitStatus" hidden>
|
|
<span class="occult-spinner occult-spinner-visible" aria-hidden="true"></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 occult-spinner-visible" aria-hidden="true"></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, '&')
|
|
.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) {
|
|
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('.', ','))}°</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.classList.remove('occult-status-hidden');
|
|
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.classList.add('occult-status-hidden');
|
|
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.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...';
|
|
}
|
|
|
|
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.classList.add('occult-status-hidden');
|
|
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'; ?>
|