1664 lines
56 KiB
PHP
1664 lines
56 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
ini_set('display_errors', '1');
|
||
error_reporting(E_ALL);
|
||
session_start();
|
||
|
||
$loggedIn = isset($_SESSION['user_id']);
|
||
$pageTitle = 'Mond-Stern-Bedeckungen - AstroTools';
|
||
$bodyClass = 'occult-page';
|
||
$headerIntroPre = 'Astronomy Engine + Hipparcos';
|
||
$headerIntroTitle = 'Mond-Stern-Bedeckungen';
|
||
$headerIntroSub = 'Astronomisch berechnete Okkultationen fuer den heutigen lokalen Tag am Standard-Standort.';
|
||
$defaultLocation = null;
|
||
$pdo = null;
|
||
$dataLoadError = null;
|
||
|
||
/**
|
||
* @param array<string, mixed> $row
|
||
*/
|
||
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';
|
||
}
|
||
|
||
$brightStars = [];
|
||
$detailMapStars = [];
|
||
$constellationLines = [];
|
||
$dayMeta = null;
|
||
$starCatalogLimit = 6.0;
|
||
$detailMapCatalogLimit = 8.0;
|
||
|
||
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) {
|
||
$dataLoadError = $exception->getMessage();
|
||
}
|
||
|
||
require __DIR__ . '/header.php';
|
||
|
||
$pageLocation = null;
|
||
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'),
|
||
];
|
||
}
|
||
|
||
if ($dataLoadError === null && $pdo instanceof PDO && $pageLocation !== null && $pageLocation['latitude'] !== null && $pageLocation['longitude'] !== null) {
|
||
$tz = new DateTimeZone($pageLocation['timezone']);
|
||
$todayLocal = new DateTimeImmutable('now', $tz);
|
||
$dayStartLocal = $todayLocal->setTime(0, 0, 0);
|
||
$dayEndLocal = $dayStartLocal->modify('+1 day');
|
||
$dayMeta = [
|
||
'localDate' => $dayStartLocal->format('Y-m-d'),
|
||
'localDateLabel' => $dayStartLocal->format('d.m.Y'),
|
||
'dayStartUtc' => $dayStartLocal->setTimezone(new DateTimeZone('UTC'))->format(DateTimeInterface::ATOM),
|
||
'dayEndUtc' => $dayEndLocal->setTimezone(new DateTimeZone('UTC'))->format(DateTimeInterface::ATOM),
|
||
];
|
||
|
||
try {
|
||
$stmt = $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` <= 6.0
|
||
ORDER BY `mag` ASC, `proper` ASC, `bf` ASC, `hip` ASC"
|
||
);
|
||
|
||
foreach ($stmt->fetchAll() as $row) {
|
||
$brightStars[] = [
|
||
'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,
|
||
];
|
||
}
|
||
|
||
$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) {
|
||
$dataLoadError = $exception->getMessage();
|
||
$brightStars = [];
|
||
$detailMapStars = [];
|
||
$constellationLines = [];
|
||
}
|
||
}
|
||
?>
|
||
|
||
<script src="js/astronomy.browser.min.js"></script>
|
||
|
||
<div class="occult-tool" id="occultTool">
|
||
<?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 ($brightStars === [] || $dayMeta === null): ?>
|
||
<section class="card occult-card">
|
||
<h2>Sterndaten nicht verfuegbar</h2>
|
||
<p class="hint">
|
||
Die Hipparcos-Sterndaten oder die Datenbankverbindung konnten nicht geladen werden.
|
||
Dadurch ist die heutige Bedeckungsberechnung momentan nicht moeglich.
|
||
</p>
|
||
<?php if ($dataLoadError !== null): ?>
|
||
<p class="hint">Technischer Hinweis: <?= htmlspecialchars($dataLoadError, ENT_QUOTES, 'UTF-8') ?></p>
|
||
<?php endif; ?>
|
||
</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><?= htmlspecialchars($pageLocation['name'], ENT_QUOTES, 'UTF-8') ?></strong>
|
||
<span><?= number_format($pageLocation['latitude'], 4, '.', '') ?>° / <?= number_format($pageLocation['longitude'], 4, '.', '') ?>°</span>
|
||
<span><?= htmlspecialchars($pageLocation['timezone'], ENT_QUOTES, 'UTF-8') ?></span>
|
||
</div>
|
||
<div class="occult-head-item occult-head-period">
|
||
<span class="occult-label">Zeitraum & Berechnung</span>
|
||
<div class="occult-date-picker-row">
|
||
<label for="occultDatePicker" class="occult-select-label">Datum</label>
|
||
<input type="date" id="occultDatePicker" class="occult-date-input" value="<?= htmlspecialchars($dayMeta['localDate'], ENT_QUOTES, 'UTF-8') ?>">
|
||
<button type="button" class="btn btn-secondary btn-sm" id="occultTodayBtn">Heute</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="occultPrevDay">Tag −</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="occultNextDay">Tag +</button>
|
||
</div>
|
||
<div class="occult-period-actions">
|
||
<button type="button" class="btn btn-primary btn-sm" id="occultStartDayBtn">1 Tag berechnen</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="occultWeekBtn">7 Tage ab hier</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="occultMonthBtn">Ganzer Monat</button>
|
||
</div>
|
||
<span class="hint" id="occultWeekHint" hidden>Berechnung kann mehrere Minuten dauern — alle Tage laufen parallel.</span>
|
||
</div>
|
||
<div class="occult-head-item">
|
||
<span class="occult-label">Sternauswahl</span>
|
||
<strong id="occultMagLabel">bis 2.0 mag</strong>
|
||
<div class="occult-mag-controls">
|
||
<label for="occultMagSelect" class="occult-select-label">Sterne bis</label>
|
||
<select id="occultMagSelect" class="satpass-select occult-select">
|
||
<option value="2.0" selected>2.0 mag</option>
|
||
<option value="2.5">2.5 mag</option>
|
||
<option value="3.0">3.0 mag</option>
|
||
<option value="3.5">3.5 mag</option>
|
||
<option value="4.0">4.0 mag</option>
|
||
<option value="4.5">4.5 mag</option>
|
||
<option value="5.0">5.0 mag</option>
|
||
<option value="5.5">5.5 mag</option>
|
||
<option value="6.0">6.0 mag</option>
|
||
</select>
|
||
</div>
|
||
<span id="occultStarCountMeta"><?= count($brightStars) ?> Hipparcos-Sterne</span>
|
||
</div>
|
||
</div>
|
||
<div id="occultStatus" class="occult-status">
|
||
<span class="occult-spinner" aria-hidden="true" id="occultSpinner" hidden></span>
|
||
<span id="occultStatusText">Zeitraum und Grenzgroesse waehlen, dann Berechnung starten.</span>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="occultAbortBtn" hidden>Abbrechen</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card occult-card">
|
||
<div class="occult-summary" id="occultSummary">
|
||
<div class="occult-stat">
|
||
<span class="occult-label">Ereignisse</span>
|
||
<strong id="occultCount">--</strong>
|
||
</div>
|
||
<div class="occult-stat">
|
||
<span class="occult-label">Naechstes Maximum</span>
|
||
<strong id="occultNext">--</strong>
|
||
</div>
|
||
<div class="occult-stat">
|
||
<span class="occult-label">Tiefste Bedeckung</span>
|
||
<strong id="occultDeepest">--</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="occult-table-wrap">
|
||
<table class="data-table occult-table">
|
||
<thead>
|
||
<tr>
|
||
<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 id="occultTableBody">
|
||
<tr>
|
||
<td colspan="9" class="occult-empty">Berechnung laeuft...</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<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">Beginn der Bedeckung, Feld 5 x 5 Grad</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>
|
||
|
||
<script>
|
||
'use strict';
|
||
|
||
const SITE_LOCATION = <?= json_encode($pageLocation, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||
const OCCULT_DAY = <?= json_encode($dayMeta, JSON_UNESCAPED_SLASHES) ?>;
|
||
const BRIGHT_STARS = <?= json_encode($brightStars, 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 STAR_CATALOG_LIMIT = <?= json_encode($starCatalogLimit, 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 COARSE_STEP_MS = 2 * 60 * 1000;
|
||
const CANDIDATE_MARGIN_DEG = 0.32;
|
||
const MOON_RADIUS_KM = 1737.4;
|
||
const TIME_FMT = new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: SITE_LOCATION.timezone,
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
const statusEl = document.getElementById('occultStatus');
|
||
const statusTextEl = document.getElementById('occultStatusText');
|
||
const tableBodyEl = document.getElementById('occultTableBody');
|
||
const countEl = document.getElementById('occultCount');
|
||
const nextEl = document.getElementById('occultNext');
|
||
const deepestEl = document.getElementById('occultDeepest');
|
||
const toolEl = document.getElementById('occultTool');
|
||
const dateLabelEl = document.getElementById('occultDateLabel');
|
||
const magLabelEl = document.getElementById('occultMagLabel');
|
||
const starCountMetaEl = document.getElementById('occultStarCountMeta');
|
||
const magSelectEl = document.getElementById('occultMagSelect');
|
||
const prevDayBtn = document.getElementById('occultPrevDay');
|
||
const nextDayBtn = document.getElementById('occultNextDay');
|
||
const startDayBtn = document.getElementById('occultStartDayBtn');
|
||
const weekBtn = document.getElementById('occultWeekBtn');
|
||
const monthBtn = document.getElementById('occultMonthBtn');
|
||
const weekHintEl = document.getElementById('occultWeekHint');
|
||
const spinnerEl = document.getElementById('occultSpinner');
|
||
const datePickerEl = document.getElementById('occultDatePicker');
|
||
const todayBtn = document.getElementById('occultTodayBtn');
|
||
const abortBtn = document.getElementById('occultAbortBtn');
|
||
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 detailStarImg = new Image();
|
||
const detailStarRaysImg = new Image();
|
||
detailStarImg.src = 'images/star16x16.png';
|
||
detailStarRaysImg.src = 'images/star16x16_rays.png';
|
||
|
||
const STATE = {
|
||
dateString: OCCULT_DAY.localDate,
|
||
maxMag: Number(magSelectEl ? magSelectEl.value : 2.0),
|
||
dayStartMs: 0,
|
||
dayEndMs: 0,
|
||
runToken: 0,
|
||
isComputing: false,
|
||
results: [],
|
||
selectedEventIndex: null,
|
||
mapSizeDeg: Number(mapSizeSelectEl ? mapSizeSelectEl.value : 5),
|
||
invertMap: false
|
||
};
|
||
|
||
function setControlsDisabled(disabled) {
|
||
STATE.isComputing = disabled;
|
||
|
||
[
|
||
magSelectEl,
|
||
prevDayBtn,
|
||
nextDayBtn,
|
||
startDayBtn,
|
||
weekBtn,
|
||
monthBtn,
|
||
datePickerEl,
|
||
todayBtn,
|
||
mapSizeSelectEl,
|
||
invertBtn
|
||
].forEach((element) => {
|
||
if (element) {
|
||
element.disabled = disabled;
|
||
}
|
||
});
|
||
|
||
if (toolEl) {
|
||
toolEl.classList.toggle('occult-ui-busy', disabled);
|
||
}
|
||
|
||
if (spinnerEl) {
|
||
spinnerEl.hidden = !disabled;
|
||
}
|
||
|
||
if (abortBtn) {
|
||
abortBtn.hidden = !disabled;
|
||
abortBtn.disabled = !disabled;
|
||
}
|
||
}
|
||
|
||
function setStatusText(message) {
|
||
if (statusTextEl) {
|
||
statusTextEl.textContent = message;
|
||
}
|
||
}
|
||
|
||
function sphericalSeparationDeg(raHoursA, decDegA, raHoursB, decDegB) {
|
||
const raA = raHoursA * 15 * Math.PI / 180;
|
||
const decA = decDegA * Math.PI / 180;
|
||
const raB = raHoursB * 15 * Math.PI / 180;
|
||
const decB = decDegB * Math.PI / 180;
|
||
const cosSep = (
|
||
Math.sin(decA) * Math.sin(decB) +
|
||
Math.cos(decA) * Math.cos(decB) * Math.cos(raA - raB)
|
||
);
|
||
return Math.acos(Math.max(-1, Math.min(1, cosSep))) * 180 / Math.PI;
|
||
}
|
||
|
||
function moonAngularRadiusDeg(distanceAu) {
|
||
if (!Number.isFinite(distanceAu) || distanceAu <= 0) {
|
||
return 0;
|
||
}
|
||
const ratio = MOON_RADIUS_KM / (distanceAu * Astronomy.KM_PER_AU);
|
||
return Math.asin(Math.max(-1, Math.min(1, ratio))) * Astronomy.RAD2DEG;
|
||
}
|
||
|
||
function evaluateGeometry(bodyName, timeInput) {
|
||
const time = timeInput instanceof Date
|
||
? timeInput
|
||
: (timeInput && timeInput.date instanceof Date ? timeInput.date : new Date(timeInput));
|
||
const moonEq = Astronomy.Equator('Moon', time, OBSERVER, true, true);
|
||
const starEq = Astronomy.Equator(bodyName, time, OBSERVER, true, true);
|
||
const sunEq = Astronomy.Equator('Sun', time, OBSERVER, true, true);
|
||
const moonHor = Astronomy.Horizon(time, OBSERVER, moonEq.ra, moonEq.dec, 'normal');
|
||
const starHor = Astronomy.Horizon(time, OBSERVER, starEq.ra, starEq.dec, 'normal');
|
||
const sunHor = Astronomy.Horizon(time, OBSERVER, sunEq.ra, sunEq.dec, 'normal');
|
||
const sepDeg = Astronomy.AngleBetween(moonEq.vec, starEq.vec);
|
||
const radiusDeg = moonAngularRadiusDeg(Number(moonEq.dist || 0));
|
||
|
||
return {
|
||
timeMs: time.getTime(),
|
||
sepDeg,
|
||
radiusDeg,
|
||
marginDeg: sepDeg - radiusDeg,
|
||
moonAltDeg: Number(moonHor.altitude || 0),
|
||
starAltDeg: Number(starHor.altitude || 0),
|
||
sunAltDeg: Number(sunHor.altitude || 0),
|
||
};
|
||
}
|
||
|
||
function makeMarginEvaluator(bodyName) {
|
||
return function marginEvaluator(astroTime) {
|
||
return evaluateGeometry(bodyName, astroTime).marginDeg;
|
||
};
|
||
}
|
||
|
||
function findRoot(bodyName, leftMs, rightMs) {
|
||
const evaluator = makeMarginEvaluator(bodyName);
|
||
try {
|
||
const root = Astronomy.Search(
|
||
evaluator,
|
||
new Astronomy.AstroTime(new Date(leftMs)),
|
||
new Astronomy.AstroTime(new Date(rightMs)),
|
||
{ dt_tolerance_seconds: 0.05, iter_limit: 60 }
|
||
);
|
||
if (root && root.date instanceof Date) {
|
||
return root.date.getTime();
|
||
}
|
||
} catch (error) {
|
||
console.warn('Praezise Kontakt-Suche fehlgeschlagen, nutze Bisektion.', error);
|
||
}
|
||
|
||
let left = leftMs;
|
||
let right = rightMs;
|
||
let leftVal = evaluateGeometry(bodyName, left).marginDeg;
|
||
|
||
for (let i = 0; i < 48; i += 1) {
|
||
const mid = (left + right) / 2;
|
||
const midVal = evaluateGeometry(bodyName, mid).marginDeg;
|
||
|
||
if (Math.sign(leftVal) === Math.sign(midVal)) {
|
||
left = mid;
|
||
leftVal = midVal;
|
||
} else {
|
||
right = mid;
|
||
}
|
||
}
|
||
|
||
return Math.round((left + right) / 2);
|
||
}
|
||
|
||
function findMinimum(bodyName, startMs, endMs) {
|
||
let left = startMs;
|
||
let right = endMs;
|
||
|
||
for (let i = 0; i < 60; i += 1) {
|
||
const span = (right - left) / 3;
|
||
const m1 = left + span;
|
||
const m2 = right - span;
|
||
const f1 = evaluateGeometry(bodyName, m1).marginDeg;
|
||
const f2 = evaluateGeometry(bodyName, m2).marginDeg;
|
||
|
||
if (f1 <= f2) {
|
||
right = m2;
|
||
} else {
|
||
left = m1;
|
||
}
|
||
}
|
||
|
||
return Math.round((left + right) / 2);
|
||
}
|
||
|
||
function formatTime(ms) {
|
||
if (ms === null) {
|
||
return '--';
|
||
}
|
||
return TIME_FMT.format(new Date(ms));
|
||
}
|
||
|
||
function formatDuration(durationMs) {
|
||
if (!Number.isFinite(durationMs) || durationMs <= 0) {
|
||
return '—';
|
||
}
|
||
|
||
const totalMinutes = Math.round(durationMs / 60000);
|
||
const hours = Math.floor(totalMinutes / 60);
|
||
const minutes = totalMinutes % 60;
|
||
|
||
if (hours > 0) {
|
||
return `${hours} h ${String(minutes).padStart(2, '0')} min`;
|
||
}
|
||
|
||
return `${minutes} min`;
|
||
}
|
||
|
||
function formatArcmin(deg) {
|
||
return `${(deg * 60).toFixed(2)}'`;
|
||
}
|
||
|
||
function formatAltitude(deg) {
|
||
return `${deg.toFixed(1)}°`;
|
||
}
|
||
|
||
function classifyVisibility(sunAltDeg) {
|
||
if (sunAltDeg > -5) {
|
||
return { key: 'day', label: 'Tag' };
|
||
}
|
||
|
||
if (sunAltDeg >= -10) {
|
||
return { key: 'twilight', label: 'Daemmerung' };
|
||
}
|
||
|
||
return { key: 'night', label: 'Nacht' };
|
||
}
|
||
|
||
function formatVisibilityBadge(visibility) {
|
||
return `<span class="occult-visibility occult-visibility-${visibility.key}">${escapeHtml(visibility.label)}</span>`;
|
||
}
|
||
|
||
function formatSignedDeg(deg) {
|
||
const sign = deg >= 0 ? '+' : '-';
|
||
return `${sign}${Math.abs(deg).toFixed(2)}°`;
|
||
}
|
||
|
||
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 getTimeZoneOffsetMillis(date, timeZone) {
|
||
const parts = getTzParts(date, timeZone);
|
||
const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
||
return asUtc - date.getTime();
|
||
}
|
||
|
||
function zonedDateTimeToUtc(year, month, day, hour, minute, second, timeZone) {
|
||
const guess = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
||
const offset = getTimeZoneOffsetMillis(guess, timeZone);
|
||
return new Date(guess.getTime() - offset);
|
||
}
|
||
|
||
function parseDateParts(dateString) {
|
||
const [year, month, day] = dateString.split('-').map(Number);
|
||
return { year, month, day };
|
||
}
|
||
|
||
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 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 updateDayRange() {
|
||
const { year, month, day } = parseDateParts(STATE.dateString);
|
||
const startUtc = zonedDateTimeToUtc(year, month, day, 0, 0, 0, SITE_LOCATION.timezone);
|
||
const endDateString = shiftDateString(STATE.dateString, 1);
|
||
const endParts = parseDateParts(endDateString);
|
||
const endUtc = zonedDateTimeToUtc(endParts.year, endParts.month, endParts.day, 0, 0, 0, SITE_LOCATION.timezone);
|
||
STATE.dayStartMs = startUtc.getTime();
|
||
STATE.dayEndMs = endUtc.getTime();
|
||
}
|
||
|
||
function getFilteredStars() {
|
||
return BRIGHT_STARS.filter((star) => Number(star.mag) <= STATE.maxMag);
|
||
}
|
||
|
||
function updateUiMeta(filteredStars) {
|
||
if (dateLabelEl) dateLabelEl.textContent = formatDateLabel(STATE.dateString);
|
||
if (magLabelEl) magLabelEl.textContent = `bis ${STATE.maxMag.toFixed(1)} mag`;
|
||
if (starCountMetaEl) starCountMetaEl.textContent = `${filteredStars.length} Sterne heller als ${STATE.maxMag.toFixed(1)} mag`;
|
||
if (datePickerEl && datePickerEl.value !== STATE.dateString) {
|
||
datePickerEl.value = STATE.dateString;
|
||
}
|
||
}
|
||
|
||
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 magnitudeToRadius(mag) {
|
||
const clamped = Math.max(-1.5, Math.min(6.5, Number(mag)));
|
||
return 1.4 + (6.5 - clamped) * 0.55;
|
||
}
|
||
|
||
function drawStarSymbol(ctx, x, y, mag, isTarget) {
|
||
const useRays = isTarget || Number(mag) <= 2.2;
|
||
const img = useRays ? detailStarRaysImg : detailStarImg;
|
||
const size = useRays
|
||
? (isTarget ? 20 : Math.max(12, 22 - Number(mag) * 2.2))
|
||
: Math.max(6, 14 - Number(mag) * 1.3);
|
||
|
||
if (img.complete && img.naturalWidth > 0) {
|
||
ctx.globalAlpha = isTarget ? 1 : Math.max(0.55, Math.min(0.95, 1.05 - Number(mag) * 0.08));
|
||
ctx.drawImage(img, x - size / 2, y - size / 2, size, size);
|
||
ctx.globalAlpha = 1;
|
||
return;
|
||
}
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, isTarget ? 4.8 : magnitudeToRadius(mag), 0, Math.PI * 2);
|
||
ctx.fillStyle = isTarget ? '#ffd66f' : '#dfeaff';
|
||
ctx.fill();
|
||
}
|
||
|
||
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 = null) {
|
||
if (!label) {
|
||
return null;
|
||
}
|
||
|
||
const metrics = getStarLabelMetrics(ctx, label, isTarget);
|
||
const box = placement ?? getLabelPlacementOptions(x, y, metrics)[0];
|
||
|
||
ctx.save();
|
||
ctx.font = metrics.font;
|
||
ctx.textAlign = 'left';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillStyle = 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 = 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 = 2) {
|
||
return !(
|
||
a.right + margin < b.left ||
|
||
a.left > b.right + margin ||
|
||
a.bottom + margin < b.top ||
|
||
a.top > b.bottom + margin
|
||
);
|
||
}
|
||
|
||
function normalizeAngle180(deg) {
|
||
let value = deg % 360;
|
||
if (value <= -180) value += 360;
|
||
if (value > 180) value -= 360;
|
||
return value;
|
||
}
|
||
|
||
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, brightSideAngleRad } = terminatorInfo;
|
||
const cosPhase = Math.max(-1, Math.min(1, 2 * phaseFraction - 1));
|
||
|
||
ctx.save();
|
||
ctx.translate(centerX, centerY);
|
||
ctx.rotate(brightSideAngleRad);
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(0, 0, radiusPx, 0, Math.PI * 2);
|
||
ctx.fillStyle = 'rgba(24, 28, 36, 0.96)';
|
||
ctx.fill();
|
||
|
||
if (phaseFraction <= 0.001) {
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeStyle = '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 = 'rgba(228, 232, 240, 0.95)';
|
||
ctx.fill();
|
||
ctx.lineWidth = 2;
|
||
ctx.strokeStyle = 'rgba(245,248,255,0.7)';
|
||
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 = 'rgba(228, 232, 240, 0.95)';
|
||
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 = 'rgba(245,248,255,0.72)';
|
||
ctx.stroke();
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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: star.hip === 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) {
|
||
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 = moonAngularRadiusDeg(Number(moonEq.dist || 0));
|
||
const moonTerminatorInfo = getMoonTerminatorInfo(time, moonEq);
|
||
const mapStars = getStarsForDetailMap(time, event);
|
||
const constellationSegments = getConstellationSegmentsForDetailMap(time, moonEq, fieldHalfDeg);
|
||
const nearbyStars = [];
|
||
const labelItems = [];
|
||
let targetOffset = { xDeg: 0, yDeg: 0 };
|
||
|
||
mapStars.forEach((item) => {
|
||
const offset = angularOffsetDeg(item.eq.ra, item.eq.dec, moonEq.ra, moonEq.dec);
|
||
if (item.isTarget) {
|
||
targetOffset = offset;
|
||
}
|
||
if (Math.abs(offset.xDeg) <= fieldHalfDeg * 1.12 && Math.abs(offset.yDeg) <= fieldHalfDeg * 1.12) {
|
||
nearbyStars.push({
|
||
star: item.star,
|
||
offset,
|
||
isTarget: item.isTarget
|
||
});
|
||
}
|
||
});
|
||
|
||
detailTitleEl.textContent = `${event.star.label} - Beginn der Bedeckung`;
|
||
detailMetaEl.textContent = `${formatTime(timeMs)} Uhr, Feld ${STATE.mapSizeDeg} x ${STATE.mapSizeDeg} Grad, Mond im Zentrum`;
|
||
|
||
ctx.clearRect(0, 0, width, height);
|
||
ctx.fillStyle = '#000000';
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
ctx.strokeStyle = '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();
|
||
});
|
||
|
||
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 drawnBox = drawStarLabel(ctx, item.x, item.y, item.label, item.isTarget, chosenPlacement);
|
||
if (drawnBox) {
|
||
placedLabelBoxes.push(drawnBox);
|
||
}
|
||
});
|
||
|
||
ctx.fillStyle = 'rgba(240,217,144,0.92)';
|
||
ctx.font = '12px Segoe UI';
|
||
ctx.fillText('Mond', centerX + Math.max(10, moonRadiusDeg * scale + 8), centerY - 10);
|
||
ctx.fillStyle = 'rgba(221,227,245,0.78)';
|
||
ctx.fillText(`${STATE.mapSizeDeg}° x ${STATE.mapSizeDeg}°`, width - padding - 72, height - 10);
|
||
|
||
detailInfoEl.innerHTML = `
|
||
<div class="occult-detail-row"><span>Zeit</span><strong>${formatTime(timeMs)} 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).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>Stern relativ zum Mond</span><strong>ΔRA ${formatSignedDeg(targetOffset.xDeg)}, ΔDec ${formatSignedDeg(targetOffset.yDeg)}</strong></div>
|
||
<div class="occult-detail-row"><span>Mondradius</span><strong>${(moonRadiusDeg * 60).toFixed(2)}'</strong></div>
|
||
<div class="occult-detail-row"><span>Sterne im Feld</span><strong>${nearbyStars.length} sichtbar</strong></div>
|
||
`;
|
||
|
||
document.querySelectorAll('#occultTableBody 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);
|
||
}
|
||
|
||
function evaluateStar(bodyName, star, overrideDayStartMs, overrideDayEndMs) {
|
||
const dayStartMs = overrideDayStartMs !== undefined ? overrideDayStartMs : STATE.dayStartMs;
|
||
const dayEndMs = overrideDayEndMs !== undefined ? overrideDayEndMs : STATE.dayEndMs;
|
||
let minMarginDeg = Infinity;
|
||
let minTimeMs = dayStartMs;
|
||
let everNegative = false;
|
||
let nearHit = false;
|
||
let previousMargin = null;
|
||
let previousTimeMs = null;
|
||
let startMarginDeg = null;
|
||
let endMarginDeg = null;
|
||
let ingressBracket = null;
|
||
let egressBracket = null;
|
||
|
||
for (let timeMs = dayStartMs; timeMs <= dayEndMs; timeMs += COARSE_STEP_MS) {
|
||
const geometry = evaluateGeometry(bodyName, timeMs);
|
||
|
||
if (geometry.marginDeg < minMarginDeg) {
|
||
minMarginDeg = geometry.marginDeg;
|
||
minTimeMs = timeMs;
|
||
}
|
||
|
||
if (geometry.marginDeg <= 0) {
|
||
everNegative = true;
|
||
}
|
||
|
||
if (geometry.marginDeg <= CANDIDATE_MARGIN_DEG) {
|
||
nearHit = true;
|
||
}
|
||
|
||
if (startMarginDeg === null) {
|
||
startMarginDeg = geometry.marginDeg;
|
||
}
|
||
endMarginDeg = geometry.marginDeg;
|
||
|
||
if (previousMargin !== null && previousTimeMs !== null && Math.sign(previousMargin) !== Math.sign(geometry.marginDeg)) {
|
||
nearHit = true;
|
||
if (previousMargin > 0 && geometry.marginDeg <= 0 && ingressBracket === null) {
|
||
ingressBracket = [previousTimeMs, timeMs];
|
||
}
|
||
if (previousMargin <= 0 && geometry.marginDeg > 0) {
|
||
egressBracket = [previousTimeMs, timeMs];
|
||
}
|
||
}
|
||
|
||
previousMargin = geometry.marginDeg;
|
||
previousTimeMs = timeMs;
|
||
}
|
||
|
||
if (!everNegative && !nearHit) {
|
||
return null;
|
||
}
|
||
|
||
if (!everNegative || minMarginDeg > 0) {
|
||
return null;
|
||
}
|
||
|
||
const ingressMs = ingressBracket ? findRoot(bodyName, ingressBracket[0], ingressBracket[1]) : null;
|
||
const egressMs = egressBracket ? findRoot(bodyName, egressBracket[0], egressBracket[1]) : null;
|
||
|
||
let minSearchStart = Math.max(STATE.dayStartMs, minTimeMs - COARSE_STEP_MS);
|
||
let minSearchEnd = Math.min(STATE.dayEndMs, minTimeMs + COARSE_STEP_MS);
|
||
if (ingressMs !== null && egressMs !== null) {
|
||
minSearchStart = ingressMs;
|
||
minSearchEnd = egressMs;
|
||
}
|
||
|
||
const maxMs = findMinimum(bodyName, minSearchStart, minSearchEnd);
|
||
const maxGeom = evaluateGeometry(bodyName, maxMs);
|
||
|
||
if (maxGeom.marginDeg > 0 || maxGeom.moonAltDeg <= 0 || maxGeom.starAltDeg <= 0) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
star,
|
||
ingressMs,
|
||
maxMs,
|
||
egressMs,
|
||
partialStart: ingressMs === null && startMarginDeg !== null && startMarginDeg <= 0,
|
||
partialEnd: egressMs === null && endMarginDeg !== null && endMarginDeg <= 0,
|
||
durationMs: ingressMs !== null && egressMs !== null ? egressMs - ingressMs : null,
|
||
limbDistanceArcmin: Math.abs(maxGeom.marginDeg) * 60,
|
||
moonAltDeg: maxGeom.moonAltDeg,
|
||
starAltDeg: maxGeom.starAltDeg,
|
||
sunAltDeg: maxGeom.sunAltDeg,
|
||
visibility: classifyVisibility(maxGeom.sunAltDeg),
|
||
};
|
||
}
|
||
|
||
function renderResults(results) {
|
||
STATE.results = results;
|
||
STATE.selectedEventIndex = null;
|
||
|
||
if (results.length === 0) {
|
||
tableBodyEl.innerHTML = `<tr><td colspan="9" class="occult-empty">Fuer dieses Datum wurden keine Mond-Stern-Bedeckungen heller als ${STATE.maxMag.toFixed(1)} mag gefunden.</td></tr>`;
|
||
countEl.textContent = '0';
|
||
nextEl.textContent = 'keine';
|
||
deepestEl.textContent = 'keine';
|
||
setStatusText('Berechnung abgeschlossen. Keine passenden Ereignisse fuer den heutigen lokalen Tag.');
|
||
if (detailCardEl) {
|
||
detailCardEl.hidden = true;
|
||
}
|
||
return;
|
||
}
|
||
|
||
tableBodyEl.innerHTML = results.map((event, index) => {
|
||
const starName = `${event.star.label}${event.star.constellation ? ` (${event.star.constellation})` : ''}`;
|
||
return `
|
||
<tr class="occult-row" data-event-index="${index}">
|
||
<td>${escapeHtml(starName)}</td>
|
||
<td>${Number(event.star.mag).toFixed(2)}</td>
|
||
<td>${formatTime(event.ingressMs)}</td>
|
||
<td>${formatTime(event.maxMs)}</td>
|
||
<td>${formatTime(event.egressMs)}</td>
|
||
<td>${formatDuration(event.durationMs)}</td>
|
||
<td>${formatVisibilityBadge(event.visibility)}</td>
|
||
<td>${formatArcmin(event.limbDistanceArcmin / 60)}</td>
|
||
<td>${formatAltitude(event.moonAltDeg)}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
const deepest = results.reduce((best, current) => {
|
||
return current.limbDistanceArcmin > best.limbDistanceArcmin ? current : best;
|
||
}, results[0]);
|
||
const nowMs = Date.now();
|
||
const nextEvent = results.find((event) => event.maxMs >= nowMs) || results[0];
|
||
|
||
countEl.textContent = String(results.length);
|
||
nextEl.textContent = `${formatTime(nextEvent.maxMs)} - ${nextEvent.star.label}`;
|
||
deepestEl.textContent = `${deepest.star.label} (${deepest.limbDistanceArcmin.toFixed(2)}')`;
|
||
setStatusText(`Berechnung abgeschlossen. ${results.length} Ereignis${results.length === 1 ? '' : 'se'} gefunden.`);
|
||
|
||
document.querySelectorAll('#occultTableBody tr[data-event-index]').forEach((row) => {
|
||
row.addEventListener('click', () => {
|
||
renderOccultationMap(Number(row.dataset.eventIndex));
|
||
});
|
||
});
|
||
|
||
renderOccultationMap(0);
|
||
}
|
||
|
||
async function computeOccultations() {
|
||
const runToken = ++STATE.runToken;
|
||
setControlsDisabled(true);
|
||
try {
|
||
updateDayRange();
|
||
const filteredStars = getFilteredStars();
|
||
updateUiMeta(filteredStars);
|
||
const results = [];
|
||
const chunks = chunkStars(filteredStars, STAR_BODIES.length);
|
||
|
||
if (filteredStars.length === 0) {
|
||
renderResults([]);
|
||
setStatusText('Keine Sterne fuer diese Grenzgroesse verfuegbar.');
|
||
if (runToken === STATE.runToken) {
|
||
setControlsDisabled(false);
|
||
}
|
||
return;
|
||
}
|
||
|
||
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
|
||
if (runToken !== STATE.runToken) {
|
||
return;
|
||
}
|
||
const chunk = chunks[chunkIndex];
|
||
defineChunk(chunk);
|
||
|
||
chunk.forEach((star, index) => {
|
||
const event = evaluateStar(STAR_BODIES[index], star);
|
||
if (event) {
|
||
results.push(event);
|
||
}
|
||
});
|
||
|
||
if ((chunkIndex + 1) % 4 === 0 || chunkIndex === chunks.length - 1) {
|
||
setStatusText(`Berechne ${formatDateLabel(STATE.dateString)}... ${Math.round(((chunkIndex + 1) / chunks.length) * 100)} %`);
|
||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||
}
|
||
}
|
||
|
||
if (runToken !== STATE.runToken) {
|
||
return;
|
||
}
|
||
|
||
results.sort((left, right) => left.maxMs - right.maxMs);
|
||
renderResults(results);
|
||
if (runToken === STATE.runToken) {
|
||
setControlsDisabled(false);
|
||
}
|
||
} catch (error) {
|
||
if (runToken === STATE.runToken) {
|
||
setControlsDisabled(false);
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
|
||
function getDaysInMonth(dateString) {
|
||
const { year, month } = parseDateParts(dateString);
|
||
// Letzten Tag des Monats ermitteln
|
||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||
const days = [];
|
||
for (let d = 1; d <= lastDay; d += 1) {
|
||
days.push(`${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}`);
|
||
}
|
||
return days;
|
||
}
|
||
|
||
function computeMultipleDays(dayStrings, labelPrefix) {
|
||
const runToken = ++STATE.runToken;
|
||
setControlsDisabled(true);
|
||
if (weekHintEl) weekHintEl.hidden = false;
|
||
|
||
const filteredStars = getFilteredStars();
|
||
updateUiMeta(filteredStars);
|
||
|
||
if (filteredStars.length === 0) {
|
||
renderResults([]);
|
||
setStatusText('Keine Sterne fuer diese Grenzgroesse verfuegbar.');
|
||
if (runToken === STATE.runToken) setControlsDisabled(false);
|
||
if (weekHintEl) weekHintEl.hidden = true;
|
||
return;
|
||
}
|
||
|
||
const TOTAL_DAYS = dayStrings.length;
|
||
const dayProgress = new Array(TOTAL_DAYS).fill(0);
|
||
const dayResults = new Array(TOTAL_DAYS).fill(null);
|
||
let completedDays = 0;
|
||
const activeWorkers = [];
|
||
|
||
function updateProgress() {
|
||
const totalPct = Math.round(dayProgress.reduce((sum, p) => sum + p, 0) / TOTAL_DAYS * 100);
|
||
const doneCount = dayProgress.filter((p) => p >= 1).length;
|
||
setStatusText(`${labelPrefix} – ${totalPct} % (${doneCount}/${TOTAL_DAYS} Tage fertig)`);
|
||
}
|
||
|
||
function finish() {
|
||
if (weekHintEl) weekHintEl.hidden = true;
|
||
if (runToken !== STATE.runToken) return;
|
||
const allResults = dayResults.flat().filter(Boolean);
|
||
allResults.sort((left, right) => left.maxMs - right.maxMs);
|
||
renderWeekResults(allResults);
|
||
setControlsDisabled(false);
|
||
}
|
||
|
||
dayStrings.forEach((dayString, dayIndex) => {
|
||
const { year, month, day } = parseDateParts(dayString);
|
||
const startUtc = zonedDateTimeToUtc(year, month, day, 0, 0, 0, SITE_LOCATION.timezone);
|
||
const endDayString = shiftDateString(dayString, 1);
|
||
const endParts = parseDateParts(endDayString);
|
||
const endUtc = zonedDateTimeToUtc(endParts.year, endParts.month, endParts.day, 0, 0, 0, SITE_LOCATION.timezone);
|
||
|
||
const worker = new Worker('js/occult-worker.js');
|
||
activeWorkers.push(worker);
|
||
|
||
worker.onmessage = function (e) {
|
||
if (runToken !== STATE.runToken) { worker.terminate(); return; }
|
||
const msg = e.data;
|
||
if (msg.type === 'progress') {
|
||
dayProgress[dayIndex] = msg.chunksDone / msg.totalChunks;
|
||
updateProgress();
|
||
} else if (msg.type === 'done') {
|
||
dayProgress[dayIndex] = 1;
|
||
dayResults[dayIndex] = msg.results;
|
||
completedDays += 1;
|
||
worker.terminate();
|
||
updateProgress();
|
||
if (completedDays === TOTAL_DAYS) finish();
|
||
}
|
||
};
|
||
|
||
worker.onerror = function () {
|
||
worker.terminate();
|
||
completedDays += 1;
|
||
dayResults[dayIndex] = [];
|
||
dayProgress[dayIndex] = 1;
|
||
if (completedDays === TOTAL_DAYS) finish();
|
||
};
|
||
|
||
worker.postMessage({
|
||
location: SITE_LOCATION,
|
||
stars: filteredStars,
|
||
dayStartMs: startUtc.getTime(),
|
||
dayEndMs: endUtc.getTime(),
|
||
dayString,
|
||
dayIndex,
|
||
totalDays: TOTAL_DAYS,
|
||
});
|
||
});
|
||
|
||
const originalRunToken = runToken;
|
||
const abortCheck = setInterval(() => {
|
||
if (STATE.runToken !== originalRunToken) {
|
||
clearInterval(abortCheck);
|
||
activeWorkers.forEach((w) => w.terminate());
|
||
if (weekHintEl) weekHintEl.hidden = true;
|
||
}
|
||
}, 200);
|
||
}
|
||
|
||
function computeWeekFromDate() {
|
||
const days = [];
|
||
for (let i = 0; i < 7; i += 1) days.push(shiftDateString(STATE.dateString, i));
|
||
const label = `7 Tage ab ${formatDateLabel(STATE.dateString)} parallel`;
|
||
computeMultipleDays(days, label);
|
||
}
|
||
|
||
function computeMonth() {
|
||
const days = getDaysInMonth(STATE.dateString);
|
||
const { year, month } = parseDateParts(STATE.dateString);
|
||
const monthLabel = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||
.format(new Date(Date.UTC(year, month - 1, 1)));
|
||
computeMultipleDays(days, `${monthLabel} (${days.length} Tage) parallel`);
|
||
}
|
||
|
||
function renderWeekResults(results) {
|
||
STATE.results = results;
|
||
STATE.selectedEventIndex = null;
|
||
|
||
if (results.length === 0) {
|
||
tableBodyEl.innerHTML = `<tr><td colspan="10" class="occult-empty">Keine Mond-Stern-Bedeckungen heller als ${STATE.maxMag.toFixed(1)} mag im gewaehlten Zeitraum gefunden.</td></tr>`;
|
||
countEl.textContent = '0';
|
||
nextEl.textContent = 'keine';
|
||
deepestEl.textContent = 'keine';
|
||
setStatusText('Wochenberechnung abgeschlossen. Keine passenden Ereignisse.');
|
||
if (detailCardEl) detailCardEl.hidden = true;
|
||
return;
|
||
}
|
||
|
||
// Temporarily add a "Datum" column header
|
||
const thead = document.querySelector('.occult-table thead tr');
|
||
if (thead && !document.getElementById('occultWeekDateTh')) {
|
||
const th = document.createElement('th');
|
||
th.id = 'occultWeekDateTh';
|
||
th.textContent = 'Datum';
|
||
thead.insertBefore(th, thead.firstChild);
|
||
}
|
||
|
||
tableBodyEl.innerHTML = results.map((event, index) => {
|
||
const starName = `${event.star.label}${event.star.constellation ? ` (${event.star.constellation})` : ''}`;
|
||
const dateCell = event._dateString
|
||
? `<td class="occult-week-date">${escapeHtml(formatDateLabel(event._dateString))}</td>`
|
||
: '<td>—</td>';
|
||
return `
|
||
<tr class="occult-row" data-event-index="${index}">
|
||
${dateCell}
|
||
<td>${escapeHtml(starName)}</td>
|
||
<td>${Number(event.star.mag).toFixed(2)}</td>
|
||
<td>${formatTime(event.ingressMs)}</td>
|
||
<td>${formatTime(event.maxMs)}</td>
|
||
<td>${formatTime(event.egressMs)}</td>
|
||
<td>${formatDuration(event.durationMs)}</td>
|
||
<td>${formatVisibilityBadge(event.visibility)}</td>
|
||
<td>${formatArcmin(event.limbDistanceArcmin / 60)}</td>
|
||
<td>${formatAltitude(event.moonAltDeg)}</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
const deepest = results.reduce((best, current) => {
|
||
return current.limbDistanceArcmin > best.limbDistanceArcmin ? current : best;
|
||
}, results[0]);
|
||
const nowMs = Date.now();
|
||
const nextEvent = results.find((event) => event.maxMs >= nowMs) || results[0];
|
||
|
||
countEl.textContent = String(results.length);
|
||
nextEl.textContent = `${formatTime(nextEvent.maxMs)} - ${nextEvent.star.label}`;
|
||
deepestEl.textContent = `${deepest.star.label} (${deepest.limbDistanceArcmin.toFixed(2)}')`;
|
||
setStatusText(`Berechnung abgeschlossen. ${results.length} Ereignis${results.length === 1 ? '' : 'se'} gefunden.`);
|
||
|
||
document.querySelectorAll('#occultTableBody tr[data-event-index]').forEach((row) => {
|
||
row.addEventListener('click', () => {
|
||
renderOccultationMap(Number(row.dataset.eventIndex));
|
||
});
|
||
});
|
||
|
||
renderOccultationMap(0);
|
||
}
|
||
|
||
function removeDateColumn() {
|
||
const weekTh = document.getElementById('occultWeekDateTh');
|
||
if (weekTh) weekTh.remove();
|
||
}
|
||
|
||
window.addEventListener('load', () => {
|
||
if (magSelectEl) {
|
||
magSelectEl.addEventListener('change', () => {
|
||
STATE.maxMag = Math.min(STAR_CATALOG_LIMIT, Number(magSelectEl.value || 2.0));
|
||
setStatusText('Grenzgroesse geaendert. Berechnung starten.');
|
||
});
|
||
}
|
||
|
||
if (prevDayBtn) {
|
||
prevDayBtn.addEventListener('click', () => {
|
||
STATE.dateString = shiftDateString(STATE.dateString, -1);
|
||
if (datePickerEl) datePickerEl.value = STATE.dateString;
|
||
setStatusText('Datum geaendert. Berechnung starten.');
|
||
});
|
||
}
|
||
|
||
if (nextDayBtn) {
|
||
nextDayBtn.addEventListener('click', () => {
|
||
STATE.dateString = shiftDateString(STATE.dateString, 1);
|
||
if (datePickerEl) datePickerEl.value = STATE.dateString;
|
||
setStatusText('Datum geaendert. Berechnung starten.');
|
||
});
|
||
}
|
||
|
||
if (datePickerEl) {
|
||
datePickerEl.addEventListener('change', () => {
|
||
if (!datePickerEl.value) return;
|
||
STATE.dateString = datePickerEl.value;
|
||
setStatusText('Datum geaendert. Berechnung starten.');
|
||
});
|
||
}
|
||
|
||
if (todayBtn) {
|
||
todayBtn.addEventListener('click', () => {
|
||
STATE.dateString = getTodayDateString();
|
||
if (datePickerEl) datePickerEl.value = STATE.dateString;
|
||
setStatusText('Datum auf heute gesetzt. Berechnung starten.');
|
||
});
|
||
}
|
||
|
||
if (startDayBtn) {
|
||
startDayBtn.addEventListener('click', () => {
|
||
removeDateColumn();
|
||
setStatusText(`Berechne ${formatDateLabel(STATE.dateString)}...`);
|
||
computeOccultations().catch((error) => {
|
||
console.error(error);
|
||
setControlsDisabled(false);
|
||
setStatusText('Die Berechnung ist fehlgeschlagen.');
|
||
});
|
||
});
|
||
}
|
||
|
||
if (weekBtn) {
|
||
weekBtn.addEventListener('click', () => {
|
||
computeWeekFromDate();
|
||
});
|
||
}
|
||
|
||
if (monthBtn) {
|
||
monthBtn.addEventListener('click', () => {
|
||
computeMonth();
|
||
});
|
||
}
|
||
|
||
if (abortBtn) {
|
||
abortBtn.addEventListener('click', () => {
|
||
if (!STATE.isComputing) return;
|
||
STATE.runToken += 1;
|
||
setControlsDisabled(false);
|
||
if (weekHintEl) weekHintEl.hidden = true;
|
||
setStatusText('Berechnung abgebrochen.');
|
||
});
|
||
}
|
||
|
||
if (mapSizeSelectEl) {
|
||
mapSizeSelectEl.addEventListener('change', () => {
|
||
STATE.mapSizeDeg = Number(mapSizeSelectEl.value || 5);
|
||
if (STATE.selectedEventIndex !== null) {
|
||
renderOccultationMap(STATE.selectedEventIndex);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (invertBtn) {
|
||
invertBtn.addEventListener('click', () => {
|
||
STATE.invertMap = !STATE.invertMap;
|
||
invertBtn.classList.toggle('is-active', STATE.invertMap);
|
||
if (STATE.selectedEventIndex !== null) {
|
||
renderOccultationMap(STATE.selectedEventIndex);
|
||
} else {
|
||
detailCanvasEl.classList.toggle('occult-canvas-inverted', STATE.invertMap);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
</script>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php require __DIR__ . '/footer.php'; ?>
|