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

1664 lines
56 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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, '.', '') ?>&deg; / <?= number_format($pageLocation['longitude'], 4, '.', '') ?>&deg;</span>
<span><?= htmlspecialchars($pageLocation['timezone'], ENT_QUOTES, 'UTF-8') ?></span>
</div>
<div class="occult-head-item occult-head-period">
<span class="occult-label">Zeitraum &amp; 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 &minus;</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 &mdash; 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 '&mdash;';
}
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)}&deg;`;
}
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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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>&Delta;RA ${formatSignedDeg(targetOffset.xDeg)}, &Delta;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'; ?>