Files
skyview.astronomiemuseum.de/public/satellitenhimmel.php
T
2026-04-02 11:17:37 +02:00

1031 lines
46 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');
}
// ── Satelliten aus DB laden ──────────────────────────────────────────────────
function tleChecksum(string $line): int
{
$sum = 0;
for ($i = 0; $i < 68; $i++) {
$c = $line[$i];
if (ctype_digit($c)) {
$sum += (int)$c;
} elseif ($c === '-') {
$sum += 1;
}
}
return $sum % 10;
}
function tleExp(float $val): string
{
if (abs($val) < 1e-99) {
return ' 00000-0';
}
$sign = $val >= 0 ? '+' : '-';
$abs = abs($val);
$exp = (int)floor(log10($abs)) + 1;
$mantissa = (int)round($abs / pow(10, $exp) * 1e5);
if ($mantissa >= 100000) {
$mantissa = 10000;
$exp++;
}
$expSign = $exp >= 0 ? '+' : '-';
return sprintf('%s%05d%s%d', $sign, $mantissa, $expSign, abs($exp));
}
function tleMmDot(float $val): string
{
$sign = $val >= 0 ? ' ' : '-';
return sprintf('%s.%08d', $sign, (int)round(abs($val) * 1e8));
}
function tleEpoch(string $epochStr): string
{
$dt = new DateTimeImmutable($epochStr, new DateTimeZone('UTC'));
$yy = (int)$dt->format('Y') % 100;
$jan1 = new DateTimeImmutable($dt->format('Y') . '-01-01 00:00:00', new DateTimeZone('UTC'));
$secs = (float)$dt->format('U') - (float)$jan1->format('U');
$day = $secs / 86400.0 + 1.0;
return sprintf('%02d%012.8f', $yy, $day);
}
function tleIntlDesig(?string $objectId): string
{
if (!$objectId) {
return str_repeat(' ', 8);
}
if (preg_match('/^(\d{4})-(\d+)([A-Z]*)$/i', trim($objectId), $m)) {
$yy = substr($m[1], 2, 2);
$launch = str_pad($m[2], 3, '0', STR_PAD_LEFT);
$piece = str_pad(strtoupper($m[3]), 3, ' ', STR_PAD_RIGHT);
return substr(str_pad($yy . $launch . $piece, 8, ' '), 0, 8);
}
return str_pad(substr($objectId, 0, 8), 8);
}
function buildLine1(array $sat, array $tle): string
{
$norad = (int)$sat['norad_cat_id'];
$class = in_array($sat['classification_type'] ?? '', ['U', 'C', 'S'], true)
? $sat['classification_type']
: 'U';
$line = sprintf(
'1 %05d%1s %-8s %14s %10s %8s %8s %1d %4d',
$norad,
$class,
tleIntlDesig($sat['object_id'] ?? null),
tleEpoch((string)$tle['epoch']),
tleMmDot((float)($tle['mean_motion_dot'] ?? 0)),
tleExp((float)($tle['mean_motion_ddot'] ?? 0)),
tleExp((float)($tle['bstar'] ?? 0)),
(int)($tle['ephemeris_type'] ?? 0),
(int)($tle['element_set_no'] ?? 0)
);
$line = str_pad($line, 68);
return $line . tleChecksum($line);
}
function buildLine2(array $sat, array $tle): string
{
$norad = (int)$sat['norad_cat_id'];
$ecc = (float)($tle['eccentricity'] ?? 0);
$eccStr = substr(sprintf('%.7f', $ecc), 2);
$line = sprintf(
'2 %05d %8.4f %8.4f %7s %8.4f %8.4f %11.8f%5d',
$norad,
(float)($tle['inclination'] ?? 0),
(float)($tle['ra_of_asc_node'] ?? 0),
$eccStr,
(float)($tle['arg_of_pericenter'] ?? 0),
(float)($tle['mean_anomaly'] ?? 0),
(float)($tle['mean_motion'] ?? 0),
(int)($tle['rev_at_epoch'] ?? 0)
);
$line = str_pad($line, 68);
return $line . tleChecksum($line);
}
// Standard-Standort vorab laden (bevor header.php eingebunden wird)
$defaultLocation = null;
$stmtDefLoc = $pdo->prepare(
'SELECT id, name, latitude, longitude, elevation, timezone
FROM app_user_locations
WHERE user_id = ? AND is_default = 1
LIMIT 1'
);
$stmtDefLoc->execute([$userId]);
$defaultLocation = $stmtDefLoc->fetch() ?: null;
$stmtTracked = $pdo->prepare("
SELECT
s.`id`,
s.`norad_cat_id`,
s.`object_name`,
s.`object_id`,
s.`classification_type`,
s.`std_mag`,
s.`rcs_size`,
s.`rcs`,
aus.`is_favorite`,
t.`epoch`,
t.`mean_motion`,
t.`eccentricity`,
t.`inclination`,
t.`ra_of_asc_node`,
t.`arg_of_pericenter`,
t.`mean_anomaly`,
t.`ephemeris_type`,
t.`element_set_no`,
t.`rev_at_epoch`,
t.`bstar`,
t.`mean_motion_dot`,
t.`mean_motion_ddot`
FROM `app_user_satellites` aus
INNER JOIN `sat_satellites` s
ON s.`id` = aus.`satellite_id`
INNER JOIN `sat_tle_current` t
ON t.`satellite_id` = s.`id`
WHERE aus.`user_id` = :user_id
AND t.`epoch` IS NOT NULL
AND t.`mean_motion` IS NOT NULL
ORDER BY aus.`is_favorite` DESC, s.`object_name` ASC
");
$stmtTracked->execute([':user_id' => $userId]);
$trackedSatellites = $stmtTracked->fetchAll();
$satellitePayload = [];
foreach ($trackedSatellites as $row) {
$satellitePayload[] = [
'id' => (int)$row['id'],
'norad_cat_id' => (int)$row['norad_cat_id'],
'object_name' => (string)$row['object_name'],
'is_favorite' => (int)$row['is_favorite'] === 1,
'std_mag' => $row['std_mag'] !== null ? (float)$row['std_mag'] : null,
'rcs_size' => $row['rcs_size'] !== null ? (string)$row['rcs_size'] : null,
'rcs' => $row['rcs'] !== null && (float)$row['rcs'] > 0 ? (float)$row['rcs'] : null,
'tle_line1' => buildLine1($row, $row),
'tle_line2' => buildLine2($row, $row),
];
}
// ── Sterne & Sternbilder ─────────────────────────────────────────────────────
$stmtStars = $pdo->query("
SELECT `proper`, `bf`, `ra`, `dec`, `mag`
FROM `star_hipparcos`
WHERE `mag` <= 5.0
AND `ra` IS NOT NULL
AND `dec` IS NOT NULL
ORDER BY `mag` ASC
");
$brightStars = $stmtStars->fetchAll();
$starPayload = array_map(fn($r) => [
'name' => (string)($r['proper'] ?: ($r['bf'] ?: '')),
'ra' => (float)$r['ra'] * 15.0,
'dec' => (float)$r['dec'],
'mag' => (float)$r['mag'],
], $brightStars);
$stmtLines = $pdo->query("
SELECT
cl.`id` AS line_id,
clp.`point_index`,
sh.`ra` * 15.0 AS ra_deg,
sh.`dec` AS dec_deg
FROM `constellation_lines` cl
INNER JOIN `constellation_line_points` clp ON clp.`line_id` = cl.`id`
INNER JOIN `star_hipparcos` sh ON sh.`hip` = clp.`hip_number`
WHERE sh.`ra` IS NOT NULL AND sh.`dec` IS NOT NULL
ORDER BY cl.`id`, clp.`point_index`
");
$lineRows = $stmtLines->fetchAll();
$constellationLines = [];
foreach ($lineRows as $row) {
$lid = (int)$row['line_id'];
$constellationLines[$lid][] = ['ra' => (float)$row['ra_deg'], 'dec' => (float)$row['dec_deg']];
}
$constellationPayload = array_values($constellationLines);
$stmtConstellationLabels = $pdo->query("
SELECT
clp.`constellation_id`,
clp.`position_index`,
clp.`ra_hours` * 15.0 AS ra_deg,
clp.`dec_degrees` AS dec_deg,
cn.`german_name`,
cn.`native_name`,
cn.`byname`
FROM `constellation_label_positions` clp
LEFT JOIN `constellation_names` cn
ON cn.`constellation_id` = clp.`constellation_id`
ORDER BY clp.`constellation_id`, clp.`position_index`
");
$constellationLabelRows = $stmtConstellationLabels->fetchAll();
$constellationLabelPayload = array_map(static function (array $row): array {
$label = trim((string)($row['german_name'] ?: $row['native_name'] ?: $row['byname'] ?: $row['constellation_id']));
return [
'id' => (string)$row['constellation_id'],
'label' => $label,
'ra' => (float)$row['ra_deg'],
'dec' => (float)$row['dec_deg'],
'position_index' => (int)$row['position_index'],
];
}, $constellationLabelRows);
// ── Python aufrufen ──────────────────────────────────────────────────────────
$pythonResult = null;
$pythonError = null;
if (!empty($satellitePayload) && !empty($defaultLocation['latitude'])) {
$loc = $defaultLocation;
$lat = (float)$loc['latitude'];
$lon = (float)$loc['longitude'];
$elev = (float)($loc['elevation'] ?? 0);
$tz = (string)($loc['timezone'] ?? 'Europe/Berlin');
// 7-Tage-Fenster: lokaler Tagesbeginn heute → +7 Tage
$tzObj = new DateTimeZone($tz);
$nowLocal = new DateTimeImmutable('now', $tzObj);
$dayStart = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $nowLocal->format('Y-m-d') . ' 00:00:00', $tzObj);
$dayEnd = $dayStart->modify('+7 days 23:59:59');
$windowStart = $dayStart->setTimezone(new DateTimeZone('UTC'))->format(DateTime::ATOM);
$windowEnd = $dayEnd->setTimezone(new DateTimeZone('UTC'))->format(DateTime::ATOM);
$scriptPath = realpath(__DIR__ . '/py/api.py');
$satellitesJson = json_encode($satellitePayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$args = [
'/usr/bin/python',
escapeshellarg($scriptPath),
escapeshellarg('satellite_passes'),
escapeshellarg((string)$lat),
escapeshellarg((string)$lon),
escapeshellarg((string)$elev),
escapeshellarg($tz),
escapeshellarg($windowStart),
escapeshellarg($windowEnd),
escapeshellarg($satellitesJson),
];
$command = implode(' ', $args) . ' 2>&1';
$output = [];
$code = 0;
exec($command, $output, $code);
$joined = implode('', $output);
$decoded = json_decode($joined, true);
if (is_array($decoded) && ($decoded['ok'] ?? false)) {
$pythonResult = $decoded;
} else {
$pythonError = $joined ?: 'Leere Antwort von Python.';
}
}
?>
<?php require __DIR__ . '/header.php'; ?>
<?php
$pageLocation = null;
if (!empty($currentLocation) && is_array($currentLocation)) {
$pageLocation = [
'name' => (string)($currentLocation['name'] ?? ''),
'latitude' => isset($currentLocation['latitude']) ? (float)$currentLocation['latitude'] : null,
'longitude' => isset($currentLocation['longitude']) ? (float)$currentLocation['longitude'] : null,
'elevation' => isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float)$currentLocation['elevation'] : 0.0,
'timezone' => (string)($currentLocation['timezone'] ?? 'Europe/Berlin'),
];
}
?>
<div class="satpass-tool">
<section class="card satpass-card">
<p class="satpass-intro">
Berechnet fuer den heutigen Tag den naechsten Ueberflug deiner ausgewaehlten Satelliten
und zeichnet deren Bahn auf einer Zenitkarte fuer deinen Standard-Standort.
</p>
</section>
<?php if ($pageLocation === null || $pageLocation['latitude'] === null || $pageLocation['longitude'] === null): ?>
<section class="card satpass-card">
<h2>Kein Standard-Standort gesetzt</h2>
<p class="hint">
Bitte waehle in den <a href="settings.php">Einstellungen</a> einen Standard-Standort aus.
</p>
</section>
<?php elseif (empty($satellitePayload)): ?>
<section class="card satpass-card">
<h2>Keine Satelliten ausgewaehlt</h2>
<p class="hint">
Es sind noch keine ausgewaehlten Satelliten mit aktuellem TLE vorhanden.
Bitte fuege in <a href="my_favorites.php">Favoriten</a> Satelliten hinzu.
</p>
</section>
<?php elseif ($pythonError !== null): ?>
<section class="card satpass-card">
<h2>Berechnungsfehler</h2>
<ul class="msg-error"><li><?= h($pythonError) ?></li></ul>
</section>
<?php else: ?>
<section class="card satpass-card satpass-head">
<div class="satpass-head-item">
<span class="satpass-label">Standort</span>
<strong><?= h($pageLocation['name']) ?></strong>
<span><?= number_format($pageLocation['latitude'], 4, '.', '') ?>deg N / <?= number_format($pageLocation['longitude'], 4, '.', '') ?>deg O / <?= number_format($pageLocation['elevation'], 0, '.', '') ?> m</span>
</div>
<div class="satpass-head-item">
<span class="satpass-label">Zeitzone</span>
<strong><?= h($pageLocation['timezone']) ?></strong>
<span>Berechnung serverbasiert (Python/sgp4)</span>
</div>
<div class="satpass-head-item">
<span class="satpass-label">Satelliten</span>
<strong><?= count($satellitePayload) ?></strong>
<span>mit aktuellem TLE</span>
</div>
</section>
<section class="card satpass-card">
<div id="satpassStatus" class="hint">Lade Ueberfluege...</div>
<div class="satpass-main-layout">
<div class="satpass-sidebar">
<div class="form-group">
<label for="satSelect">Satellit</label>
<select id="satSelect" class="satpass-select">
<option value="">– Satellit waehlen –</option>
</select>
</div>
<div class="form-group">
<label for="passSelect">Ueberflug</label>
<select id="passSelect" class="satpass-select" disabled>
<option value="">– zuerst Satellit waehlen –</option>
</select>
</div>
<div id="satpassPassInfo" class="satpass-pass-info"></div>
</div>
<div class="satpass-map-panel">
<div class="satpass-map-controls">
<button type="button" class="btn btn-secondary btn-sm" id="satpassExportBtn">PNG Export</button>
<button type="button" class="btn btn-secondary btn-sm" id="satpassInvertBtn">Invertiert</button>
</div>
<p id="satpassSkyTime" class="satpass-sky-time"></p>
<div class="satpass-canvas-wrap">
<canvas id="satSkyCanvas"></canvas>
</div>
</div>
</div>
</section>
<script>
const SITE_LOCATION = <?= json_encode($pageLocation, JSON_UNESCAPED_SLASHES) ?>;
const BRIGHT_STARS = <?= json_encode($starPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
const CONSTELLATION_LINES = <?= json_encode($constellationPayload, JSON_UNESCAPED_SLASHES) ?>;
const CONSTELLATION_LABELS = <?= json_encode($constellationLabelPayload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
// Pre-computed pass data from Python
const PYTHON_RESULTS = <?= json_encode($pythonResult['results'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
const WINDOW_START = <?= json_encode($pythonResult['window_start'] ?? null) ?>;
const WINDOW_END = <?= json_encode($pythonResult['window_end'] ?? null) ?>;
// Parse ISO times in each pass and path point to Date objects
PYTHON_RESULTS.forEach(sat => {
sat.passes.forEach(pass => {
pass.aosTime = new Date(pass.aos_utc);
pass.losTime = new Date(pass.los_utc);
pass.peakTime = new Date(pass.peak_utc);
pass.aosAzimuth = pass.aos_az;
pass.losAzimuth = pass.los_az;
pass.peakElevation = pass.peak_el;
pass.peakAzimuth = pass.peak_az;
pass.durationMs = (pass.losTime - pass.aosTime);
pass.peakMag = pass.peak_mag;
pass.shadowEntry = pass.shadow_entry
? { time: new Date(pass.shadow_entry.t), azimuthDeg: pass.shadow_entry.az, elevationDeg: pass.shadow_entry.el }
: null;
pass.color = sat.color;
pass.path = pass.path.map(pt => ({
time: new Date(pt.t),
azimuthDeg: pt.az,
elevationDeg: pt.el,
shadow: pt.shadow,
color: sat.color,
}));
});
});
</script>
<script>
// ── Rendering + UI (kein satellite.js noetig) ──────────────────────
const statusEl = document.getElementById('satpassStatus');
const canvas = document.getElementById('satSkyCanvas');
const exportBtn = document.getElementById('satpassExportBtn');
const invertBtn = document.getElementById('satpassInvertBtn');
const skyTimeEl = document.getElementById('satpassSkyTime');
const satSelectEl = document.getElementById('satSelect');
const passSelectEl = document.getElementById('passSelect');
const passInfoEl = document.getElementById('satpassPassInfo');
const ctx = canvas.getContext('2d');
const exportLogoImg = new Image();
exportLogoImg.src = 'images/logoAM.png';
let invertSky = false;
let exportOpaqueSky = false;
let lastReferenceTime = null;
let lastShowStars = false;
let lastSelectedSatelliteName = '';
let lastSelectedPass = null;
let lastSkyData = [];
function deg2rad(deg) { return deg * Math.PI / 180; }
function rad2deg(rad) { return rad * 180 / Math.PI; }
function getGMSTDeg(date) {
const jd = date.getTime() / 86400000.0 + 2440587.5;
let gmst = 280.46061837 + 360.98564736629 * (jd - 2451545.0);
return ((gmst % 360) + 360) % 360;
}
function equatorialToHorizontal(raDeg, decDeg, latDeg, lonDeg, date) {
const lst = ((getGMSTDeg(date) + lonDeg) % 360 + 360) % 360;
const H = deg2rad(lst - raDeg);
const dec = deg2rad(decDeg);
const phi = deg2rad(latDeg);
const sinAlt = Math.sin(phi)*Math.sin(dec) + Math.cos(phi)*Math.cos(dec)*Math.cos(H);
const altRad = Math.asin(Math.max(-1, Math.min(1, sinAlt)));
const cosAlt = Math.cos(altRad);
let azRad = 0;
if (cosAlt > 1e-10) {
const cosAz = (Math.sin(dec) - Math.sin(phi)*sinAlt) / (Math.cos(phi)*cosAlt);
azRad = Math.acos(Math.max(-1, Math.min(1, cosAz)));
if (Math.sin(H) > 0) azRad = 2*Math.PI - azRad;
}
return { altDeg: rad2deg(altRad), azimuthDeg: rad2deg(azRad) };
}
function getTzParts(date, timeZone) {
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone, year:'numeric', month:'2-digit', day:'2-digit',
hour:'2-digit', minute:'2-digit', second:'2-digit', hourCycle:'h23'
}).formatToParts(date);
const r = {};
for (const p of parts) if (p.type !== 'literal') r[p.type] = Number(p.value);
return r;
}
function formatTime(date) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: SITE_LOCATION.timezone, hour:'2-digit', minute:'2-digit', hourCycle:'h23'
}).format(date);
}
function formatDateLocal(date) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: SITE_LOCATION.timezone, day:'2-digit', month:'2-digit', year:'numeric'
}).format(date);
}
function formatDateTime(date) { return `${formatDateLocal(date)} ${formatTime(date)}`; }
function formatWeekdayShort(date) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: SITE_LOCATION.timezone, weekday:'short'
}).format(date).replace('.', '');
}
function padNumber(v) { return String(v).padStart(2, '0'); }
function formatDurationMs(ms) {
const totalMinutes = Math.max(0, Math.round(ms / 60000));
const h = Math.floor(totalMinutes / 60), m = totalMinutes % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function formatDurationLong(ms) {
const totalMinutes = Math.max(0, Math.round(ms / 60000));
const h = Math.floor(totalMinutes / 60), m = totalMinutes % 60;
if (h > 0 && m > 0) return `${h} ${h===1?'Stunde':'Stunden'} ${m} Minuten`;
if (h > 0) return `${h} ${h===1?'Stunde':'Stunden'}`;
return `${m} Minuten`;
}
function azToCardinal(az) {
const dirs = ['N','NO','O','SO','S','SW','W','NW'];
return dirs[Math.round(az/45) % 8];
}
function azToDirectionName(az) {
const dirs = ['Norden','Nordosten','Osten','Suedosten','Sueden','Suedwesten','Westen','Nordwesten'];
return dirs[Math.round(az/45) % 8];
}
function altitudeToPhrase(el) {
if (el < 15) return 'nahe dem Horizont';
if (el < 35) return 'halbhoch';
return 'hoch am Himmel';
}
function getSunAltitudeDeg(date) {
// Simple sun position (low precision, good enough for day/twilight/night)
const jd = date.getTime() / 86400000.0 + 2440587.5;
const n = jd - 2451545.0;
const L = (280.46 + 0.9856474 * n) % 360;
const g = deg2rad((357.528 + 0.9856003 * n) % 360);
const lam = deg2rad(L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2*g));
const eps = deg2rad(23.439 - 0.0000004 * n);
const raDeg = rad2deg(Math.atan2(Math.cos(eps)*Math.sin(lam), Math.cos(lam)));
const decDeg = rad2deg(Math.asin(Math.sin(eps)*Math.sin(lam)));
return equatorialToHorizontal(raDeg, decDeg,
Number(SITE_LOCATION.latitude), Number(SITE_LOCATION.longitude), date).altDeg;
}
function classifySkyBrightness(sunAltDeg) {
if (sunAltDeg > -4) return {key:'day', label:'Tag'};
if (sunAltDeg >= -12) return {key:'twilight', label:'Daemmerung'};
return {key:'night', label:'Nacht'};
}
function classifyVisibility(pass) {
const sunAltDeg = getSunAltitudeDeg(pass.peakTime);
const sky = classifySkyBrightness(sunAltDeg);
const elevDeg = pass.peakElevation;
let sunBand = 5;
if (sunAltDeg > -4) sunBand = 0;
else if (sunAltDeg > -6) sunBand = 1;
else if (sunAltDeg > -8) sunBand = 2;
else if (sunAltDeg > -10) sunBand = 3;
else if (sunAltDeg > -12) sunBand = 4;
let elevBand = 0;
if (elevDeg >= 60) elevBand = 4;
else if (elevDeg >= 40) elevBand = 3;
else if (elevDeg >= 20) elevBand = 2;
else if (elevDeg >= 10) elevBand = 1;
const matrix = [
['ungenuegend','ungenuegend','mangelhaft','mangelhaft','ausreichend'],
['ungenuegend','mangelhaft','ausreichend','befriedigend','gut'],
['mangelhaft','ausreichend','gut','sehr gut','perfekt'],
['mangelhaft','befriedigend','gut','sehr gut','perfekt'],
['ausreichend','befriedigend','gut','sehr gut','perfekt'],
['ausreichend','befriedigend','gut','gut','sehr gut']
];
const label = matrix[sunBand][elevBand];
return {key: label.replace(/\s+/g,''), label, sky, sunAltDeg};
}
function formatVisibilityBadge(v) {
return `<span class="satpass-visibility satpass-visibility-${v.key}">${v.label}</span>`;
}
function formatMag(result) {
if (!result) return '–';
return (result.estimated ? '~' : '') + result.mag.toFixed(1);
}
function buildPathDescription(path) {
const vis = path.filter(p => p.elevationDeg >= 0);
if (!vis.length) return '';
const descriptors = [];
vis.forEach(p => {
const text = `${altitudeToPhrase(p.elevationDeg)} im ${azToDirectionName(p.azimuthDeg)}`;
if (descriptors[descriptors.length-1] !== text) descriptors.push(text);
});
const limited = descriptors.length > 8
? Array.from({length:8}, (_,i) => {
const pos = Math.round(i*(descriptors.length-1)/7);
return descriptors[pos];
}).filter((v,i,a) => i===0 || v!==a[i-1])
: descriptors;
if (limited.length === 1) return `Sie ist dabei ${limited[0]} zu beobachten.`;
if (limited.length === 2) return `Sie ist zunaechst ${limited[0]} zu beobachten und zuletzt ${limited[1]}.`;
return `Sie ist zunaechst ${limited[0]} zu beobachten, spaeter ${limited.slice(1,-1).join(', ')} und zuletzt ${limited[limited.length-1]}.`;
}
function getVisiblePhase(pass) {
const visibleEndTime = pass.shadowEntry && pass.shadowEntry.time < pass.losTime
? pass.shadowEntry.time : pass.losTime;
const visiblePath = pass.path
.filter(p => p.time <= visibleEndTime && p.elevationDeg >= 0)
.map(p => ({...p}));
return {
visibleEndTime,
visibleDurationMs: Math.max(0, visibleEndTime - pass.aosTime),
visiblePath
};
}
function buildObservationSummary(satName, pass, visibility) {
if (visibility.sky.key === 'day') return `${satName} ist bei diesem Ueberflug tagsueber nicht zu sehen.`;
const visiblePhase = getVisiblePhase(pass);
const pathText = buildPathDescription(visiblePhase.visiblePath);
const shadowText = pass.shadowEntry
? ` Sie tritt um ${formatTime(pass.shadowEntry.time)} Uhr in den Erdschatten ein.` : '';
const magText = pass.peakMag !== null
? ` Ihre maximale Helligkeit betraegt ${formatMag(pass.peakMag)} mag.` : '';
return `Die Rahmenbedingungen fuer die Beobachtung von ${satName} am ${formatDateLocal(pass.aosTime)} (${formatWeekdayShort(pass.aosTime)}) um ${formatTime(pass.aosTime)} Uhr sind ${visibility.label}. `
+ `Der Ueberflug ist ${formatDurationLong(visiblePhase.visibleDurationMs)} lang und bis ${formatTime(visiblePhase.visibleEndTime)} Uhr zu sehen. `
+ `${pathText} Die maximale Horizonthoehe betraegt ${pass.peakElevation.toFixed(0)}&deg;.`
+ `${shadowText}${magText}`;
}
// ── Sky canvas rendering ───────────────────────────────────────────
function projectPoint(azimuthDeg, elevationDeg, radius, cx, cy) {
const r = ((90 - elevationDeg) / 90) * radius;
const a = deg2rad(azimuthDeg);
return { x: cx + r*Math.sin(a), y: cy - r*Math.cos(a) };
}
function drawEndpointLabel(point, text, color, cx, cy, radius) {
const offsetX = point.x < cx ? 10 : -10;
const offsetY = point.y < cy ? -10 : 16;
const fontSize = 11, paddingX = 5, paddingY = 3;
ctx.save();
ctx.font = `${fontSize}px Segoe UI`;
ctx.textBaseline = 'middle';
const textWidth = ctx.measureText(text).width;
let labelX = point.x + offsetX;
let labelY = point.y + offsetY;
if (offsetX < 0) labelX -= textWidth;
const minX = cx - radius + 6, maxX = cx + radius - textWidth - paddingX*2 - 6;
const minY = cy - radius + fontSize + paddingY + 6, maxY = cy + radius - fontSize - paddingY - 6;
labelX = Math.max(minX, Math.min(maxX, labelX));
labelY = Math.max(minY, Math.min(maxY, labelY));
ctx.fillStyle = invertSky ? 'rgba(255,255,255,0.88)' : 'rgba(8,12,20,0.82)';
ctx.fillRect(labelX-paddingX, labelY-(fontSize/2)-paddingY, textWidth+paddingX*2, fontSize+paddingY*2);
ctx.fillStyle = color;
ctx.textAlign = 'left';
ctx.fillText(text, labelX, labelY);
ctx.restore();
}
function rectsOverlap(a, b, margin=0) {
return !(a.right+margin < b.left || a.left > b.right+margin || a.bottom+margin < b.top || a.top > b.bottom+margin);
}
function buildTextRect(x, y, width, fontSize) {
return {left:x, top:y-fontSize, right:x+width, bottom:y+2};
}
function findFreeLabelPlacement(anchorX, anchorY, width, fontSize, occupiedRects, cx, cy, radius, preferredAlign='left') {
const candidates = [
{dx:8,dy:-8,align:preferredAlign},{dx:8,dy:12,align:preferredAlign},
{dx:-8,dy:-8,align:'right'},{dx:-8,dy:12,align:'right'},
{dx:0,dy:-12,align:'center'},{dx:0,dy:16,align:'center'},
{dx:18,dy:0,align:'left'},{dx:-18,dy:0,align:'right'},
{dx:18,dy:-16,align:'left'},{dx:-18,dy:-16,align:'right'},
{dx:18,dy:18,align:'left'},{dx:-18,dy:18,align:'right'}
];
const minX=cx-radius+6, maxX=cx+radius-width-6;
const minY=cy-radius+fontSize+6, maxY=cy+radius-4;
let fallback=null;
for (const c of candidates) {
let x=anchorX+c.dx, y=anchorY+c.dy;
if (c.align==='center') x-=width/2;
else if (c.align==='right') x-=width;
x=Math.max(minX,Math.min(maxX,x)); y=Math.max(minY,Math.min(maxY,y));
const rect=buildTextRect(x,y,width,fontSize);
const collides=occupiedRects.some(o=>rectsOverlap(rect,o,2));
if (!fallback) fallback={x,y,align:c.align,rect};
if (!collides) return {x,y,align:c.align,rect};
}
return fallback;
}
function drawSky(passes, referenceTime=null, showStars=true) {
lastReferenceTime = referenceTime;
lastShowStars = showStars;
const box = canvas.parentElement.getBoundingClientRect();
const size = Math.max(320, Math.floor(Math.min(box.width, 820)));
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = `${size}px`;
canvas.style.height = `${size}px`;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,size,size);
const cx=size/2, cy=size/2, radius=size*0.43;
const palette = invertSky ? {
skyFill: exportOpaqueSky ? 'rgb(252,252,248)' : 'rgba(252,252,248,0.98)',
grid:'rgba(60,64,76,0.26)', gridText:'rgba(40,44,56,0.90)', cardinal:'#171b24',
constellationLine:'rgba(88,96,118,0.76)', constellationLabel:'rgba(44,50,66,0.92)',
star: a=>`rgba(10,12,18,${Math.max(0.72,a).toFixed(2)})`,
starLabel:'rgba(18,22,32,0.94)', shadowLine:'rgba(150,154,166,0.82)',
shadowMarker:'rgba(118,124,138,0.95)', shadowMarkerStroke:'rgba(24,28,36,0.95)',
shadowLabel:'#111722'
} : {
skyFill: exportOpaqueSky ? 'rgb(4,6,20)' : 'rgba(4,6,20,0.55)',
grid:'rgba(201,168,76,0.18)', gridText:'rgba(136,153,187,0.85)', cardinal:'#f0d990',
constellationLine:'rgba(132,152,214,0.58)', constellationLabel:'rgba(120,136,186,0.68)',
star: a=>`rgba(220,230,255,${a.toFixed(2)})`,
starLabel:'rgba(180,200,255,0.70)', shadowLine:'rgba(140,140,150,0.5)',
shadowMarker:'rgba(155,165,190,0.95)', shadowMarkerStroke:'rgba(15,20,32,0.95)',
shadowLabel:'#d7dcec'
};
ctx.fillStyle = palette.skyFill;
ctx.beginPath(); ctx.arc(cx,cy,radius,0,Math.PI*2); ctx.fill();
ctx.strokeStyle = palette.grid; ctx.lineWidth = 1;
[radius, radius*(2/3), radius*(1/3)].forEach((r,i) => {
ctx.beginPath(); ctx.arc(cx,cy,r,0,Math.PI*2); ctx.stroke();
ctx.fillStyle = palette.gridText; ctx.font = '12px Segoe UI';
ctx.fillText(`${i===0?0:i===1?30:60}°`, cx+6, cy-r+14);
});
ctx.strokeStyle = palette.grid;
ctx.beginPath();
ctx.moveTo(cx,cy-radius); ctx.lineTo(cx,cy+radius);
ctx.moveTo(cx-radius,cy); ctx.lineTo(cx+radius,cy);
ctx.stroke();
ctx.fillStyle = palette.cardinal; ctx.font = '700 14px Segoe UI'; ctx.textAlign = 'center';
ctx.fillText('N', cx, cy-radius+18); ctx.fillText('S', cx, cy+radius-10);
ctx.fillText('W', cx-radius+14, cy+5); ctx.fillText('O', cx+radius-14, cy+5);
ctx.fillText('Zenit', cx, cy-8);
const lat = Number(SITE_LOCATION.latitude), lon = Number(SITE_LOCATION.longitude);
const starTime = referenceTime ?? new Date();
// Sternbildlinien
ctx.strokeStyle = palette.constellationLine; ctx.lineWidth = 1.15; ctx.setLineDash([]);
for (const line of CONSTELLATION_LINES) {
if (line.length < 2) continue;
let started = false;
for (let i=0; i<line.length; i++) {
const pos = equatorialToHorizontal(line[i].ra, line[i].dec, lat, lon, starTime);
if (pos.altDeg < 0) { started=false; continue; }
const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy);
if (!started) { ctx.beginPath(); ctx.moveTo(pt.x,pt.y); started=true; }
else { ctx.lineTo(pt.x,pt.y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(pt.x,pt.y); }
}
}
if (showStars) {
skyTimeEl.textContent = referenceTime
? `Sternpositionen zum Maximum: ${formatTime(referenceTime)} Uhr`
: `Sternpositionen: jetzt (${formatTime(starTime)} Uhr)`;
const occupiedLabelRects = [];
for (const star of BRIGHT_STARS) {
const pos = equatorialToHorizontal(star.ra, star.dec, lat, lon, starTime);
if (pos.altDeg < 0) continue;
const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy);
const r = Math.max(1.0, Math.min(5.0, 4.5 - star.mag*1.0));
const a = Math.max(0.5, Math.min(0.95, 0.95 - star.mag*0.12));
ctx.beginPath(); ctx.arc(pt.x,pt.y,r,0,Math.PI*2);
ctx.fillStyle = palette.star(a); ctx.fill();
if (star.name && star.mag <= 2.0) {
ctx.fillStyle = palette.starLabel; ctx.font = '10px Segoe UI'; ctx.textAlign = 'left';
const lx = pt.x+r+3, ly = pt.y+3;
const lw = ctx.measureText(star.name).width;
ctx.fillText(star.name, lx, ly);
occupiedLabelRects.push(buildTextRect(lx,ly,lw,10));
}
}
ctx.font = '11px Segoe UI'; ctx.fillStyle = palette.constellationLabel;
for (const con of CONSTELLATION_LABELS) {
const pos = equatorialToHorizontal(con.ra, con.dec, lat, lon, starTime);
if (pos.altDeg < 0) continue;
const pt = projectPoint(pos.azimuthDeg, pos.altDeg, radius, cx, cy);
const lw = ctx.measureText(con.label).width;
const pl = findFreeLabelPlacement(pt.x,pt.y,lw,11,occupiedLabelRects,cx,cy,radius,'center');
if (!pl) continue;
ctx.textAlign = 'left'; ctx.fillText(con.label, pl.x, pl.y);
occupiedLabelRects.push(pl.rect);
}
} else {
skyTimeEl.textContent = '';
}
// Satellitenbahnen
passes.filter(item=>item.pass).forEach(item => {
const pass = item.pass;
const pts = pass.path.filter(p => p.elevationDeg >= 0);
if (pts.length < 2) return;
ctx.lineWidth = 2.5; ctx.lineCap = 'round';
for (let i=0; i<pts.length-1; i++) {
const p0 = projectPoint(pts[i].azimuthDeg, pts[i].elevationDeg, radius, cx, cy);
const p1 = projectPoint(pts[i+1].azimuthDeg, pts[i+1].elevationDeg, radius, cx, cy);
const inShadow = pts[i].shadow;
const trackCol = invertSky ? 'rgba(8,10,14,0.96)' : PYTHON_RESULTS[0]?.color ?? '#f0d990';
ctx.beginPath(); ctx.moveTo(p0.x,p0.y); ctx.lineTo(p1.x,p1.y);
if (inShadow) {
ctx.lineWidth = 1.0;
ctx.strokeStyle = palette.shadowLine;
} else {
ctx.lineWidth = invertSky ? 3.2 : 2.5;
ctx.strokeStyle = trackCol;
}
ctx.stroke();
}
const startPt = projectPoint(pts[0].azimuthDeg, pts[0].elevationDeg, radius, cx, cy);
const endPt = projectPoint(pts[pts.length-1].azimuthDeg, pts[pts.length-1].elevationDeg, radius, cx, cy);
const trackColor = invertSky ? 'rgba(8,10,14,0.96)' : (PYTHON_RESULTS[0]?.color ?? '#f0d990');
ctx.beginPath(); ctx.arc(startPt.x,startPt.y,3.5,0,Math.PI*2); ctx.fillStyle=trackColor; ctx.fill();
ctx.beginPath(); ctx.arc(endPt.x,endPt.y,3.5,0,Math.PI*2); ctx.fillStyle=trackColor; ctx.fill();
drawEndpointLabel(startPt, formatTime(pass.aosTime), trackColor, cx, cy, radius);
drawEndpointLabel(endPt, formatTime(pass.losTime), trackColor, cx, cy, radius);
if (pass.shadowEntry) {
const sp = projectPoint(pass.shadowEntry.azimuthDeg, pass.shadowEntry.elevationDeg, radius, cx, cy);
ctx.beginPath(); ctx.arc(sp.x,sp.y,4,0,Math.PI*2);
ctx.fillStyle=palette.shadowMarker; ctx.fill();
ctx.strokeStyle=palette.shadowMarkerStroke; ctx.lineWidth=1.5; ctx.stroke();
drawEndpointLabel(sp, `ES ${formatTime(pass.shadowEntry.time)}`, palette.shadowLabel, cx, cy, radius);
}
});
}
// ── Dropdowns ─────────────────────────────────────────────────────
function selectPass(satIdx, passIdx) {
const satResult = PYTHON_RESULTS[satIdx];
const pass = satResult?.passes[passIdx];
if (!pass) return;
lastSelectedSatelliteName = satResult.name;
lastSelectedPass = pass;
const visibility = classifyVisibility(pass);
const observationSummary = buildObservationSummary(satResult.name, pass, visibility);
passInfoEl.innerHTML = `
<table class="data-table mb-sm">
<tr><td>Beginn</td><td>${formatDateTime(pass.aosTime)} (${azToCardinal(pass.aosAzimuth ?? 0)})</td></tr>
<tr><td>Maximum</td><td>${formatDateTime(pass.peakTime)} / ${pass.peakElevation.toFixed(1)}&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() {
const now = new Date();
const rangeStart = WINDOW_START ? new Intl.DateTimeFormat('de-DE', {
timeZone: SITE_LOCATION.timezone, day:'2-digit', month:'2-digit', year:'numeric'
}).format(new Date(WINDOW_START)) : '';
const rangeEnd = WINDOW_END ? new Intl.DateTimeFormat('de-DE', {
timeZone: SITE_LOCATION.timezone, day:'2-digit', month:'2-digit', year:'numeric'
}).format(new Date(WINDOW_END)) : '';
satSelectEl.innerHTML = '<option value="">– Satellit waehlen –</option>';
PYTHON_RESULTS.forEach((sat, satIdx) => {
const upcomingCount = sat.passes.filter(p => p.losTime > now).length;
const opt = document.createElement('option');
opt.value = satIdx;
opt.textContent = `${sat.name} (${upcomingCount} in den naechsten 7 Tagen)`;
satSelectEl.appendChild(opt);
});
satSelectEl.onchange = () => {
const satIdx = parseInt(satSelectEl.value, 10);
passSelectEl.innerHTML = '';
passInfoEl.innerHTML = '';
if (isNaN(satIdx)) {
passSelectEl.disabled = true;
passSelectEl.innerHTML = '<option value="">– zuerst Satellit waehlen –</option>';
drawSky([], null, false);
return;
}
const sat = PYTHON_RESULTS[satIdx];
passSelectEl.disabled = false;
passSelectEl.innerHTML = '<option value="">– Ueberflug waehlen –</option>';
sat.passes.forEach((p, passIdx) => {
const isPast = p.losTime <= now;
const opt = document.createElement('option');
opt.value = passIdx;
opt.textContent = `${formatDateLocal(p.aosTime)} ${formatTime(p.aosTime)} – ${formatTime(p.losTime)} | max ${p.peakElevation.toFixed(0)}°${p.peakMag!==null?' | '+formatMag(p.peakMag)+' mag':''}${isPast?' (vorbei)':''}`;
if (isPast) opt.style.opacity = '0.5';
passSelectEl.appendChild(opt);
});
const nextIdx = sat.passes.findIndex(p => p.losTime > now);
if (nextIdx >= 0) { passSelectEl.value = nextIdx; selectPass(satIdx, nextIdx); }
};
passSelectEl.onchange = () => {
const satIdx = parseInt(satSelectEl.value, 10);
const passIdx = parseInt(passSelectEl.value, 10);
if (!isNaN(satIdx) && !isNaN(passIdx)) selectPass(satIdx, passIdx);
};
statusEl.textContent = `Berechnet vom heutigen lokalen Tag 00:00 Uhr bis in 7 Tagen (${rangeStart} bis ${rangeEnd}).`;
}
// ── PNG Export ────────────────────────────────────────────────────
function canvasToBlob(targetCanvas, type='image/png') {
return new Promise((resolve, reject) => {
targetCanvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('PNG-Export fehlgeschlagen.')), type);
});
}
function ensureExportLogoLoaded() {
return new Promise((resolve, reject) => {
if (exportLogoImg.complete && exportLogoImg.naturalWidth > 0) { resolve(); return; }
const onLoad = () => { exportLogoImg.removeEventListener('load', onLoad); exportLogoImg.removeEventListener('error', onErr); resolve(); };
const onErr = () => { exportLogoImg.removeEventListener('load', onLoad); exportLogoImg.removeEventListener('error', onErr); reject(new Error('Logo nicht ladbar.')); };
exportLogoImg.addEventListener('load', onLoad, {once:true});
exportLogoImg.addEventListener('error', onErr, {once:true});
});
}
function createSatpassExportFileName() {
const date = lastSelectedPass?.aosTime instanceof Date ? lastSelectedPass.aosTime : new Date();
const parts = getTzParts(date, SITE_LOCATION.timezone);
const name = (lastSelectedSatelliteName || 'satellit').toLowerCase().replace(/[^a-z0-9]+/gi,'_').replace(/^_+|_+$/g,'') || 'satellit';
return `satellitenhimmel_${name}_${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}_${padNumber(parts.hour)}-${padNumber(parts.minute)}.png`;
}
async function exportSatpassPng() {
const prevExport = exportOpaqueSky;
try {
exportOpaqueSky = true;
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
const ec = document.createElement('canvas');
ec.width = canvas.width; ec.height = canvas.height;
const ectx = ec.getContext('2d');
ectx.fillStyle = '#ffffff'; ectx.fillRect(0,0,ec.width,ec.height);
ectx.drawImage(canvas, 0, 0);
try {
await ensureExportLogoLoaded();
const margin = Math.round(ec.width*0.025), lw = Math.round(ec.width*0.16);
const lh = Math.round(lw*(254/400));
ectx.drawImage(exportLogoImg, ec.width-lw-margin, ec.height-lh-margin, lw, lh);
} catch {}
const blob = await canvasToBlob(ec, 'image/png');
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = createSatpassExportFileName();
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
} catch (err) {
console.error(err);
statusEl.textContent = 'PNG-Export fehlgeschlagen.';
} finally {
exportOpaqueSky = prevExport;
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
}
}
// ── Init ──────────────────────────────────────────────────────────
window.addEventListener('resize', () => drawSky(lastSkyData, lastReferenceTime, lastShowStars));
invertBtn?.addEventListener('click', () => {
invertSky = !invertSky;
invertBtn.classList.toggle('is-active', invertSky);
drawSky(lastSkyData, lastReferenceTime, lastShowStars);
});
exportBtn?.addEventListener('click', () => exportSatpassPng().catch(err => {
console.error(err); statusEl.textContent = 'PNG-Export fehlgeschlagen.';
}));
buildDropdowns();
drawSky([], null, false);
</script>
<?php endif; ?>
</div>
<?php require __DIR__ . '/footer.php'; ?>