Files
skyview.astronomiemuseum.de/public/satellitenhimmel.php
T

1238 lines
55 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
ini_set('display_errors', '1');
error_reporting(E_ALL);
session_start();
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="satpassInvertBtn">Invertiert</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 invertBtn = document.getElementById('satpassInvertBtn');
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.png';
let invertSky = false;
let exportOpaqueSky = false;
let lastReferenceTime = null;
let lastShowStars = false;
let lastSelectedSatelliteName = '';
let lastSelectedPass = null;
let lastSkyData = [];
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 dirs = ['N','NO','O','SO','S','SW','W','NW'];
return dirs[Math.round(az/45) % 8];
}
function azToDirectionName(az) {
const dirs = ['Norden','Nordosten','Osten','Suedosten','Sueden','Suedwesten','Westen','Nordwesten'];
return dirs[Math.round(az/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)}&deg;.`
+ `${shadowText}${magText}`;
}
// ── Sky canvas rendering ───────────────────────────────────────────
function projectPoint(azimuthDeg, elevationDeg, radius, cx, cy) {
const r = ((90 - elevationDeg) / 90) * radius;
const a = deg2rad(azimuthDeg);
return { x: cx + r*Math.sin(a), y: cy - r*Math.cos(a) };
}
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 box = canvas.parentElement.getBoundingClientRect();
const size = Math.max(320, Math.floor(Math.min(box.width, 820)));
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 cx=size/2, cy=size/2, radius=size*0.43;
const palette = 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'
};
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('W', cx-radius+14, cy+5); ctx.fillText('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)}&deg;</td></tr>
<tr><td>Sichtbares Maximum</td><td>${visibility.visiblePeak.elevationDeg >= 0 ? `${formatDateTime(visibility.visiblePeak.time)} / ${visibility.visiblePeak.elevationDeg.toFixed(1)}&deg;` : '–'}</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() {
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)}.png`;
}
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 = '#ffffff'; 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();
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);
});
exportBtn?.addEventListener('click', () => exportSatpassPng().catch(err => {
console.error(err); statusEl.textContent = 'PNG-Export fehlgeschlagen.';
}));
buildDropdowns();
drawSky([], null, false);
</script>
<?php endif; ?>
</div>
<?php require __DIR__ . '/footer.php'; ?>