Files
skyview.astronomiemuseum.de/public/satellitenhimmel_old.php
T
2026-03-31 09:44:27 +02:00

1604 lines
61 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
ini_set('display_errors', '1');
error_reporting(E_ALL);
session_start();
if (!isset($_SESSION['user_id'])) {
header('Location: login.php');
exit;
}
$config = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$config['host'],
$config['dbname'],
$config['charset'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$config['user'],
$config['pass'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$loggedIn = true;
$pageTitle = 'Satellitenhimmel - AstroTools';
$bodyClass = 'satpass-page';
$userId = (int)$_SESSION['user_id'];
function h(?string $value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function 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)}&deg;.` +
`${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)}&deg;</td></tr>
<tr><td>Ende</td><td>${formatDateTime(pass.losTime)} (${azToCardinal(pass.losAzimuth ?? 0)})</td></tr>
<tr><td>Dauer</td><td>${formatDurationMs(pass.durationMs)}</td></tr>
${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'; ?>