1031 lines
46 KiB
PHP
1031 lines
46 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');
|
||
}
|
||
|
||
// ── 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 ──────────────────────────────────────────────────────────
|
||
|
||
$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('+7 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');
|
||
$satellitesJson = json_encode($satellitePayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||
|
||
$args = [
|
||
'/usr/bin/python',
|
||
escapeshellarg($scriptPath),
|
||
escapeshellarg('satellite_passes'),
|
||
escapeshellarg((string)$lat),
|
||
escapeshellarg((string)$lon),
|
||
escapeshellarg((string)$elev),
|
||
escapeshellarg($tz),
|
||
escapeshellarg($windowStart),
|
||
escapeshellarg($windowEnd),
|
||
escapeshellarg($satellitesJson),
|
||
];
|
||
|
||
$command = implode(' ', $args) . ' 2>&1';
|
||
$output = [];
|
||
$code = 0;
|
||
exec($command, $output, $code);
|
||
|
||
$joined = implode('', $output);
|
||
$decoded = json_decode($joined, true);
|
||
|
||
if (is_array($decoded) && ($decoded['ok'] ?? false)) {
|
||
$pythonResult = $decoded;
|
||
} else {
|
||
$pythonError = $joined ?: '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 fuer den heutigen Tag den naechsten Ueberflug deiner ausgewaehlten Satelliten
|
||
und zeichnet deren Bahn auf einer Zenitkarte fuer 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 waehle 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 ausgewaehlt</h2>
|
||
<p class="hint">
|
||
Es sind noch keine ausgewaehlten 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 waehlen –</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="passSelect">Ueberflug</label>
|
||
<select id="passSelect" class="satpass-select" disabled>
|
||
<option value="">– zuerst Satellit waehlen –</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) ?>;
|
||
|
||
// 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 classifyVisibility(pass) {
|
||
const sunAltDeg = getSunAltitudeDeg(pass.peakTime);
|
||
const sky = classifySkyBrightness(sunAltDeg);
|
||
const elevDeg = pass.peakElevation;
|
||
let sunBand = 5;
|
||
if (sunAltDeg > -4) sunBand = 0;
|
||
else if (sunAltDeg > -6) sunBand = 1;
|
||
else if (sunAltDeg > -8) sunBand = 2;
|
||
else if (sunAltDeg > -10) sunBand = 3;
|
||
else if (sunAltDeg > -12) sunBand = 4;
|
||
let elevBand = 0;
|
||
if (elevDeg >= 60) elevBand = 4;
|
||
else if (elevDeg >= 40) elevBand = 3;
|
||
else if (elevDeg >= 20) elevBand = 2;
|
||
else if (elevDeg >= 10) elevBand = 1;
|
||
const matrix = [
|
||
['ungenuegend','ungenuegend','mangelhaft','mangelhaft','ausreichend'],
|
||
['ungenuegend','mangelhaft','ausreichend','befriedigend','gut'],
|
||
['mangelhaft','ausreichend','gut','sehr gut','perfekt'],
|
||
['mangelhaft','befriedigend','gut','sehr gut','perfekt'],
|
||
['ausreichend','befriedigend','gut','sehr gut','perfekt'],
|
||
['ausreichend','befriedigend','gut','gut','sehr gut']
|
||
];
|
||
const label = matrix[sunBand][elevBand];
|
||
return {key: label.replace(/\s+/g,''), label, sky, sunAltDeg};
|
||
}
|
||
|
||
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 visibleEndTime = pass.shadowEntry && pass.shadowEntry.time < pass.losTime
|
||
? pass.shadowEntry.time : pass.losTime;
|
||
const visiblePath = pass.path
|
||
.filter(p => p.time <= visibleEndTime && p.elevationDeg >= 0)
|
||
.map(p => ({...p}));
|
||
return {
|
||
visibleEndTime,
|
||
visibleDurationMs: Math.max(0, visibleEndTime - pass.aosTime),
|
||
visiblePath
|
||
};
|
||
}
|
||
|
||
function buildObservationSummary(satName, pass, visibility) {
|
||
if (visibility.sky.key === 'day') return `${satName} ist bei diesem Ueberflug tagsueber nicht zu sehen.`;
|
||
const visiblePhase = getVisiblePhase(pass);
|
||
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 Rahmenbedingungen fuer die Beobachtung von ${satName} am ${formatDateLocal(pass.aosTime)} (${formatWeekdayShort(pass.aosTime)}) um ${formatTime(pass.aosTime)} Uhr sind ${visibility.label}. `
|
||
+ `Der Ueberflug ist ${formatDurationLong(visiblePhase.visibleDurationMs)} lang und bis ${formatTime(visiblePhase.visibleEndTime)} Uhr zu sehen. `
|
||
+ `${pathText} Die maximale Horizonthoehe betraegt ${pass.peakElevation.toFixed(0)}°.`
|
||
+ `${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 = 11, paddingX = 5, paddingY = 3;
|
||
ctx.save();
|
||
ctx.font = `${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));
|
||
ctx.fillStyle = invertSky ? 'rgba(255,255,255,0.88)' : 'rgba(8,12,20,0.82)';
|
||
ctx.fillRect(labelX-paddingX, labelY-(fontSize/2)-paddingY, textWidth+paddingX*2, fontSize+paddingY*2);
|
||
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 (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>Ende</td><td>${formatDateTime(pass.losTime)} (${azToCardinal(pass.losAzimuth ?? 0)})</td></tr>
|
||
<tr><td>Dauer</td><td>${formatDurationMs(pass.durationMs)}</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>Himmel</td><td>${visibility.sky.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 waehlen –</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 naechsten 7 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 waehlen –</option>';
|
||
drawSky([], null, false);
|
||
return;
|
||
}
|
||
const sat = PYTHON_RESULTS[satIdx];
|
||
passSelectEl.disabled = false;
|
||
passSelectEl.innerHTML = '<option value="">– Ueberflug waehlen –</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 7 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'; ?>
|