1458 lines
69 KiB
PHP
1458 lines
69 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
ini_set('display_errors', '1');
|
||
error_reporting(E_ALL);
|
||
session_start();
|
||
|
||
if (!isset($_SESSION['user_id'])) {
|
||
header('Location: login.php');
|
||
exit;
|
||
}
|
||
|
||
$config = require __DIR__ . '/../config/database.php';
|
||
|
||
$dsn = sprintf(
|
||
'mysql:host=%s;dbname=%s;charset=%s',
|
||
$config['host'],
|
||
$config['dbname'],
|
||
$config['charset'] ?? 'utf8mb4'
|
||
);
|
||
|
||
$pdo = new PDO(
|
||
$dsn,
|
||
$config['user'],
|
||
$config['pass'],
|
||
[
|
||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||
]
|
||
);
|
||
|
||
$loggedIn = true;
|
||
$pageTitle = 'Satellitenhimmel - AstroTools';
|
||
$bodyClass = 'satpass-page';
|
||
$userId = (int)$_SESSION['user_id'];
|
||
|
||
function h(?string $value): string
|
||
{
|
||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||
}
|
||
|
||
function getPythonCandidates(): array
|
||
{
|
||
if (PHP_OS_FAMILY === 'Windows') {
|
||
return ['python', 'py'];
|
||
}
|
||
|
||
return ['/usr/bin/python3', '/usr/bin/python', 'python3', 'python'];
|
||
}
|
||
|
||
function runPythonCommand(string $scriptPath, array $args): array
|
||
{
|
||
putenv('PYTHONUTF8=1');
|
||
putenv('PYTHONIOENCODING=utf-8');
|
||
|
||
$escapedArguments = array_map('escapeshellarg', array_merge([$scriptPath], $args));
|
||
$lastError = 'Python konnte nicht gestartet werden.';
|
||
|
||
foreach (getPythonCandidates() as $pythonBinary) {
|
||
$command = $pythonBinary . ' ' . implode(' ', $escapedArguments) . ' 2>&1';
|
||
$output = [];
|
||
$resultCode = 0;
|
||
exec($command, $output, $resultCode);
|
||
|
||
$joined = trim(implode("\n", $output));
|
||
$decoded = json_decode($joined, true);
|
||
|
||
if (is_array($decoded)) {
|
||
return [
|
||
'ok' => (bool) ($decoded['ok'] ?? false),
|
||
'data' => $decoded,
|
||
'raw' => $joined,
|
||
'command' => $command,
|
||
];
|
||
}
|
||
|
||
if ($joined !== '') {
|
||
$lastError = $joined;
|
||
} elseif ($resultCode !== 0) {
|
||
$lastError = 'Fehlercode ' . $resultCode . ' bei ' . $pythonBinary;
|
||
}
|
||
}
|
||
|
||
return [
|
||
'ok' => false,
|
||
'error' => $lastError,
|
||
];
|
||
}
|
||
|
||
// ── Satelliten aus DB laden ──────────────────────────────────────────────────
|
||
|
||
function tleChecksum(string $line): int
|
||
{
|
||
$sum = 0;
|
||
for ($i = 0; $i < 68; $i++) {
|
||
$c = $line[$i];
|
||
if (ctype_digit($c)) {
|
||
$sum += (int)$c;
|
||
} elseif ($c === '-') {
|
||
$sum += 1;
|
||
}
|
||
}
|
||
return $sum % 10;
|
||
}
|
||
|
||
function tleExp(float $val): string
|
||
{
|
||
if (abs($val) < 1e-99) {
|
||
return ' 00000-0';
|
||
}
|
||
$sign = $val >= 0 ? '+' : '-';
|
||
$abs = abs($val);
|
||
$exp = (int)floor(log10($abs)) + 1;
|
||
$mantissa = (int)round($abs / pow(10, $exp) * 1e5);
|
||
if ($mantissa >= 100000) {
|
||
$mantissa = 10000;
|
||
$exp++;
|
||
}
|
||
$expSign = $exp >= 0 ? '+' : '-';
|
||
return sprintf('%s%05d%s%d', $sign, $mantissa, $expSign, abs($exp));
|
||
}
|
||
|
||
function tleMmDot(float $val): string
|
||
{
|
||
$sign = $val >= 0 ? ' ' : '-';
|
||
return sprintf('%s.%08d', $sign, (int)round(abs($val) * 1e8));
|
||
}
|
||
|
||
function tleEpoch(string $epochStr): string
|
||
{
|
||
$dt = new DateTimeImmutable($epochStr, new DateTimeZone('UTC'));
|
||
$yy = (int)$dt->format('Y') % 100;
|
||
$jan1 = new DateTimeImmutable($dt->format('Y') . '-01-01 00:00:00', new DateTimeZone('UTC'));
|
||
$secs = (float)$dt->format('U') - (float)$jan1->format('U');
|
||
$day = $secs / 86400.0 + 1.0;
|
||
return sprintf('%02d%012.8f', $yy, $day);
|
||
}
|
||
|
||
function tleIntlDesig(?string $objectId): string
|
||
{
|
||
if (!$objectId) {
|
||
return str_repeat(' ', 8);
|
||
}
|
||
if (preg_match('/^(\d{4})-(\d+)([A-Z]*)$/i', trim($objectId), $m)) {
|
||
$yy = substr($m[1], 2, 2);
|
||
$launch = str_pad($m[2], 3, '0', STR_PAD_LEFT);
|
||
$piece = str_pad(strtoupper($m[3]), 3, ' ', STR_PAD_RIGHT);
|
||
return substr(str_pad($yy . $launch . $piece, 8, ' '), 0, 8);
|
||
}
|
||
return str_pad(substr($objectId, 0, 8), 8);
|
||
}
|
||
|
||
function buildLine1(array $sat, array $tle): string
|
||
{
|
||
$norad = (int)$sat['norad_cat_id'];
|
||
$class = in_array($sat['classification_type'] ?? '', ['U', 'C', 'S'], true)
|
||
? $sat['classification_type']
|
||
: 'U';
|
||
$line = sprintf(
|
||
'1 %05d%1s %-8s %14s %10s %8s %8s %1d %4d',
|
||
$norad,
|
||
$class,
|
||
tleIntlDesig($sat['object_id'] ?? null),
|
||
tleEpoch((string)$tle['epoch']),
|
||
tleMmDot((float)($tle['mean_motion_dot'] ?? 0)),
|
||
tleExp((float)($tle['mean_motion_ddot'] ?? 0)),
|
||
tleExp((float)($tle['bstar'] ?? 0)),
|
||
(int)($tle['ephemeris_type'] ?? 0),
|
||
(int)($tle['element_set_no'] ?? 0)
|
||
);
|
||
$line = str_pad($line, 68);
|
||
return $line . tleChecksum($line);
|
||
}
|
||
|
||
function buildLine2(array $sat, array $tle): string
|
||
{
|
||
$norad = (int)$sat['norad_cat_id'];
|
||
$ecc = (float)($tle['eccentricity'] ?? 0);
|
||
$eccStr = substr(sprintf('%.7f', $ecc), 2);
|
||
$line = sprintf(
|
||
'2 %05d %8.4f %8.4f %7s %8.4f %8.4f %11.8f%5d',
|
||
$norad,
|
||
(float)($tle['inclination'] ?? 0),
|
||
(float)($tle['ra_of_asc_node'] ?? 0),
|
||
$eccStr,
|
||
(float)($tle['arg_of_pericenter'] ?? 0),
|
||
(float)($tle['mean_anomaly'] ?? 0),
|
||
(float)($tle['mean_motion'] ?? 0),
|
||
(int)($tle['rev_at_epoch'] ?? 0)
|
||
);
|
||
$line = str_pad($line, 68);
|
||
return $line . tleChecksum($line);
|
||
}
|
||
|
||
// Standard-Standort vorab laden (bevor header.php eingebunden wird)
|
||
$defaultLocation = null;
|
||
$stmtDefLoc = $pdo->prepare(
|
||
'SELECT id, name, latitude, longitude, elevation, timezone
|
||
FROM app_user_locations
|
||
WHERE user_id = ? AND is_default = 1
|
||
LIMIT 1'
|
||
);
|
||
$stmtDefLoc->execute([$userId]);
|
||
$defaultLocation = $stmtDefLoc->fetch() ?: null;
|
||
|
||
$stmtTracked = $pdo->prepare("
|
||
SELECT
|
||
s.`id`,
|
||
s.`norad_cat_id`,
|
||
s.`object_name`,
|
||
s.`object_id`,
|
||
s.`classification_type`,
|
||
s.`std_mag`,
|
||
s.`rcs_size`,
|
||
s.`rcs`,
|
||
aus.`is_favorite`,
|
||
t.`epoch`,
|
||
t.`mean_motion`,
|
||
t.`eccentricity`,
|
||
t.`inclination`,
|
||
t.`ra_of_asc_node`,
|
||
t.`arg_of_pericenter`,
|
||
t.`mean_anomaly`,
|
||
t.`ephemeris_type`,
|
||
t.`element_set_no`,
|
||
t.`rev_at_epoch`,
|
||
t.`bstar`,
|
||
t.`mean_motion_dot`,
|
||
t.`mean_motion_ddot`
|
||
FROM `app_user_satellites` aus
|
||
INNER JOIN `sat_satellites` s
|
||
ON s.`id` = aus.`satellite_id`
|
||
INNER JOIN `sat_tle_current` t
|
||
ON t.`satellite_id` = s.`id`
|
||
WHERE aus.`user_id` = :user_id
|
||
AND t.`epoch` IS NOT NULL
|
||
AND t.`mean_motion` IS NOT NULL
|
||
ORDER BY aus.`is_favorite` DESC, s.`object_name` ASC
|
||
");
|
||
$stmtTracked->execute([':user_id' => $userId]);
|
||
$trackedSatellites = $stmtTracked->fetchAll();
|
||
|
||
$satellitePayload = [];
|
||
foreach ($trackedSatellites as $row) {
|
||
$satellitePayload[] = [
|
||
'id' => (int)$row['id'],
|
||
'norad_cat_id' => (int)$row['norad_cat_id'],
|
||
'object_name' => (string)$row['object_name'],
|
||
'is_favorite' => (int)$row['is_favorite'] === 1,
|
||
'std_mag' => $row['std_mag'] !== null ? (float)$row['std_mag'] : null,
|
||
'rcs_size' => $row['rcs_size'] !== null ? (string)$row['rcs_size'] : null,
|
||
'rcs' => $row['rcs'] !== null && (float)$row['rcs'] > 0 ? (float)$row['rcs'] : null,
|
||
'tle_line1' => buildLine1($row, $row),
|
||
'tle_line2' => buildLine2($row, $row),
|
||
];
|
||
}
|
||
|
||
// ── Sterne & Sternbilder ─────────────────────────────────────────────────────
|
||
|
||
$stmtStars = $pdo->query("
|
||
SELECT `proper`, `bf`, `ra`, `dec`, `mag`
|
||
FROM `star_hipparcos`
|
||
WHERE `mag` <= 5.0
|
||
AND `ra` IS NOT NULL
|
||
AND `dec` IS NOT NULL
|
||
ORDER BY `mag` ASC
|
||
");
|
||
$brightStars = $stmtStars->fetchAll();
|
||
$starPayload = array_map(fn($r) => [
|
||
'name' => (string)($r['proper'] ?: ($r['bf'] ?: '')),
|
||
'ra' => (float)$r['ra'] * 15.0,
|
||
'dec' => (float)$r['dec'],
|
||
'mag' => (float)$r['mag'],
|
||
], $brightStars);
|
||
|
||
$stmtLines = $pdo->query("
|
||
SELECT
|
||
cl.`id` AS line_id,
|
||
clp.`point_index`,
|
||
sh.`ra` * 15.0 AS ra_deg,
|
||
sh.`dec` AS dec_deg
|
||
FROM `constellation_lines` cl
|
||
INNER JOIN `constellation_line_points` clp ON clp.`line_id` = cl.`id`
|
||
INNER JOIN `star_hipparcos` sh ON sh.`hip` = clp.`hip_number`
|
||
WHERE sh.`ra` IS NOT NULL AND sh.`dec` IS NOT NULL
|
||
ORDER BY cl.`id`, clp.`point_index`
|
||
");
|
||
$lineRows = $stmtLines->fetchAll();
|
||
$constellationLines = [];
|
||
foreach ($lineRows as $row) {
|
||
$lid = (int)$row['line_id'];
|
||
$constellationLines[$lid][] = ['ra' => (float)$row['ra_deg'], 'dec' => (float)$row['dec_deg']];
|
||
}
|
||
$constellationPayload = array_values($constellationLines);
|
||
|
||
$stmtConstellationLabels = $pdo->query("
|
||
SELECT
|
||
clp.`constellation_id`,
|
||
clp.`position_index`,
|
||
clp.`ra_hours` * 15.0 AS ra_deg,
|
||
clp.`dec_degrees` AS dec_deg,
|
||
cn.`german_name`,
|
||
cn.`native_name`,
|
||
cn.`byname`
|
||
FROM `constellation_label_positions` clp
|
||
LEFT JOIN `constellation_names` cn
|
||
ON cn.`constellation_id` = clp.`constellation_id`
|
||
ORDER BY clp.`constellation_id`, clp.`position_index`
|
||
");
|
||
$constellationLabelRows = $stmtConstellationLabels->fetchAll();
|
||
$constellationLabelPayload = array_map(static function (array $row): array {
|
||
$label = trim((string)($row['german_name'] ?: $row['native_name'] ?: $row['byname'] ?: $row['constellation_id']));
|
||
return [
|
||
'id' => (string)$row['constellation_id'],
|
||
'label' => $label,
|
||
'ra' => (float)$row['ra_deg'],
|
||
'dec' => (float)$row['dec_deg'],
|
||
'position_index' => (int)$row['position_index'],
|
||
];
|
||
}, $constellationLabelRows);
|
||
|
||
// ── Python aufrufen ──────────────────────────────────────────────────────────
|
||
|
||
$satellitePassWindowDays = 7;
|
||
$pythonResult = null;
|
||
$pythonError = null;
|
||
|
||
if (!empty($satellitePayload) && !empty($defaultLocation['latitude'])) {
|
||
$loc = $defaultLocation;
|
||
$lat = (float)$loc['latitude'];
|
||
$lon = (float)$loc['longitude'];
|
||
$elev = (float)($loc['elevation'] ?? 0);
|
||
$tz = (string)($loc['timezone'] ?? 'Europe/Berlin');
|
||
|
||
// 7-Tage-Fenster: lokaler Tagesbeginn heute → +7 Tage
|
||
$tzObj = new DateTimeZone($tz);
|
||
$nowLocal = new DateTimeImmutable('now', $tzObj);
|
||
$dayStart = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $nowLocal->format('Y-m-d') . ' 00:00:00', $tzObj);
|
||
$dayEnd = $dayStart->modify('+' . $satellitePassWindowDays . ' days 23:59:59');
|
||
$windowStart = $dayStart->setTimezone(new DateTimeZone('UTC'))->format(DateTime::ATOM);
|
||
$windowEnd = $dayEnd->setTimezone(new DateTimeZone('UTC'))->format(DateTime::ATOM);
|
||
|
||
$scriptPath = realpath(__DIR__ . '/py/api.py');
|
||
if ($scriptPath === false) {
|
||
$pythonError = 'Das Python-Skript für den Satellitenhimmel wurde nicht gefunden.';
|
||
} else {
|
||
$satellitesJson = json_encode($satellitePayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||
$satellitesPayload = base64_encode((string)$satellitesJson);
|
||
|
||
$pythonCall = runPythonCommand($scriptPath, [
|
||
'satellite_passes',
|
||
(string)$lat,
|
||
(string)$lon,
|
||
(string)$elev,
|
||
$tz,
|
||
$windowStart,
|
||
$windowEnd,
|
||
$satellitesPayload,
|
||
]);
|
||
|
||
if ($pythonCall['ok'] ?? false) {
|
||
$pythonResult = $pythonCall['data'];
|
||
} else {
|
||
$pythonError = $pythonCall['raw'] ?? $pythonCall['error'] ?? 'Leere Antwort von Python.';
|
||
}
|
||
}
|
||
}
|
||
?>
|
||
<?php require __DIR__ . '/header.php'; ?>
|
||
|
||
<?php
|
||
$pageLocation = null;
|
||
if (!empty($currentLocation) && is_array($currentLocation)) {
|
||
$pageLocation = [
|
||
'name' => (string)($currentLocation['name'] ?? ''),
|
||
'latitude' => isset($currentLocation['latitude']) ? (float)$currentLocation['latitude'] : null,
|
||
'longitude' => isset($currentLocation['longitude']) ? (float)$currentLocation['longitude'] : null,
|
||
'elevation' => isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float)$currentLocation['elevation'] : 0.0,
|
||
'timezone' => (string)($currentLocation['timezone'] ?? 'Europe/Berlin'),
|
||
];
|
||
}
|
||
?>
|
||
|
||
<div class="satpass-tool">
|
||
<section class="card satpass-card">
|
||
<p class="satpass-intro">
|
||
Berechnet für den heutigen Tag den nächsten Überflug deiner ausgewählten Satelliten
|
||
und zeichnet deren Bahn auf einer Zenitkarte für deinen Standard-Standort.
|
||
</p>
|
||
</section>
|
||
|
||
<?php if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null): ?>
|
||
<section class="card satpass-card">
|
||
<h2>Kein Standard-Standort gesetzt</h2>
|
||
<p class="hint">
|
||
Bitte wähle in den <a href="settings.php">Einstellungen</a> einen Standard-Standort aus.
|
||
</p>
|
||
</section>
|
||
<?php elseif (empty($satellitePayload)): ?>
|
||
<section class="card satpass-card">
|
||
<h2>Keine Satelliten ausgewählt</h2>
|
||
<p class="hint">
|
||
Es sind noch keine ausgewählten Satelliten mit aktuellem TLE vorhanden.
|
||
Bitte fuege in <a href="my_favorites.php">Favoriten</a> Satelliten hinzu.
|
||
</p>
|
||
</section>
|
||
<?php elseif ($pythonError !== null): ?>
|
||
<section class="card satpass-card">
|
||
<h2>Berechnungsfehler</h2>
|
||
<ul class="msg-error"><li><?= h($pythonError) ?></li></ul>
|
||
</section>
|
||
<?php else: ?>
|
||
<section class="card satpass-card satpass-head">
|
||
<div class="satpass-head-item">
|
||
<span class="satpass-label">Standort</span>
|
||
<strong><?= h($pageLocation['name']) ?></strong>
|
||
<span><?= number_format($pageLocation['latitude'], 4, '.', '') ?>deg N / <?= number_format($pageLocation['longitude'], 4, '.', '') ?>deg O / <?= number_format($pageLocation['elevation'], 0, '.', '') ?> m</span>
|
||
</div>
|
||
<div class="satpass-head-item">
|
||
<span class="satpass-label">Zeitzone</span>
|
||
<strong><?= h($pageLocation['timezone']) ?></strong>
|
||
<span>Berechnung serverbasiert (Python/sgp4)</span>
|
||
</div>
|
||
<div class="satpass-head-item">
|
||
<span class="satpass-label">Satelliten</span>
|
||
<strong><?= count($satellitePayload) ?></strong>
|
||
<span>mit aktuellem TLE</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card satpass-card">
|
||
<div id="satpassStatus" class="hint">Lade Ueberfluege...</div>
|
||
|
||
<div class="satpass-main-layout">
|
||
<div class="satpass-sidebar">
|
||
<div class="form-group">
|
||
<label for="satSelect">Satellit</label>
|
||
<select id="satSelect" class="satpass-select">
|
||
<option value="">– Satellit wählen –</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="passSelect">Überflug</label>
|
||
<select id="passSelect" class="satpass-select" disabled>
|
||
<option value="">– zuerst Satellit wählen –</option>
|
||
</select>
|
||
</div>
|
||
<div id="satpassPassInfo" class="satpass-pass-info"></div>
|
||
</div>
|
||
|
||
<div class="satpass-map-panel">
|
||
<div class="satpass-map-controls">
|
||
<button type="button" class="btn btn-secondary btn-sm" id="satpassExportBtn">PNG Export</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="satpassExportSvgBtn">SVG Export</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="satpassInvertBtn">Invertiert</button>
|
||
<button type="button" class="btn btn-secondary btn-sm" id="satpassMirrorBtn">Ost links / West rechts</button>
|
||
</div>
|
||
<p id="satpassSkyTime" class="satpass-sky-time"></p>
|
||
<div class="satpass-canvas-wrap">
|
||
<canvas id="satSkyCanvas"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<script>
|
||
const SITE_LOCATION = <?= json_encode($pageLocation, JSON_UNESCAPED_SLASHES) ?>;
|
||
const BRIGHT_STARS = <?= json_encode($starPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||
const CONSTELLATION_LINES = <?= json_encode($constellationPayload, JSON_UNESCAPED_SLASHES) ?>;
|
||
const CONSTELLATION_LABELS = <?= json_encode($constellationLabelPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||
|
||
// Pre-computed pass data from Python
|
||
const PYTHON_RESULTS = <?= json_encode($pythonResult['results'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||
const WINDOW_START = <?= json_encode($pythonResult['window_start'] ?? null) ?>;
|
||
const WINDOW_END = <?= json_encode($pythonResult['window_end'] ?? null) ?>;
|
||
const PASS_WINDOW_DAYS = <?= (int)$satellitePassWindowDays ?>;
|
||
|
||
// Parse ISO times in each pass and path point to Date objects
|
||
PYTHON_RESULTS.forEach(sat => {
|
||
sat.passes.forEach(pass => {
|
||
pass.aosTime = new Date(pass.aos_utc);
|
||
pass.losTime = new Date(pass.los_utc);
|
||
pass.peakTime = new Date(pass.peak_utc);
|
||
pass.aosAzimuth = pass.aos_az;
|
||
pass.losAzimuth = pass.los_az;
|
||
pass.peakElevation = pass.peak_el;
|
||
pass.peakAzimuth = pass.peak_az;
|
||
pass.durationMs = (pass.losTime - pass.aosTime);
|
||
pass.peakMag = pass.peak_mag;
|
||
pass.shadowEntry = pass.shadow_entry
|
||
? { time: new Date(pass.shadow_entry.t), azimuthDeg: pass.shadow_entry.az, elevationDeg: pass.shadow_entry.el }
|
||
: null;
|
||
pass.color = sat.color;
|
||
pass.path = pass.path.map(pt => ({
|
||
time: new Date(pt.t),
|
||
azimuthDeg: pt.az,
|
||
elevationDeg: pt.el,
|
||
shadow: pt.shadow,
|
||
color: sat.color,
|
||
}));
|
||
});
|
||
});
|
||
</script>
|
||
<script>
|
||
// ── Rendering + UI (kein satellite.js noetig) ──────────────────────
|
||
|
||
const statusEl = document.getElementById('satpassStatus');
|
||
const canvas = document.getElementById('satSkyCanvas');
|
||
const exportBtn = document.getElementById('satpassExportBtn');
|
||
const exportSvgBtn = document.getElementById('satpassExportSvgBtn');
|
||
const invertBtn = document.getElementById('satpassInvertBtn');
|
||
const mirrorBtn = document.getElementById('satpassMirrorBtn');
|
||
const skyTimeEl = document.getElementById('satpassSkyTime');
|
||
const satSelectEl = document.getElementById('satSelect');
|
||
const passSelectEl = document.getElementById('passSelect');
|
||
const passInfoEl = document.getElementById('satpassPassInfo');
|
||
const ctx = canvas.getContext('2d');
|
||
const exportLogoImg = new Image();
|
||
exportLogoImg.src = 'images/logoAM_sw.png';
|
||
const EXPORT_BACKGROUND_COLOR = '#f3efe6';
|
||
let invertSky = false;
|
||
let mirrorEastWest = true;
|
||
let exportOpaqueSky = false;
|
||
let lastReferenceTime = null;
|
||
let lastShowStars = false;
|
||
let lastSelectedSatelliteName = '';
|
||
let lastSelectedPass = null;
|
||
let lastSkyData = [];
|
||
|
||
function getDisplayedAzimuthDeg(azimuthDeg) {
|
||
const normalizedAzimuth = ((Number(azimuthDeg) % 360) + 360) % 360;
|
||
if (!mirrorEastWest) return normalizedAzimuth;
|
||
return (360 - normalizedAzimuth) % 360;
|
||
}
|
||
|
||
function updateMirrorButton() {
|
||
if (!mirrorBtn) return;
|
||
mirrorBtn.textContent = mirrorEastWest
|
||
? 'Ost links / West rechts'
|
||
: 'West links / Ost rechts';
|
||
mirrorBtn.classList.toggle('is-active', mirrorEastWest);
|
||
}
|
||
|
||
function deg2rad(deg) { return deg * Math.PI / 180; }
|
||
function rad2deg(rad) { return rad * 180 / Math.PI; }
|
||
|
||
function getGMSTDeg(date) {
|
||
const jd = date.getTime() / 86400000.0 + 2440587.5;
|
||
let gmst = 280.46061837 + 360.98564736629 * (jd - 2451545.0);
|
||
return ((gmst % 360) + 360) % 360;
|
||
}
|
||
|
||
function equatorialToHorizontal(raDeg, decDeg, latDeg, lonDeg, date) {
|
||
const lst = ((getGMSTDeg(date) + lonDeg) % 360 + 360) % 360;
|
||
const H = deg2rad(lst - raDeg);
|
||
const dec = deg2rad(decDeg);
|
||
const phi = deg2rad(latDeg);
|
||
const sinAlt = Math.sin(phi)*Math.sin(dec) + Math.cos(phi)*Math.cos(dec)*Math.cos(H);
|
||
const altRad = Math.asin(Math.max(-1, Math.min(1, sinAlt)));
|
||
const cosAlt = Math.cos(altRad);
|
||
let azRad = 0;
|
||
if (cosAlt > 1e-10) {
|
||
const cosAz = (Math.sin(dec) - Math.sin(phi)*sinAlt) / (Math.cos(phi)*cosAlt);
|
||
azRad = Math.acos(Math.max(-1, Math.min(1, cosAz)));
|
||
if (Math.sin(H) > 0) azRad = 2*Math.PI - azRad;
|
||
}
|
||
return { altDeg: rad2deg(altRad), azimuthDeg: rad2deg(azRad) };
|
||
}
|
||
|
||
function getTzParts(date, timeZone) {
|
||
const parts = new Intl.DateTimeFormat('en-GB', {
|
||
timeZone, year:'numeric', month:'2-digit', day:'2-digit',
|
||
hour:'2-digit', minute:'2-digit', second:'2-digit', hourCycle:'h23'
|
||
}).formatToParts(date);
|
||
const r = {};
|
||
for (const p of parts) if (p.type !== 'literal') r[p.type] = Number(p.value);
|
||
return r;
|
||
}
|
||
|
||
function formatTime(date) {
|
||
return new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: SITE_LOCATION.timezone, hour:'2-digit', minute:'2-digit', hourCycle:'h23'
|
||
}).format(date);
|
||
}
|
||
|
||
function formatDateLocal(date) {
|
||
return new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: SITE_LOCATION.timezone, day:'2-digit', month:'2-digit', year:'numeric'
|
||
}).format(date);
|
||
}
|
||
|
||
function formatDateTime(date) { return `${formatDateLocal(date)} ${formatTime(date)}`; }
|
||
|
||
function formatWeekdayShort(date) {
|
||
return new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: SITE_LOCATION.timezone, weekday:'short'
|
||
}).format(date).replace('.', '');
|
||
}
|
||
|
||
function padNumber(v) { return String(v).padStart(2, '0'); }
|
||
|
||
function formatDurationMs(ms) {
|
||
const totalMinutes = Math.max(0, Math.round(ms / 60000));
|
||
const h = Math.floor(totalMinutes / 60), m = totalMinutes % 60;
|
||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||
}
|
||
|
||
function formatDurationLong(ms) {
|
||
const totalMinutes = Math.max(0, Math.round(ms / 60000));
|
||
const h = Math.floor(totalMinutes / 60), m = totalMinutes % 60;
|
||
if (h > 0 && m > 0) return `${h} ${h===1?'Stunde':'Stunden'} ${m} Minuten`;
|
||
if (h > 0) return `${h} ${h===1?'Stunde':'Stunden'}`;
|
||
return `${m} Minuten`;
|
||
}
|
||
|
||
function azToCardinal(az) {
|
||
const displayAz = getDisplayedAzimuthDeg(az);
|
||
const dirs = ['N','NW','W','SW','S','SO','O','NO'];
|
||
return dirs[Math.round(displayAz/45) % 8];
|
||
}
|
||
|
||
function azToDirectionName(az) {
|
||
const displayAz = getDisplayedAzimuthDeg(az);
|
||
const dirs = ['Norden','Nordwesten','Westen','Suedwesten','Sueden','Suedosten','Osten','Nordosten'];
|
||
return dirs[Math.round(displayAz/45) % 8];
|
||
}
|
||
|
||
function altitudeToPhrase(el) {
|
||
if (el < 15) return 'nahe dem Horizont';
|
||
if (el < 35) return 'halbhoch';
|
||
return 'hoch am Himmel';
|
||
}
|
||
|
||
function getSunAltitudeDeg(date) {
|
||
// Simple sun position (low precision, good enough for day/twilight/night)
|
||
const jd = date.getTime() / 86400000.0 + 2440587.5;
|
||
const n = jd - 2451545.0;
|
||
const L = (280.46 + 0.9856474 * n) % 360;
|
||
const g = deg2rad((357.528 + 0.9856003 * n) % 360);
|
||
const lam = deg2rad(L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2*g));
|
||
const eps = deg2rad(23.439 - 0.0000004 * n);
|
||
const raDeg = rad2deg(Math.atan2(Math.cos(eps)*Math.sin(lam), Math.cos(lam)));
|
||
const decDeg = rad2deg(Math.asin(Math.sin(eps)*Math.sin(lam)));
|
||
return equatorialToHorizontal(raDeg, decDeg,
|
||
Number(SITE_LOCATION.latitude), Number(SITE_LOCATION.longitude), date).altDeg;
|
||
}
|
||
|
||
function classifySkyBrightness(sunAltDeg) {
|
||
if (sunAltDeg > -4) return {key:'day', label:'Tag'};
|
||
if (sunAltDeg >= -12) return {key:'twilight', label:'Daemmerung'};
|
||
return {key:'night', label:'Nacht'};
|
||
}
|
||
|
||
function clamp(value, min = 0, max = 1) {
|
||
return Math.min(max, Math.max(min, value));
|
||
}
|
||
|
||
function interpolatePiecewise(value, segments) {
|
||
if (!Number.isFinite(value) || !segments.length) return 0;
|
||
if (value <= segments[0].from) return clamp(segments[0].out_from);
|
||
for (const segment of segments) {
|
||
if (value <= segment.to) {
|
||
const span = segment.to - segment.from;
|
||
if (span <= 0) return clamp(segment.out_to);
|
||
const t = clamp((value - segment.from) / span, 0, 1);
|
||
return clamp(segment.out_from + (segment.out_to - segment.out_from) * t);
|
||
}
|
||
}
|
||
return clamp(segments[segments.length - 1].out_to);
|
||
}
|
||
|
||
function normalizeVisibilityKey(label) {
|
||
return String(label || '')
|
||
.toLowerCase()
|
||
.normalize('NFD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-+|-+$/g, '');
|
||
}
|
||
|
||
function getMoonPhaseScore(date) {
|
||
const synodicMonthDays = 29.530588853;
|
||
const knownNewMoonUtc = Date.UTC(2000, 0, 6, 18, 14, 0);
|
||
const ageDays = ((((date.getTime() - knownNewMoonUtc) / 86400000) % synodicMonthDays) + synodicMonthDays) % synodicMonthDays;
|
||
const phaseAngle = (ageDays / synodicMonthDays) * 2 * Math.PI;
|
||
const illumination = (1 - Math.cos(phaseAngle)) / 2;
|
||
if (illumination <= 0.10) return { key: 'new_moon', score: 1.00, label: 'Neumond', illumination };
|
||
if (illumination <= 0.55) return { key: 'half_moon', score: 0.80, label: 'Halbmond', illumination };
|
||
if (illumination <= 0.85) return { key: 'three_quarter', score: 0.50, label: 'Dreiviertelmond', illumination };
|
||
return { key: 'full_moon', score: 0.30, label: 'Vollmond', illumination };
|
||
}
|
||
|
||
function getDarknessCondition(sunAltDeg, visiblePeak) {
|
||
if (sunAltDeg > -4) return { key: 'daylight', score: 0.05, label: 'Tageshimmel' };
|
||
if (sunAltDeg >= -12) return { key: 'twilight', score: 0.65, label: 'Daemmerung' };
|
||
if ((visiblePeak?.elevationDeg ?? -1) < 0) return { key: 'deep_night', score: 0.20, label: 'Nicht sichtbar / Erdschatten' };
|
||
return { key: 'ideal', score: 1.00, label: 'Dunkler Himmel, Satellit sonnenbeschienen' };
|
||
}
|
||
|
||
function getVisibilityVerdict(score) {
|
||
const thresholds = [
|
||
{ min: 0.00, max: 0.05, label: 'Nicht zu sehen' },
|
||
{ min: 0.05, max: 0.18, label: 'Kaum sichtbar' },
|
||
{ min: 0.18, max: 0.35, label: 'Schwach' },
|
||
{ min: 0.35, max: 0.52, label: 'Maessig' },
|
||
{ min: 0.52, max: 0.68, label: 'Gut' },
|
||
{ min: 0.68, max: 0.82, label: 'Sehr gut' },
|
||
{ min: 0.82, max: 0.92, label: 'Ausgezeichnet' },
|
||
{ min: 0.92, max: 1.01, label: 'Perfekt' }
|
||
];
|
||
const match = thresholds.find(entry => score >= entry.min && score < entry.max) || thresholds[0];
|
||
return { key: normalizeVisibilityKey(match.label), label: match.label };
|
||
}
|
||
|
||
function classifyVisibility(pass) {
|
||
const visiblePhase = getVisiblePhase(pass);
|
||
const visiblePeak = getVisiblePeak(pass, visiblePhase);
|
||
const sunAltDeg = getSunAltitudeDeg(visiblePeak.time);
|
||
const sky = classifySkyBrightness(sunAltDeg);
|
||
const darkness = getDarknessCondition(sunAltDeg, visiblePeak);
|
||
const moonPhase = getMoonPhaseScore(visiblePeak.time);
|
||
const elevationScore = interpolatePiecewise(visiblePeak.elevationDeg ?? 0, [
|
||
{ from: 0, to: 5, out_from: 0.00, out_to: 0.00 },
|
||
{ from: 5, to: 10, out_from: 0.10, out_to: 0.20 },
|
||
{ from: 10, to: 20, out_from: 0.20, out_to: 0.40 },
|
||
{ from: 20, to: 30, out_from: 0.40, out_to: 0.60 },
|
||
{ from: 30, to: 60, out_from: 0.60, out_to: 0.90 },
|
||
{ from: 60, to: 90, out_from: 0.90, out_to: 1.00 }
|
||
]);
|
||
const hasVisibleBrightnessPeak = isPeakVisible(pass);
|
||
const magnitudeValue = hasVisibleBrightnessPeak && Number.isFinite(pass.peakMag?.mag) ? pass.peakMag.mag : 3.5;
|
||
const magnitudeScore = interpolatePiecewise(magnitudeValue, [
|
||
{ from: -5.0, to: -4.0, out_from: 1.00, out_to: 1.00 },
|
||
{ from: -4.0, to: -2.0, out_from: 1.00, out_to: 0.85 },
|
||
{ from: -2.0, to: 0.0, out_from: 0.85, out_to: 0.60 },
|
||
{ from: 0.0, to: 2.0, out_from: 0.60, out_to: 0.30 },
|
||
{ from: 2.0, to: 3.0, out_from: 0.30, out_to: 0.10 },
|
||
{ from: 3.0, to: 4.0, out_from: 0.10, out_to: 0.05 }
|
||
]);
|
||
const durationSec = Math.max(0, Math.round(visiblePhase.visibleDurationMs / 1000));
|
||
const durationScore = interpolatePiecewise(durationSec, [
|
||
{ from: 0, to: 30, out_from: 0.00, out_to: 0.15 },
|
||
{ from: 30, to: 90, out_from: 0.15, out_to: 0.40 },
|
||
{ from: 90, to: 180, out_from: 0.40, out_to: 0.75 },
|
||
{ from: 180, to: 300, out_from: 0.75, out_to: 0.95 },
|
||
{ from: 300, to: 400, out_from: 0.95, out_to: 1.00 }
|
||
]);
|
||
|
||
const partials = {
|
||
elevation: clamp(elevationScore),
|
||
magnitude: clamp(magnitudeScore),
|
||
darkness: clamp(darkness.score),
|
||
duration: clamp(durationScore),
|
||
moonPhase: clamp(moonPhase.score)
|
||
};
|
||
|
||
let score =
|
||
partials.elevation * 0.36 +
|
||
partials.magnitude * 0.31 +
|
||
partials.darkness * 0.21 +
|
||
partials.duration * 0.09 +
|
||
partials.moonPhase * 0.03;
|
||
|
||
if ((visiblePeak.elevationDeg ?? 0) < 5) score = 0.0;
|
||
if (darkness.key === 'daylight') score = Math.min(score, 0.05);
|
||
if (magnitudeValue > 3) score *= 0.4;
|
||
|
||
score = clamp(score);
|
||
const verdict = getVisibilityVerdict(score);
|
||
return {
|
||
key: verdict.key,
|
||
label: verdict.label,
|
||
score,
|
||
scorePercent: Math.round(score * 100),
|
||
sky,
|
||
sunAltDeg,
|
||
darkness,
|
||
moonPhase,
|
||
durationSec,
|
||
visiblePeak,
|
||
usesVisiblePeakOnly: true,
|
||
partials
|
||
};
|
||
}
|
||
|
||
function formatVisibilityBadge(v) {
|
||
return `<span class="satpass-visibility satpass-visibility-${v.key}">${v.label}</span>`;
|
||
}
|
||
|
||
function formatMag(result) {
|
||
if (!result) return '–';
|
||
return (result.estimated ? '~' : '') + result.mag.toFixed(1);
|
||
}
|
||
|
||
function buildPathDescription(path) {
|
||
const vis = path.filter(p => p.elevationDeg >= 0);
|
||
if (!vis.length) return '';
|
||
const descriptors = [];
|
||
vis.forEach(p => {
|
||
const text = `${altitudeToPhrase(p.elevationDeg)} im ${azToDirectionName(p.azimuthDeg)}`;
|
||
if (descriptors[descriptors.length-1] !== text) descriptors.push(text);
|
||
});
|
||
const limited = descriptors.length > 8
|
||
? Array.from({length:8}, (_,i) => {
|
||
const pos = Math.round(i*(descriptors.length-1)/7);
|
||
return descriptors[pos];
|
||
}).filter((v,i,a) => i===0 || v!==a[i-1])
|
||
: descriptors;
|
||
if (limited.length === 1) return `Sie ist dabei ${limited[0]} zu beobachten.`;
|
||
if (limited.length === 2) return `Sie ist zunaechst ${limited[0]} zu beobachten und zuletzt ${limited[1]}.`;
|
||
return `Sie ist zunaechst ${limited[0]} zu beobachten, spaeter ${limited.slice(1,-1).join(', ')} und zuletzt ${limited[limited.length-1]}.`;
|
||
}
|
||
|
||
function getVisiblePhase(pass) {
|
||
const visiblePath = pass.path
|
||
.filter(p => p.elevationDeg >= 0 && !p.shadow)
|
||
.map(p => ({...p}));
|
||
const visibleStartTime = visiblePath.length
|
||
? visiblePath[0].time
|
||
: pass.aosTime;
|
||
const visibleEndTime = visiblePath.length
|
||
? visiblePath[visiblePath.length - 1].time
|
||
: pass.aosTime;
|
||
return {
|
||
visibleStartTime,
|
||
visibleEndTime,
|
||
visibleDurationMs: Math.max(0, visibleEndTime - visibleStartTime),
|
||
visiblePath
|
||
};
|
||
}
|
||
|
||
function getVisiblePeak(pass, visiblePhase = null) {
|
||
const phase = visiblePhase ?? getVisiblePhase(pass);
|
||
if (!phase.visiblePath.length) {
|
||
return {
|
||
time: pass.aosTime,
|
||
azimuthDeg: pass.aosAzimuth ?? 0,
|
||
elevationDeg: -1
|
||
};
|
||
}
|
||
let peakPoint = phase.visiblePath[0];
|
||
for (const point of phase.visiblePath) {
|
||
if (point.elevationDeg > peakPoint.elevationDeg) peakPoint = point;
|
||
}
|
||
return {
|
||
time: peakPoint.time,
|
||
azimuthDeg: peakPoint.azimuthDeg,
|
||
elevationDeg: peakPoint.elevationDeg
|
||
};
|
||
}
|
||
|
||
function isPeakVisible(pass) {
|
||
if (!(pass.peakTime instanceof Date) || Number.isNaN(pass.peakTime.getTime())) return false;
|
||
if ((pass.peakElevation ?? -1) < 0) return false;
|
||
const visiblePhase = getVisiblePhase(pass);
|
||
if (!visiblePhase.visiblePath.length) return false;
|
||
return visiblePhase.visiblePath.some(point => Math.abs(point.time - pass.peakTime) <= 5000);
|
||
}
|
||
|
||
function buildObservationSummary(satName, pass, visibility) {
|
||
if (visibility.darkness.key === 'daylight') return `${satName} ist bei diesem Überflug am Tageshimmel praktisch nicht zu sehen.`;
|
||
const visiblePhase = getVisiblePhase(pass);
|
||
if (!visiblePhase.visiblePath.length) {
|
||
return `${satName} liegt bei diesem Überflug waehrend der gesamten Passage im Erdschatten und ist deshalb nicht sichtbar.`;
|
||
}
|
||
const visiblePeak = visibility.visiblePeak ?? getVisiblePeak(pass, visiblePhase);
|
||
const pathText = buildPathDescription(visiblePhase.visiblePath);
|
||
const shadowText = pass.shadowEntry
|
||
? ` Sie tritt um ${formatTime(pass.shadowEntry.time)} Uhr in den Erdschatten ein.` : '';
|
||
const magText = pass.peakMag !== null
|
||
? ` Ihre maximale Helligkeit betraegt ${formatMag(pass.peakMag)} mag.` : '';
|
||
return `Die Beobachtungsqualitaet von ${satName} am ${formatDateLocal(pass.aosTime)} (${formatWeekdayShort(pass.aosTime)}) um ${formatTime(pass.aosTime)} Uhr ist ${visibility.label} (${visibility.scorePercent} von 100 Punkten). `
|
||
+ `Der Überflug ist ${formatDurationLong(visiblePhase.visibleDurationMs)} lang und bis ${formatTime(visiblePhase.visibleEndTime)} Uhr zu sehen. `
|
||
+ `${pathText} Die fuer die Bewertung relevante maximale sichtbare Horizonthoehe betraegt ${visiblePeak.elevationDeg.toFixed(0)}°.`
|
||
+ `${shadowText}${magText}`;
|
||
}
|
||
|
||
// ── Sky canvas rendering ───────────────────────────────────────────
|
||
|
||
function projectPoint(azimuthDeg, elevationDeg, radius, cx, cy) {
|
||
const r = ((90 - elevationDeg) / 90) * radius;
|
||
const a = deg2rad(getDisplayedAzimuthDeg(azimuthDeg));
|
||
return { x: cx + r*Math.sin(a), y: cy - r*Math.cos(a) };
|
||
}
|
||
|
||
function getSkyRenderMetrics() {
|
||
const box = canvas.parentElement.getBoundingClientRect();
|
||
const size = Math.max(320, Math.floor(Math.min(box.width, 820)));
|
||
return { size, cx: size / 2, cy: size / 2, radius: size * 0.43 };
|
||
}
|
||
|
||
function getSkyPalette() {
|
||
return invertSky ? {
|
||
skyFill: exportOpaqueSky ? 'rgb(252,252,248)' : 'rgba(252,252,248,0.98)',
|
||
grid:'rgba(60,64,76,0.26)', gridText:'rgba(40,44,56,0.90)', cardinal:'#171b24',
|
||
constellationLine:'rgba(88,96,118,0.76)', constellationLabel:'rgba(44,50,66,0.92)',
|
||
star: a=>`rgba(10,12,18,${Math.max(0.72,a).toFixed(2)})`,
|
||
starLabel:'rgba(18,22,32,0.94)', shadowLine:'rgba(150,154,166,0.82)',
|
||
shadowMarker:'rgba(118,124,138,0.95)', shadowMarkerStroke:'rgba(24,28,36,0.95)',
|
||
shadowLabel:'#111722'
|
||
} : {
|
||
skyFill: exportOpaqueSky ? 'rgb(4,6,20)' : 'rgba(4,6,20,0.55)',
|
||
grid:'rgba(201,168,76,0.18)', gridText:'rgba(136,153,187,0.85)', cardinal:'#f0d990',
|
||
constellationLine:'rgba(132,152,214,0.58)', constellationLabel:'rgba(120,136,186,0.68)',
|
||
star: a=>`rgba(220,230,255,${a.toFixed(2)})`,
|
||
starLabel:'rgba(180,200,255,0.70)', shadowLine:'rgba(140,140,150,0.5)',
|
||
shadowMarker:'rgba(155,165,190,0.95)', shadowMarkerStroke:'rgba(15,20,32,0.95)',
|
||
shadowLabel:'#d7dcec'
|
||
};
|
||
}
|
||
|
||
function estimateTextWidth(text, fontSize, weight = 400) {
|
||
ctx.save();
|
||
ctx.font = `${weight} ${fontSize}px Segoe UI`;
|
||
const width = ctx.measureText(String(text)).width;
|
||
ctx.restore();
|
||
return width;
|
||
}
|
||
|
||
function escapeXml(value) {
|
||
return String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function toSvgNumber(value) {
|
||
return Number(value).toFixed(2);
|
||
}
|
||
|
||
function buildSvgEndpointLabel(point, text, color, cx, cy, radius) {
|
||
const offsetX = point.x < cx ? 10 : -10;
|
||
const offsetY = point.y < cy ? -10 : 16;
|
||
const fontSize = 12;
|
||
const paddingX = 7;
|
||
const paddingY = 4;
|
||
const textWidth = estimateTextWidth(text, fontSize, 700);
|
||
let labelX = point.x + offsetX;
|
||
let labelY = point.y + offsetY;
|
||
if (offsetX < 0) labelX -= textWidth;
|
||
const minX = cx - radius + 6;
|
||
const maxX = cx + radius - textWidth - paddingX * 2 - 6;
|
||
const minY = cy - radius + fontSize + paddingY + 6;
|
||
const maxY = cy + radius - fontSize - paddingY - 6;
|
||
labelX = Math.max(minX, Math.min(maxX, labelX));
|
||
labelY = Math.max(minY, Math.min(maxY, labelY));
|
||
const boxX = labelX - paddingX;
|
||
const boxY = labelY - (fontSize / 2) - paddingY;
|
||
const boxW = textWidth + paddingX * 2;
|
||
const boxH = fontSize + paddingY * 2;
|
||
const fill = invertSky ? '#ffffff' : '#050814';
|
||
const stroke = invertSky ? '#1b2230' : 'rgba(255,255,255,0.22)';
|
||
return `
|
||
<rect x="${toSvgNumber(boxX)}" y="${toSvgNumber(boxY)}" width="${toSvgNumber(boxW)}" height="${toSvgNumber(boxH)}" fill="${fill}" stroke="${stroke}" stroke-width="1.2" />
|
||
<text x="${toSvgNumber(labelX)}" y="${toSvgNumber(labelY)}" fill="${color}" font-family="Segoe UI, Arial, sans-serif" font-size="${fontSize}" font-weight="700" dominant-baseline="middle">${escapeXml(text)}</text>`;
|
||
}
|
||
|
||
function drawEndpointLabel(point, text, color, cx, cy, radius) {
|
||
const offsetX = point.x < cx ? 10 : -10;
|
||
const offsetY = point.y < cy ? -10 : 16;
|
||
const fontSize = 12, paddingX = 7, paddingY = 4;
|
||
ctx.save();
|
||
ctx.font = `700 ${fontSize}px Segoe UI`;
|
||
ctx.textBaseline = 'middle';
|
||
const textWidth = ctx.measureText(text).width;
|
||
let labelX = point.x + offsetX;
|
||
let labelY = point.y + offsetY;
|
||
if (offsetX < 0) labelX -= textWidth;
|
||
const minX = cx - radius + 6, maxX = cx + radius - textWidth - paddingX*2 - 6;
|
||
const minY = cy - radius + fontSize + paddingY + 6, maxY = cy + radius - fontSize - paddingY - 6;
|
||
labelX = Math.max(minX, Math.min(maxX, labelX));
|
||
labelY = Math.max(minY, Math.min(maxY, labelY));
|
||
const boxX = labelX - paddingX;
|
||
const boxY = labelY - (fontSize / 2) - paddingY;
|
||
const boxW = textWidth + paddingX * 2;
|
||
const boxH = fontSize + paddingY * 2;
|
||
ctx.fillStyle = invertSky ? '#ffffff' : '#050814';
|
||
ctx.strokeStyle = invertSky ? '#1b2230' : 'rgba(255,255,255,0.22)';
|
||
ctx.lineWidth = 1.2;
|
||
ctx.fillRect(boxX, boxY, boxW, boxH);
|
||
ctx.strokeRect(boxX, boxY, boxW, boxH);
|
||
ctx.fillStyle = color;
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(text, labelX, labelY);
|
||
ctx.restore();
|
||
}
|
||
|
||
function rectsOverlap(a, b, margin=0) {
|
||
return !(a.right+margin < b.left || a.left > b.right+margin || a.bottom+margin < b.top || a.top > b.bottom+margin);
|
||
}
|
||
|
||
function buildTextRect(x, y, width, fontSize) {
|
||
return {left:x, top:y-fontSize, right:x+width, bottom:y+2};
|
||
}
|
||
|
||
function findFreeLabelPlacement(anchorX, anchorY, width, fontSize, occupiedRects, cx, cy, radius, preferredAlign='left') {
|
||
const candidates = [
|
||
{dx:8,dy:-8,align:preferredAlign},{dx:8,dy:12,align:preferredAlign},
|
||
{dx:-8,dy:-8,align:'right'},{dx:-8,dy:12,align:'right'},
|
||
{dx:0,dy:-12,align:'center'},{dx:0,dy:16,align:'center'},
|
||
{dx:18,dy:0,align:'left'},{dx:-18,dy:0,align:'right'},
|
||
{dx:18,dy:-16,align:'left'},{dx:-18,dy:-16,align:'right'},
|
||
{dx:18,dy:18,align:'left'},{dx:-18,dy:18,align:'right'}
|
||
];
|
||
const minX=cx-radius+6, maxX=cx+radius-width-6;
|
||
const minY=cy-radius+fontSize+6, maxY=cy+radius-4;
|
||
let fallback=null;
|
||
for (const c of candidates) {
|
||
let x=anchorX+c.dx, y=anchorY+c.dy;
|
||
if (c.align==='center') x-=width/2;
|
||
else if (c.align==='right') x-=width;
|
||
x=Math.max(minX,Math.min(maxX,x)); y=Math.max(minY,Math.min(maxY,y));
|
||
const rect=buildTextRect(x,y,width,fontSize);
|
||
const collides=occupiedRects.some(o=>rectsOverlap(rect,o,2));
|
||
if (!fallback) fallback={x,y,align:c.align,rect};
|
||
if (!collides) return {x,y,align:c.align,rect};
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
function drawSky(passes, referenceTime=null, showStars=true) {
|
||
lastReferenceTime = referenceTime;
|
||
lastShowStars = showStars;
|
||
const { size, cx, cy, radius } = getSkyRenderMetrics();
|
||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
canvas.width = size * dpr;
|
||
canvas.height = size * dpr;
|
||
canvas.style.width = `${size}px`;
|
||
canvas.style.height = `${size}px`;
|
||
ctx.setTransform(dpr,0,0,dpr,0,0);
|
||
ctx.clearRect(0,0,size,size);
|
||
const palette = getSkyPalette();
|
||
|
||
ctx.fillStyle = palette.skyFill;
|
||
ctx.beginPath(); ctx.arc(cx,cy,radius,0,Math.PI*2); ctx.fill();
|
||
|
||
ctx.strokeStyle = palette.grid; ctx.lineWidth = 1;
|
||
[radius, radius*(2/3), radius*(1/3)].forEach((r,i) => {
|
||
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.stroke();
|
||
ctx.fillStyle = palette.gridText; ctx.font = '12px Segoe UI';
|
||
ctx.fillText(`${i===0?0:i===1?30:60}°`, cx+6, cy-r+14);
|
||
});
|
||
ctx.strokeStyle = palette.grid;
|
||
ctx.beginPath();
|
||
ctx.moveTo(cx,cy-radius); ctx.lineTo(cx,cy+radius);
|
||
ctx.moveTo(cx-radius,cy); ctx.lineTo(cx+radius,cy);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = palette.cardinal; ctx.font = '700 14px Segoe UI'; ctx.textAlign = 'center';
|
||
ctx.fillText('N', cx, cy-radius+18); ctx.fillText('S', cx, cy+radius-10);
|
||
ctx.fillText(mirrorEastWest ? 'O' : 'W', cx-radius+14, cy+5);
|
||
ctx.fillText(mirrorEastWest ? 'W' : 'O', cx+radius-14, cy+5);
|
||
ctx.fillText('Zenit', cx, cy-8);
|
||
|
||
const lat = Number(SITE_LOCATION.latitude), lon = Number(SITE_LOCATION.longitude);
|
||
const starTime = referenceTime ?? new Date();
|
||
|
||
// Sternbildlinien
|
||
ctx.strokeStyle = palette.constellationLine; ctx.lineWidth = 1.15; ctx.setLineDash([]);
|
||
for (const line of CONSTELLATION_LINES) {
|
||
if (line.length < 2) continue;
|
||
let started = false;
|
||
for (let i=0; i<line.length; i++) {
|
||
const pos = equatorialToHorizontal(line[i].ra, line[i].dec, lat, lon, starTime);
|
||
if (pos.altDeg < 0) { started=false; continue; }
|
||
const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy);
|
||
if (!started) { ctx.beginPath(); ctx.moveTo(pt.x,pt.y); started=true; }
|
||
else { ctx.lineTo(pt.x,pt.y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pt.x,pt.y); }
|
||
}
|
||
}
|
||
|
||
if (showStars) {
|
||
skyTimeEl.textContent = referenceTime
|
||
? `Sternpositionen zum Maximum: ${formatTime(referenceTime)} Uhr`
|
||
: `Sternpositionen: jetzt (${formatTime(starTime)} Uhr)`;
|
||
const occupiedLabelRects = [];
|
||
for (const star of BRIGHT_STARS) {
|
||
const pos = equatorialToHorizontal(star.ra, star.dec, lat, lon, starTime);
|
||
if (pos.altDeg < 0) continue;
|
||
const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy);
|
||
const r = Math.max(1.0, Math.min(5.0, 4.5 - star.mag*1.0));
|
||
const a = Math.max(0.5, Math.min(0.95, 0.95 - star.mag*0.12));
|
||
ctx.beginPath(); ctx.arc(pt.x,pt.y,r,0,Math.PI*2);
|
||
ctx.fillStyle = palette.star(a); ctx.fill();
|
||
if (star.name && star.mag <= 2.0) {
|
||
ctx.fillStyle = palette.starLabel; ctx.font = '10px Segoe UI'; ctx.textAlign = 'left';
|
||
const lx = pt.x+r+3, ly = pt.y+3;
|
||
const lw = ctx.measureText(star.name).width;
|
||
ctx.fillText(star.name, lx, ly);
|
||
occupiedLabelRects.push(buildTextRect(lx,ly,lw,10));
|
||
}
|
||
}
|
||
ctx.font = '11px Segoe UI'; ctx.fillStyle = palette.constellationLabel;
|
||
for (const con of CONSTELLATION_LABELS) {
|
||
const pos = equatorialToHorizontal(con.ra, con.dec, lat, lon, starTime);
|
||
if (pos.altDeg < 0) continue;
|
||
const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy);
|
||
const lw = ctx.measureText(con.label).width;
|
||
const pl = findFreeLabelPlacement(pt.x,pt.y,lw,11,occupiedLabelRects,cx,cy,radius,'center');
|
||
if (!pl) continue;
|
||
ctx.textAlign = 'left'; ctx.fillText(con.label, pl.x, pl.y);
|
||
occupiedLabelRects.push(pl.rect);
|
||
}
|
||
} else {
|
||
skyTimeEl.textContent = '';
|
||
}
|
||
|
||
// Satellitenbahnen
|
||
passes.filter(item=>item.pass).forEach(item => {
|
||
const pass = item.pass;
|
||
const pts = pass.path.filter(p => p.elevationDeg >= 0);
|
||
if (pts.length < 2) return;
|
||
ctx.lineWidth = 2.5; ctx.lineCap = 'round';
|
||
for (let i=0; i<pts.length-1; i++) {
|
||
const p0 = projectPoint(pts[i].azimuthDeg, pts[i].elevationDeg, radius, cx, cy);
|
||
const p1 = projectPoint(pts[i+1].azimuthDeg, pts[i+1].elevationDeg, radius, cx, cy);
|
||
const inShadow = pts[i].shadow;
|
||
const trackCol = invertSky ? 'rgba(8,10,14,0.96)' : PYTHON_RESULTS[0]?.color ?? '#f0d990';
|
||
ctx.beginPath(); ctx.moveTo(p0.x,p0.y); ctx.lineTo(p1.x,p1.y);
|
||
if (inShadow) {
|
||
ctx.lineWidth = 1.0;
|
||
ctx.strokeStyle = palette.shadowLine;
|
||
} else {
|
||
ctx.lineWidth = invertSky ? 3.2 : 2.5;
|
||
ctx.strokeStyle = trackCol;
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
const startPt = projectPoint(pts[0].azimuthDeg, pts[0].elevationDeg, radius, cx, cy);
|
||
const endPt = projectPoint(pts[pts.length-1].azimuthDeg, pts[pts.length-1].elevationDeg, radius, cx, cy);
|
||
const trackColor = invertSky ? 'rgba(8,10,14,0.96)' : (PYTHON_RESULTS[0]?.color ?? '#f0d990');
|
||
ctx.beginPath(); ctx.arc(startPt.x,startPt.y,3.5,0,Math.PI*2); ctx.fillStyle=trackColor; ctx.fill();
|
||
ctx.beginPath(); ctx.arc(endPt.x,endPt.y,3.5,0,Math.PI*2); ctx.fillStyle=trackColor; ctx.fill();
|
||
drawEndpointLabel(startPt, formatTime(pass.aosTime), trackColor, cx, cy, radius);
|
||
drawEndpointLabel(endPt, formatTime(pass.losTime), trackColor, cx, cy, radius);
|
||
if (isPeakVisible(pass)) {
|
||
const peakPt = projectPoint(pass.peakAzimuth, pass.peakElevation, radius, cx, cy);
|
||
ctx.beginPath(); ctx.arc(peakPt.x,peakPt.y,4.5,0,Math.PI*2);
|
||
ctx.fillStyle = trackColor; ctx.fill();
|
||
ctx.strokeStyle = invertSky ? 'rgba(248,248,244,0.95)' : 'rgba(8,12,20,0.92)';
|
||
ctx.lineWidth = 1.5; ctx.stroke();
|
||
drawEndpointLabel(peakPt, formatTime(pass.peakTime), trackColor, cx, cy, radius);
|
||
}
|
||
if (pass.shadowEntry) {
|
||
const sp = projectPoint(pass.shadowEntry.azimuthDeg, pass.shadowEntry.elevationDeg, radius, cx, cy);
|
||
ctx.beginPath(); ctx.arc(sp.x,sp.y,4,0,Math.PI*2);
|
||
ctx.fillStyle=palette.shadowMarker; ctx.fill();
|
||
ctx.strokeStyle=palette.shadowMarkerStroke; ctx.lineWidth=1.5; ctx.stroke();
|
||
drawEndpointLabel(sp, `ES ${formatTime(pass.shadowEntry.time)}`, palette.shadowLabel, cx, cy, radius);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ── Dropdowns ─────────────────────────────────────────────────────
|
||
|
||
function selectPass(satIdx, passIdx) {
|
||
const satResult = PYTHON_RESULTS[satIdx];
|
||
const pass = satResult?.passes[passIdx];
|
||
if (!pass) return;
|
||
lastSelectedSatelliteName = satResult.name;
|
||
lastSelectedPass = pass;
|
||
const visibility = classifyVisibility(pass);
|
||
const observationSummary = buildObservationSummary(satResult.name, pass, visibility);
|
||
|
||
passInfoEl.innerHTML = `
|
||
<table class="data-table mb-sm">
|
||
<tr><td>Beginn</td><td>${formatDateTime(pass.aosTime)} (${azToCardinal(pass.aosAzimuth ?? 0)})</td></tr>
|
||
<tr><td>Maximum</td><td>${formatDateTime(pass.peakTime)} / ${pass.peakElevation.toFixed(1)}°</td></tr>
|
||
<tr><td>Sichtbares Maximum</td><td>${visibility.visiblePeak.elevationDeg >= 0 ? `${formatDateTime(visibility.visiblePeak.time)} / ${visibility.visiblePeak.elevationDeg.toFixed(1)}°` : '–'}</td></tr>
|
||
<tr><td>Ende</td><td>${formatDateTime(pass.losTime)} (${azToCardinal(pass.losAzimuth ?? 0)})</td></tr>
|
||
<tr><td>Dauer</td><td>${formatDurationMs(pass.durationMs)}</td></tr>
|
||
<tr><td>Sichtdauer</td><td>${formatDurationMs(visibility.durationSec * 1000)}</td></tr>
|
||
${pass.shadowEntry ? `<tr><td>Erdschatten</td><td>${formatDateTime(pass.shadowEntry.time)} (${azToCardinal(pass.shadowEntry.azimuthDeg)})</td></tr>` : ''}
|
||
<tr><td>Sichtbarkeit</td><td>${formatVisibilityBadge(visibility)}</td></tr>
|
||
<tr><td>Score</td><td>${visibility.score.toFixed(2)} (${visibility.scorePercent}/100)</td></tr>
|
||
<tr><td>Himmel</td><td>${visibility.sky.label}</td></tr>
|
||
<tr><td>Dunkelheit</td><td>${visibility.darkness.label}</td></tr>
|
||
<tr><td>Mondphase</td><td>${visibility.moonPhase.label}</td></tr>
|
||
<tr><td>Helligkeit</td><td class="satpass-mag">${formatMag(pass.peakMag)}</td></tr>
|
||
</table>
|
||
<p class="satpass-observation-text">${observationSummary}</p>`;
|
||
|
||
lastSkyData = [{ name: satResult.name, pass }];
|
||
drawSky(lastSkyData, pass.peakTime, true);
|
||
}
|
||
|
||
function buildDropdowns() {
|
||
const now = new Date();
|
||
const rangeStart = WINDOW_START ? new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: SITE_LOCATION.timezone, day:'2-digit', month:'2-digit', year:'numeric'
|
||
}).format(new Date(WINDOW_START)) : '';
|
||
const rangeEnd = WINDOW_END ? new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: SITE_LOCATION.timezone, day:'2-digit', month:'2-digit', year:'numeric'
|
||
}).format(new Date(WINDOW_END)) : '';
|
||
|
||
satSelectEl.innerHTML = '<option value="">– Satellit wählen –</option>';
|
||
PYTHON_RESULTS.forEach((sat, satIdx) => {
|
||
const upcomingCount = sat.passes.filter(p => p.losTime > now).length;
|
||
const opt = document.createElement('option');
|
||
opt.value = satIdx;
|
||
opt.textContent = `${sat.name} (${upcomingCount} in den nächsten ${PASS_WINDOW_DAYS} Tagen)`;
|
||
satSelectEl.appendChild(opt);
|
||
});
|
||
|
||
satSelectEl.onchange = () => {
|
||
const satIdx = parseInt(satSelectEl.value, 10);
|
||
passSelectEl.innerHTML = '';
|
||
passInfoEl.innerHTML = '';
|
||
if (isNaN(satIdx)) {
|
||
passSelectEl.disabled = true;
|
||
passSelectEl.innerHTML = '<option value="">– zuerst Satellit wählen –</option>';
|
||
drawSky([], null, false);
|
||
return;
|
||
}
|
||
const sat = PYTHON_RESULTS[satIdx];
|
||
passSelectEl.disabled = false;
|
||
passSelectEl.innerHTML = '<option value="">– Überflug wählen –</option>';
|
||
sat.passes.forEach((p, passIdx) => {
|
||
const isPast = p.losTime <= now;
|
||
const opt = document.createElement('option');
|
||
opt.value = passIdx;
|
||
opt.textContent = `${formatDateLocal(p.aosTime)} ${formatTime(p.aosTime)} – ${formatTime(p.losTime)} | max ${p.peakElevation.toFixed(0)}°${p.peakMag!==null?' | '+formatMag(p.peakMag)+' mag':''}${isPast?' (vorbei)':''}`;
|
||
if (isPast) opt.style.opacity = '0.5';
|
||
passSelectEl.appendChild(opt);
|
||
});
|
||
const nextIdx = sat.passes.findIndex(p => p.losTime > now);
|
||
if (nextIdx >= 0) { passSelectEl.value = nextIdx; selectPass(satIdx, nextIdx); }
|
||
};
|
||
|
||
passSelectEl.onchange = () => {
|
||
const satIdx = parseInt(satSelectEl.value, 10);
|
||
const passIdx = parseInt(passSelectEl.value, 10);
|
||
if (!isNaN(satIdx) && !isNaN(passIdx)) selectPass(satIdx, passIdx);
|
||
};
|
||
|
||
statusEl.textContent = `Berechnet vom heutigen lokalen Tag 00:00 Uhr bis in ${PASS_WINDOW_DAYS} Tagen (${rangeStart} bis ${rangeEnd}).`;
|
||
}
|
||
|
||
// ── PNG Export ────────────────────────────────────────────────────
|
||
|
||
function canvasToBlob(targetCanvas, type='image/png') {
|
||
return new Promise((resolve, reject) => {
|
||
targetCanvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('PNG-Export fehlgeschlagen.')), type);
|
||
});
|
||
}
|
||
|
||
function ensureExportLogoLoaded() {
|
||
return new Promise((resolve, reject) => {
|
||
if (exportLogoImg.complete && exportLogoImg.naturalWidth > 0) { resolve(); return; }
|
||
const onLoad = () => { exportLogoImg.removeEventListener('load', onLoad); exportLogoImg.removeEventListener('error', onErr); resolve(); };
|
||
const onErr = () => { exportLogoImg.removeEventListener('load', onLoad); exportLogoImg.removeEventListener('error', onErr); reject(new Error('Logo nicht ladbar.')); };
|
||
exportLogoImg.addEventListener('load', onLoad, {once:true});
|
||
exportLogoImg.addEventListener('error', onErr, {once:true});
|
||
});
|
||
}
|
||
|
||
function createSatpassExportFileName(extension = 'png') {
|
||
const date = lastSelectedPass?.aosTime instanceof Date ? lastSelectedPass.aosTime : new Date();
|
||
const parts = getTzParts(date, SITE_LOCATION.timezone);
|
||
const name = (lastSelectedSatelliteName || 'satellit').toLowerCase().replace(/[^a-z0-9]+/gi,'_').replace(/^_+|_+$/g,'') || 'satellit';
|
||
return `satellitenhimmel_${name}_${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}_${padNumber(parts.hour)}-${padNumber(parts.minute)}.${extension}`;
|
||
}
|
||
|
||
function generateSatpassSvgMarkup() {
|
||
const prevExport = exportOpaqueSky;
|
||
exportOpaqueSky = true;
|
||
try {
|
||
const { size, cx, cy, radius } = getSkyRenderMetrics();
|
||
const palette = getSkyPalette();
|
||
const lat = Number(SITE_LOCATION.latitude);
|
||
const lon = Number(SITE_LOCATION.longitude);
|
||
const starTime = lastReferenceTime ?? new Date();
|
||
const parts = [];
|
||
parts.push(`<?xml version="1.0" encoding="UTF-8"?>`);
|
||
parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="Satellitenhimmel">`);
|
||
parts.push(`<rect width="${size}" height="${size}" fill="#ffffff" />`);
|
||
parts.push(`<circle cx="${toSvgNumber(cx)}" cy="${toSvgNumber(cy)}" r="${toSvgNumber(radius)}" fill="${palette.skyFill}" />`);
|
||
|
||
[radius, radius*(2/3), radius*(1/3)].forEach((r, i) => {
|
||
parts.push(`<circle cx="${toSvgNumber(cx)}" cy="${toSvgNumber(cy)}" r="${toSvgNumber(r)}" fill="none" stroke="${palette.grid}" stroke-width="1" />`);
|
||
parts.push(`<text x="${toSvgNumber(cx + 6)}" y="${toSvgNumber(cy - r + 14)}" fill="${palette.gridText}" font-family="Segoe UI, Arial, sans-serif" font-size="12">${i===0?0:i===1?30:60}°</text>`);
|
||
});
|
||
parts.push(`<line x1="${toSvgNumber(cx)}" y1="${toSvgNumber(cy - radius)}" x2="${toSvgNumber(cx)}" y2="${toSvgNumber(cy + radius)}" stroke="${palette.grid}" stroke-width="1" />`);
|
||
parts.push(`<line x1="${toSvgNumber(cx - radius)}" y1="${toSvgNumber(cy)}" x2="${toSvgNumber(cx + radius)}" y2="${toSvgNumber(cy)}" stroke="${palette.grid}" stroke-width="1" />`);
|
||
parts.push(`<text x="${toSvgNumber(cx)}" y="${toSvgNumber(cy - radius + 18)}" fill="${palette.cardinal}" font-family="Segoe UI, Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle">N</text>`);
|
||
parts.push(`<text x="${toSvgNumber(cx)}" y="${toSvgNumber(cy + radius - 10)}" fill="${palette.cardinal}" font-family="Segoe UI, Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle">S</text>`);
|
||
parts.push(`<text x="${toSvgNumber(cx - radius + 14)}" y="${toSvgNumber(cy + 5)}" fill="${palette.cardinal}" font-family="Segoe UI, Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle">${mirrorEastWest ? 'O' : 'W'}</text>`);
|
||
parts.push(`<text x="${toSvgNumber(cx + radius - 14)}" y="${toSvgNumber(cy + 5)}" fill="${palette.cardinal}" font-family="Segoe UI, Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle">${mirrorEastWest ? 'W' : 'O'}</text>`);
|
||
parts.push(`<text x="${toSvgNumber(cx)}" y="${toSvgNumber(cy - 8)}" fill="${palette.cardinal}" font-family="Segoe UI, Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle">Zenit</text>`);
|
||
|
||
for (const line of CONSTELLATION_LINES) {
|
||
if (!Array.isArray(line) || line.length < 2) continue;
|
||
let segment = [];
|
||
for (const point of line) {
|
||
const pos = equatorialToHorizontal(point.ra, point.dec, lat, lon, starTime);
|
||
if (pos.altDeg < 0) {
|
||
if (segment.length > 1) {
|
||
parts.push(`<polyline points="${segment.map(p => `${toSvgNumber(p.x)},${toSvgNumber(p.y)}`).join(' ')}" fill="none" stroke="${palette.constellationLine}" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round" />`);
|
||
}
|
||
segment = [];
|
||
continue;
|
||
}
|
||
segment.push(projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy));
|
||
}
|
||
if (segment.length > 1) {
|
||
parts.push(`<polyline points="${segment.map(p => `${toSvgNumber(p.x)},${toSvgNumber(p.y)}`).join(' ')}" fill="none" stroke="${palette.constellationLine}" stroke-width="1.15" stroke-linecap="round" stroke-linejoin="round" />`);
|
||
}
|
||
}
|
||
|
||
if (lastShowStars) {
|
||
const occupiedLabelRects = [];
|
||
for (const star of BRIGHT_STARS) {
|
||
const pos = equatorialToHorizontal(star.ra, star.dec, lat, lon, starTime);
|
||
if (pos.altDeg < 0) continue;
|
||
const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy);
|
||
const starRadius = Math.max(1.0, Math.min(5.0, 4.5 - star.mag*1.0));
|
||
const alpha = Math.max(0.5, Math.min(0.95, 0.95 - star.mag*0.12));
|
||
parts.push(`<circle cx="${toSvgNumber(pt.x)}" cy="${toSvgNumber(pt.y)}" r="${toSvgNumber(starRadius)}" fill="${palette.star(alpha)}" />`);
|
||
if (star.name && star.mag <= 2.0) {
|
||
const lx = pt.x + starRadius + 3;
|
||
const ly = pt.y + 3;
|
||
const lw = estimateTextWidth(star.name, 10);
|
||
parts.push(`<text x="${toSvgNumber(lx)}" y="${toSvgNumber(ly)}" fill="${palette.starLabel}" font-family="Segoe UI, Arial, sans-serif" font-size="10">${escapeXml(star.name)}</text>`);
|
||
occupiedLabelRects.push(buildTextRect(lx, ly, lw, 10));
|
||
}
|
||
}
|
||
for (const con of CONSTELLATION_LABELS) {
|
||
const pos = equatorialToHorizontal(con.ra, con.dec, lat, lon, starTime);
|
||
if (pos.altDeg < 0) continue;
|
||
const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy);
|
||
const lw = estimateTextWidth(con.label, 11);
|
||
const pl = findFreeLabelPlacement(pt.x, pt.y, lw, 11, occupiedLabelRects, cx, cy, radius, 'center');
|
||
if (!pl) continue;
|
||
parts.push(`<text x="${toSvgNumber(pl.x)}" y="${toSvgNumber(pl.y)}" fill="${palette.constellationLabel}" font-family="Segoe UI, Arial, sans-serif" font-size="11">${escapeXml(con.label)}</text>`);
|
||
occupiedLabelRects.push(pl.rect);
|
||
}
|
||
}
|
||
|
||
for (const item of lastSkyData.filter(entry => entry?.pass)) {
|
||
const pass = item.pass;
|
||
const pts = pass.path.filter(p => p.elevationDeg >= 0);
|
||
if (pts.length < 2) continue;
|
||
const trackColor = invertSky ? 'rgba(8,10,14,0.96)' : (PYTHON_RESULTS[0]?.color ?? '#f0d990');
|
||
for (let i = 0; i < pts.length - 1; i++) {
|
||
const p0 = projectPoint(pts[i].azimuthDeg, pts[i].elevationDeg, radius, cx, cy);
|
||
const p1 = projectPoint(pts[i + 1].azimuthDeg, pts[i + 1].elevationDeg, radius, cx, cy);
|
||
if (pts[i].shadow) {
|
||
parts.push(`<line x1="${toSvgNumber(p0.x)}" y1="${toSvgNumber(p0.y)}" x2="${toSvgNumber(p1.x)}" y2="${toSvgNumber(p1.y)}" stroke="${palette.shadowLine}" stroke-width="1" stroke-linecap="round" />`);
|
||
} else {
|
||
parts.push(`<line x1="${toSvgNumber(p0.x)}" y1="${toSvgNumber(p0.y)}" x2="${toSvgNumber(p1.x)}" y2="${toSvgNumber(p1.y)}" stroke="${trackColor}" stroke-width="${invertSky ? '3.2' : '2.5'}" stroke-linecap="round" />`);
|
||
}
|
||
}
|
||
const startPt = projectPoint(pts[0].azimuthDeg, pts[0].elevationDeg, radius, cx, cy);
|
||
const endPt = projectPoint(pts[pts.length - 1].azimuthDeg, pts[pts.length - 1].elevationDeg, radius, cx, cy);
|
||
parts.push(`<circle cx="${toSvgNumber(startPt.x)}" cy="${toSvgNumber(startPt.y)}" r="3.5" fill="${trackColor}" />`);
|
||
parts.push(`<circle cx="${toSvgNumber(endPt.x)}" cy="${toSvgNumber(endPt.y)}" r="3.5" fill="${trackColor}" />`);
|
||
parts.push(buildSvgEndpointLabel(startPt, formatTime(pass.aosTime), trackColor, cx, cy, radius));
|
||
parts.push(buildSvgEndpointLabel(endPt, formatTime(pass.losTime), trackColor, cx, cy, radius));
|
||
if (isPeakVisible(pass)) {
|
||
const peakPt = projectPoint(pass.peakAzimuth, pass.peakElevation, radius, cx, cy);
|
||
parts.push(`<circle cx="${toSvgNumber(peakPt.x)}" cy="${toSvgNumber(peakPt.y)}" r="4.5" fill="${trackColor}" stroke="${invertSky ? 'rgba(248,248,244,0.95)' : 'rgba(8,12,20,0.92)'}" stroke-width="1.5" />`);
|
||
parts.push(buildSvgEndpointLabel(peakPt, formatTime(pass.peakTime), trackColor, cx, cy, radius));
|
||
}
|
||
if (pass.shadowEntry) {
|
||
const sp = projectPoint(pass.shadowEntry.azimuthDeg, pass.shadowEntry.elevationDeg, radius, cx, cy);
|
||
parts.push(`<circle cx="${toSvgNumber(sp.x)}" cy="${toSvgNumber(sp.y)}" r="4" fill="${palette.shadowMarker}" stroke="${palette.shadowMarkerStroke}" stroke-width="1.5" />`);
|
||
parts.push(buildSvgEndpointLabel(sp, `ES ${formatTime(pass.shadowEntry.time)}`, palette.shadowLabel, cx, cy, radius));
|
||
}
|
||
}
|
||
|
||
parts.push(`</svg>`);
|
||
return parts.join('');
|
||
} finally {
|
||
exportOpaqueSky = prevExport;
|
||
}
|
||
}
|
||
|
||
async function exportSatpassSvg() {
|
||
try {
|
||
const markup = generateSatpassSvgMarkup();
|
||
const blob = new Blob([markup], { type: 'image/svg+xml;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = createSatpassExportFileName('svg');
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||
} catch (err) {
|
||
console.error(err);
|
||
statusEl.textContent = 'SVG-Export fehlgeschlagen.';
|
||
}
|
||
}
|
||
|
||
async function exportSatpassPng() {
|
||
const prevExport = exportOpaqueSky;
|
||
try {
|
||
exportOpaqueSky = true;
|
||
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
|
||
const ec = document.createElement('canvas');
|
||
ec.width = canvas.width; ec.height = canvas.height;
|
||
const ectx = ec.getContext('2d');
|
||
ectx.fillStyle = EXPORT_BACKGROUND_COLOR;
|
||
ectx.fillRect(0,0,ec.width,ec.height);
|
||
ectx.drawImage(canvas, 0, 0);
|
||
try {
|
||
await ensureExportLogoLoaded();
|
||
const margin = Math.round(ec.width*0.025), lw = Math.round(ec.width*0.16);
|
||
const lh = Math.round(lw*(254/400));
|
||
ectx.drawImage(exportLogoImg, ec.width-lw-margin, ec.height-lh-margin, lw, lh);
|
||
} catch {}
|
||
const blob = await canvasToBlob(ec, 'image/png');
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = createSatpassExportFileName('png');
|
||
document.body.appendChild(a); a.click(); a.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||
} catch (err) {
|
||
console.error(err);
|
||
statusEl.textContent = 'PNG-Export fehlgeschlagen.';
|
||
} finally {
|
||
exportOpaqueSky = prevExport;
|
||
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
|
||
}
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────
|
||
|
||
window.addEventListener('resize', () => drawSky(lastSkyData, lastReferenceTime, lastShowStars));
|
||
invertBtn?.addEventListener('click', () => {
|
||
invertSky = !invertSky;
|
||
invertBtn.classList.toggle('is-active', invertSky);
|
||
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
|
||
});
|
||
mirrorBtn?.addEventListener('click', () => {
|
||
mirrorEastWest = !mirrorEastWest;
|
||
updateMirrorButton();
|
||
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
|
||
});
|
||
exportBtn?.addEventListener('click', () => exportSatpassPng().catch(err => {
|
||
console.error(err); statusEl.textContent = 'PNG-Export fehlgeschlagen.';
|
||
}));
|
||
exportSvgBtn?.addEventListener('click', () => exportSatpassSvg().catch(err => {
|
||
console.error(err); statusEl.textContent = 'SVG-Export fehlgeschlagen.';
|
||
}));
|
||
|
||
updateMirrorButton();
|
||
buildDropdowns();
|
||
drawSky([], null, false);
|
||
</script>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php require __DIR__ . '/footer.php'; ?>
|