Compare commits
10
Commits
12ab61355e
...
2bf2848905
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf2848905 | ||
|
|
1bfb3c4982 | ||
|
|
eb13067055 | ||
|
|
4592f91cf5 | ||
|
|
08bae7479c | ||
|
|
0bf8f1f8dc | ||
|
|
d9a8b07a8d | ||
|
|
fb75b180e2 | ||
|
|
72f6b968ce | ||
|
|
e2689abe75 |
@@ -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: $?\")"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+456
-8
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user