1604 lines
61 KiB
PHP
1604 lines
61 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 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);
|
||
}
|
||
|
||
$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),
|
||
];
|
||
}
|
||
|
||
$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);
|
||
|
||
// Sternbildlinien: Liniensegmente mit ra/dec je Punkt
|
||
$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();
|
||
|
||
// Gruppieren nach line_id → Array von Linien, jede Linie = Array von {ra, dec}
|
||
$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);
|
||
?>
|
||
<?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">
|
||
Diese Seite verwendet den im Header geladenen Standard-Standort aus der Datenbank.
|
||
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 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 komplett im Browser</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">Berechne 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 type="module">
|
||
import { twoline2satrec, propagate, gstime, eciToEcf, ecfToLookAngles, sunPos, shadowFraction } from 'https://cdn.jsdelivr.net/npm/satellite.js@7/dist/index.js';
|
||
|
||
const SITE_LOCATION = <?= json_encode($pageLocation, JSON_UNESCAPED_SLASHES) ?>;
|
||
const SATELLITES = <?= json_encode($satellitePayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||
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) ?>;
|
||
const PASS_STEP_SECONDS = 5;
|
||
const MIN_ELEVATION_DEG = 0;
|
||
const COLORS = ['#f0d990', '#4fc3d8', '#ff9f68', '#9ad77d', '#c7b0ff', '#ffd166', '#7bdff2', '#ff7b9c', '#b8f2e6', '#f7a072'];
|
||
|
||
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;
|
||
|
||
function deg2rad(deg) {
|
||
return deg * Math.PI / 180;
|
||
}
|
||
|
||
function rad2deg(rad) {
|
||
return rad * 180 / Math.PI;
|
||
}
|
||
|
||
function clampAzimuthDegrees(deg) {
|
||
let value = deg % 360;
|
||
if (value < 0) value += 360;
|
||
return value;
|
||
}
|
||
|
||
function observerGd() {
|
||
return {
|
||
longitude: deg2rad(Number(SITE_LOCATION.longitude)),
|
||
latitude: deg2rad(Number(SITE_LOCATION.latitude)),
|
||
height: Number(SITE_LOCATION.elevation || 0) / 1000
|
||
};
|
||
}
|
||
|
||
function getLookAngles(satrec, obsGd, date) {
|
||
const pv = propagate(satrec, date);
|
||
if (!pv || !pv.position) {
|
||
return null;
|
||
}
|
||
|
||
const gmst = gstime(date);
|
||
const positionEcf = eciToEcf(pv.position, gmst);
|
||
const lookAngles = ecfToLookAngles(obsGd, positionEcf);
|
||
|
||
return {
|
||
elevationDeg: rad2deg(lookAngles.elevation),
|
||
azimuthDeg: clampAzimuthDegrees(rad2deg(lookAngles.azimuth)),
|
||
rangeKm: lookAngles.rangeSat
|
||
};
|
||
}
|
||
|
||
function findCrossingTime(satrec, obsGd, t1, t2, minElevationDeg, rising) {
|
||
let left = new Date(t1.getTime());
|
||
let right = new Date(t2.getTime());
|
||
|
||
for (let i = 0; i < 35; i += 1) {
|
||
const mid = new Date((left.getTime() + right.getTime()) / 2);
|
||
const angles = getLookAngles(satrec, obsGd, mid);
|
||
if (!angles) {
|
||
break;
|
||
}
|
||
|
||
const above = angles.elevationDeg >= minElevationDeg;
|
||
if (rising) {
|
||
if (above) right = mid;
|
||
else left = mid;
|
||
} else {
|
||
if (above) left = mid;
|
||
else right = mid;
|
||
}
|
||
|
||
if (Math.abs(right.getTime() - left.getTime()) <= 1000) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return new Date((left.getTime() + right.getTime()) / 2);
|
||
}
|
||
|
||
function refinePeakTime(satrec, obsGd, approxPeak, windowSeconds = 30) {
|
||
let bestTime = approxPeak;
|
||
let bestAngles = getLookAngles(satrec, obsGd, approxPeak);
|
||
|
||
if (!bestAngles) {
|
||
return null;
|
||
}
|
||
|
||
for (let offset = -windowSeconds; offset <= windowSeconds; offset += 1) {
|
||
const t = new Date(approxPeak.getTime() + offset * 1000);
|
||
const angles = getLookAngles(satrec, obsGd, t);
|
||
if (!angles) {
|
||
continue;
|
||
}
|
||
if (angles.elevationDeg > bestAngles.elevationDeg) {
|
||
bestAngles = angles;
|
||
bestTime = t;
|
||
}
|
||
}
|
||
|
||
return {
|
||
time: bestTime,
|
||
elevationDeg: bestAngles.elevationDeg,
|
||
azimuthDeg: bestAngles.azimuthDeg
|
||
};
|
||
}
|
||
|
||
function getShadowFraction(satrec, date) {
|
||
const pv = propagate(satrec, date);
|
||
if (!pv || !pv.position) return 0;
|
||
const jday = date.getTime() / 86400000.0 + 2440587.5;
|
||
const sun = sunPos(jday);
|
||
return shadowFraction(sun.rsun, pv.position);
|
||
}
|
||
|
||
function sampleTrajectory(satrec, obsGd, start, end, color) {
|
||
const points = [];
|
||
let t = new Date(start.getTime());
|
||
|
||
while (t <= end) {
|
||
const angles = getLookAngles(satrec, obsGd, t);
|
||
if (angles && angles.elevationDeg >= MIN_ELEVATION_DEG) {
|
||
points.push({
|
||
time: new Date(t.getTime()),
|
||
azimuthDeg: angles.azimuthDeg,
|
||
elevationDeg: angles.elevationDeg,
|
||
shadow: getShadowFraction(satrec, t),
|
||
color
|
||
});
|
||
}
|
||
t = new Date(t.getTime() + PASS_STEP_SECONDS * 1000);
|
||
}
|
||
|
||
const endAngles = getLookAngles(satrec, obsGd, end);
|
||
if (endAngles) {
|
||
points.push({
|
||
time: new Date(end.getTime()),
|
||
azimuthDeg: endAngles.azimuthDeg,
|
||
elevationDeg: endAngles.elevationDeg,
|
||
shadow: getShadowFraction(satrec, end),
|
||
color
|
||
});
|
||
}
|
||
|
||
return points;
|
||
}
|
||
|
||
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 result = {};
|
||
for (const part of parts) {
|
||
if (part.type !== 'literal') {
|
||
result[part.type] = Number(part.value);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function getTimeZoneOffsetMillis(date, timeZone) {
|
||
const parts = getTzParts(date, timeZone);
|
||
const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
|
||
return asUtc - date.getTime();
|
||
}
|
||
|
||
function zonedDateTimeToUtc(year, month, day, hour, minute, second, timeZone) {
|
||
const guess = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
|
||
const offset = getTimeZoneOffsetMillis(guess, timeZone);
|
||
return new Date(guess.getTime() - offset);
|
||
}
|
||
|
||
function formatTime(date) {
|
||
return new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: SITE_LOCATION.timezone,
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hourCycle: 'h23'
|
||
}).format(date);
|
||
}
|
||
|
||
function formatDateTime(date) {
|
||
return `${formatDateLocal(date)} ${formatTime(date)}`;
|
||
}
|
||
|
||
function padNumber(value) {
|
||
return String(value).padStart(2, '0');
|
||
}
|
||
|
||
function canvasToBlob(targetCanvas, type = 'image/png') {
|
||
return new Promise((resolve, reject) => {
|
||
targetCanvas.toBlob((blob) => {
|
||
if (blob) {
|
||
resolve(blob);
|
||
} else {
|
||
reject(new Error('PNG-Export konnte nicht erstellt werden.'));
|
||
}
|
||
}, type);
|
||
});
|
||
}
|
||
|
||
function ensureExportLogoLoaded() {
|
||
return new Promise((resolve, reject) => {
|
||
if (exportLogoImg.complete && exportLogoImg.naturalWidth > 0) {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
const handleLoad = () => {
|
||
exportLogoImg.removeEventListener('load', handleLoad);
|
||
exportLogoImg.removeEventListener('error', handleError);
|
||
resolve();
|
||
};
|
||
const handleError = () => {
|
||
exportLogoImg.removeEventListener('load', handleLoad);
|
||
exportLogoImg.removeEventListener('error', handleError);
|
||
reject(new Error('Logo konnte nicht geladen werden.'));
|
||
};
|
||
|
||
exportLogoImg.addEventListener('load', handleLoad, { once: true });
|
||
exportLogoImg.addEventListener('error', handleError, { once: true });
|
||
});
|
||
}
|
||
|
||
function createSatpassExportFileName() {
|
||
const date = lastSelectedPass?.aosTime instanceof Date ? lastSelectedPass.aosTime : new Date();
|
||
const parts = getTzParts(date, SITE_LOCATION.timezone);
|
||
const satelliteName = (lastSelectedSatelliteName || 'satellit')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/gi, '_')
|
||
.replace(/^_+|_+$/g, '') || 'satellit';
|
||
return `satellitenhimmel_${satelliteName}_${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}_${padNumber(parts.hour)}-${padNumber(parts.minute)}.png`;
|
||
}
|
||
|
||
async function exportSatpassPng() {
|
||
const previousExportOpaqueSky = exportOpaqueSky;
|
||
try {
|
||
exportOpaqueSky = true;
|
||
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
|
||
|
||
const exportCanvas = document.createElement('canvas');
|
||
exportCanvas.width = canvas.width;
|
||
exportCanvas.height = canvas.height;
|
||
const exportCtx = exportCanvas.getContext('2d');
|
||
exportCtx.fillStyle = '#ffffff';
|
||
exportCtx.fillRect(0, 0, exportCanvas.width, exportCanvas.height);
|
||
exportCtx.drawImage(canvas, 0, 0);
|
||
|
||
try {
|
||
await ensureExportLogoLoaded();
|
||
const margin = Math.round(exportCanvas.width * 0.025);
|
||
const logoWidth = Math.round(exportCanvas.width * 0.16);
|
||
const logoHeight = Math.round(logoWidth * (254 / 400));
|
||
const logoX = exportCanvas.width - logoWidth - margin;
|
||
const logoY = exportCanvas.height - logoHeight - margin;
|
||
exportCtx.drawImage(exportLogoImg, logoX, logoY, logoWidth, logoHeight);
|
||
} catch (logoError) {
|
||
console.warn('Export-Logo konnte nicht eingefuegt werden.', logoError);
|
||
}
|
||
|
||
const blob = await canvasToBlob(exportCanvas, 'image/png');
|
||
const downloadUrl = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = downloadUrl;
|
||
link.download = createSatpassExportFileName();
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 2000);
|
||
} catch (error) {
|
||
console.error(error);
|
||
statusEl.textContent = 'PNG-Export fehlgeschlagen.';
|
||
} finally {
|
||
exportOpaqueSky = previousExportOpaqueSky;
|
||
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
|
||
}
|
||
}
|
||
|
||
function formatDurationMs(ms) {
|
||
const totalMinutes = Math.max(0, Math.round(ms / 60000));
|
||
const hours = Math.floor(totalMinutes / 60);
|
||
const minutes = totalMinutes % 60;
|
||
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
||
}
|
||
|
||
function formatDurationLong(ms) {
|
||
const totalMinutes = Math.max(0, Math.round(ms / 60000));
|
||
const hours = Math.floor(totalMinutes / 60);
|
||
const minutes = totalMinutes % 60;
|
||
|
||
if (hours > 0 && minutes > 0) {
|
||
return `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'} ${minutes} Minuten`;
|
||
}
|
||
if (hours > 0) {
|
||
return `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`;
|
||
}
|
||
return `${minutes} Minuten`;
|
||
}
|
||
|
||
function formatWeekdayShort(date) {
|
||
return new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: SITE_LOCATION.timezone,
|
||
weekday: 'short'
|
||
}).format(date).replace('.', '');
|
||
}
|
||
|
||
function formatDateLocal(date) {
|
||
return new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: SITE_LOCATION.timezone,
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric'
|
||
}).format(date);
|
||
}
|
||
|
||
function eciToRaDecDeg(vec) {
|
||
const radius = Math.sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z);
|
||
if (!radius) {
|
||
return { raDeg: 0, decDeg: 0 };
|
||
}
|
||
const raDeg = clampAzimuthDegrees(rad2deg(Math.atan2(vec.y, vec.x)));
|
||
const decDeg = rad2deg(Math.asin(Math.max(-1, Math.min(1, vec.z / radius))));
|
||
return { raDeg, decDeg };
|
||
}
|
||
|
||
function getSunAltitudeDeg(date) {
|
||
const jday = date.getTime() / 86400000.0 + 2440587.5;
|
||
const sun = sunPos(jday);
|
||
if (!sun || !sun.rsun) {
|
||
return -90;
|
||
}
|
||
const sunEq = eciToRaDecDeg(sun.rsun);
|
||
return equatorialToHorizontal(
|
||
sunEq.raDeg,
|
||
sunEq.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 elevationDeg = 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 elevationBand = 0;
|
||
if (elevationDeg >= 60) elevationBand = 4;
|
||
else if (elevationDeg >= 40) elevationBand = 3;
|
||
else if (elevationDeg >= 20) elevationBand = 2;
|
||
else if (elevationDeg >= 10) elevationBand = 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][elevationBand];
|
||
const key = label.replace(/\s+/g, '');
|
||
return { key, label, sky, sunAltDeg };
|
||
}
|
||
|
||
function formatVisibilityBadge(visibility) {
|
||
return `<span class="satpass-visibility satpass-visibility-${visibility.key}">${visibility.label}</span>`;
|
||
}
|
||
|
||
function azToDirectionName(azimuth) {
|
||
const dirs = ['Norden', 'Nordosten', 'Osten', 'Suedosten', 'Sueden', 'Suedwesten', 'Westen', 'Nordwesten'];
|
||
return dirs[Math.round(azimuth / 45) % 8];
|
||
}
|
||
|
||
function altitudeToPhrase(elevationDeg) {
|
||
if (elevationDeg < 15) return 'nahe dem Horizont';
|
||
if (elevationDeg < 35) return 'halbhoch';
|
||
return 'hoch am Himmel';
|
||
}
|
||
|
||
function buildPathDescription(path) {
|
||
const visiblePoints = path.filter((point) => point.elevationDeg >= 0);
|
||
if (visiblePoints.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
const descriptors = [];
|
||
visiblePoints.forEach((point) => {
|
||
const text = `${altitudeToPhrase(point.elevationDeg)} im ${azToDirectionName(point.azimuthDeg)}`;
|
||
if (descriptors[descriptors.length - 1] !== text) {
|
||
descriptors.push(text);
|
||
}
|
||
});
|
||
|
||
const limited = descriptors.length > 8
|
||
? Array.from({ length: 8 }, (_, index) => {
|
||
const pos = Math.round(index * (descriptors.length - 1) / 7);
|
||
return descriptors[pos];
|
||
}).filter((value, index, arr) => index === 0 || value !== arr[index - 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((point) => point.time <= visibleEndTime && point.elevationDeg >= 0)
|
||
.map((point) => ({ ...point }));
|
||
|
||
if (
|
||
pass.shadowEntry &&
|
||
pass.shadowEntry.time < pass.losTime &&
|
||
pass.shadowEntry.elevationDeg >= 0 &&
|
||
(visiblePath.length === 0 || visiblePath[visiblePath.length - 1].time.getTime() !== pass.shadowEntry.time.getTime())
|
||
) {
|
||
visiblePath.push({
|
||
time: new Date(pass.shadowEntry.time.getTime()),
|
||
azimuthDeg: pass.shadowEntry.azimuthDeg,
|
||
elevationDeg: pass.shadowEntry.elevationDeg,
|
||
shadow: 0.5
|
||
});
|
||
}
|
||
|
||
return {
|
||
visibleEndTime,
|
||
visibleDurationMs: Math.max(0, visibleEndTime.getTime() - pass.aosTime.getTime()),
|
||
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}`;
|
||
}
|
||
|
||
function findShadowEntry(path) {
|
||
if (!Array.isArray(path) || path.length < 2) {
|
||
return null;
|
||
}
|
||
|
||
for (let i = 1; i < path.length; i += 1) {
|
||
const prev = path[i - 1];
|
||
const next = path[i];
|
||
const prevShadow = Number(prev.shadow || 0);
|
||
const nextShadow = Number(next.shadow || 0);
|
||
|
||
if (prev.elevationDeg < 0 || next.elevationDeg < 0) {
|
||
continue;
|
||
}
|
||
|
||
if (prevShadow <= 0.5 && nextShadow > 0.5) {
|
||
const denom = nextShadow - prevShadow;
|
||
const fraction = denom > 0 ? (0.5 - prevShadow) / denom : 0;
|
||
const t = Math.max(0, Math.min(1, fraction));
|
||
return {
|
||
time: new Date(prev.time.getTime() + (next.time.getTime() - prev.time.getTime()) * t),
|
||
azimuthDeg: prev.azimuthDeg + (next.azimuthDeg - prev.azimuthDeg) * t,
|
||
elevationDeg: prev.elevationDeg + (next.elevationDeg - prev.elevationDeg) * t
|
||
};
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
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 azToCardinal(azimuth) {
|
||
const dirs = ['N', 'NO', 'O', 'SO', 'S', 'SW', 'W', 'NW'];
|
||
return dirs[Math.round(azimuth / 45) % 8];
|
||
}
|
||
|
||
const KM_PER_AU = 149597870.7;
|
||
|
||
function vecSub(a, b) { return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }; }
|
||
function vecLen(v) { return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z); }
|
||
function vecDot(a, b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
|
||
|
||
function observerEci(date) {
|
||
const gst = gstime(date);
|
||
const lat = deg2rad(Number(SITE_LOCATION.latitude));
|
||
const lon = deg2rad(Number(SITE_LOCATION.longitude));
|
||
const alt = Number(SITE_LOCATION.elevation || 0) / 1000;
|
||
const r = 6378.137 + alt;
|
||
const lst = lon + gst;
|
||
return {
|
||
x: r * Math.cos(lat) * Math.cos(lst),
|
||
y: r * Math.cos(lat) * Math.sin(lst),
|
||
z: r * Math.sin(lat)
|
||
};
|
||
}
|
||
|
||
// Kasten-Young Luftmasse
|
||
function airmassKastenYoung(hDeg) {
|
||
if (hDeg <= 0) return 40;
|
||
const sinH = Math.sin(deg2rad(hDeg));
|
||
return 1 / (sinH + 0.50572 * Math.pow(hDeg + 6.07995, -1.6364));
|
||
}
|
||
|
||
// Lambertische Phasenfunktion phi(alpha)
|
||
// qs.mag std_mag ist definiert bei 1000 km und Phase=90deg → phi normiert auf phi(90deg)=1/π
|
||
const PHI_AT_90 = 1 / Math.PI;
|
||
function phiLambert(alphaRad) {
|
||
if (alphaRad >= Math.PI) return 1e-10;
|
||
return Math.max(1e-10, (Math.sin(alphaRad) + (Math.PI - alphaRad) * Math.cos(alphaRad)) / Math.PI);
|
||
}
|
||
|
||
// Schaetzt m1000 aus numerischem RCS (m²) oder Groessenkategorie
|
||
// Formel: m1000 ≈ 7.5 - 2.5·log10(RCS) (diffuse Kugel, 1000 km, Phase 90°)
|
||
function estimateM1000(rcs, rcsSize) {
|
||
if (rcs !== null && rcs !== undefined && rcs > 0) {
|
||
return { m1000: 7.5 - 2.5 * Math.log10(rcs), estimated: true };
|
||
}
|
||
switch ((rcsSize || '').toUpperCase()) {
|
||
case 'LARGE': return { m1000: 3.5, estimated: true };
|
||
case 'MEDIUM': return { m1000: 6.0, estimated: true };
|
||
case 'SMALL': return { m1000: 8.5, estimated: true };
|
||
default: return null;
|
||
}
|
||
}
|
||
|
||
const K_EXTINCTION = 0.18;
|
||
|
||
function apparentMagnitude(item, satrec, date) {
|
||
let m1000, estimated = false;
|
||
if (item.std_mag !== null && item.std_mag !== undefined) {
|
||
m1000 = item.std_mag;
|
||
} else {
|
||
const est = estimateM1000(item.rcs, item.rcs_size);
|
||
if (!est) return null;
|
||
m1000 = est.m1000;
|
||
estimated = true;
|
||
}
|
||
|
||
const pv = propagate(satrec, date);
|
||
if (!pv || !pv.position) return null;
|
||
|
||
const jday = date.getTime() / 86400000.0 + 2440587.5;
|
||
const sun = sunPos(jday);
|
||
const satEci = pv.position;
|
||
const sunEci = { x: sun.rsun.x * KM_PER_AU, y: sun.rsun.y * KM_PER_AU, z: sun.rsun.z * KM_PER_AU };
|
||
const obsEci = observerEci(date);
|
||
|
||
const toSat = vecSub(satEci, obsEci);
|
||
const rangeKm = vecLen(toSat);
|
||
if (rangeKm < 1) return null;
|
||
|
||
// Phasenwinkel alpha (Sonne→Sat→Beobachter)
|
||
const toSun = vecSub(sunEci, satEci);
|
||
const toObs = vecSub(obsEci, satEci);
|
||
const cosA = vecDot(toSun, toObs) / (vecLen(toSun) * vecLen(toObs));
|
||
const alphaRad = Math.acos(Math.max(-1, Math.min(1, cosA)));
|
||
|
||
// Hoehenwinkel des Satelliten (fuer Extinktion)
|
||
const cosEl = vecDot(obsEci, toSat) / (vecLen(obsEci) * rangeKm);
|
||
const hDeg = rad2deg(Math.asin(Math.max(-1, Math.min(1, cosEl))));
|
||
|
||
// Formel: m = m1000 + 5·log10(ρ/1000) − 2.5·log10(φ/φ90) + k·X(h)
|
||
// φ/φ90 normiert damit m1000 (qs.mag) bei Phase=90° korrekt ist
|
||
const distCorr = 5 * Math.log10(rangeKm / 1000);
|
||
const phaseCorr = -2.5 * Math.log10(phiLambert(alphaRad) / PHI_AT_90);
|
||
const extinction = K_EXTINCTION * airmassKastenYoung(Math.max(1, hDeg));
|
||
|
||
const mag = m1000 + distCorr + phaseCorr + extinction;
|
||
return isFinite(mag) ? { mag, estimated } : null;
|
||
}
|
||
|
||
function formatMag(result) {
|
||
if (!result) return '–';
|
||
const { mag, estimated } = result;
|
||
return (estimated ? '~' : '') + mag.toFixed(1);
|
||
}
|
||
|
||
function computeAllPassesForSatellite(item, windowStart, windowEnd, color) {
|
||
const satrec = twoline2satrec(item.tle_line1, item.tle_line2);
|
||
const obsGd = observerGd();
|
||
const passes = [];
|
||
|
||
let current = new Date(windowStart.getTime());
|
||
let previousAngles = getLookAngles(satrec, obsGd, current);
|
||
if (!previousAngles) return passes;
|
||
|
||
let inPass = previousAngles.elevationDeg >= MIN_ELEVATION_DEG;
|
||
let aosTime = inPass ? new Date(current.getTime()) : null;
|
||
let aosAzimuth = inPass ? previousAngles.azimuthDeg : null;
|
||
let maxElevation = inPass ? previousAngles.elevationDeg : -Infinity;
|
||
let maxElevationTime = inPass ? new Date(current.getTime()) : null;
|
||
|
||
while (current < windowEnd) {
|
||
const next = new Date(current.getTime() + PASS_STEP_SECONDS * 1000);
|
||
if (next > windowEnd) break;
|
||
|
||
const currentAngles = getLookAngles(satrec, obsGd, next);
|
||
if (!currentAngles) { current = next; continue; }
|
||
|
||
const wasAbove = previousAngles.elevationDeg >= MIN_ELEVATION_DEG;
|
||
const isAbove = currentAngles.elevationDeg >= MIN_ELEVATION_DEG;
|
||
|
||
if (!inPass && !wasAbove && isAbove) {
|
||
aosTime = findCrossingTime(satrec, obsGd, current, next, MIN_ELEVATION_DEG, true);
|
||
const aosAngles = getLookAngles(satrec, obsGd, aosTime);
|
||
inPass = true;
|
||
aosAzimuth = aosAngles ? aosAngles.azimuthDeg : null;
|
||
maxElevation = currentAngles.elevationDeg;
|
||
maxElevationTime = new Date(next.getTime());
|
||
}
|
||
|
||
if (inPass) {
|
||
if (currentAngles.elevationDeg > maxElevation) {
|
||
maxElevation = currentAngles.elevationDeg;
|
||
maxElevationTime = new Date(next.getTime());
|
||
}
|
||
|
||
if (wasAbove && !isAbove) {
|
||
const losTime = findCrossingTime(satrec, obsGd, current, next, MIN_ELEVATION_DEG, false);
|
||
const losAngles = getLookAngles(satrec, obsGd, losTime);
|
||
const refinedPeak = maxElevationTime
|
||
? refinePeakTime(satrec, obsGd, maxElevationTime, 30)
|
||
: null;
|
||
|
||
const peakT = refinedPeak ? refinedPeak.time : maxElevationTime;
|
||
const peakMag = apparentMagnitude(item, satrec, peakT);
|
||
const path = sampleTrajectory(satrec, obsGd, aosTime, losTime, color);
|
||
|
||
passes.push({
|
||
color,
|
||
aosTime,
|
||
losTime,
|
||
aosAzimuth,
|
||
losAzimuth: losAngles ? losAngles.azimuthDeg : null,
|
||
peakTime: peakT,
|
||
peakElevation: refinedPeak ? refinedPeak.elevationDeg : maxElevation,
|
||
peakAzimuth: refinedPeak ? refinedPeak.azimuthDeg : currentAngles.azimuthDeg,
|
||
peakMag,
|
||
durationMs: losTime.getTime() - aosTime.getTime(),
|
||
path,
|
||
shadowEntry: findShadowEntry(path)
|
||
});
|
||
|
||
inPass = false;
|
||
aosTime = null;
|
||
maxElevation = -Infinity;
|
||
maxElevationTime = null;
|
||
}
|
||
}
|
||
|
||
previousAngles = currentAngles;
|
||
current = next;
|
||
}
|
||
|
||
return passes;
|
||
}
|
||
|
||
function getTodayWindow() {
|
||
const now = new Date();
|
||
const tz = SITE_LOCATION.timezone;
|
||
const parts = getTzParts(now, tz);
|
||
const dayStart = zonedDateTimeToUtc(parts.year, parts.month, parts.day, 0, 0, 0, tz);
|
||
const dayEnd = zonedDateTimeToUtc(parts.year, parts.month, parts.day + 7, 23, 59, 59, tz);
|
||
const rangeStartLabel = new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: tz,
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric'
|
||
}).format(dayStart);
|
||
const rangeEndLabel = new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: tz,
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric'
|
||
}).format(dayEnd);
|
||
return {
|
||
now,
|
||
dayStart,
|
||
dayEnd,
|
||
calcStart: new Date(Math.max(now.getTime(), dayStart.getTime())),
|
||
label: new Intl.DateTimeFormat('de-DE', {
|
||
timeZone: tz,
|
||
weekday: 'long',
|
||
day: '2-digit',
|
||
month: '2-digit',
|
||
year: 'numeric'
|
||
}).format(now),
|
||
rangeLabel: `${rangeStartLabel} bis ${rangeEndLabel}`
|
||
};
|
||
}
|
||
|
||
let allSatelliteResults = [];
|
||
|
||
function selectPass(satIdx, passIdx) {
|
||
const satResult = allSatelliteResults[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);
|
||
|
||
// Passinfo-Panel fuellen
|
||
passInfoEl.innerHTML = `
|
||
<table class="data-table" style="margin-top:.75rem">
|
||
<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(satelliteResults, dayLabel) {
|
||
allSatelliteResults = satelliteResults;
|
||
const now = new Date();
|
||
|
||
// Satellit-Dropdown
|
||
satSelectEl.innerHTML = '<option value="">– Satellit waehlen –</option>';
|
||
satelliteResults.forEach((item, satIdx) => {
|
||
const color = COLORS[satIdx % COLORS.length];
|
||
const upcomingCount = item.passes.filter(p => p.losTime > now).length;
|
||
const opt = document.createElement('option');
|
||
opt.value = satIdx;
|
||
opt.textContent = `${item.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 item = satelliteResults[satIdx];
|
||
passSelectEl.disabled = false;
|
||
passSelectEl.innerHTML = '<option value="">– Ueberflug waehlen –</option>';
|
||
item.passes.forEach((p, passIdx) => {
|
||
const isPast = p.losTime <= now;
|
||
const opt = document.createElement('option');
|
||
opt.value = passIdx;
|
||
const passDateText = formatDateLocal(p.aosTime);
|
||
opt.textContent = `${passDateText} ${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);
|
||
});
|
||
|
||
// Naechsten bevorstehenden Pass vorauswaehlen
|
||
const nextIdx = item.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 (${dayLabel}).`;
|
||
|
||
}
|
||
|
||
function projectPoint(azimuthDeg, elevationDeg, radius, cx, cy) {
|
||
const r = ((90 - elevationDeg) / 90) * radius;
|
||
const a = deg2rad(azimuthDeg);
|
||
return {
|
||
x: cx + r * Math.sin(a), // West links, Ost rechts
|
||
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 paddingX = 5;
|
||
const paddingY = 3;
|
||
const fontSize = 11;
|
||
|
||
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;
|
||
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));
|
||
|
||
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;
|
||
const maxX = cx + radius - width - 6;
|
||
const minY = cy - radius + fontSize + 6;
|
||
const maxY = cy + radius - 4;
|
||
|
||
let fallback = null;
|
||
|
||
for (const candidate of candidates) {
|
||
let x = anchorX + candidate.dx;
|
||
let y = anchorY + candidate.dy;
|
||
|
||
if (candidate.align === 'center') {
|
||
x -= width / 2;
|
||
} else if (candidate.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((occupied) => rectsOverlap(rect, occupied, 2));
|
||
|
||
if (!fallback) {
|
||
fallback = { x, y, align: candidate.align, rect };
|
||
}
|
||
|
||
if (!collides) {
|
||
return { x, y, align: candidate.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;
|
||
const cy = size / 2;
|
||
const 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: (alpha) => `rgba(10, 12, 18, ${Math.max(0.72, alpha).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: (alpha) => `rgba(220, 230, 255, ${alpha.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, index) => {
|
||
ctx.beginPath();
|
||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
ctx.fillStyle = palette.gridText;
|
||
ctx.font = '12px Segoe UI';
|
||
ctx.fillText(`${(index === 0 ? 0 : index === 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);
|
||
const lon = Number(SITE_LOCATION.longitude);
|
||
const starTime = referenceTime ?? new Date();
|
||
|
||
// Sternbildlinien immer zeichnen
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
// Helle Sterne zeichnen (nur wenn showStars)
|
||
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 alpha = 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(alpha);
|
||
ctx.fill();
|
||
if (star.name && star.mag <= 2.0) {
|
||
ctx.fillStyle = palette.starLabel;
|
||
ctx.font = '10px Segoe UI';
|
||
ctx.textAlign = 'left';
|
||
const starLabelX = pt.x + r + 3;
|
||
const starLabelY = pt.y + 3;
|
||
const starLabelWidth = ctx.measureText(star.name).width;
|
||
ctx.fillText(star.name, starLabelX, starLabelY);
|
||
occupiedLabelRects.push(buildTextRect(starLabelX, starLabelY, starLabelWidth, 10));
|
||
}
|
||
}
|
||
|
||
ctx.font = '11px Segoe UI';
|
||
ctx.fillStyle = palette.constellationLabel;
|
||
for (const constellation of CONSTELLATION_LABELS) {
|
||
const pos = equatorialToHorizontal(constellation.ra, constellation.dec, lat, lon, starTime);
|
||
if (pos.altDeg < 0) continue;
|
||
const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy);
|
||
const labelWidth = ctx.measureText(constellation.label).width;
|
||
const placement = findFreeLabelPlacement(pt.x, pt.y, labelWidth, 11, occupiedLabelRects, cx, cy, radius, 'center');
|
||
if (!placement) continue;
|
||
ctx.textAlign = 'left';
|
||
ctx.fillText(constellation.label, placement.x, placement.y);
|
||
occupiedLabelRects.push(placement.rect);
|
||
}
|
||
} else {
|
||
skyTimeEl.textContent = '';
|
||
}
|
||
|
||
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;
|
||
|
||
// Bahn segmentweise zeichnen: beleuchtet = Farbe, Schatten = gestrichelt + gedimmt
|
||
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 > 0.5;
|
||
|
||
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 = invertSky ? 'rgba(8, 10, 14, 0.96)' : pass.color;
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
|
||
const startPoint = projectPoint(pts[0].azimuthDeg, pts[0].elevationDeg, radius, cx, cy);
|
||
const endPoint = projectPoint(pts[pts.length - 1].azimuthDeg, pts[pts.length - 1].elevationDeg, radius, cx, cy);
|
||
const trackMarkerColor = invertSky ? 'rgba(8, 10, 14, 0.96)' : pass.color;
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(startPoint.x, startPoint.y, 3.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = trackMarkerColor;
|
||
ctx.fill();
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(endPoint.x, endPoint.y, 3.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = trackMarkerColor;
|
||
ctx.fill();
|
||
|
||
drawEndpointLabel(startPoint, formatTime(pass.aosTime), trackMarkerColor, cx, cy, radius);
|
||
drawEndpointLabel(endPoint, formatTime(pass.losTime), trackMarkerColor, cx, cy, radius);
|
||
|
||
if (pass.shadowEntry) {
|
||
const shadowPoint = projectPoint(pass.shadowEntry.azimuthDeg, pass.shadowEntry.elevationDeg, radius, cx, cy);
|
||
ctx.beginPath();
|
||
ctx.arc(shadowPoint.x, shadowPoint.y, 4, 0, Math.PI * 2);
|
||
ctx.fillStyle = palette.shadowMarker;
|
||
ctx.fill();
|
||
ctx.strokeStyle = palette.shadowMarkerStroke;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
drawEndpointLabel(shadowPoint, `ES ${formatTime(pass.shadowEntry.time)}`, palette.shadowLabel, cx, cy, radius);
|
||
}
|
||
|
||
});
|
||
}
|
||
|
||
let lastSkyData = [];
|
||
|
||
function computeAllPasses() {
|
||
const windowInfo = getTodayWindow();
|
||
const now = new Date();
|
||
|
||
const satelliteResults = SATELLITES.map((item, index) => {
|
||
const color = COLORS[index % COLORS.length];
|
||
const passes = computeAllPassesForSatellite(item, windowInfo.dayStart, windowInfo.dayEnd, color);
|
||
return { name: item.object_name, norad: item.norad_cat_id, passes, color };
|
||
});
|
||
|
||
buildDropdowns(satelliteResults, windowInfo.rangeLabel);
|
||
drawSky([], null, false);
|
||
}
|
||
|
||
window.addEventListener('resize', () => drawSky(lastSkyData));
|
||
|
||
if (invertBtn) {
|
||
invertBtn.addEventListener('click', () => {
|
||
invertSky = !invertSky;
|
||
invertBtn.classList.toggle('is-active', invertSky);
|
||
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
|
||
});
|
||
}
|
||
|
||
if (exportBtn) {
|
||
exportBtn.addEventListener('click', () => {
|
||
exportSatpassPng().catch((error) => {
|
||
console.error(error);
|
||
statusEl.textContent = 'PNG-Export fehlgeschlagen.';
|
||
});
|
||
});
|
||
}
|
||
|
||
computeAllPasses();
|
||
</script>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<?php require __DIR__ . '/footer.php'; ?>
|