Compare commits

...
10 Commits
Author SHA1 Message Date
Eskimue 2bf2848905 Add antenna conversion tab 2026-07-24 13:13:32 +02:00
Eskimue 1bfb3c4982 Remove unused voyager model 2026-07-24 10:41:55 +02:00
Eskimue eb13067055 Add oriented 3D spacecraft models to solar system 2026-07-24 10:38:19 +02:00
Eskimue 4592f91cf5 Dies und das 2026-07-23 19:29:52 +02:00
EskimueandClaude Sonnet 5 08bae7479c Einzelbild-Export (24 fps) für Sonnenfinsternis-Simulation hinzufügen
Ein neuer Button rendert die Zeitleiste der aktuell aktiven Ansicht
(Weltraum oder Standort) Bild für Bild bei 24 fps und lädt sie als ZIP
herunter. Jedes Frame wird einzeln an einen neuen PHP-Endpunkt
hochgeladen und in einem Temp-Verzeichnis abgelegt statt im Browser
gesammelt zu werden - das hält den Speicherbedarf sowohl client- als
auch serverseitig unabhängig von der Frame-Anzahl vorhersagbar
(wichtig bei mehreren tausend Frames). Das serverseitige Zippen kommt
ohne ZipArchive-Modul aus und liefert die Datei in festen Blöcken statt
sie komplett im Speicher zu puffern. Zusätzlich lässt sich der
exportierte Zeitbereich per Von/Bis-Uhrzeit einschränken, statt immer
die komplette Finsternis zu exportieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 08:43:30 +02:00
Eskimue 0bf8f1f8dc dies und das 2026-07-15 09:03:39 +02:00
EskimueandClaude Sonnet 5 d9a8b07a8d Standort-Ansicht für Sonnenfinsternis-Simulation hinzufügen
Zusätzlich zur 3D-Weltraum-Ansicht zeigt ein neuer Umschalter jetzt die
Finsternis als Sonnenscheibe mit Mond davor, wie sie am gespeicherten
Favoritenstandort tatsächlich am Himmel erscheint. Dazu werden Sonne
und Mond topozentrisch (inkl. lunarer Parallaxe) berechnet und die
Ausrichtung per parallaktischem Winkel auf Zenit-oben gedreht, statt
nur Norden-oben wie in Sternkarten.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 09:03:19 +02:00
EskimueandClaude Sonnet 5 fb75b180e2 Finsternis-Suche: Standardzeitraum auf aktuelles Jahr +10 setzen
Dropdown-Bereich auf 1950-2100 erweitert, Standardauswahl war bisher
fix auf 1999-2000 statt am aktuellen Jahr orientiert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 08:39:55 +02:00
EskimueandClaude Sonnet 5 72f6b968ce Info-Kasten auf Sonnenfinsternis-Seite ein-/ausklappbar machen
Der Infokasten oben rechts blockierte auf kleinen Bildschirmen zu viel
von der 3D-Ansicht; ein Toggle-Button erlaubt jetzt das Einklappen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 08:36:42 +02:00
Eskimue e2689abe75 Verbessere Baryzentrum-Darstellung und Tagesauflösung 2026-07-13 20:33:15 +02:00
12 changed files with 1481 additions and 550 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(git commit -m ' *)",
"Skill(run)",
"Skill(run:*)",
"Bash(php -v)",
"Bash(curl -s -D - -o /dev/null http://127.0.0.1:8931/sonnenfinsternis.php)",
"Bash(curl -s -c /tmp/sf_cookies.txt -D - -o /tmp/sf_dev.html -w \"\\\\nHTTP %{http_code}\\\\n\" \"http://127.0.0.1:8931/sonnenfinsternis.php?dev_login=1\")",
"Bash(php -l \"/c/Users/ich/source/repos/skyview.astronomiemuseum.de/public/sonnenfinsternis.php\")",
"Bash(php -l \"/c/Users/ich/source/repos/skyview.astronomiemuseum.de/public/ajax/sonnenfinsternis_export.php\")",
"Bash(curl -s -o __TRACKED_VAR__/sf_dev10.html -w 'HTTP %{http_code}\\\\n' http://127.0.0.1:8931/sonnenfinsternis.php?dev_login=1)",
"Bash(php -r ' *)",
"Bash(/c/Users/ich/pinokio/bin/miniconda/node.exe --check __TRACKED_VAR__/sf_rendered8.mjs)",
"Bash(echo \"exit: $?\")"
]
}
}
+239
View File
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
session_start();
const SF_EXPORT_MAX_FRAME_BYTES = 8 * 1024 * 1024;
const SF_EXPORT_MAX_INDEX = 99999;
const SF_EXPORT_TTL_SECONDS = 3 * 3600;
function sfExportJson(array $payload, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=UTF-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function sfExportRequireLogin(): void
{
if (!isset($_SESSION['user_id'])) {
sfExportJson(['success' => false, 'message' => 'Bitte anmelden.'], 403);
}
}
function sfExportRequireToken(): void
{
$sessionToken = isset($_SESSION['sonnenfinsternis_export_token']) && is_string($_SESSION['sonnenfinsternis_export_token'])
? $_SESSION['sonnenfinsternis_export_token']
: '';
$submittedToken = isset($_REQUEST['token']) ? (string) $_REQUEST['token'] : '';
if ($sessionToken === '' || $submittedToken === '' || !hash_equals($sessionToken, $submittedToken)) {
sfExportJson(['success' => false, 'message' => 'Sicherheitsprüfung fehlgeschlagen. Bitte Seite neu laden.'], 403);
}
}
function sfExportDir(string $exportId): string
{
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'sfexport_' . $exportId;
}
function sfExportRequireOwnedExportId(): string
{
$exportId = isset($_REQUEST['exportId']) ? (string) $_REQUEST['exportId'] : '';
if (!preg_match('/^[a-f0-9]{32}$/', $exportId) || !isset($_SESSION['sonnenfinsternis_export_ids'][$exportId])) {
sfExportJson(['success' => false, 'message' => 'Unbekannte oder abgelaufene Export-ID.'], 403);
}
return $exportId;
}
function sfExportDeleteDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
foreach (glob($dir . DIRECTORY_SEPARATOR . '*') ?: [] as $file) {
@unlink($file);
}
@rmdir($dir);
}
function sfExportSweepStale(): void
{
$base = sys_get_temp_dir();
foreach (glob($base . DIRECTORY_SEPARATOR . 'sfexport_*', GLOB_ONLYDIR) ?: [] as $dir) {
$mtime = @filemtime($dir);
if ($mtime !== false && (time() - $mtime) > SF_EXPORT_TTL_SECONDS) {
sfExportDeleteDir($dir);
}
}
}
// ─── Store-Format-ZIP-Writer (kein ZipArchive-Modul vorausgesetzt) ─────────
function sfZipLocalHeader(string $name, int $crc, int $size, int $dosTime, int $dosDate): string
{
return pack(
'VvvvvvVVVvv',
0x04034b50, 20, 0, 0, $dosTime, $dosDate, $crc, $size, $size, strlen($name), 0
) . $name;
}
function sfZipCentralHeader(string $name, int $crc, int $size, int $dosTime, int $dosDate, int $offset): string
{
return pack(
'VvvvvvvVVVvvvvvVV',
0x02014b50, 20, 20, 0, 0, $dosTime, $dosDate, $crc, $size, $size,
strlen($name), 0, 0, 0, 0, 0, $offset
) . $name;
}
function sfBuildZip(array $sourceFiles, string $zipPath): void
{
$fh = fopen($zipPath, 'wb');
if ($fh === false) {
throw new RuntimeException('ZIP-Datei konnte nicht angelegt werden.');
}
$dt = getdate();
$dosTime = ($dt['hours'] << 11) | ($dt['minutes'] << 5) | intdiv($dt['seconds'], 2);
$dosDate = (($dt['year'] - 1980) << 9) | ($dt['mon'] << 5) | $dt['mday'];
$centralBlob = '';
$offset = 0;
foreach ($sourceFiles as $name => $path) {
$data = file_get_contents($path);
if ($data === false) {
fclose($fh);
@unlink($zipPath);
throw new RuntimeException('Frame-Datei konnte nicht gelesen werden: ' . $path);
}
$crc = crc32($data);
$size = strlen($data);
fwrite($fh, sfZipLocalHeader($name, $crc, $size, $dosTime, $dosDate));
fwrite($fh, $data);
unset($data);
$centralBlob .= sfZipCentralHeader($name, $crc, $size, $dosTime, $dosDate, $offset);
$offset += 30 + strlen($name) + $size;
}
$centralStart = $offset;
fwrite($fh, $centralBlob);
fwrite($fh, pack(
'VvvvvVVv',
0x06054b50, 0, 0, count($sourceFiles), count($sourceFiles), strlen($centralBlob), $centralStart, 0
));
fclose($fh);
}
// ─── Routing ────────────────────────────────────────────────────────────────
sfExportRequireLogin();
sfExportRequireToken();
$action = isset($_REQUEST['action']) ? (string) $_REQUEST['action'] : '';
if ($action === 'start') {
sfExportSweepStale();
$exportId = bin2hex(random_bytes(16));
$dir = sfExportDir($exportId);
if (!mkdir($dir, 0700, true) && !is_dir($dir)) {
sfExportJson(['success' => false, 'message' => 'Temp-Verzeichnis konnte nicht angelegt werden.'], 500);
}
$_SESSION['sonnenfinsternis_export_ids'][$exportId] = time();
sfExportJson(['success' => true, 'exportId' => $exportId]);
}
if ($action === 'frame') {
$exportId = sfExportRequireOwnedExportId();
$index = isset($_REQUEST['index']) ? (string) $_REQUEST['index'] : '';
if (!ctype_digit($index) || (int) $index > SF_EXPORT_MAX_INDEX) {
sfExportJson(['success' => false, 'message' => 'Ungültiger Frame-Index.'], 400);
}
$raw = file_get_contents('php://input', false, null, 0, SF_EXPORT_MAX_FRAME_BYTES + 1);
if ($raw === false || $raw === '' || strlen($raw) > SF_EXPORT_MAX_FRAME_BYTES) {
sfExportJson(['success' => false, 'message' => 'Ungültige oder zu große Bilddaten.'], 400);
}
$filename = sfExportDir($exportId) . DIRECTORY_SEPARATOR . sprintf('frame_%05d.png', (int) $index);
if (file_put_contents($filename, $raw) === false) {
sfExportJson(['success' => false, 'message' => 'Frame konnte nicht gespeichert werden.'], 500);
}
sfExportJson(['success' => true]);
}
if ($action === 'cancel') {
$exportId = sfExportRequireOwnedExportId();
sfExportDeleteDir(sfExportDir($exportId));
unset($_SESSION['sonnenfinsternis_export_ids'][$exportId]);
sfExportJson(['success' => true]);
}
if ($action === 'finish') {
$exportId = sfExportRequireOwnedExportId();
$dir = sfExportDir($exportId);
$frameFiles = glob($dir . DIRECTORY_SEPARATOR . 'frame_*.png') ?: [];
sort($frameFiles);
if ($frameFiles === []) {
sfExportJson(['success' => false, 'message' => 'Keine Frames für diesen Export gefunden.'], 404);
}
$sourceFiles = [];
foreach ($frameFiles as $path) {
$sourceFiles[basename($path)] = $path;
}
// Das Zippen tausender Frames dauert laenger als PHPs Standard-Zeitlimit
// (30s) - hier ist es ein legitimer, einmaliger Vorgang fuer einen
// eingeloggten Nutzer, daher wird das Limit fuer diesen Request aufgehoben.
set_time_limit(0);
$zipPath = $dir . DIRECTORY_SEPARATOR . 'export.zip';
try {
sfBuildZip($sourceFiles, $zipPath);
} catch (Throwable $e) {
sfExportDeleteDir($dir);
unset($_SESSION['sonnenfinsternis_export_ids'][$exportId]);
sfExportJson(['success' => false, 'message' => 'ZIP konnte nicht erstellt werden: ' . $e->getMessage()], 500);
}
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="sonnenfinsternis_export_' . substr($exportId, 0, 8) . '.zip"');
header('Content-Length: ' . (string) filesize($zipPath));
// Aktive Output-Buffer wuerden die komplette (potenziell mehrere GB grosse)
// ZIP-Datei im Speicher aufsammeln statt sie durchzureichen - das war die
// Ursache des Speicherfehlers. Deshalb Buffer abschalten und die Datei in
// festen Bloecken direkt ausgeben, statt readfile() das ini-abhaengige
// Verhalten zu ueberlassen.
while (ob_get_level() > 0) {
ob_end_clean();
}
$out = fopen($zipPath, 'rb');
if ($out !== false) {
while (!feof($out)) {
echo fread($out, 1024 * 1024);
flush();
}
fclose($out);
}
sfExportDeleteDir($dir);
unset($_SESSION['sonnenfinsternis_export_ids'][$exportId]);
exit;
}
sfExportJson(['success' => false, 'message' => 'Unbekannte Aktion.'], 400);
+152
View File
@@ -246,6 +246,7 @@ $toolDefaultLocalDateTime = (new DateTimeImmutable('now', new DateTimeZone($tool
<button type="button" class="astro-conv-tab" data-tab-target="timeTab" role="tab" aria-selected="false">Zeit</button> <button type="button" class="astro-conv-tab" data-tab-target="timeTab" role="tab" aria-selected="false">Zeit</button>
<button type="button" class="astro-conv-tab" data-tab-target="coordinatesTab" role="tab" aria-selected="false">Koordinaten</button> <button type="button" class="astro-conv-tab" data-tab-target="coordinatesTab" role="tab" aria-selected="false">Koordinaten</button>
<button type="button" class="astro-conv-tab" data-tab-target="angleTab" role="tab" aria-selected="false">Winkel</button> <button type="button" class="astro-conv-tab" data-tab-target="angleTab" role="tab" aria-selected="false">Winkel</button>
<button type="button" class="astro-conv-tab" data-tab-target="antennaTab" role="tab" aria-selected="false">Antennen</button>
<button type="button" class="astro-conv-tab" data-tab-target="toolsTab" role="tab" aria-selected="false">Diverse Tools</button> <button type="button" class="astro-conv-tab" data-tab-target="toolsTab" role="tab" aria-selected="false">Diverse Tools</button>
</div> </div>
@@ -517,6 +518,49 @@ $toolDefaultLocalDateTime = (new DateTimeImmutable('now', new DateTimeZone($tool
</div> </div>
</section> </section>
<section class="card astro-conv-panel astro-conv-tab-panel" id="antennaTab" role="tabpanel">
<h2>Antennen-Umrechner</h2>
<p>Frequenz und Wellenlaenge ineinander umrechnen sowie Richtwerte fuer einen Halbwellendipol berechnen.</p>
<div class="astro-conv-grid" id="antennaConverter">
<div class="astro-conv-field">
<label for="antennaFrequencyMhz">Frequenz (MHz)</label>
<input class="astro-conv-input" id="antennaFrequencyMhz" type="text" inputmode="decimal" value="145">
</div>
<div class="astro-conv-field">
<label for="antennaWavelengthM">Wellenlaenge (m)</label>
<input class="astro-conv-input" id="antennaWavelengthM" type="text" inputmode="decimal">
</div>
<div class="astro-conv-field">
<label for="antennaWavelengthCm">Wellenlaenge (cm)</label>
<input class="astro-conv-input" id="antennaWavelengthCm" type="text" readonly>
</div>
<div class="astro-conv-field">
<label for="antennaHalfwaveTotalM"><span class="astro-conv-label-help" title="Theoretische Gesamtlaenge eines Halbwellendipols: Lambda / 2.">Dipol gesamt theoretisch (m)</span></label>
<input class="astro-conv-input" id="antennaHalfwaveTotalM" type="text" readonly>
</div>
<div class="astro-conv-field">
<label for="antennaHalfwavePracticalM"><span class="astro-conv-label-help" title="Praxisnaher Richtwert mit Verkuerzungsfaktor 0,95 fuer einen einfachen Drahtdipol.">Dipol gesamt praktisch (m)</span></label>
<input class="astro-conv-input" id="antennaHalfwavePracticalM" type="text" readonly>
</div>
<div class="astro-conv-field">
<label for="antennaLegLengthM"><span class="astro-conv-label-help" title="Laenge eines einzelnen Dipol-Schenkels. Praktischer Richtwert: Lambda x 0,95 / 4.">Ein Schenkel (m)</span></label>
<input class="astro-conv-input" id="antennaLegLengthM" type="text" readonly>
</div>
</div>
<div class="astro-conv-actions">
<button type="button" class="btn btn-secondary" id="antennaClear">Leeren</button>
<button type="button" class="btn btn-primary" id="antenna2m">2-m-Band</button>
<button type="button" class="btn btn-primary" id="antennaCb">CB-Funk</button>
</div>
</section>
<section class="card astro-conv-panel astro-conv-tab-panel" id="toolsTab" role="tabpanel"> <section class="card astro-conv-panel astro-conv-tab-panel" id="toolsTab" role="tabpanel">
<h2>Diverse Tools</h2> <h2>Diverse Tools</h2>
<p>Sonnenstand für den Standard-Standort aus dem Header berechnen. Datum und Uhrzeit werden in der lokalen Zeitzone des Standorts interpretiert.</p> <p>Sonnenstand für den Standard-Standort aus dem Header berechnen. Datum und Uhrzeit werden in der lokalen Zeitzone des Standorts interpretiert.</p>
@@ -647,6 +691,14 @@ $toolDefaultLocalDateTime = (new DateTimeImmutable('now', new DateTimeZone($tool
arcsec: document.getElementById('angleArcsec'), arcsec: document.getElementById('angleArcsec'),
rad: document.getElementById('angleRad') rad: document.getElementById('angleRad')
}; };
const antennaFields = {
frequencyMhz: document.getElementById('antennaFrequencyMhz'),
wavelengthM: document.getElementById('antennaWavelengthM'),
wavelengthCm: document.getElementById('antennaWavelengthCm'),
halfwaveTotalM: document.getElementById('antennaHalfwaveTotalM'),
halfwavePracticalM: document.getElementById('antennaHalfwavePracticalM'),
legLengthM: document.getElementById('antennaLegLengthM')
};
const toolFields = { const toolFields = {
dateTimeLocal: document.getElementById('toolsDateTimeLocal'), dateTimeLocal: document.getElementById('toolsDateTimeLocal'),
locationName: document.getElementById('toolsLocationName'), locationName: document.getElementById('toolsLocationName'),
@@ -664,6 +716,7 @@ $toolDefaultLocalDateTime = (new DateTimeImmutable('now', new DateTimeZone($tool
let timeRequestId = 0; let timeRequestId = 0;
let coordinateRequestId = 0; let coordinateRequestId = 0;
let angleRequestId = 0; let angleRequestId = 0;
let antennaRequestId = 0;
let toolRequestId = 0; let toolRequestId = 0;
const DECIMAL_DIVISION_PRECISION = 32; const DECIMAL_DIVISION_PRECISION = 32;
@@ -1088,6 +1141,12 @@ $toolDefaultLocalDateTime = (new DateTimeImmutable('now', new DateTimeZone($tool
wireTooltipHover(field); wireTooltipHover(field);
}); });
const antennaFieldEntries = Object.entries(antennaFields);
antennaFieldEntries.forEach(function ([unit, field]) {
attachTooltip(field, 'antenna-' + unit);
wireTooltipHover(field);
});
const updateDistanceFrom = async function (sourceUnit) { const updateDistanceFrom = async function (sourceUnit) {
const rawValue = distanceFields[sourceUnit].value.trim(); const rawValue = distanceFields[sourceUnit].value.trim();
if (rawValue === '') { if (rawValue === '') {
@@ -1661,6 +1720,98 @@ $toolDefaultLocalDateTime = (new DateTimeImmutable('now', new DateTimeZone($tool
updateAngleFrom('arcsec'); updateAngleFrom('arcsec');
}); });
const formatNumber = function (value, maxFractionDigits) {
if (!Number.isFinite(value)) {
return '';
}
return value.toLocaleString('de-DE', {
maximumFractionDigits: maxFractionDigits,
useGrouping: true
});
};
const clearAntennaOutputs = function () {
['wavelengthCm', 'halfwaveTotalM', 'halfwavePracticalM', 'legLengthM'].forEach(function (key) {
antennaFields[key].value = '';
updateTooltip(antennaFields[key]);
});
};
const updateAntennaFrom = function (sourceUnit) {
const sourceField = antennaFields[sourceUnit];
if (!sourceField) {
return;
}
const rawValue = sourceField.value.trim();
if (rawValue === '') {
Object.entries(antennaFields).forEach(function ([key, field]) {
if (key !== sourceUnit) {
field.value = '';
updateTooltip(field);
}
});
return;
}
const parsedValue = parseDecimalInput(rawValue);
const numericValue = parsedValue ? decimalToNumber(parsedValue) : Number.NaN;
if (!Number.isFinite(numericValue) || numericValue <= 0) {
clearAntennaOutputs();
return;
}
const requestId = ++antennaRequestId;
const wavelengthMeters = sourceUnit === 'frequencyMhz'
? 299.792458 / numericValue
: numericValue;
const frequencyMhz = sourceUnit === 'wavelengthM'
? 299.792458 / numericValue
: numericValue;
if (requestId !== antennaRequestId || !Number.isFinite(wavelengthMeters) || wavelengthMeters <= 0) {
clearAntennaOutputs();
return;
}
const practicalFactor = 0.95;
antennaFields.frequencyMhz.value = formatNumber(frequencyMhz, 6);
antennaFields.wavelengthM.value = formatNumber(wavelengthMeters, 6);
antennaFields.wavelengthCm.value = formatNumber(wavelengthMeters * 100, 2);
antennaFields.halfwaveTotalM.value = formatNumber(wavelengthMeters / 2, 4);
antennaFields.halfwavePracticalM.value = formatNumber((wavelengthMeters / 2) * practicalFactor, 4);
antennaFields.legLengthM.value = formatNumber((wavelengthMeters / 4) * practicalFactor, 4);
Object.values(antennaFields).forEach(function (field) {
updateTooltip(field);
});
};
['frequencyMhz', 'wavelengthM'].forEach(function (key) {
antennaFields[key].addEventListener('input', function () {
updateAntennaFrom(key);
});
});
document.getElementById('antennaClear').addEventListener('click', function () {
Object.values(antennaFields).forEach(function (field) {
field.value = '';
updateTooltip(field);
});
antennaFields.frequencyMhz.focus();
});
document.getElementById('antenna2m').addEventListener('click', function () {
antennaFields.frequencyMhz.value = '145';
updateAntennaFrom('frequencyMhz');
});
document.getElementById('antennaCb').addEventListener('click', function () {
antennaFields.frequencyMhz.value = '27,2';
updateAntennaFrom('frequencyMhz');
});
const clearToolOutputs = function () { const clearToolOutputs = function () {
toolFields.dateTimeUtc.value = ''; toolFields.dateTimeUtc.value = '';
toolFields.altitude.value = ''; toolFields.altitude.value = '';
@@ -1752,6 +1903,7 @@ $toolDefaultLocalDateTime = (new DateTimeImmutable('now', new DateTimeZone($tool
updateTimeFrom('utc'); updateTimeFrom('utc');
updateCoordinatesFrom('equatorial'); updateCoordinatesFrom('equatorial');
updateAngleFrom('deg'); updateAngleFrom('deg');
updateAntennaFrom('frequencyMhz');
updateSolarTool(); updateSolarTool();
})(); })();
</script> </script>
+102 -41
View File
@@ -4,7 +4,7 @@ session_start();
require_once __DIR__ . '/python_api.php'; require_once __DIR__ . '/python_api.php';
$result = runPythonApi('barycenter', ['20', '12', '24']); $result = runPythonApi('barycenter', ['20', '365', '24']);
require __DIR__ . '/header.php'; require __DIR__ . '/header.php';
@@ -135,6 +135,25 @@ require __DIR__ . '/header.php';
color: var(--gold-pale); color: var(--gold-pale);
min-width: 60px; min-width: 60px;
} }
.bc-toggle-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem 1rem;
margin-top: 0.65rem;
}
.bc-toggle {
display: inline-flex;
align-items: center;
gap: 0.45rem;
font-size: 0.82rem;
color: var(--text-dim);
cursor: pointer;
user-select: none;
}
.bc-toggle input {
accent-color: var(--gold);
cursor: pointer;
}
input[type=range].bc-slider { input[type=range].bc-slider {
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
@@ -169,29 +188,30 @@ input[type=range].bc-slider {
<?php else: <?php else:
$cur = $result['data']['current']; $cur = $result['data']['current'];
$distSR = $cur['dist_solar_radii']; $distSR = $cur['dist_solar_radii'];
$projDistSR = $cur['proj_dist_solar_radii'];
$inside = $cur['inside_sun']; $inside = $cur['inside_sun'];
$contributions = $result['data']['contributions']; $contributions = $result['data']['contributions'];
?> ?>
<div class="bc-stats-row"> <div class="bc-stats-row">
<div class="bc-stat"> <div class="bc-stat">
<span class="bc-stat-label">Abstand vom Sonnenmittelpunkt</span> <span class="bc-stat-label">Räumlicher Abstand vom Sonnenmittelpunkt</span>
<span class="bc-stat-value"><?= number_format($distSR, 3, ',', '') ?> R☉</span> <span class="bc-stat-value"><?= number_format($distSR, 3, ',', '') ?> R☉</span>
<span class="bc-stat-sub"><?= number_format($cur['dist_au'], 5, ',', '') ?> AU</span> <span class="bc-stat-sub">XY-Projektion: <?= number_format($projDistSR, 3, ',', '') ?> R☉ · <?= number_format($cur['dist_au'], 5, ',', '') ?> AU</span>
</div> </div>
<div class="bc-stat"> <div class="bc-stat">
<span class="bc-stat-label">Position</span> <span class="bc-stat-label">Position in der Grafik</span>
<span class="bc-stat-value"> <span class="bc-stat-value">
<span class="bc-inside-badge <?= $inside ? 'inside' : 'outside' ?>"> <span class="bc-inside-badge <?= $inside ? 'inside' : 'outside' ?>">
<?= $inside ? 'Innerhalb der Sonne' : 'Außerhalb der Sonne' ?> <?= $inside ? 'Innerhalb der Sonne' : 'Außerhalb der Sonne' ?>
</span> </span>
</span> </span>
<span class="bc-stat-sub">Stand: <?= htmlspecialchars($cur['date_utc'], ENT_QUOTES, 'UTF-8') ?></span> <span class="bc-stat-sub">Bewertet als XY-Projektion · Stand: <?= htmlspecialchars($cur['date_utc'], ENT_QUOTES, 'UTF-8') ?></span>
</div> </div>
<div class="bc-stat"> <div class="bc-stat">
<span class="bc-stat-label">Maximum (2006–2050)</span> <span class="bc-stat-label">Maximum der Grafik (2006–2050)</span>
<span class="bc-stat-value"><?= number_format($result['data']['max_dist_solar_radii'], 3, ',', '') ?> R☉</span> <span class="bc-stat-value"><?= number_format($result['data']['max_proj_dist_solar_radii'], 3, ',', '') ?> R☉</span>
<span class="bc-stat-sub">Maximaler Abstand im Zeitraum</span> <span class="bc-stat-sub">Größter projizierter Abstand im Zeitraum</span>
</div> </div>
</div> </div>
@@ -205,8 +225,7 @@ input[type=range].bc-slider {
<div class="bc-legend"> <div class="bc-legend">
<span class="bc-legend-item"><span class="bc-legend-dot" style="background:#f5c842"></span> Sonne</span> <span class="bc-legend-item"><span class="bc-legend-dot" style="background:#f5c842"></span> Sonne</span>
<span class="bc-legend-item"><span class="bc-legend-dot" style="background:rgba(255,255,255,0.25);border:1px dashed #666"></span> Sonnenoberfläche</span> <span class="bc-legend-item"><span class="bc-legend-dot" style="background:rgba(255,255,255,0.25);border:1px dashed #666"></span> Sonnenoberfläche</span>
<span class="bc-legend-item"><span class="bc-legend-dot" style="background:#4fc3d8"></span> Baryzentrum (Vergangenheit)</span> <span class="bc-legend-item"><span class="bc-legend-dot" style="background:#4fc3d8"></span> Baryzentrum-Bahn</span>
<span class="bc-legend-item"><span class="bc-legend-dot" style="background:#c9a84c;opacity:0.7"></span> Baryzentrum (Prognose)</span>
<span class="bc-legend-item"><span class="bc-legend-dot" style="background:#ff6060"></span> Baryzentrum (heute)</span> <span class="bc-legend-item"><span class="bc-legend-dot" style="background:#ff6060"></span> Baryzentrum (heute)</span>
</div> </div>
<div class="bc-anim-controls"> <div class="bc-anim-controls">
@@ -214,6 +233,16 @@ input[type=range].bc-slider {
<input type="range" class="bc-slider" id="bc-slider" min="0" max="240" value="240" step="1"> <input type="range" class="bc-slider" id="bc-slider" min="0" max="240" value="240" step="1">
<span class="bc-year-label" id="bc-year-label">heute</span> <span class="bc-year-label" id="bc-year-label">heute</span>
</div> </div>
<div class="bc-toggle-row">
<label class="bc-toggle">
<input type="checkbox" id="bc-toggle-path" checked>
<span>Baryzentrum-Linie</span>
</label>
<label class="bc-toggle">
<input type="checkbox" id="bc-toggle-radii" checked>
<span>1R- und 2R-Radius</span>
</label>
</div>
</div> </div>
</div> </div>
<div> <div>
@@ -233,6 +262,10 @@ input[type=range].bc-slider {
Die Bahn folgt keinem einfachen Kreis – Die Bahn folgt keinem einfachen Kreis –
die Überlagerung der Planetenumläufe erzeugt eine komplexe Spirale. die Überlagerung der Planetenumläufe erzeugt eine komplexe Spirale.
</p> </p>
<p style="font-size:0.9rem;color:var(--text-dim);line-height:1.6;margin-top:0.75rem">
Die Grafik ist eine XY-Projektion. Deshalb wird die Lage relativ zur Sonnenscheibe
hier ebenfalls in der Projektion bewertet, nicht mit dem räumlichen 3D-Abstand.
</p>
</div> </div>
<div class="card" style="margin-top:1.5rem"> <div class="card" style="margin-top:1.5rem">
@@ -279,7 +312,7 @@ input[type=range].bc-slider {
const CUR = <?= json_encode($cur, JSON_UNESCAPED_UNICODE) ?>; const CUR = <?= json_encode($cur, JSON_UNESCAPED_UNICODE) ?>;
const NOW_INDEX = <?= (int)$result['data']['now_index'] ?>; const NOW_INDEX = <?= (int)$result['data']['now_index'] ?>;
const SUN_RADIUS_AU = <?= json_encode($result['data']['sun_radius_au']) ?>; const SUN_RADIUS_AU = <?= json_encode($result['data']['sun_radius_au']) ?>;
const MAX_DIST_SR = <?= json_encode($result['data']['max_dist_solar_radii']) ?>; const MAX_DIST_SR = <?= json_encode($result['data']['max_proj_dist_solar_radii']) ?>;
const canvas = document.getElementById('bc-canvas'); const canvas = document.getElementById('bc-canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
@@ -292,13 +325,19 @@ input[type=range].bc-slider {
const slider = document.getElementById('bc-slider'); const slider = document.getElementById('bc-slider');
const playBtn = document.getElementById('bc-play'); const playBtn = document.getElementById('bc-play');
const yearLabel = document.getElementById('bc-year-label'); const yearLabel = document.getElementById('bc-year-label');
const pathToggle = document.getElementById('bc-toggle-path');
const radiiToggle = document.getElementById('bc-toggle-radii');
slider.max = SERIES.length - 1; slider.max = SERIES.length - 1;
slider.value = NOW_INDEX; slider.value = NOW_INDEX;
function formatDate(yearFrac) { function formatDate(dateValue) {
const y = Math.floor(yearFrac); if (typeof dateValue === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(dateValue)) {
const m = Math.round((yearFrac - y) * 12) + 1; return dateValue;
}
const y = Math.floor(dateValue);
const m = Math.round((dateValue - y) * 12) + 1;
return y + '-' + String(m).padStart(2, '0'); return y + '-' + String(m).padStart(2, '0');
} }
@@ -322,20 +361,22 @@ input[type=range].bc-slider {
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
// Background grid rings if (radiiToggle.checked) {
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; const guideRadii = [1, 2];
ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(255,255,255,0.07)';
for (let r = 1; r <= Math.ceil(MAX_DIST_SR * 1.2); r++) { ctx.lineWidth = 1;
ctx.beginPath(); ctx.font = '10px sans-serif';
ctx.arc(cx, cy, r * scale, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.25)';
ctx.stroke(); ctx.textAlign = 'left';
} for (const r of guideRadii) {
// Ring labels if (r > MAX_DIST_SR * 1.25) {
ctx.font = '10px sans-serif'; continue;
ctx.fillStyle = 'rgba(255,255,255,0.25)'; }
ctx.textAlign = 'left'; ctx.beginPath();
for (let r = 1; r <= Math.ceil(MAX_DIST_SR * 1.2); r++) { ctx.arc(cx, cy, r * scale, 0, Math.PI * 2);
ctx.fillText(r + ' R☉', cx + r * scale + 3, cy - 3); ctx.stroke();
ctx.fillText(r + ' R☉', cx + r * scale + 3, cy - 3);
}
} }
// Sun glow // Sun glow
@@ -354,21 +395,22 @@ input[type=range].bc-slider {
ctx.fillStyle = '#f5c842'; ctx.fillStyle = '#f5c842';
ctx.fill(); ctx.fill();
// Sun surface dashed circle if (radiiToggle.checked) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, sunRadiusPx, 0, Math.PI * 2); ctx.arc(cx, cy, sunRadiusPx, 0, Math.PI * 2);
ctx.setLineDash([4, 4]); ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.strokeStyle = 'rgba(255,255,255,0.2)';
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
}
// Barycenter path up to visibleUpTo // Barycenter path up to visibleUpTo
const end = Math.min(visibleUpTo, SERIES.length - 1); const end = Math.min(visibleUpTo, SERIES.length - 1);
// Vergangener Pfad (bis NOW_INDEX) — cyan // Vergangener Pfad (bis NOW_INDEX) — cyan
const pastEnd = Math.min(end, NOW_INDEX); const pastEnd = Math.min(end, NOW_INDEX);
if (pastEnd >= 1) { if (pathToggle.checked && pastEnd >= 1) {
ctx.beginPath(); ctx.beginPath();
const p0 = SERIES[0]; const p0 = SERIES[0];
ctx.moveTo(cx + p0.x / SUN_RADIUS_AU * scale, cy - p0.y / SUN_RADIUS_AU * scale); ctx.moveTo(cx + p0.x / SUN_RADIUS_AU * scale, cy - p0.y / SUN_RADIUS_AU * scale);
@@ -383,7 +425,7 @@ input[type=range].bc-slider {
} }
// Zukünftiger Pfad (ab NOW_INDEX) — gold gedimmt, gestrichelt // Zukünftiger Pfad (ab NOW_INDEX) — gold gedimmt, gestrichelt
if (end > NOW_INDEX) { if (pathToggle.checked && end > NOW_INDEX) {
ctx.beginPath(); ctx.beginPath();
const pn = SERIES[NOW_INDEX]; const pn = SERIES[NOW_INDEX];
ctx.moveTo(cx + pn.x / SUN_RADIUS_AU * scale, cy - pn.y / SUN_RADIUS_AU * scale); ctx.moveTo(cx + pn.x / SUN_RADIUS_AU * scale, cy - pn.y / SUN_RADIUS_AU * scale);
@@ -391,9 +433,9 @@ input[type=range].bc-slider {
const p = SERIES[i]; const p = SERIES[i];
ctx.lineTo(cx + p.x / SUN_RADIUS_AU * scale, cy - p.y / SUN_RADIUS_AU * scale); ctx.lineTo(cx + p.x / SUN_RADIUS_AU * scale, cy - p.y / SUN_RADIUS_AU * scale);
} }
ctx.strokeStyle = 'rgba(201, 168, 76, 0.45)'; ctx.strokeStyle = 'rgba(79, 195, 216, 0.55)';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.setLineDash([5, 4]); ctx.setLineDash([]);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
} }
@@ -421,7 +463,7 @@ input[type=range].bc-slider {
ctx.font = '11px sans-serif'; ctx.font = '11px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.65)'; ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText(pCur.d.toFixed(3) + ' R☉', px + 8, py - 6); ctx.fillText(pCur.dp.toFixed(3) + ' R☉', px + 8, py - 6);
} }
function updateLabel() { function updateLabel() {
@@ -433,12 +475,31 @@ input[type=range].bc-slider {
} }
} }
function setVisibleIndex(nextIndex) {
const clamped = Math.max(0, Math.min(SERIES.length - 1, nextIndex));
visibleUpTo = clamped;
slider.value = clamped;
updateLabel();
draw();
}
function handleWheelStep(event) {
event.preventDefault();
if (animPlaying) stopAnim();
const direction = event.deltaY > 0 ? 1 : -1;
setVisibleIndex(parseInt(slider.value, 10) + direction);
}
slider.addEventListener('input', () => { slider.addEventListener('input', () => {
visibleUpTo = parseInt(slider.value, 10); visibleUpTo = parseInt(slider.value, 10);
updateLabel(); updateLabel();
draw(); draw();
if (animPlaying) stopAnim(); if (animPlaying) stopAnim();
}); });
slider.addEventListener('wheel', handleWheelStep, { passive: false });
canvas.addEventListener('wheel', handleWheelStep, { passive: false });
pathToggle.addEventListener('change', draw);
radiiToggle.addEventListener('change', draw);
function stopAnim() { function stopAnim() {
animPlaying = false; animPlaying = false;
+70
View File
@@ -3880,12 +3880,14 @@ body.solarsystem-page:not(.jupitersystem-page) .solarsystem-stage-card {
} }
.solarsystem-input-row input[type="date"], .solarsystem-input-row input[type="date"],
.solarsystem-input-row input[type="time"],
.solarsystem-input-row select, .solarsystem-input-row select,
.solarsystem-sidebar input[type="range"] { .solarsystem-sidebar input[type="range"] {
width: 100%; width: 100%;
} }
.solarsystem-input-row input[type="date"], .solarsystem-input-row input[type="date"],
.solarsystem-input-row input[type="time"],
.solarsystem-input-row select, .solarsystem-input-row select,
.solarsystem-multiselect { .solarsystem-multiselect {
min-height: 2.6rem; min-height: 2.6rem;
@@ -3899,6 +3901,7 @@ body.solarsystem-page:not(.jupitersystem-page) .solarsystem-stage-card {
} }
.solarsystem-input-row input[type="date"], .solarsystem-input-row input[type="date"],
.solarsystem-input-row input[type="time"],
.solarsystem-input-row select { .solarsystem-input-row select {
padding: 0.55rem 0.8rem; padding: 0.55rem 0.8rem;
} }
@@ -4845,6 +4848,50 @@ body.solarsystem-page:not(.jupitersystem-page) .solarsystem-stage-wrap {
line-height: 1.4; line-height: 1.4;
} }
/* Info panel: ein-/ausklappbar */
.eclipse-info-panel {
padding-top: 2.4rem;
}
.eclipse-info-toggle {
position: absolute;
top: 0.55rem;
right: 0.55rem;
width: 1.6rem;
height: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
background: rgba(201, 168, 76, 0.08);
border: 1px solid rgba(201, 168, 76, 0.25);
border-radius: 6px;
color: var(--gold-pale);
cursor: pointer;
line-height: 1;
font-size: 0.75rem;
}
.eclipse-info-toggle:hover {
background: rgba(201, 168, 76, 0.18);
}
.eclipse-info-toggle-icon {
display: inline-block;
transition: transform 0.2s;
}
.eclipse-info-panel.is-collapsed .eclipse-info-toggle-icon {
transform: rotate(-90deg);
}
.eclipse-info-panel.is-collapsed .eclipse-info-body {
display: none;
}
.eclipse-info-panel.is-collapsed {
padding: 0.55rem 0.6rem;
}
/* View toggle */ /* View toggle */
.eclipse-view-toggle { .eclipse-view-toggle {
position: absolute; position: absolute;
@@ -4855,6 +4902,29 @@ body.solarsystem-page:not(.jupitersystem-page) .solarsystem-stage-wrap {
gap: 0.4rem; gap: 0.4rem;
} }
.eclipse-view-toggle .btn.is-active {
background: rgba(201, 168, 76, 0.18);
color: var(--gold-pale);
border-color: rgba(201, 168, 76, 0.38);
}
.eclipse-local-hint {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1.5;
}
.eclipse-local-hint.is-visible {
display: flex;
}
/* Timeline */ /* Timeline */
.eclipse-timeline { .eclipse-timeline {
position: absolute; position: absolute;
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20 -7
View File
@@ -5780,7 +5780,7 @@ def action_barycenter(args: list[str]) -> dict:
steps_per_year = int(args[1]) if len(args) > 1 and args[1].isdigit() else 12 steps_per_year = int(args[1]) if len(args) > 1 and args[1].isdigit() else 12
years_forward = int(args[2]) if len(args) > 2 and args[2].isdigit() else 0 years_forward = int(args[2]) if len(args) > 2 and args[2].isdigit() else 0
years_back = max(1, min(years_back, 100)) years_back = max(1, min(years_back, 100))
steps_per_year = max(4, min(steps_per_year, 52)) steps_per_year = max(4, min(steps_per_year, 366))
years_forward = max(0, min(years_forward, 100)) years_forward = max(0, min(years_forward, 100))
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -5793,6 +5793,8 @@ def action_barycenter(args: list[str]) -> dict:
cur_y = -sun_state.y cur_y = -sun_state.y
cur_dist_au = math.hypot(cur_x, cur_y, -sun_state.z) cur_dist_au = math.hypot(cur_x, cur_y, -sun_state.z)
cur_dist_sr = cur_dist_au / _SUN_RADIUS_AU cur_dist_sr = cur_dist_au / _SUN_RADIUS_AU
cur_proj_dist_au = math.hypot(cur_x, cur_y)
cur_proj_dist_sr = cur_proj_dist_au / _SUN_RADIUS_AU
# -- Zeitreihe -- # -- Zeitreihe --
total_steps = (years_back + years_forward) * steps_per_year total_steps = (years_back + years_forward) * steps_per_year
@@ -5800,24 +5802,32 @@ def action_barycenter(args: list[str]) -> dict:
start_jd = now_time.tt - years_back * 365.25 start_jd = now_time.tt - years_back * 365.25
series = [] series = []
max_dist_au = 0.0 max_dist_au = 0.0
max_proj_dist_au = 0.0
for i in range(total_steps + 1): for i in range(total_steps + 1):
t = astronomy.Time(start_jd + i * days_per_step) t = astronomy.Time(start_jd + i * days_per_step)
s = astronomy.BaryState(astronomy.Body.Sun, t) s = astronomy.BaryState(astronomy.Body.Sun, t)
tx, ty = -s.x, -s.y tx, ty = -s.x, -s.y
d = math.hypot(tx, ty, -s.z) d = math.hypot(tx, ty, -s.z)
dp = math.hypot(tx, ty)
if d > max_dist_au: if d > max_dist_au:
max_dist_au = d max_dist_au = d
if dp > max_proj_dist_au:
max_proj_dist_au = dp
dt_utc = datetime(2000, 1, 1, 12, tzinfo=timezone.utc) + timedelta(days=t.tt - 0.0) dt_utc = datetime(2000, 1, 1, 12, tzinfo=timezone.utc) + timedelta(days=t.tt - 0.0)
# Epoche J2000.0 = 2000-01-01 12:00 TT ≈ UTC (Differenz <1 min hier vernachlässigt) # Epoche J2000.0 = 2000-01-01 12:00 TT ≈ UTC (Differenz <1 min hier vernachlässigt)
series.append({"x": round(tx, 6), "y": round(ty, 6), "d": round(d / _SUN_RADIUS_AU, 3)}) series.append({
"x": round(tx, 6),
"y": round(ty, 6),
"d": round(d / _SUN_RADIUS_AU, 3),
"dp": round(dp / _SUN_RADIUS_AU, 3),
})
# Zeitstempel für Animationslabel (nur Jahr.Monat reicht) # Zeitstempel für Animationslabel als echte UTC-Kalenderdaten
series_dates = [] series_dates = []
for i in range(total_steps + 1): for i in range(total_steps + 1):
t_days = start_jd + i * days_per_step t_days = start_jd + i * days_per_step
# Grobe Umrechnung J2000-TT → Jahr dt_utc = datetime(2000, 1, 1, 12, tzinfo=timezone.utc) + timedelta(days=t_days)
year_frac = 2000.0 + (t_days / 365.25) series_dates.append(dt_utc.strftime("%Y-%m-%d"))
series_dates.append(round(year_frac, 3))
# -- Planetenmassen (GM relativ zur Sonne = 1) für gewichteten Beitrag -- # -- Planetenmassen (GM relativ zur Sonne = 1) für gewichteten Beitrag --
# Quellen: IAU 2012 / astronomy-engine Konstanten # Quellen: IAU 2012 / astronomy-engine Konstanten
@@ -5872,13 +5882,16 @@ def action_barycenter(args: list[str]) -> dict:
"y": round(cur_y, 6), "y": round(cur_y, 6),
"dist_au": round(cur_dist_au, 6), "dist_au": round(cur_dist_au, 6),
"dist_solar_radii": round(cur_dist_sr, 4), "dist_solar_radii": round(cur_dist_sr, 4),
"inside_sun": cur_dist_sr <= 1.0, "proj_dist_au": round(cur_proj_dist_au, 6),
"proj_dist_solar_radii": round(cur_proj_dist_sr, 4),
"inside_sun": cur_proj_dist_sr <= 1.0,
"date_utc": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "date_utc": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
}, },
"series": series, "series": series,
"series_dates": series_dates, "series_dates": series_dates,
"now_index": now_index, "now_index": now_index,
"max_dist_solar_radii": round(max_dist_au / _SUN_RADIUS_AU, 3), "max_dist_solar_radii": round(max_dist_au / _SUN_RADIUS_AU, 3),
"max_proj_dist_solar_radii": round(max_proj_dist_au / _SUN_RADIUS_AU, 3),
"sun_radius_au": _SUN_RADIUS_AU, "sun_radius_au": _SUN_RADIUS_AU,
"contributions": contributions, "contributions": contributions,
"years_back": years_back, "years_back": years_back,
+424 -494
View File
File diff suppressed because it is too large Load Diff
+456 -8
View File
@@ -18,6 +18,11 @@ session_start();
$loggedIn = isset($_SESSION['user_id']); $loggedIn = isset($_SESSION['user_id']);
$pageTitle = 'Sonnenfinsternis – AstroTools'; $pageTitle = 'Sonnenfinsternis – AstroTools';
$bodyClass = 'solarsystem-page'; $bodyClass = 'solarsystem-page';
if (!isset($_SESSION['sonnenfinsternis_export_token']) || !is_string($_SESSION['sonnenfinsternis_export_token'])) {
$_SESSION['sonnenfinsternis_export_token'] = bin2hex(random_bytes(16));
}
$sonnenfinsternisExportToken = $_SESSION['sonnenfinsternis_export_token'];
?> ?>
<?php require __DIR__ . '/header.php'; ?> <?php require __DIR__ . '/header.php'; ?>
<?php <?php
@@ -68,17 +73,44 @@ $userAlt = isset($currentLocation['elevation']) ? (float)$currentLocation['eleva
<div class="solarsystem-label">Kontaktzeiten (UTC)</div> <div class="solarsystem-label">Kontaktzeiten (UTC)</div>
<table class="data-table" id="eclipseContactTable"></table> <table class="data-table" id="eclipseContactTable"></table>
</div> </div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Export</div>
<label class="solarsystem-label eclipse-control-label" for="exportFromTime">Von (UTC)</label>
<div class="solarsystem-input-row">
<input type="time" step="1" id="exportFromTime">
</div>
<label class="solarsystem-label eclipse-control-label" for="exportToTime">Bis (UTC)</label>
<div class="solarsystem-input-row">
<input type="time" step="1" id="exportToTime">
</div>
<button class="btn btn-secondary btn-full" id="btnExportFrames" disabled>Als Einzelbilder exportieren (24 fps)</button>
<p class="hint" id="exportStatus"></p>
</div>
</aside> </aside>
<div class="solarsystem-stage-wrap" id="eclipseStageWrap"> <div class="solarsystem-stage-wrap" id="eclipseStageWrap">
<div class="eclipse-view-toggle">
<button class="btn btn-sm btn-secondary is-active" id="btnSpaceView">Weltraum</button>
<button class="btn btn-sm btn-secondary" id="btnLocalView">Standort</button>
</div>
<canvas id="eclipseCanvas" class="eclipse-canvas"></canvas> <canvas id="eclipseCanvas" class="eclipse-canvas"></canvas>
<canvas id="eclipseLocalCanvas" class="eclipse-canvas is-hidden"></canvas>
<div id="eclipseLocalHint" class="eclipse-local-hint"></div>
<div id="eclipseScaleNote" class="eclipse-scale-note"> <div id="eclipseScaleNote" class="eclipse-scale-note">
Distanzen stark komprimiert<br>Größenverhältnisse für die Anschauung angepasst Distanzen stark komprimiert<br>Größenverhältnisse für die Anschauung angepasst
</div> </div>
<div class="solarsystem-info-panel" id="eclipseInfoPanel"> <div class="solarsystem-info-panel eclipse-info-panel" id="eclipseInfoPanel">
<div class="eclipse-info-empty">Wähle eine Finsternis aus der Liste</div> <button type="button" class="eclipse-info-toggle" id="eclipseInfoToggle" aria-expanded="true" aria-controls="eclipseInfoBody" title="Ein-/Ausklappen">
<span class="eclipse-info-toggle-icon">▾</span>
</button>
<div class="eclipse-info-body" id="eclipseInfoBody">
<div class="eclipse-info-empty">Wähle eine Finsternis aus der Liste</div>
</div>
</div> </div>
<div class="eclipse-timeline" id="eclipseTimeline"> <div class="eclipse-timeline" id="eclipseTimeline">
@@ -109,6 +141,8 @@ $userAlt = isset($currentLocation['elevation']) ? (float)$currentLocation['eleva
<script type="module"> <script type="module">
const USER_LAT = <?= $userLat !== null ? $userLat : 'null' ?>; const USER_LAT = <?= $userLat !== null ? $userLat : 'null' ?>;
const USER_LON = <?= $userLon !== null ? $userLon : 'null' ?>; const USER_LON = <?= $userLon !== null ? $userLon : 'null' ?>;
const EXPORT_TOKEN = <?= json_encode($sonnenfinsternisExportToken, JSON_UNESCAPED_SLASHES) ?>;
const EXPORT_ENDPOINT = 'ajax/sonnenfinsternis_export.php';
const USER_ALT = <?= $userAlt !== null ? $userAlt : '0' ?>; const USER_ALT = <?= $userAlt !== null ? $userAlt : '0' ?>;
const AE = window.Astronomy; const AE = window.Astronomy;
@@ -119,6 +153,10 @@ const R_EARTH_VIS = 1.0;
const D_SM_VIS = 7.5; const D_SM_VIS = 7.5;
const D_ME_VIS = 8.0; const D_ME_VIS = 8.0;
const EARTH_TILT = 23.44 * Math.PI / 180; const EARTH_TILT = 23.44 * Math.PI / 180;
const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;
const EXPORT_WIDTH = 1280;
const EXPORT_HEIGHT = Math.round(EXPORT_WIDTH * 0.58);
const LOCAL_MATCH_HOURS = 18; const LOCAL_MATCH_HOURS = 18;
const D_EM_VIS = 3.1; const D_EM_VIS = 3.1;
const SUN_RADIUS_KM = 695700; const SUN_RADIUS_KM = 695700;
@@ -130,8 +168,18 @@ let penumbraProxyScale = 1.00;
const MEAN_MOON_DIST_AU = 384400 / AE.KM_PER_AU; const MEAN_MOON_DIST_AU = 384400 / AE.KM_PER_AU;
const canvas3d = document.getElementById('eclipseCanvas'); const canvas3d = document.getElementById('eclipseCanvas');
const canvasLocal = document.getElementById('eclipseLocalCanvas');
const localHint = document.getElementById('eclipseLocalHint');
const btnSpaceView = document.getElementById('btnSpaceView');
const btnLocalView = document.getElementById('btnLocalView');
const btnExportFrames = document.getElementById('btnExportFrames');
const exportStatus = document.getElementById('exportStatus');
const exportFromTimeInput = document.getElementById('exportFromTime');
const exportToTimeInput = document.getElementById('exportToTime');
const stageWrap = document.getElementById('eclipseStageWrap'); const stageWrap = document.getElementById('eclipseStageWrap');
const infoPanel = document.getElementById('eclipseInfoPanel'); const infoPanel = document.getElementById('eclipseInfoPanel');
const infoBody = document.getElementById('eclipseInfoBody');
const infoToggleBtn = document.getElementById('eclipseInfoToggle');
const timelineBar = document.getElementById('eclipseTimelineBar'); const timelineBar = document.getElementById('eclipseTimelineBar');
const tlCursor = document.getElementById('eclipseTimelineCursor'); const tlCursor = document.getElementById('eclipseTimelineCursor');
const tlDisplay = document.getElementById('eclipseTimeDisplay'); const tlDisplay = document.getElementById('eclipseTimeDisplay');
@@ -150,6 +198,11 @@ let eclipseListData = [];
let currentLocal = null; let currentLocal = null;
let localObserver = null; let localObserver = null;
let geometryContacts = null; let geometryContacts = null;
let viewMode = 'space';
let exportInProgress = false;
let exportCancelRequested = false;
let exportFromAnchor = null;
let exportToAnchor = null;
let simTime = 0; let simTime = 0;
let playing = false; let playing = false;
let playSpeed = 10; let playSpeed = 10;
@@ -193,7 +246,7 @@ async function init() {
THREE = await import('./js/three.module.js'); THREE = await import('./js/three.module.js');
renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true, alpha: false }); renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15; renderer.toneMappingExposure = 1.15;
@@ -383,15 +436,22 @@ function resizeAll() {
renderer.setSize(w, h); renderer.setSize(w, h);
camera.aspect = w / h; camera.aspect = w / h;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvasLocal.width = Math.round(w * dpr);
canvasLocal.height = Math.round(h * dpr);
if (viewMode === 'local') drawLocalView();
} }
function buildYearSelectors() { function buildYearSelectors() {
const yf = document.getElementById('eclipseYearFrom'); const yf = document.getElementById('eclipseYearFrom');
const yt = document.getElementById('eclipseYearTo'); const yt = document.getElementById('eclipseYearTo');
const now = new Date().getFullYear(); const now = new Date().getFullYear();
for (let y = 1995; y <= now + 20; y++) { const from = now;
yf.add(new Option(y, y, y === 1999, y === 1999)); const to = now + 10;
yt.add(new Option(y, y, y === 2000, y === 2000)); for (let y = 1950; y <= 2100; y++) {
yf.add(new Option(y, y, y === from, y === from));
yt.add(new Option(y, y, y === to, y === to));
} }
} }
@@ -445,6 +505,7 @@ function selectEclipse(idx) {
if (!eclipseListData[idx]) return; if (!eclipseListData[idx]) return;
currentEclipse = eclipseListData[idx]; currentEclipse = eclipseListData[idx];
currentLocal = findLocalCircumstances(currentEclipse); currentLocal = findLocalCircumstances(currentEclipse);
updateLocalViewAvailability();
eclipseListEl.querySelectorAll('.eclipse-list-item').forEach((el, i) => eclipseListEl.querySelectorAll('.eclipse-list-item').forEach((el, i) =>
el.classList.toggle('is-selected', i === idx) el.classList.toggle('is-selected', i === idx)
@@ -468,6 +529,8 @@ function selectEclipse(idx) {
playing = false; playing = false;
btnPlay.textContent = '▶ Play'; btnPlay.textContent = '▶ Play';
lastRAF = performance.now(); lastRAF = performance.now();
btnExportFrames.disabled = false;
populateExportRange();
} }
function findLocalCircumstances(globalEclipse) { function findLocalCircumstances(globalEclipse) {
@@ -483,6 +546,371 @@ function findLocalCircumstances(globalEclipse) {
} }
} }
function updateLocalViewAvailability() {
if (!localObserver) {
btnLocalView.disabled = true;
btnLocalView.title = 'Kein Standort hinterlegt (siehe Einstellungen)';
} else if (!currentLocal) {
btnLocalView.disabled = true;
btnLocalView.title = 'Von diesem Standort aus nicht sichtbar';
} else {
btnLocalView.disabled = false;
btnLocalView.title = '';
}
if (btnLocalView.disabled && viewMode === 'local') setView('space');
}
// Topozentrische Sonne/Mond-Geometrie am Favoritenstandort, Zenit oben (parallaktischer
// Winkel angewendet), siehe Plan "Standort-Ansicht" fuer Herleitung der Formeln.
function computeLocalSkyGeometry(date) {
if (!localObserver) return null;
const astroTime = AE.MakeTime(date);
const sunEq = AE.Equator('Sun', astroTime, localObserver, true, true);
const moonEq = AE.Equator('Moon', astroTime, localObserver, true, true);
const sunDistKm = sunEq.dist * AE.KM_PER_AU;
const moonDistKm = moonEq.dist * AE.KM_PER_AU;
const sunAngR = Math.asin(Math.min(1, SUN_RADIUS_KM / sunDistKm));
const moonAngR = Math.asin(Math.min(1, MOON_RADIUS_KM / moonDistKm));
const sunRaR = sunEq.ra * 15 * DEG2RAD;
const sunDecR = sunEq.dec * DEG2RAD;
const moonRaR = moonEq.ra * 15 * DEG2RAD;
const moonDecR = moonEq.dec * DEG2RAD;
const dRa = moonRaR - sunRaR;
const cosSep = Math.sin(sunDecR) * Math.sin(moonDecR) +
Math.cos(sunDecR) * Math.cos(moonDecR) * Math.cos(dRa);
const sep = Math.acos(Math.max(-1, Math.min(1, cosSep)));
const pa = Math.atan2(
Math.sin(dRa),
Math.cos(sunDecR) * Math.tan(moonDecR) - Math.sin(sunDecR) * Math.cos(dRa)
);
const gastHours = AE.SiderealTime(astroTime);
const lstHours = ((gastHours + localObserver.longitude / 15) % 24 + 24) % 24;
const hourAngleR = (lstHours - sunEq.ra) * 15 * DEG2RAD;
const latR = localObserver.latitude * DEG2RAD;
const parallacticAngle = Math.atan2(
Math.sin(hourAngleR),
Math.tan(latR) * Math.cos(sunDecR) - Math.sin(sunDecR) * Math.cos(hourAngleR)
);
const paZenith = pa - parallacticAngle;
const dx = sep * Math.sin(paZenith);
const dy = -sep * Math.cos(paZenith);
const sunHor = AE.Horizon(astroTime, localObserver, sunEq.ra, sunEq.dec, 'normal');
return {
visible: sunHor.altitude > -sunAngR * RAD2DEG,
sunAngR,
moonAngR,
sep,
dx,
dy,
sunAltitudeDeg: sunHor.altitude
};
}
function drawLocalView() {
const ctx = canvasLocal.getContext('2d');
const W = canvasLocal.width;
const H = canvasLocal.height;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#010309';
ctx.fillRect(0, 0, W, H);
if (!localObserver) {
localHint.textContent = 'Kein Standort hinterlegt.';
localHint.classList.add('is-visible');
return;
}
if (!currentEclipse) {
localHint.textContent = 'Wähle zunächst eine Finsternis aus der Liste.';
localHint.classList.add('is-visible');
return;
}
const geo = computeLocalSkyGeometry(simDateAt());
if (!geo || !geo.visible) {
localHint.textContent = geo
? `Sonne unter dem Horizont am Favoritenstandort (Höhe ${geo.sunAltitudeDeg.toFixed(1)}°).`
: 'Standortgeometrie nicht bestimmbar.';
localHint.classList.add('is-visible');
return;
}
localHint.classList.remove('is-visible');
const cx = W / 2, cy = H / 2;
const R = Math.min(W, H) * 0.32;
const sc = R / geo.sunAngR;
const sunR = R;
const moonR = geo.moonAngR * sc;
const mx = cx + geo.dx * sc;
const my = cy + geo.dy * sc;
const glow = ctx.createRadialGradient(cx, cy, sunR * 0.9, cx, cy, sunR * 2.2);
glow.addColorStop(0, 'rgba(255,241,180,0.35)');
glow.addColorStop(1, 'rgba(255,241,180,0)');
ctx.fillStyle = glow;
ctx.fillRect(0, 0, W, H);
const sunGrad = ctx.createRadialGradient(cx - sunR * 0.2, cy - sunR * 0.2, sunR * 0.05, cx, cy, sunR);
sunGrad.addColorStop(0, '#fff8dd');
sunGrad.addColorStop(0.75, '#ffe9a8');
sunGrad.addColorStop(1, '#f2b64c');
ctx.fillStyle = sunGrad;
ctx.beginPath();
ctx.arc(cx, cy, sunR, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#03040a';
ctx.beginPath();
ctx.arc(mx, my, moonR, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(10,8,4,0.35)';
ctx.lineWidth = Math.max(1, sunR * 0.01);
ctx.beginPath();
ctx.arc(mx, my, moonR, 0, Math.PI * 2);
ctx.stroke();
// Dezenter Korona-Schimmer bei Totalitaet/Ringphase
if (geo.sep < Math.max(geo.sunAngR, geo.moonAngR) * 0.15) {
const corona = ctx.createRadialGradient(cx, cy, sunR * 0.98, cx, cy, sunR * 1.5);
corona.addColorStop(0, 'rgba(255,255,240,0.55)');
corona.addColorStop(1, 'rgba(255,255,240,0)');
ctx.fillStyle = corona;
ctx.beginPath();
ctx.arc(cx, cy, sunR * 1.5, 0, Math.PI * 2);
ctx.fill();
}
}
function setView(mode) {
viewMode = mode;
btnSpaceView.classList.toggle('is-active', mode === 'space');
btnLocalView.classList.toggle('is-active', mode === 'local');
canvas3d.classList.toggle('is-hidden', mode !== 'space');
canvasLocal.classList.toggle('is-hidden', mode !== 'local');
localHint.classList.remove('is-visible');
if (mode === 'local') drawLocalView();
}
function fmtTimeInput(date) {
return date.toISOString().slice(11, 19);
}
// Belegt die Von/Bis-Felder mit dem vollen Simulationszeitraum der aktuell
// gewaehlten Finsternis vor. Die Ankerdaten merken sich den UTC-Kalendertag
// jedes Feldes, damit beim Auslesen (parseExportRange) aus der reinen Uhrzeit
// wieder ein eindeutiges Datum rekonstruiert werden kann.
function populateExportRange() {
const { min, max } = timeRange();
exportFromAnchor = simDateAt(min);
exportToAnchor = simDateAt(max);
exportFromTimeInput.value = fmtTimeInput(exportFromAnchor);
exportToTimeInput.value = fmtTimeInput(exportToAnchor);
}
function combineAnchorAndTime(anchorDate, timeStr) {
const [h, m, s] = timeStr.split(':').map(Number);
return new Date(Date.UTC(
anchorDate.getUTCFullYear(), anchorDate.getUTCMonth(), anchorDate.getUTCDate(),
h || 0, m || 0, s || 0
));
}
// Liest die vom Nutzer eingestellten Von/Bis-Zeiten, verankert sie am
// jeweiligen Kalendertag der Vorbelegung und begrenzt sie auf den technisch
// gueltigen Simulationszeitraum der Finsternis.
function parseExportRange() {
const { min: fullMin, max: fullMax } = timeRange();
if (!exportFromAnchor || !exportToAnchor || !exportFromTimeInput.value || !exportToTimeInput.value) {
return { min: fullMin, max: fullMax };
}
const fromDate = combineAnchorAndTime(exportFromAnchor, exportFromTimeInput.value);
const toDate = combineAnchorAndTime(exportToAnchor, exportToTimeInput.value);
const peakMs = eventPeakDate().getTime();
const min = Math.max(fullMin, Math.min(fullMax, (fromDate.getTime() - peakMs) / 60000));
const max = Math.max(fullMin, Math.min(fullMax, (toDate.getTime() - peakMs) / 60000));
if (max <= min) {
throw new Error('Die "Bis"-Zeit muss nach der "Von"-Zeit liegen.');
}
return { min, max };
}
// ─── Einzelbild-Export (24 fps) ─────────────────────────────────────────────
// Jedes Frame wird einzeln gerendert und sofort an einen PHP-Endpunkt
// hochgeladen, der es in einem Temp-Verzeichnis ablegt - der Browser haelt nie
// mehr als ein Bild gleichzeitig im Speicher. Am Ende baut der Server daraus
// die ZIP-Datei und liefert sie direkt aus (siehe public/ajax/sonnenfinsternis_export.php).
// Bei vielen tausend sequentiellen Requests (ein Upload pro Frame) reicht ein
// einzelner kurzer Netzwerk-/Server-Haenger (z.B. der PHP-Entwicklungsserver
// unter Dauerlast), um fetch() mit "Failed to fetch" abbrechen zu lassen. Echte
// Server-Antworten (Auth-/Validierungsfehler) werden dagegen sofort durchgereicht,
// da ein erneuter Versuch dort nichts aendern wuerde.
async function exportApiCall(action, params, body, maxAttempts = 4) {
const qs = new URLSearchParams({ action, token: EXPORT_TOKEN, ...params });
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let res;
try {
res = await fetch(`${EXPORT_ENDPOINT}?${qs}`, {
method: 'POST',
body: body || null
});
} catch (err) {
lastErr = err;
if (attempt < maxAttempts) await delay(300 * attempt);
continue;
}
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || `Server-Fehler (${res.status})`);
}
return data;
}
throw lastErr;
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function dataUrlToBytes(dataUrl) {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
// Rendert Frame `i` und liest ihn als PNG-Bytes aus. canvas.toDataURL() liefert die
// Bilddaten synchron als Base64-String zurueck (statt ueber canvas.toBlob(), das
// Chromium bei groesseren Bildern intern ueber eine Temp-Datei abwickelt - greift in
// dem Moment z.B. ein Virenscanner oder OneDrive darauf zu, schlaegt das Auslesen
// mit einem NotReadableError fehl). So wird dieser Umweg komplett vermieden.
async function captureFrame(canvas, isSpaceView) {
const maxAttempts = 3;
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
if (isSpaceView) {
if (renderer.getContext().isContextLost()) {
throw new Error('WebGL-Kontext verloren (vermutlich Grafikspeicher-Engpass).');
}
renderer.render(scene, camera);
}
return dataUrlToBytes(canvas.toDataURL('image/png'));
} catch (err) {
lastErr = err;
if (attempt < maxAttempts) await delay(150 * attempt);
}
}
throw lastErr;
}
async function exportFrames() {
if (!currentEclipse || exportInProgress) return;
let min, max;
try {
({ min, max } = parseExportRange());
} catch (err) {
exportStatus.textContent = err.message;
return;
}
const realSeconds = (max - min) * 60 / playSpeed;
const frameCount = Math.max(1, Math.round(realSeconds * 24));
const totalFrames = frameCount + 1;
const ok = window.confirm(
`Export mit ${totalFrames} Einzelbildern bei aktueller Geschwindigkeit (×${playSpeed}) starten?\n` +
`Größere Geschwindigkeiten erzeugen weniger Bilder. Fortfahren?`
);
if (!ok) return;
const savedSimTime = simTime;
const savedPlaying = playing;
playing = false;
exportInProgress = true;
exportCancelRequested = false;
btnExportFrames.textContent = 'Abbrechen';
btnSpaceView.disabled = true;
btnLocalView.disabled = true;
const activeCanvas = viewMode === 'local' ? canvasLocal : canvas3d;
const dtMin = (max - min) / frameCount;
// Feste, moderate Export-Aufloesung statt der Live-Anzeigegroesse (die je nach
// Bildschirm/DPI ein Vielfaches groesser sein kann) - haelt den Speicherbedarf
// pro Frame vorhersagbar und begrenzt.
renderer.setSize(EXPORT_WIDTH, EXPORT_HEIGHT, false);
camera.aspect = EXPORT_WIDTH / EXPORT_HEIGHT;
camera.updateProjectionMatrix();
canvasLocal.width = EXPORT_WIDTH;
canvasLocal.height = EXPORT_HEIGHT;
let exportId = null;
try {
exportStatus.textContent = 'Starte Export…';
({ exportId } = await exportApiCall('start', {}));
for (let i = 0; i <= frameCount; i++) {
if (exportCancelRequested) {
await exportApiCall('cancel', { exportId });
exportStatus.textContent = 'Export abgebrochen.';
return;
}
simTime = min + i * dtMin;
updateSim();
let buf;
try {
buf = await captureFrame(activeCanvas, viewMode === 'space');
} catch (err) {
throw new Error(`Frame ${i + 1}/${totalFrames}: ${err.message || err}`);
}
await exportApiCall('frame', { exportId, index: i }, buf);
exportStatus.textContent = `Frame ${i + 1}/${totalFrames} hochgeladen…`;
}
exportStatus.textContent = 'Erzeuge ZIP-Datei…';
const qs = new URLSearchParams({ action: 'finish', token: EXPORT_TOKEN, exportId });
window.location.href = `${EXPORT_ENDPOINT}?${qs}`;
exportStatus.textContent = `Export abgeschlossen (${totalFrames} Bilder).`;
} catch (err) {
console.error('Einzelbild-Export fehlgeschlagen', err);
exportStatus.textContent = `Export fehlgeschlagen: ${err.message || err}`;
if (exportId) {
try { await exportApiCall('cancel', { exportId }); } catch (_) { /* bestmoeglich aufraeumen */ }
}
} finally {
exportInProgress = false;
exportCancelRequested = false;
btnExportFrames.textContent = 'Als Einzelbilder exportieren (24 fps)';
btnSpaceView.disabled = false;
updateLocalViewAvailability();
simTime = savedSimTime;
playing = savedPlaying;
resizeAll();
updateSim();
if (viewMode === 'space') renderer.render(scene, camera);
lastRAF = performance.now();
}
}
function geometryEventKind() { function geometryEventKind() {
return currentEclipse?.kind || null; return currentEclipse?.kind || null;
} }
@@ -971,6 +1399,8 @@ function updateSim() {
tlDisplay.textContent = cur.toISOString().slice(11, 16) + ' UTC'; tlDisplay.textContent = cur.toISOString().slice(11, 16) + ' UTC';
updateInfoPhase(cov); updateInfoPhase(cov);
if (viewMode === 'local') drawLocalView();
} }
function eventPeakDate() { function eventPeakDate() {
@@ -980,6 +1410,8 @@ function eventPeakDate() {
function renderLoop(now) { function renderLoop(now) {
requestAnimationFrame(renderLoop); requestAnimationFrame(renderLoop);
if (exportInProgress) return;
if (playing && currentEclipse) { if (playing && currentEclipse) {
const dt = (now - lastRAF) / 1000; const dt = (now - lastRAF) / 1000;
lastRAF = now; lastRAF = now;
@@ -993,7 +1425,7 @@ function renderLoop(now) {
updateSim(); updateSim();
} }
renderer.render(scene, camera); if (viewMode === 'space') renderer.render(scene, camera);
} }
function fmtUTC(date) { function fmtUTC(date) {
@@ -1013,7 +1445,7 @@ function renderInfo() {
const centralLon = ec.longitude !== undefined && ec.longitude !== null ? `${ec.longitude.toFixed(2)}°` : '—'; const centralLon = ec.longitude !== undefined && ec.longitude !== null ? `${ec.longitude.toFixed(2)}°` : '—';
const distance = ec.distance !== undefined && ec.distance !== null ? `${Math.round(ec.distance)} km` : '—'; const distance = ec.distance !== undefined && ec.distance !== null ? `${Math.round(ec.distance)} km` : '—';
infoPanel.innerHTML = ` infoBody.innerHTML = `
<div class="eclipse-info-type ${tc}">${tl}</div> <div class="eclipse-info-type ${tc}">${tl}</div>
<div class="eclipse-info-date">${ds}</div> <div class="eclipse-info-date">${ds}</div>
<div class="eclipse-info-time">${ts}</div> <div class="eclipse-info-time">${ts}</div>
@@ -1261,6 +1693,22 @@ function setupInteraction() {
} }
document.getElementById('eclipseSearchBtn').addEventListener('click', searchEclipses); document.getElementById('eclipseSearchBtn').addEventListener('click', searchEclipses);
infoToggleBtn.addEventListener('click', () => {
const collapsed = infoPanel.classList.toggle('is-collapsed');
infoToggleBtn.setAttribute('aria-expanded', String(!collapsed));
});
btnSpaceView.addEventListener('click', () => setView('space'));
btnLocalView.addEventListener('click', () => setView('local'));
btnExportFrames.addEventListener('click', () => {
if (exportInProgress) {
exportCancelRequested = true;
} else {
exportFrames();
}
});
} }
init().catch(err => { init().catch(err => {