Files

2605 lines
102 KiB
PHP
Raw Permalink 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('memory_limit', '512M');
session_start();
$loggedIn = isset($_SESSION['user_id']);
$starsApiRequested = isset($_GET['stars_api']) && (string)$_GET['stars_api'] === '1';
$pageTitle = 'Sternenhimmel - AstroTools';
$bodyClass = 'sternenhimmel-page';
$constellationData = ['constellations' => []];
$constellationDataError = null;
$starDataError = null;
$messierData = [];
$messierDataError = null;
$availableLocations = [];
$starDbMagLimit = 12.0;
try {
$dbConfig = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$dbConfig['host'],
$dbConfig['dbname'],
$dbConfig['charset'] ?? 'utf8mb4'
);
$planetariumPdo = new PDO($dsn, $dbConfig['user'], $dbConfig['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$constellations = [];
$ensureConstellation = static function (string $id) use (&$constellations): void {
if (!isset($constellations[$id])) {
$constellations[$id] = [
'id' => $id,
'lines' => [],
'label_positions' => [],
'common_name' => [
'german' => null,
'native' => null,
'byname' => null,
'context' => null,
],
];
}
};
$nameStmt = $planetariumPdo->query(
"SELECT constellation_id, german_name, native_name, byname, context_text
FROM constellation_names
ORDER BY constellation_id"
);
foreach ($nameStmt as $row) {
$id = (string) $row['constellation_id'];
$ensureConstellation($id);
$constellations[$id]['common_name'] = [
'german' => $row['german_name'] !== null ? (string) $row['german_name'] : null,
'native' => $row['native_name'] !== null ? (string) $row['native_name'] : null,
'byname' => $row['byname'] !== null ? (string) $row['byname'] : null,
'context' => $row['context_text'] !== null ? (string) $row['context_text'] : null,
];
}
$labelStmt = $planetariumPdo->query(
"SELECT constellation_id, position_index, ra_hours, dec_degrees
FROM constellation_label_positions
ORDER BY constellation_id, position_index"
);
foreach ($labelStmt as $row) {
$id = (string) $row['constellation_id'];
$ensureConstellation($id);
$constellations[$id]['label_positions'][] = [
(float) $row['ra_hours'],
(float) $row['dec_degrees'],
];
}
$lineStmt = $planetariumPdo->query(
"SELECT cl.constellation_id, cl.line_index, clp.point_index, clp.hip_number
FROM constellation_lines cl
INNER JOIN constellation_line_points clp ON clp.line_id = cl.id
ORDER BY cl.constellation_id, cl.line_index, clp.point_index"
);
foreach ($lineStmt as $row) {
$id = (string) $row['constellation_id'];
$lineIndex = (int) $row['line_index'];
$ensureConstellation($id);
$constellations[$id]['lines'][$lineIndex] ??= [];
$constellations[$id]['lines'][$lineIndex][] = (int) $row['hip_number'];
}
foreach ($constellations as &$constellation) {
if ($constellation['lines']) {
ksort($constellation['lines']);
$constellation['lines'] = array_values($constellation['lines']);
}
}
unset($constellation);
uasort(
$constellations,
static fn(array $a, array $b): int => strcmp($a['id'], $b['id'])
);
if ($starsApiRequested) {
$starData = [];
$starStmt = $planetariumPdo->query(
"SELECT hip, proper, bayer, con, spect, mag, ci, rarad, decrad
FROM star_hipparcos
WHERE mag IS NOT NULL
AND rarad IS NOT NULL
AND decrad IS NOT NULL
AND mag <= " . (float)$starDbMagLimit . "
ORDER BY mag ASC"
);
while ($row = $starStmt->fetch()) {
$starData[] = [
'hip' => $row['hip'] !== null ? (int)$row['hip'] : null,
'proper' => trim((string)($row['proper'] ?? '')),
'bayer' => trim((string)($row['bayer'] ?? '')),
'con' => trim((string)($row['con'] ?? '')),
'spect' => trim((string)($row['spect'] ?? '')),
'ra' => (float)$row['rarad'],
'dec' => (float)$row['decrad'],
'mag' => (float)$row['mag'],
'ci' => $row['ci'] !== null ? (float)$row['ci'] : null,
];
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'ok' => true,
'stars' => $starData,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$constellationData['constellations'] = array_values($constellations);
$messierStmt = $planetariumPdo->query(
"SELECT messier, name, object_type_abrev, ra_deg, dec_deg, v_mag, major_axis
FROM dso_objects
WHERE messier IS NOT NULL
AND messier <> ''
AND ra_deg IS NOT NULL
AND dec_deg IS NOT NULL
ORDER BY CAST(REGEXP_REPLACE(messier, '[^0-9]', '') AS UNSIGNED)"
);
while ($row = $messierStmt->fetch()) {
if (!preg_match('/(\d+)/', (string)$row['messier'], $m)) {
continue;
}
$messierData[] = [
'm' => (int)$m[1],
'name' => trim((string)($row['name'] ?? '')),
'type' => trim((string)($row['object_type_abrev'] ?? 'As')),
'ra' => ((float)$row['ra_deg']) / 15.0,
'dec' => (float)$row['dec_deg'],
'mag' => $row['v_mag'] !== null ? (float)$row['v_mag'] : 99.0,
'size' => $row['major_axis'] !== null ? (float)$row['major_axis'] : 8.0,
];
}
if ($loggedIn && isset($_SESSION['user_id'])) {
$userLocationStmt = $planetariumPdo->prepare(
"SELECT id, name, latitude, longitude, elevation, is_default
FROM app_user_locations
WHERE user_id = ?
ORDER BY is_default DESC, name ASC, id ASC"
);
$userLocationStmt->execute([(int)$_SESSION['user_id']]);
while ($row = $userLocationStmt->fetch()) {
if ($row['latitude'] === null || $row['longitude'] === null) {
continue;
}
$availableLocations[] = [
'id' => $row['id'] !== null ? (int)$row['id'] : null,
'name' => trim((string)($row['name'] ?? 'Standort')),
'latitude' => (float)$row['latitude'],
'longitude' => (float)$row['longitude'],
'elevation' => $row['elevation'] !== null ? (float)$row['elevation'] : 0.0,
'is_default' => !empty($row['is_default']),
];
}
}
// Favoritenkometen des Users laden – Bahnelemente für JS-seitige Berechnung
$favCometData = [];
if ($loggedIn && isset($_SESSION['user_id'])) {
$favCometStmt = $planetariumPdo->prepare(
"SELECT c.designation_and_name,
c.year_of_perihelion, c.month_of_perihelion, c.day_of_perihelion,
c.perihelion_dist_au, c.eccentricity,
c.arg_perihelion_deg, c.ascending_node_deg, c.inclination_deg,
c.absolute_magnitude_h, c.slope_parameter_g
FROM app_user_comets uc
JOIN comets_mpc c ON c.id = uc.comet_id
WHERE uc.user_id = ? AND uc.is_favorite = 1
ORDER BY c.designation_and_name
LIMIT 8"
);
$favCometStmt->execute([(int)$_SESSION['user_id']]);
foreach ($favCometStmt->fetchAll() as $r) {
$favCometData[] = [
'name' => (string)$r['designation_and_name'],
'q' => (float)$r['perihelion_dist_au'],
'e' => (float)$r['eccentricity'],
'w' => (float)$r['arg_perihelion_deg'],
'Om' => (float)$r['ascending_node_deg'],
'inc' => (float)$r['inclination_deg'],
'Tp_y' => (int)$r['year_of_perihelion'],
'Tp_m' => (int)$r['month_of_perihelion'],
'Tp_d' => (float)$r['day_of_perihelion'],
'H' => $r['absolute_magnitude_h'] !== null ? (float)$r['absolute_magnitude_h'] : null,
'G' => $r['slope_parameter_g'] !== null ? (float)$r['slope_parameter_g'] : null,
];
}
}
} catch (Throwable $e) {
if ($starsApiRequested) {
header('Content-Type: application/json; charset=utf-8');
http_response_code(500);
echo json_encode([
'ok' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$constellationDataError = $e->getMessage();
$starDataError = $e->getMessage();
$messierDataError = $e->getMessage();
}
$favCometDataJson = json_encode(
$favCometData ?? [],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$constellationDataJson = json_encode(
$constellationData,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$constellationDataErrorJson = json_encode(
$constellationDataError,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$starDataErrorJson = json_encode(
$starDataError,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$messierDataJson = json_encode(
$messierData,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$messierDataErrorJson = json_encode(
$messierDataError,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
?>
<?php require __DIR__ . '/header.php'; ?>
<?php
$planetariumLocation = [
'name' => 'Sternwarte Sonneberg',
'latitude' => 50.3547,
'longitude' => 11.1900,
'elevation' => 640.0,
];
if (!empty($currentLocation) && is_array($currentLocation)) {
$planetariumLocation = [
'id' => isset($currentLocation['id']) ? (int)$currentLocation['id'] : null,
'name' => (string)($currentLocation['name'] ?? 'Standard-Standort'),
'latitude' => isset($currentLocation['latitude']) ? (float)$currentLocation['latitude'] : 50.3547,
'longitude' => isset($currentLocation['longitude']) ? (float)$currentLocation['longitude'] : 11.1900,
'elevation' => isset($currentLocation['elevation']) && $currentLocation['elevation'] !== null ? (float)$currentLocation['elevation'] : 0.0,
];
}
if (!$availableLocations) {
$availableLocations[] = [
'id' => $planetariumLocation['id'] ?? null,
'name' => $planetariumLocation['name'],
'latitude' => $planetariumLocation['latitude'],
'longitude' => $planetariumLocation['longitude'],
'elevation' => $planetariumLocation['elevation'],
'is_default' => true,
];
}
?>
<script src="<?= htmlspecialchars($publicBasePath . 'js/astronomy.browser.min.js', ENT_QUOTES, 'UTF-8') ?>"></script>
<main>
<div class="container">
<section class="planetarium-toolbar">
<div class="info-bar">
<label class="toolbar-location">
<span>Ort</span>
<select class="toolbar-location-select" id="locationSelect">
<?php foreach ($availableLocations as $locationOption): ?>
<option
value="<?= htmlspecialchars((string)($locationOption['id'] ?? ''), ENT_QUOTES, 'UTF-8') ?>"
<?= (($locationOption['id'] ?? null) === ($planetariumLocation['id'] ?? null)) ? ' selected' : '' ?>
>
<?= htmlspecialchars((string)$locationOption['name'], ENT_QUOTES, 'UTF-8') ?>
</option>
<?php endforeach; ?>
</select>
</label>
<span>LAT <span class="val" id="lblLat">50.3547° N</span></span>
<span>LON <span class="val" id="lblLon">11.1900° E</span></span>
<span>UTC <span class="val" id="lblTime">--:--:--</span></span>
<span>LST <span class="val" id="lblLST">--h--m</span></span>
<span>Sterne <span class="val" id="lblCount">0</span></span>
</div>
<div class="controls">
<div class="star-control-stack">
<div class="ctrl-group">
<input type="datetime-local" id="dtPicker">
<button class="btn live" id="btnNow">● JETZT</button>
</div>
<!-- Zeitsprung-Buttons: Sterntag = 86164.0916s -->
<div class="time-steps">
<div class="step-row">
<button class="step-btn" data-sec="-300">−5m</button>
<button class="step-btn" data-sec="-900">−15m</button>
<button class="step-btn" data-sec="-3600">−1h</button>
<button class="step-btn" data-sec="-7200">−2h</button>
<button class="step-btn" data-sec="-43200">−12h</button>
<button class="step-btn" data-sec="-86164.0916">−1★</button>
<span class="step-label">◀ zurück</span>
</div>
<div class="step-row">
<button class="step-btn" data-sec="300">+5m</button>
<button class="step-btn" data-sec="900">+15m</button>
<button class="step-btn" data-sec="3600">+1h</button>
<button class="step-btn" data-sec="7200">+2h</button>
<button class="step-btn" data-sec="43200">+12h</button>
<button class="step-btn" data-sec="86164.0916">+1★</button>
<span class="step-label">vor ▶</span>
</div>
</div>
</div>
</div>
<!-- Einstellungen -->
<div id="settingsWrap">
<button id="btnSettings" title="Anzeigeeinstellungen">⚙</button>
<input type="file" id="fileInput" class="file-input-hidden" accept=".csv">
<div id="settingsPanel">
<div class="sp-title">⚙ Anzeigeeinstellungen</div>
<div class="sp-row stack">
<span class="sp-label">Koordinatengitter</span>
<div class="sp-radio-group">
<label class="sp-radio"><input type="radio" name="gridMode" id="gridEq" value="eq" checked><span class="sp-rbtn"></span><span class="sp-text">Äquatorial</span></label>
<label class="sp-radio"><input type="radio" name="gridMode" id="gridAltaz" value="altaz" ><span class="sp-rbtn"></span><span class="sp-text">Alt / Az</span></label>
<label class="sp-radio"><input type="radio" name="gridMode" id="gridOff" value="off" ><span class="sp-rbtn"></span><span class="sp-text">Aus</span></label>
</div>
<div class="sp-toggles sp-toggles-compact">
<label class="sp-toggle"><input type="checkbox" id="cfgEqLabels" checked><span class="sp-pill"></span><span class="sp-text">Beschrift.</span></label>
</div>
</div>
<div class="sp-row">
<span class="sp-label">Ekliptik</span>
<div class="sp-toggles">
<label class="sp-toggle"><input type="checkbox" id="cfgEclipShow" checked><span class="sp-pill"></span><span class="sp-text">an</span></label>
</div>
</div>
<div class="sp-row">
<span class="sp-label">Sternbilder</span>
<div class="sp-toggles">
<label class="sp-toggle"><input type="checkbox" id="cfgConstShow" checked><span class="sp-pill"></span><span class="sp-text">Linien</span></label>
<label class="sp-toggle"><input type="checkbox" id="cfgConstLabels" checked><span class="sp-pill"></span><span class="sp-text">Namen</span></label>
</div>
</div>
<div class="sp-row">
<span class="sp-label">Sterne</span>
<div class="sp-toggles">
<label class="sp-toggle"><input type="checkbox" id="cfgStarsShow" checked><span class="sp-pill"></span><span class="sp-text">Sterne</span></label>
<label class="sp-toggle"><input type="checkbox" id="cfgStarsLabels" checked><span class="sp-pill"></span><span class="sp-text">Namen</span></label>
<label class="sp-toggle"><input type="checkbox" id="cfgStarsAutoMag" checked><span class="sp-pill"></span><span class="sp-text">Auto Zoom</span></label>
</div>
</div>
<div class="sp-row stack">
<span class="sp-label">Sterne Mag-Limit</span>
<div class="sp-slider">
<input type="range" id="magLimit" min="1" max="12" step="0.5" value="12">
<span class="sp-val" id="magVal">12.0</span>
</div>
</div>
<div class="sp-row">
<span class="sp-label">Planeten</span>
<div class="sp-toggles">
<label class="sp-toggle"><input type="checkbox" id="cfgPlanetsShow" checked><span class="sp-pill"></span><span class="sp-text">Objekte</span></label>
<label class="sp-toggle"><input type="checkbox" id="cfgPlanetsLabels" checked><span class="sp-pill"></span><span class="sp-text">Namen</span></label>
</div>
</div>
<?php if (!empty($favCometData)): ?>
<div class="sp-row">
<span class="sp-label">Fav.-Kometen</span>
<div class="sp-toggles">
<label class="sp-toggle"><input type="checkbox" id="cfgCometsShow" checked><span class="sp-pill"></span><span class="sp-text">Objekte</span></label>
<label class="sp-toggle"><input type="checkbox" id="cfgCometsLabels" checked><span class="sp-pill"></span><span class="sp-text">Namen</span></label>
</div>
</div>
<?php endif; ?>
<div class="sp-row">
<span class="sp-label">Sonne / Mond</span>
<div class="sp-toggles">
<label class="sp-toggle"><input type="checkbox" id="cfgSunMoonShow" checked><span class="sp-pill"></span><span class="sp-text">Objekte</span></label>
<label class="sp-toggle"><input type="checkbox" id="cfgSunMoonLabels" checked><span class="sp-pill"></span><span class="sp-text">Labels</span></label>
</div>
</div>
<div class="sp-row">
<span class="sp-label">Messier</span>
<div class="sp-toggles">
<label class="sp-toggle"><input type="checkbox" id="cfgMessierShow" checked><span class="sp-pill"></span><span class="sp-text">Objekte</span></label>
<label class="sp-toggle"><input type="checkbox" id="cfgMessierLabels" checked><span class="sp-pill"></span><span class="sp-text">Namen</span></label>
<label class="sp-toggle"><input type="checkbox" id="cfgMessierAutoMag" checked><span class="sp-pill"></span><span class="sp-text">Auto Zoom</span></label>
</div>
</div>
<div class="sp-row stack">
<span class="sp-label">Messier Mag-Limit</span>
<div class="sp-slider">
<input type="range" id="magLimitMessier" min="1" max="16" step="0.5" value="6">
<span class="sp-val" id="magValMessier">6.0</span>
</div>
</div>
<div class="sp-title sp-title-offset">⬇ Bild exportieren</div>
<div class="sp-row">
<button id="btnExport" class="star-export-btn">
⬇ PNG speichern (1000 × 1000 px)
</button>
</div>
</div>
</div>
</section>
<div id="main">
<canvas id="sky"></canvas>
<div id="starLoadingOverlay" aria-live="polite">
<div class="star-loading-head">
<span class="star-loading-title">Lade Sterne</span>
<span class="star-loading-status" id="starLoadingStatus">0%</span>
</div>
<div class="star-loading-bar">
<div class="star-loading-fill" id="starLoadingFill"></div>
</div>
<div class="star-loading-note" id="starLoadingNote">Sternkatalog wird vorbereitet ...</div>
</div>
<div id="tooltip"></div>
<div id="zoomIndicator"></div>
</div>
<script>
window.addEventListener('load', function() {
if (typeof Astronomy === 'undefined') {
console.warn('astronomy-engine.js nicht geladen');
return;
}
// ═══════════════════════════════════════════════════════════════════════════════
// KONFIGURATION – feste Defaults
// ═══════════════════════════════════════════════════════════════════════════════
const CFG_DEFAULT = {
equatorialGrid: {
decStep: 10, raStep: 2,
colorEquator: 'rgba(70,150,255,0.60)', colorLines: 'rgba(45,95,155,0.30)',
widthEquator: 1.1, widthLines: 1.5, dash: [3,6],
colorLabels: 'rgba(70,120,195,0.60)', labelDecStep: 20
},
altazGrid: {
altStep: 15, altMax: 75, azStep: 30,
colorCircles: 'rgba(28,55,80,0.40)', colorLines: 'rgba(28,55,80,0.30)',
width: 0.5, colorLabels: 'rgba(55,95,130,0.45)'
},
ecliptic: {
color: 'rgba(255,175,35,0.52)', width: 1.1, dash: [5,5]
},
constellations: {
color: 'rgba(100,160,220,0.38)', width: 0.8, dash: [],
colorLabels: 'rgba(120,175,230,0.50)', fontLabels: '9px Rajdhani, sans-serif'
},
stars: {
maxRenderMag: 9.5, defaultMagLimit: 6.5,
glowBelowMag: 2.0, labelBelowMag: 2.5,
colorLabel: 'rgba(155,198,228,0.80)', fontLabel: '10px Rajdhani,sans-serif'
},
messier: {
colorOc:'#ffe08a', strokeOc:'#ffcc44',
colorGc:'#ffaa44', strokeGc:'#ff8800',
colorNb:'#66ccff', strokeNb:'#33aaff',
colorSc:'#88ddaa', strokeSc:'#44bb77',
colorPn:'#aaffaa', strokePn:'#55ee55',
colorGx:'#ffaacc', strokeGx:'#ff66aa',
colorAs:'#aaaaaa', strokeAs:'#888888',
fontLabels: '9px Rajdhani,sans-serif'
},
planets: {
Mercury: { color:'#b0b0b0', r:3.5 }, Venus: { color:'#fffacd', r:5.0 },
Mars: { color:'#ff5533', r:4.0 }, Jupiter: { color:'#ffddaa', r:5.5 },
Saturn: { color:'#ffe496', r:4.5 }, Uranus: { color:'#88ffee', r:3.5 },
Neptune: { color:'#4466ff', r:3.0 }
},
sun: {
radius:12, colorCenter:'#fff7aa', colorMid:'#ffdd00', colorEdge:'#ff9900',
colorLabel:'#ffdd44', colorLabelBelow:'#aa8800'
},
moon: {
radius:10, colorCenter:'#f0ead8', colorMid:'#ddd5be',
colorEdge:'#c8c0a8', colorLabel:'#c8c0a8'
},
horizon: { color:'rgba(50,165,85,0.38)', width:8 },
compass: {
colorMajor:'#4a9eff', colorMinor:'rgba(74,158,255,0.42)',
fontMajor:'bold 13px Rajdhani,sans-serif', fontMinor:'10px Rajdhani,sans-serif'
},
sky: {
colorCenter:'#070f1b', colorMid:'#040a11',
colorEdge:'#020508', background:'#020408'
}
};
let CFG = CFG_DEFAULT;
const HEADER_LOCATION = <?= json_encode($planetariumLocation, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const USER_LOCATIONS = <?= json_encode($availableLocations, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const LOCATION_BY_ID = new Map(USER_LOCATIONS.map(loc => [String(loc.id ?? ''), loc]));
// ═══════════════════════════════════════════════════════════════════════════════
// STANDORT – dynamisch (Standard: Sternwarte Sonneberg)
// ═══════════════════════════════════════════════════════════════════════════════
let LAT_DEG = Number(HEADER_LOCATION.latitude);
let LON_DEG = Number(HEADER_LOCATION.longitude);
let ELEV_M = Number(HEADER_LOCATION.elevation || 0);
let OBSERVER = new Astronomy.Observer(LAT_DEG, LON_DEG, ELEV_M);
function setLocation(lat, lon, elev, name = null, locationId = null) {
LAT_DEG = lat;
LON_DEG = lon;
ELEV_M = elev || 0;
OBSERVER = new Astronomy.Observer(LAT_DEG, LON_DEG, ELEV_M);
// Info-Leiste aktualisieren
const fmt = (v, pos, neg) => Math.abs(v).toFixed(4) + '° ' + (v >= 0 ? pos : neg);
const locationSelectEl = document.getElementById('locationSelect');
if (locationSelectEl && locationId !== null && locationId !== undefined) {
locationSelectEl.value = String(locationId);
}
document.getElementById('lblLat').textContent = fmt(LAT_DEG, 'N', 'S');
document.getElementById('lblLon').textContent = fmt(LON_DEG, 'E', 'W');
render();
}
function applyLocationOption(locationId) {
const location = LOCATION_BY_ID.get(String(locationId));
if (!location) return;
setLocation(
Number(location.latitude),
Number(location.longitude),
Number(location.elevation || 0),
location.name || 'Standort',
location.id ?? ''
);
}
// ═══════════════════════════════════════════════════════════════════════════════
// ZUSTAND
// ═══════════════════════════════════════════════════════════════════════════════
let stars = [];
let magLimit = 12.0;
let messierMagLimit = 6.0;
let autoStarMagByZoom = true;
let autoMessierMagByZoom = true;
let liveMode = true;
let simTime = new Date();
let showEq = true;
let showAltaz = false;
let showPlanets = true;
let showSunMoon = true;
let showEclip = true;
let showMessier = true;
let showConst = true;
let showComets = true;
// Label-Sichtbarkeit (separat von Layer-Sichtbarkeit)
let labelEq = true;
let labelAltaz = true;
let labelConst = true;
let labelStars = true;
let labelPlanets = true;
let labelSunMoon = true;
let labelMessier = true;
let labelComets = true;
let showStars = true;
// ═══════════════════════════════════════════════════════════════════════════════
const EMBEDDED_CONSTELLATION_DATA = <?php echo $constellationDataJson ?: '{"constellations":[]}'; ?>;
const EMBEDDED_CONSTELLATION_ERROR = <?php echo $constellationDataErrorJson ?: 'null'; ?>;
const STAR_DATA_URL = 'sternenhimmel.php?stars_api=1';
const EMBEDDED_STAR_ERROR = <?php echo $starDataErrorJson ?: 'null'; ?>;
const EMBEDDED_MESSIER_DATA = <?php echo $messierDataJson ?: '[]'; ?>;
const EMBEDDED_MESSIER_ERROR = <?php echo $messierDataErrorJson ?: 'null'; ?>;
const MESSIER = Array.isArray(EMBEDDED_MESSIER_DATA) ? EMBEDDED_MESSIER_DATA : [];
const FAV_COMETS = <?php echo $favCometDataJson ?: '[]'; ?>;
let constellationData = EMBEDDED_CONSTELLATION_DATA;
let hipMap = new Map();
function buildMStyle() {
return {
Oc: { color: CFG.messier.colorOc, stroke: CFG.messier.strokeOc },
Gc: { color: CFG.messier.colorGc, stroke: CFG.messier.strokeGc },
Nb: { color: CFG.messier.colorNb, stroke: CFG.messier.strokeNb },
Sc: { color: CFG.messier.colorSc, stroke: CFG.messier.strokeSc },
Pn: { color: CFG.messier.colorPn, stroke: CFG.messier.strokePn },
Gx: { color: CFG.messier.colorGx, stroke: CFG.messier.strokeGx },
As: { color: CFG.messier.colorAs, stroke: CFG.messier.strokeAs },
};
}
let M_STYLE = buildMStyle();
function getTime() { return liveMode ? new Date() : simTime; }
// ═══════════════════════════════════════════════════════════════════════════════
// CANVAS
// ═══════════════════════════════════════════════════════════════════════════════
const canvas = document.getElementById('sky');
let ctx = canvas.getContext('2d');
let W, H, R, cx, cy;
let baseR = 0;
let zoomLevel = 1.0;
let panX = 0, panY = 0;
const ZOOM_MIN = 1.0, ZOOM_MAX = 12.0;
const starLoadingOverlay = document.getElementById('starLoadingOverlay');
const starLoadingFill = document.getElementById('starLoadingFill');
const starLoadingStatus = document.getElementById('starLoadingStatus');
const starLoadingNote = document.getElementById('starLoadingNote');
let starsLoaded = false;
function resize() {
const el = document.getElementById('main');
W = el.clientWidth; H = el.clientHeight;
canvas.width = W; canvas.height = H;
baseR = Math.min(W, H) * 0.455;
R = baseR * zoomLevel;
cx = W / 2 + panX;
cy = H / 2 + panY;
render();
}
window.addEventListener('resize', resize);
// ═══════════════════════════════════════════════════════════════════════════════
// ZOOM & PAN
// ═══════════════════════════════════════════════════════════════════════════════
function applyZoom(newZoom, pivotX, pivotY) {
newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, newZoom));
const scale = newZoom / zoomLevel;
// Pivot-Punkt bleibt fixiert: panX/Y so anpassen dass der Himmelspunkt
// unter dem Mauszeiger stabil bleibt
panX = (pivotX - W / 2) * (1 - scale) + panX * scale;
panY = (pivotY - H / 2) * (1 - scale) + panY * scale;
zoomLevel = newZoom;
R = baseR * zoomLevel;
cx = W / 2 + panX;
cy = H / 2 + panY;
applyAutoMagLimitFromZoom();
applyAutoMessierMagLimitFromZoom();
updateZoomIndicator();
render();
}
function resetZoom() {
zoomLevel = 1.0; panX = 0; panY = 0;
R = baseR; cx = W / 2; cy = H / 2;
applyAutoMagLimitFromZoom();
applyAutoMessierMagLimitFromZoom();
updateZoomIndicator();
render();
}
function updateZoomIndicator() {
const el = document.getElementById('zoomIndicator');
if (!el) return;
if (zoomLevel <= 1.01) { el.style.display = 'none'; return; }
el.style.display = 'block';
el.textContent = zoomLevel.toFixed(1) + '×';
}
function getAutoMagLimitForZoom() {
const zoomClamped = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoomLevel));
const normalized = (zoomClamped - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN);
const rawMag = 6 + normalized * 6;
return Math.round(rawMag * 2) / 2;
}
function getAutoMessierMagLimitForZoom() {
const zoomClamped = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, zoomLevel));
const normalized = (zoomClamped - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN);
const rawMag = 6 + normalized * 10;
return Math.round(rawMag * 2) / 2;
}
function syncMagLimitUi() {
const slider = document.getElementById('magLimit');
const valueEl = document.getElementById('magVal');
if (slider) {
slider.value = String(magLimit);
slider.disabled = autoStarMagByZoom;
}
if (valueEl) {
valueEl.textContent = magLimit.toFixed(1);
}
}
function syncMessierMagLimitUi() {
const slider = document.getElementById('magLimitMessier');
const valueEl = document.getElementById('magValMessier');
if (slider) {
slider.value = String(messierMagLimit);
slider.disabled = autoMessierMagByZoom;
}
if (valueEl) {
valueEl.textContent = messierMagLimit.toFixed(1);
}
}
function applyAutoMagLimitFromZoom() {
if (!autoStarMagByZoom) return;
magLimit = getAutoMagLimitForZoom();
syncMagLimitUi();
}
function applyAutoMessierMagLimitFromZoom() {
if (!autoMessierMagByZoom) return;
messierMagLimit = getAutoMessierMagLimitForZoom();
syncMessierMagLimitUi();
}
// Mausrad → Zoom
canvas.addEventListener('wheel', function(e) {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
applyZoom(zoomLevel * factor, mx, my);
}, { passive: false });
// Doppelklick → Zoom zurücksetzen
canvas.addEventListener('dblclick', function(e) {
if (zoomLevel > 1.01) { resetZoom(); e.stopPropagation(); }
});
// Drag → Pan (nur wenn gezoomt)
let dragStart = null;
canvas.addEventListener('mousedown', function(e) {
if (zoomLevel > 1.01 && e.button === 0) {
dragStart = { x: e.clientX, y: e.clientY, px: panX, py: panY };
canvas.style.cursor = 'grabbing';
}
});
window.addEventListener('mousemove', function(e) {
if (!dragStart) return;
panX = dragStart.px + (e.clientX - dragStart.x);
panY = dragStart.py + (e.clientY - dragStart.y);
R = baseR * zoomLevel;
cx = W / 2 + panX;
cy = H / 2 + panY;
render();
});
window.addEventListener('mouseup', function(e) {
if (dragStart) { dragStart = null; canvas.style.cursor = 'crosshair'; }
});
// Touch-Pinch-Zoom
let lastTouchDist = null;
canvas.addEventListener('touchstart', function(e) {
if (e.touches.length === 2) lastTouchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY);
}, { passive: true });
canvas.addEventListener('touchmove', function(e) {
if (e.touches.length !== 2 || !lastTouchDist) return;
e.preventDefault();
const dist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY);
const rect = canvas.getBoundingClientRect();
const mx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const my = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
applyZoom(zoomLevel * (dist / lastTouchDist), mx, my);
lastTouchDist = dist;
}, { passive: false });
canvas.addEventListener('touchend', () => { lastTouchDist = null; });
// alt/az in Grad → Canvas-Pixel
// ═══════════════════════════════════════════════════════════════════════════════
function project(altDeg, azDeg) {
if (altDeg < -0.5) return null;
if (!isFinite(altDeg) || !isFinite(azDeg)) return null;
return projectFullSky(altDeg, azDeg);
}
function projectFullSky(altDeg, azDeg) {
if (!isFinite(altDeg) || !isFinite(azDeg)) return null;
const rho = (90.0 - altDeg) / 90.0;
const azR = azDeg * Math.PI / 180.0;
const x = cx - rho * R * Math.sin(azR);
const y = cy - rho * R * Math.cos(azR);
if (!isFinite(x) || !isFinite(y)) return null;
return { x, y };
}
function projectClampedToHorizon(altDeg, azDeg) {
if (!isFinite(altDeg) || !isFinite(azDeg)) return null;
return projectFullSky(Math.max(0, altDeg), azDeg);
}
function createSafeRadialGradient(x0, y0, r0, x1, y1, r1) {
const values = [x0, y0, r0, x1, y1, r1];
if (!values.every(Number.isFinite)) return null;
if (r0 < 0 || r1 <= 0) return null;
try {
return ctx.createRadialGradient(x0, y0, r0, x1, y1, r1);
} catch {
return null;
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// KOORDINATENUMRECHNUNG – ausschließlich via Astronomy Engine
// ═══════════════════════════════════════════════════════════════════════════════
// Sterne: ra/dec in Radiant (J2000) → Horizon
function starHorizon(star, date) {
const raH = star.ra * (12.0 / Math.PI); // rad → Stunden
const decD = star.dec * (180.0 / Math.PI); // rad → Grad
return Astronomy.Horizon(date, OBSERVER, raH, decD, 'normal');
}
// RA in Stunden, Dec in Grad → Horizon
function eqHorizon(raH, decD, date) {
return Astronomy.Horizon(date, OBSERVER, raH, decD, 'normal');
}
// ═══════════════════════════════════════════════════════════════════════════════
// PLANETEN
// ═══════════════════════════════════════════════════════════════════════════════
const PLANETS = [
{ body: Astronomy.Body.Mercury, name: 'Merkur', ...CFG.planets.Mercury },
{ body: Astronomy.Body.Venus, name: 'Venus', ...CFG.planets.Venus },
{ body: Astronomy.Body.Mars, name: 'Mars', ...CFG.planets.Mars },
{ body: Astronomy.Body.Jupiter, name: 'Jupiter', ...CFG.planets.Jupiter },
{ body: Astronomy.Body.Saturn, name: 'Saturn', ...CFG.planets.Saturn },
{ body: Astronomy.Body.Uranus, name: 'Uranus', ...CFG.planets.Uranus },
{ body: Astronomy.Body.Neptune, name: 'Neptun', ...CFG.planets.Neptune },
];
let planetCache = [];
// Sonne & Mond
let sunCache = null;
let moonCache = null;
function computeSunMoon(date) {
try {
// ── Sonne ────────────────────────────────────────────────────────────────
const sunEq = Astronomy.Equator(Astronomy.Body.Sun, date, OBSERVER, true, true);
const sunHor = eqHorizon(sunEq.ra, sunEq.dec, date);
sunCache = {
name: 'Sonne', ra: sunEq.ra, dec: sunEq.dec,
alt: sunHor.altitude, az: sunHor.azimuth, _xy: null
};
// ── Mond ────────────────────────────────────────────────────────────────
// Astronomy.Equator für Mond: geocentrisch=false → topozentrisch
const moonEq = Astronomy.Equator(Astronomy.Body.Moon, date, OBSERVER, true, true);
const moonHor = eqHorizon(moonEq.ra, moonEq.dec, date);
const moonIllum = Astronomy.Illumination(Astronomy.Body.Moon, date);
const moonPhase = Astronomy.MoonPhase(date); // 0=Neumond, 90=ErstesViertel, 180=Vollmond, 270=LetztesViertel
moonCache = {
name: 'Mond', ra: moonEq.ra, dec: moonEq.dec,
alt: moonHor.altitude, az: moonHor.azimuth,
phase_fraction: moonIllum.phase_fraction,
phase_angle: moonIllum.phase_angle,
moon_phase: moonPhase,
_xy: null
};
} catch(e) { console.warn('Sun/Moon error:', e); }
}
function drawMoonPhase(pt, r, moon) {
const phaseDeg = ((Number(moon.moon_phase) % 360) + 360) % 360;
const phaseRad = phaseDeg * Math.PI / 180;
const waxing = phaseDeg < 180;
const cosPhase = Math.cos(phaseRad);
// Dunkle Grundscheibe
const darkDisk = createSafeRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r);
if (darkDisk) {
darkDisk.addColorStop(0, 'rgba(58, 62, 74, 0.98)');
darkDisk.addColorStop(0.75, 'rgba(36, 40, 50, 0.98)');
darkDisk.addColorStop(1, 'rgba(18, 22, 30, 0.98)');
ctx.fillStyle = darkDisk;
} else {
ctx.fillStyle = 'rgba(34, 38, 46, 0.98)';
}
ctx.beginPath();
ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
ctx.fill();
// Beleuchteten Bereich als Fläche zwischen Terminator und sichtbarem Rand zeichnen
ctx.save();
ctx.beginPath();
ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
ctx.clip();
const moonLight = createSafeRadialGradient(pt.x - r * 0.22, pt.y - r * 0.28, r * 0.2, pt.x, pt.y, r * 1.08);
if (moonLight) {
moonLight.addColorStop(0, 'rgba(245, 241, 228, 0.98)');
moonLight.addColorStop(0.6, 'rgba(214, 207, 188, 0.98)');
moonLight.addColorStop(1, 'rgba(158, 152, 138, 0.98)');
ctx.fillStyle = moonLight;
} else {
ctx.fillStyle = 'rgba(214, 207, 188, 0.98)';
}
if (moon.phase_fraction >= 0.999) {
ctx.beginPath();
ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
ctx.fill();
} else if (moon.phase_fraction > 0.001) {
ctx.beginPath();
for (let i = 0; i <= 72; i++) {
const t = -r + (2 * r * i / 72);
const xr = Math.sqrt(Math.max(0, r * r - t * t));
const xt = (waxing ? cosPhase : -cosPhase) * xr;
const px = pt.x + xt;
const py = pt.y + t;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
for (let i = 72; i >= 0; i--) {
const t = -r + (2 * r * i / 72);
const xr = Math.sqrt(Math.max(0, r * r - t * t));
const px = pt.x + (waxing ? xr : -xr);
const py = pt.y + t;
ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function drawSunMoon(date) {
computeSunMoon(date);
// ── SONNE ─────────────────────────────────────────────────────────────────
if (sunCache) {
sunCache._xy = null;
if (sunCache.alt >= 0) {
const pt = project(sunCache.alt, sunCache.az);
if (pt) {
sunCache._xy = pt;
const S = CFG.sun;
const r = S.radius;
const sunGlow = createSafeRadialGradient(pt.x, pt.y, r, pt.x, pt.y, r * 5);
if (sunGlow) {
sunGlow.addColorStop(0, 'rgba(255,220,80,0.25)');
sunGlow.addColorStop(1, 'rgba(255,180,0,0)');
ctx.fillStyle = sunGlow;
ctx.beginPath(); ctx.arc(pt.x, pt.y, r * 5, 0, Math.PI * 2); ctx.fill();
}
const sunDisk = createSafeRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r);
if (sunDisk) {
sunDisk.addColorStop(0, S.colorCenter);
sunDisk.addColorStop(0.6, S.colorMid);
sunDisk.addColorStop(1, S.colorEdge);
ctx.fillStyle = sunDisk;
} else {
ctx.fillStyle = S.colorMid;
}
ctx.beginPath(); ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = S.colorLabel;
ctx.font = 'bold 10px Rajdhani,sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
if (labelSunMoon) ctx.fillText('Sonne', pt.x + r + 4, pt.y);
}
}
}
// ── MOND ──────────────────────────────────────────────────────────────────
if (moonCache && moonCache.alt > 0) {
const pt = project(moonCache.alt, moonCache.az);
if (pt) {
moonCache._xy = pt;
const M = CFG.moon;
const r = M.radius;
const moonGlow = createSafeRadialGradient(pt.x, pt.y, r, pt.x, pt.y, r * 3.5);
if (moonGlow) {
moonGlow.addColorStop(0, 'rgba(210,200,170,0.18)');
moonGlow.addColorStop(1, 'rgba(180,170,140,0)');
ctx.fillStyle = moonGlow;
ctx.beginPath(); ctx.arc(pt.x, pt.y, r * 3.5, 0, Math.PI * 2); ctx.fill();
}
drawMoonPhase(pt, r, moonCache);
ctx.strokeStyle = 'rgba(200,190,160,0.45)';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2);
ctx.stroke();
const pct = Math.round(moonCache.phase_fraction * 100);
ctx.fillStyle = M.colorLabel;
ctx.font = 'bold 10px Rajdhani,sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
if (labelSunMoon) ctx.fillText('Mond ' + pct + '%', pt.x + r + 4, pt.y);
}
}
}
function computePlanets(date) {
planetCache = [];
for (const p of PLANETS) {
try {
const eq = Astronomy.Equator(p.body, date, OBSERVER, true, true);
const hor = eqHorizon(eq.ra, eq.dec, date);
const illum = Astronomy.Illumination(p.body, date);
planetCache.push({ ...p, ra: eq.ra, dec: eq.dec,
alt: hor.altitude, az: hor.azimuth,
mag: illum.mag, _xy: null });
} catch(e) {}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// LAYER: ÄQUATORIALGITTER
// Dec-Kreise alle 10°, RA-Linien alle 2h – vollständig über Astronomy Engine
// ═══════════════════════════════════════════════════════════════════════════════
function drawEquatorialGrid(date) {
const STEPS = 200;
const C = CFG.equatorialGrid;
for (let dec = -80; dec <= 80; dec += C.decStep) {
const isEq = dec === 0;
ctx.strokeStyle = isEq ? C.colorEquator : C.colorLines;
ctx.lineWidth = isEq ? C.widthEquator : C.widthLines;
ctx.setLineDash(isEq ? [] : C.dash);
ctx.beginPath();
let pen = false;
for (let i = 0; i <= STEPS; i++) {
const raH = (i / STEPS) * 24.0;
const hor = eqHorizon(raH, dec, date);
const pt = project(hor.altitude, hor.azimuth);
if (!pt) { pen = false; continue; }
pen ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y);
pen = true;
}
ctx.stroke();
// Label
if (labelEq && dec !== 0 && dec % C.labelDecStep === 0) {
for (let raH = 0; raH < 24; raH += 6) {
const hor = eqHorizon(raH, dec, date);
if (hor.altitude < 5 || hor.altitude > 80) continue;
const pt = project(hor.altitude, hor.azimuth);
if (!pt) continue;
ctx.fillStyle = C.colorLabels;
ctx.font = '8px Share Tech Mono,monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText((dec > 0 ? '+' : '') + dec + '°', pt.x + 2, pt.y);
break;
}
}
}
// ── Rektaszensionslinien ─────────────────────────────────────────────────
for (let raH = 0; raH < 24; raH += C.raStep) {
ctx.strokeStyle = C.colorLines;
ctx.lineWidth = C.widthLines;
ctx.setLineDash(C.dash);
ctx.beginPath();
let pen = false;
for (let i = 0; i <= STEPS; i++) {
const dec = -90 + (180 * i / STEPS);
const hor = eqHorizon(raH, dec, date);
const pt = project(hor.altitude, hor.azimuth);
if (!pt) { pen = false; continue; }
pen ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y);
pen = true;
}
ctx.stroke();
// RA-Label nahe am Horizont
if (labelEq) for (let dec = -8; dec <= 8; dec += 2) {
const hor = eqHorizon(raH, dec, date);
if (hor.altitude < 1 || hor.altitude > 12) continue;
const pt = project(hor.altitude, hor.azimuth);
if (!pt) continue;
ctx.fillStyle = C.colorLabels;
ctx.font = '8px Share Tech Mono,monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText(raH + 'h', pt.x, pt.y - 2);
break;
}
}
ctx.setLineDash([]);
}
// ═══════════════════════════════════════════════════════════════════════════════
// LAYER: EKLIPTIK
// Schiefe der Ekliptik exakt nach IAU 2006 aus JD
// ═══════════════════════════════════════════════════════════════════════════════
function drawEcliptic(date) {
// Julianisches Datum
const atime = Astronomy.MakeTime(date);
const jd = atime.tt + 2451545.0;
const T = (jd - 2451545.0) / 36525.0;
// IAU 2006 Schiefe der Ekliptik in Bogensekunden → Grad
const epsArcsec = 84381.406
- 46.836769 * T
- 0.0001831 * T * T
+ 0.00200340 * T * T * T
- 0.000000576 * T * T * T * T
- 0.0000000434 * T * T * T * T * T;
const epsR = (epsArcsec / 3600.0) * Math.PI / 180.0;
ctx.strokeStyle = CFG.ecliptic.color;
ctx.lineWidth = CFG.ecliptic.width;
ctx.setLineDash(CFG.ecliptic.dash);
const STEPS = 360;
ctx.beginPath();
let pen = false;
for (let i = 0; i <= STEPS; i++) {
const lam = (i / STEPS) * 2 * Math.PI;
const raR = Math.atan2(Math.cos(epsR) * Math.sin(lam), Math.cos(lam));
const decR = Math.asin(Math.sin(epsR) * Math.sin(lam));
const raH = ((raR * 12.0 / Math.PI) + 24) % 24;
const decD = decR * 180.0 / Math.PI;
const hor = eqHorizon(raH, decD, date);
const pt = project(hor.altitude, hor.azimuth);
if (!pt) { pen = false; continue; }
pen ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y);
pen = true;
}
ctx.stroke();
ctx.setLineDash([]);
}
function drawMilkyWay(date) {
if (!MILKY_WAY_RINGS.length) return;
const buildPath = (ring) => {
// Clip polygon segments to the horizon (alt >= 0).
// When a segment crosses alt=0, interpolate the exact crossing point so that
// below-horizon projections (which can land far outside the circle and corrupt
// the fill winding-number calculation) never enter the path.
let subpathOpen = false;
let hasAny = false;
let prevPt = null;
let prevAlt = null;
for (const coord of ring.coords) {
const raDeg = ((coord[0] % 360) + 360) % 360;
const decDeg = coord[1];
const hor = eqHorizon(raDeg / 15, decDeg, date);
const alt = hor.altitude;
const currPt = projectFullSky(alt, hor.azimuth);
if (!currPt || !isFinite(alt)) {
if (subpathOpen) { ctx.closePath(); subpathOpen = false; }
prevPt = null; prevAlt = null;
continue;
}
if (prevPt === null) {
if (alt >= 0) {
ctx.moveTo(currPt.x, currPt.y);
subpathOpen = true; hasAny = true;
}
} else {
const in0 = prevAlt >= 0;
const in1 = alt >= 0;
if (in0 && in1) {
ctx.lineTo(currPt.x, currPt.y);
} else if (in0 && !in1) {
// Exiting horizon: draw to the interpolated crossing, then close subpath.
const t = prevAlt / (prevAlt - alt);
ctx.lineTo(prevPt.x + t * (currPt.x - prevPt.x), prevPt.y + t * (currPt.y - prevPt.y));
ctx.closePath(); subpathOpen = false;
} else if (!in0 && in1) {
// Entering horizon: start new subpath at the interpolated crossing.
if (subpathOpen) { ctx.closePath(); subpathOpen = false; }
const t = prevAlt / (prevAlt - alt);
ctx.moveTo(prevPt.x + t * (currPt.x - prevPt.x), prevPt.y + t * (currPt.y - prevPt.y));
ctx.lineTo(currPt.x, currPt.y);
subpathOpen = true; hasAny = true;
}
// both below horizon: skip
}
prevPt = currPt;
prevAlt = alt;
}
if (subpathOpen) ctx.closePath();
return hasAny;
};
const fillPass = (baseColor, withBlur) => {
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.shadowBlur = withBlur ? CFG.milkyWay.blur : 0;
ctx.shadowColor = CFG.milkyWay.glow;
for (const ring of MILKY_WAY_RINGS) {
const weight = Math.max(0.45, Math.min(1, Math.log10(ring.areaAbs + 10) / 3));
const alpha = withBlur
? (0.006 + weight * 0.012)
: (0.008 + weight * 0.016);
ctx.beginPath();
if (!buildPath(ring)) continue;
ctx.fillStyle = baseColor.replace('__ALPHA__', alpha.toFixed(3));
ctx.fill();
}
ctx.restore();
};
fillPass('rgba(180, 208, 255, __ALPHA__)', true);
fillPass('rgba(210, 228, 255, __ALPHA__)', false);
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.strokeStyle = CFG.milkyWay.stroke;
ctx.lineWidth = 0.7;
for (const ring of MILKY_WAY_RINGS) {
ctx.beginPath();
if (!buildPath(ring)) continue;
ctx.stroke();
}
ctx.restore();
}
// ═══════════════════════════════════════════════════════════════════════════════
// LAYER: HÖHEN-/AZIMUT-GITTER (rein geometrisch, kein Sternbezug)
// ═══════════════════════════════════════════════════════════════════════════════
function drawAltazGrid() {
const C = CFG.altazGrid;
ctx.lineWidth = C.width;
for (let alt = C.altStep; alt <= C.altMax; alt += C.altStep) {
const rho = (90 - alt) / 90;
ctx.strokeStyle = C.colorCircles;
ctx.beginPath(); ctx.arc(cx, cy, rho * R, 0, Math.PI * 2); ctx.stroke();
if (labelAltaz) {
ctx.fillStyle = C.colorLabels;
ctx.font = '8px Share Tech Mono,monospace';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(alt + '°', cx + rho * R + 3, cy);
}
}
ctx.strokeStyle = C.colorLines;
for (let az = 0; az < 360; az += C.azStep) {
const azR = az * Math.PI / 180;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx - R * Math.sin(azR), cy - R * Math.cos(azR));
ctx.stroke();
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// FARBE AUS B-V
// ═══════════════════════════════════════════════════════════════════════════════
function clamp01(v) {
return Math.max(0, Math.min(1, v));
}
function toHexByte(v) {
return Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, '0');
}
function rgbToHex(r, g, b) {
return '#' + toHexByte(r) + toHexByte(g) + toHexByte(b);
}
function hexToRgb(hex) {
const value = String(hex || '').trim().replace('#', '');
if (!/^[0-9a-f]{6}$/i.test(value)) return { r: 255, g: 255, b: 255 };
return {
r: parseInt(value.slice(0, 2), 16),
g: parseInt(value.slice(2, 4), 16),
b: parseInt(value.slice(4, 6), 16),
};
}
function colorWithAlpha(hex, alpha) {
const rgb = hexToRgb(hex);
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${clamp01(alpha)})`;
}
function temperatureToColor(tempK) {
const temp = Math.max(1000, Math.min(40000, tempK)) / 100;
let r, g, b;
if (temp <= 66) {
r = 255;
g = 99.4708025861 * Math.log(temp) - 161.1195681661;
b = temp <= 19 ? 0 : 138.5177312231 * Math.log(temp - 10) - 305.0447927307;
} else {
r = 329.698727446 * Math.pow(temp - 60, -0.1332047592);
g = 288.1221695283 * Math.pow(temp - 60, -0.0755148492);
b = 255;
}
// Leicht entsättigen, damit die Darstellung natürlicher und weniger "neonhaft" wirkt.
const mix = 0.18;
r = r * (1 - mix) + 255 * mix;
g = g * (1 - mix) + 245 * mix;
b = b * (1 - mix) + 235 * mix;
return rgbToHex(r, g, b);
}
function bvToTemperature(bv) {
const x = Math.max(-0.4, Math.min(2.0, Number(bv)));
return 4600 * (
1 / (0.92 * x + 1.7) +
1 / (0.92 * x + 0.62)
);
}
function spectralClassToColor(spect) {
const code = String(spect || '').trim().toUpperCase();
if (!code) return '#ffffff';
const type = code[0];
if (type === 'O') return temperatureToColor(32000);
if (type === 'B') return temperatureToColor(18000);
if (type === 'A') return temperatureToColor(9000);
if (type === 'F') return temperatureToColor(7200);
if (type === 'G') return temperatureToColor(5800);
if (type === 'K') return temperatureToColor(4500);
if (type === 'M') return temperatureToColor(3200);
return '#ffffff';
}
function bvToColor(bv, spect) {
if (Number.isFinite(Number(bv))) {
return temperatureToColor(bvToTemperature(bv));
}
return spectralClassToColor(spect);
}
const STAR_SPRITE_CACHE = new Map();
function getStarSprite(radius, color, magnitude) {
const quantRadius = Math.max(0.28, Math.round(radius * 8) / 8);
const brightFactor = clamp01((2.2 - magnitude) / 4.5);
const brightBucket = Math.round(brightFactor * 4);
const spikeMode = magnitude < 0.5 ? 2 : (magnitude < 1.4 ? 1 : 0);
const key = [quantRadius.toFixed(2), color, brightBucket, spikeMode].join('|');
if (STAR_SPRITE_CACHE.has(key)) return STAR_SPRITE_CACHE.get(key);
const outerHaloRadius = Math.max(quantRadius * 3.4, 1.15);
const innerHaloRadius = Math.max(quantRadius * 1.65, 0.72);
const coreRadius = Math.max(quantRadius * 0.34, 0.18);
const spikeLength = spikeMode ? Math.max(quantRadius * (7 + brightFactor * 8), 4.5) : 0;
const pad = Math.ceil(Math.max(outerHaloRadius, spikeLength) + 3);
const size = pad * 2;
const spriteCanvas = document.createElement('canvas');
spriteCanvas.width = size;
spriteCanvas.height = size;
const sctx = spriteCanvas.getContext('2d');
const cxSprite = pad;
const cySprite = pad;
const outerHalo = sctx.createRadialGradient(cxSprite, cySprite, 0, cxSprite, cySprite, outerHaloRadius);
outerHalo.addColorStop(0.00, colorWithAlpha(color, 0.05 + brightFactor * 0.05));
outerHalo.addColorStop(0.20, colorWithAlpha(color, 0.02 + brightFactor * 0.03));
outerHalo.addColorStop(0.55, colorWithAlpha(color, 0.006 + brightFactor * 0.012));
outerHalo.addColorStop(1.00, 'transparent');
sctx.fillStyle = outerHalo;
sctx.beginPath();
sctx.arc(cxSprite, cySprite, outerHaloRadius, 0, Math.PI * 2);
sctx.fill();
const innerHalo = sctx.createRadialGradient(cxSprite, cySprite, 0, cxSprite, cySprite, innerHaloRadius);
innerHalo.addColorStop(0.00, colorWithAlpha(color, 0.20));
innerHalo.addColorStop(0.35, colorWithAlpha(color, 0.05 + brightFactor * 0.04));
innerHalo.addColorStop(1.00, 'transparent');
sctx.fillStyle = innerHalo;
sctx.beginPath();
sctx.arc(cxSprite, cySprite, innerHaloRadius, 0, Math.PI * 2);
sctx.fill();
if (spikeMode) {
const spikeAlpha = 0.02 + brightFactor * 0.07;
sctx.strokeStyle = colorWithAlpha(color, spikeAlpha);
sctx.lineWidth = 0.55 + brightFactor * 0.35;
sctx.lineCap = 'round';
sctx.beginPath();
sctx.moveTo(cxSprite - spikeLength, cySprite);
sctx.lineTo(cxSprite + spikeLength, cySprite);
sctx.moveTo(cxSprite, cySprite - spikeLength);
sctx.lineTo(cxSprite, cySprite + spikeLength);
if (spikeMode === 2) {
const diag = spikeLength * 0.72;
sctx.moveTo(cxSprite - diag, cySprite - diag);
sctx.lineTo(cxSprite + diag, cySprite + diag);
sctx.moveTo(cxSprite - diag, cySprite + diag);
sctx.lineTo(cxSprite + diag, cySprite - diag);
}
sctx.stroke();
}
sctx.fillStyle = colorWithAlpha(color, 0.96);
sctx.beginPath();
sctx.arc(cxSprite, cySprite, quantRadius, 0, Math.PI * 2);
sctx.fill();
sctx.fillStyle = colorWithAlpha('#ffffff', 0.56 + brightFactor * 0.16);
sctx.beginPath();
sctx.arc(cxSprite, cySprite, coreRadius, 0, Math.PI * 2);
sctx.fill();
const sprite = { canvas: spriteCanvas, pad };
STAR_SPRITE_CACHE.set(key, sprite);
return sprite;
}
function drawStarSprite(x, y, radius, color, magnitude) {
if (magnitude > 3.4) {
ctx.fillStyle = colorWithAlpha(color, 0.92);
ctx.beginPath();
ctx.arc(x, y, Math.max(radius * 0.72, 0.32), 0, Math.PI * 2);
ctx.fill();
return;
}
const sprite = getStarSprite(radius, color, magnitude);
ctx.drawImage(sprite.canvas, x - sprite.pad, y - sprite.pad);
}
// ═══════════════════════════════════════════════════════════════════════════════
// CSV PARSEN
// ═══════════════════════════════════════════════════════════════════════════════
function parseHYG(text) {
const lines = text.trim().split('\n');
const header = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
const idx = {};
header.forEach((h, i) => idx[h] = i);
const out = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(',');
// Sol (id=0) herausfiltern
if ((cols[idx['id']] || '').trim() === '0') continue;
const mag = parseFloat(cols[idx['mag']]);
if (isNaN(mag) || mag > 9.5) continue;
const ra = parseFloat(cols[idx['rarad']]);
const dec = parseFloat(cols[idx['decrad']]);
if (isNaN(ra) || isNaN(dec)) continue;
out.push({
hip: (cols[idx['hip']] || '').trim(),
proper: (cols[idx['proper']] || '').trim(),
bayer: (cols[idx['bayer']] || '').trim(),
con: (cols[idx['con']] || '').trim(),
spect: (cols[idx['spect']] || '').trim(),
ra, dec, mag,
ci: parseFloat(cols[idx['ci']]),
_x: 0, _y: 0, _visible: false
});
}
return out;
}
// ═══════════════════════════════════════════════════════════════════════════════
// LAYER: STERNBILDER
// Linien zwischen HIP-Sternen, Beschriftung mit deutschem Namen
// ═══════════════════════════════════════════════════════════════════════════════
function drawConstellations(date) {
if (!constellationData || hipMap.size === 0) return;
for (const con of constellationData.constellations) {
// Alle Polylinien des Sternbilds zeichnen
for (const segment of con.lines) {
ctx.beginPath();
ctx.strokeStyle = CFG.constellations.color;
ctx.lineWidth = CFG.constellations.width;
ctx.setLineDash(CFG.constellations.dash);
let pen = false;
for (const hip of segment) {
const star = hipMap.get(hip);
if (!star) { pen = false; continue; }
const hor = starHorizon(star, date);
if (hor.altitude < 0) { pen = false; continue; }
const pt = project(hor.altitude, hor.azimuth);
if (!pt) { pen = false; continue; }
pen ? ctx.lineTo(pt.x, pt.y) : ctx.moveTo(pt.x, pt.y);
pen = true;
}
ctx.stroke();
}
ctx.setLineDash([]);
const label = con.common_name?.german || con.common_name?.native || con.id;
if (!labelConst || !label) continue;
ctx.fillStyle = CFG.constellations.colorLabels;
ctx.font = CFG.constellations.fontLabels;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const labelPositions = Array.isArray(con.label_positions)
? con.label_positions
.filter(pos => Array.isArray(pos) && pos.length >= 2)
.map(pos => [Number(pos[0]), Number(pos[1])])
.filter(([raH, decD]) => Number.isFinite(raH) && Number.isFinite(decD))
: [];
if (labelPositions.length > 0) {
for (const [raH, decD] of labelPositions) {
const hor = eqHorizon(raH, decD, date);
const pt = project(hor.altitude, hor.azimuth);
if (!pt) continue;
ctx.fillText(label, pt.x, pt.y);
}
continue;
}
// Fallback: Mittelpunkt aus allen beteiligten sichtbaren Sternen.
let sumX = 0, sumY = 0, count = 0;
const allHips = con.lines.flat();
const seen = new Set();
for (const hip of allHips) {
if (seen.has(hip)) continue;
seen.add(hip);
const star = hipMap.get(hip);
if (!star) continue;
const hor = starHorizon(star, date);
if (hor.altitude < 2) continue;
const pt = project(hor.altitude, hor.azimuth);
if (!pt) continue;
sumX += pt.x; sumY += pt.y; count++;
}
if (count > 0) {
ctx.fillText(label, sumX / count, sumY / count);
}
}
}
function drawMessier(date) {
for (const obj of MESSIER) {
if (obj.mag > messierMagLimit) { obj._xy = null; continue; }
const hor = eqHorizon(obj.ra, obj.dec, date);
if (hor.altitude < 0) continue;
const pt = project(hor.altitude, hor.azimuth);
if (!pt) continue;
obj._xy = pt;
obj._alt = hor.altitude;
obj._az = hor.azimuth;
const style = M_STYLE[obj.type] || M_STYLE.As;
// Symbolradius: min 4px, skaliert mit scheinbarer Größe (leicht)
const symR = Math.max(4, Math.min(14, obj.size * 0.08 + 4));
ctx.strokeStyle = style.stroke;
ctx.fillStyle = style.color + '33'; // halbtransparente Füllung
ctx.lineWidth = 1.0;
if (obj.type === 'Gc') {
// Kugelhaufen: Kreis mit Kreuz
ctx.beginPath(); ctx.arc(pt.x, pt.y, symR, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.beginPath();
ctx.moveTo(pt.x - symR, pt.y); ctx.lineTo(pt.x + symR, pt.y);
ctx.moveTo(pt.x, pt.y - symR); ctx.lineTo(pt.x, pt.y + symR);
ctx.strokeStyle = style.stroke + 'aa';
ctx.stroke();
} else if (obj.type === 'Oc') {
// Offener Haufen: gestrichelter Kreis
ctx.setLineDash([2, 2]);
ctx.beginPath(); ctx.arc(pt.x, pt.y, symR, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
} else if (obj.type === 'Pn') {
// Planetarischer Nebel: Kreis mit vier Strichen außen
ctx.beginPath(); ctx.arc(pt.x, pt.y, symR * 0.6, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
for (let a = 0; a < 360; a += 90) {
const ar = a * Math.PI / 180;
ctx.beginPath();
ctx.moveTo(pt.x + symR * 0.8 * Math.cos(ar), pt.y + symR * 0.8 * Math.sin(ar));
ctx.lineTo(pt.x + symR * 1.4 * Math.cos(ar), pt.y + symR * 1.4 * Math.sin(ar));
ctx.stroke();
}
} else if (obj.type === 'Gx') {
// Galaxie: Ellipse
ctx.save();
ctx.translate(pt.x, pt.y);
ctx.rotate(Math.PI / 4);
ctx.beginPath();
ctx.ellipse(0, 0, symR, symR * 0.5, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.restore();
} else if (obj.type === 'Nb' || obj.type === 'Sc') {
// Nebel: Rechteck
ctx.beginPath();
ctx.rect(pt.x - symR, pt.y - symR * 0.6, symR * 2, symR * 1.2);
ctx.fill(); ctx.stroke();
} else {
// Sonstige / Asterismus
ctx.beginPath(); ctx.arc(pt.x, pt.y, symR * 0.7, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
}
// Label: M-Nummer + Name
if (labelMessier) {
const label = 'M' + obj.m + (obj.name ? ' ' + obj.name : '');
ctx.fillStyle = style.stroke;
ctx.font = '9px Rajdhani,sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(label, pt.x + symR + 3, pt.y);
}
}
}
// ── Kometenposition: Bahnmechanik (Port aus comets.py) ───────────────────────
const COMET_K = 0.01720209895; // Gauß'sche Gravitationskonstante
const COMET_OBLIQ = 23.439279444444445 * Math.PI / 180; // J2000-Schiefe
function cometSolveElliptic(M, e) {
let E = e < 0.8 ? M : (M >= 0 ? Math.PI : -Math.PI);
for (let i = 0; i < 30; i++) {
const d = (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
E -= d;
if (Math.abs(d) < 1e-12) break;
}
return E;
}
function cometSolveHyperbolic(M, e) {
let F = M === 0 ? 0 : Math.asinh(M / e);
for (let i = 0; i < 40; i++) {
const d = (e * Math.sinh(F) - F - M) / (e * Math.cosh(F) - 1);
F -= d;
if (Math.abs(d) < 1e-12) break;
}
return F;
}
function cometSolveParabolic(deltaDays, q) {
const s = COMET_K * deltaDays / Math.sqrt(2 * q * q * q);
let W = s;
for (let i = 0; i < 40; i++) {
const d = (W + W*W*W/3 - s) / (1 + W*W);
W -= d;
if (Math.abs(d) < 1e-12) break;
}
return W;
}
function cometNuR(deltaDays, q, e) {
const TOL = 1e-6;
if (e < 1 - TOL) {
const a = q / (1 - e);
const n = COMET_K / Math.pow(a, 1.5);
const M = ((n * deltaDays) % (2 * Math.PI));
const E = cometSolveElliptic(M, e);
const r = a * (1 - e * Math.cos(E));
const nu = 2 * Math.atan2(Math.sqrt(1+e)*Math.sin(E/2), Math.sqrt(1-e)*Math.cos(E/2));
return [nu, r];
}
if (e > 1 + TOL) {
const a = q / (e - 1);
const M = COMET_K * deltaDays / Math.pow(a, 1.5);
const F = cometSolveHyperbolic(M, e);
const r = a * (e * Math.cosh(F) - 1);
const nu = 2 * Math.atan2(Math.sqrt(e+1)*Math.sinh(F/2), Math.sqrt(e-1)*Math.cosh(F/2));
return [nu, r];
}
const W = cometSolveParabolic(deltaDays, q);
return [2 * Math.atan(W), q * (1 + W*W)];
}
function cometRaDec(comet, date) {
// Periheldatum in ms
const dayFloor = Math.floor(comet.Tp_d);
const fracMs = (comet.Tp_d - dayFloor) * 86400000;
const tpMs = Date.UTC(comet.Tp_y, comet.Tp_m - 1, dayFloor) + fracMs;
const deltaDays = (date.getTime() - tpMs) / 86400000;
const [nu, r] = cometNuR(deltaDays, comet.q, comet.e);
const w = comet.w * Math.PI / 180;
const Om = comet.Om * Math.PI / 180;
const inc = comet.inc * Math.PI / 180;
const u = w + nu;
const cOs = Math.cos(Om), sOs = Math.sin(Om);
const cI = Math.cos(inc), sI = Math.sin(inc);
const cU = Math.cos(u), sU = Math.sin(u);
const xE = r * (cOs*cU - sOs*sU*cI);
const yE = r * (sOs*cU + cOs*sU*cI);
const zE = r * (sU * sI);
// Ekliptik → Äquatorial (J2000)
const cosEps = Math.cos(COMET_OBLIQ), sinEps = Math.sin(COMET_OBLIQ);
const xEq = xE;
const yEq = yE*cosEps - zE*sinEps;
const zEq = yE*sinEps + zE*cosEps;
// Erdvektor (Astronomy Engine) subtrahieren → geozentrisch
const astTime = Astronomy.MakeTime(date);
const earth = Astronomy.HelioVector(Astronomy.Body.Earth, astTime);
const gx = xEq - earth.x;
const gy = yEq - earth.y;
const gz = zEq - earth.z;
const dist = Math.sqrt(gx*gx + gy*gy + gz*gz);
if (dist === 0) return null;
let ra = Math.atan2(gy, gx) * (180 / Math.PI) / 15;
if (ra < 0) ra += 24;
const dec = Math.asin(gz / dist) * (180 / Math.PI);
// Helligkeit + heliozentrische Distanz
const rH = Math.sqrt(xEq*xEq + yEq*yEq + zEq*zEq);
let mag = null;
if (comet.H !== null && comet.G !== null && rH > 0 && dist > 0) {
mag = comet.H + 5*Math.log10(dist) + 2.5*comet.G*Math.log10(rH);
}
return { ra, dec, mag, rH };
}
function drawComets(date) {
if (!FAV_COMETS || FAV_COMETS.length === 0) return;
for (const comet of FAV_COMETS) {
let pos;
try { pos = cometRaDec(comet, date); } catch(e) { comet._xy = null; continue; }
if (!pos) { comet._xy = null; continue; }
const hor = eqHorizon(pos.ra, pos.dec, date);
if (hor.altitude < 0) { comet._xy = null; continue; }
const pt = project(hor.altitude, hor.azimuth);
if (!pt) { comet._xy = null; continue; }
comet._xy = pt;
comet._pos = pos;
// Schweifrichtung: vom Sonnenzentrum weg (auch wenn Sonne unter dem Horizont)
let tailAngle = -Math.PI * 0.65;
if (sunCache && Number.isFinite(sunCache.az) && Number.isFinite(sunCache.alt)) {
// Virtuelle Sonnenposition berechnen – ohne Horizont-Prüfung,
// damit der Schweif auch bei Nacht korrekt von der Sonne weg zeigt
const sunRho = (90.0 - sunCache.alt) / 90.0;
const sunAzR = sunCache.az * Math.PI / 180.0;
const sunVX = cx - sunRho * R * Math.sin(sunAzR);
const sunVY = cy - sunRho * R * Math.cos(sunAzR);
tailAngle = Math.atan2(pt.y - sunVY, pt.x - sunVX);
}
// Schweifläng: sonnennah = lang, sonnenfern = kurz (20/rH AU, Bereich 6–55 px)
const tailLen = pos.rH > 0 ? Math.max(6, Math.min(55, 20 / pos.rH)) : 12;
const tx = pt.x + Math.cos(tailAngle) * tailLen;
const ty = pt.y + Math.sin(tailAngle) * tailLen;
const grd = ctx.createLinearGradient(pt.x, pt.y, tx, ty);
grd.addColorStop(0, 'rgba(201,168,76,0.80)');
grd.addColorStop(0.5, 'rgba(240,217,144,0.35)');
grd.addColorStop(1, 'rgba(201,168,76,0)');
ctx.beginPath();
ctx.moveTo(pt.x, pt.y);
ctx.lineTo(tx, ty);
ctx.strokeStyle = grd;
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.stroke();
const comaR = 4;
const glow = createSafeRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, comaR * 2.5);
if (glow) {
glow.addColorStop(0, 'rgba(255,245,200,0.95)');
glow.addColorStop(0.4, 'rgba(201,168,76,0.60)');
glow.addColorStop(1, 'rgba(201,168,76,0)');
ctx.beginPath();
ctx.arc(pt.x, pt.y, comaR * 2.5, 0, Math.PI * 2);
ctx.fillStyle = glow;
ctx.fill();
}
ctx.beginPath();
ctx.arc(pt.x, pt.y, comaR * 0.7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,250,220,0.95)';
ctx.fill();
if (labelComets) {
const magStr = pos.mag !== null && isFinite(pos.mag) ? ' (' + pos.mag.toFixed(1) + 'mag)' : '';
ctx.font = '10px Rajdhani,sans-serif';
ctx.fillStyle = 'rgba(240,217,144,0.90)';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(comet.name, pt.x + comaR * 2.5 + 4, pt.y - 6);
}
}
}
function render() {
const date = getTime();
ctx.clearRect(0, 0, W, H);
// Hintergrund
ctx.fillStyle = CFG.sky.background;
ctx.fillRect(0, 0, W, H);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
const skyG = createSafeRadialGradient(cx, cy, 0, cx, cy, R);
if (skyG) {
skyG.addColorStop(0, CFG.sky.colorCenter);
skyG.addColorStop(0.8, CFG.sky.colorMid);
skyG.addColorStop(1, CFG.sky.colorEdge);
ctx.fillStyle = skyG;
} else {
ctx.fillStyle = CFG.sky.background;
}
ctx.fill();
ctx.clip();
if (showAltaz) drawAltazGrid();
if (showEq) drawEquatorialGrid(date);
if (showEclip) drawEcliptic(date);
if (showConst) drawConstellations(date);
if (showMessier) drawMessier(date);
// ── Sterne ───────────────────────────────────────────────────────────────
const visStars = showStars ? stars.filter(s => s.mag <= magLimit) : [];
for (const s of visStars) {
s._visible = false;
const hor = starHorizon(s, date);
if (hor.altitude < 0) continue;
const pt = project(hor.altitude, hor.azimuth);
if (!pt) continue;
s._x = pt.x; s._y = pt.y; s._visible = true;
const t = (magLimit - s.mag) / (magLimit + 1.5);
const radius = Math.max(0.4, t * 3.6);
const color = bvToColor(s.ci, s.spect);
drawStarSprite(pt.x, pt.y, radius, color, s.mag);
if (labelStars && s.proper && s.mag < CFG.stars.labelBelowMag) {
ctx.fillStyle = CFG.stars.colorLabel;
ctx.font = CFG.stars.fontLabel;
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(s.proper, pt.x + radius + 3, pt.y - 2);
}
}
// ── Sonne & Mond ─────────────────────────────────────────────────────────
if (showSunMoon) drawSunMoon(date);
// ── Planeten ─────────────────────────────────────────────────────────────
if (showPlanets) {
computePlanets(date);
for (const p of planetCache) {
if (!Number.isFinite(p.alt) || !Number.isFinite(p.az) || p.alt < 0) continue;
const pt = project(p.alt, p.az);
if (!pt) continue;
const radius = Number(p.r);
if (!Number.isFinite(radius) || radius <= 0) continue;
p._xy = pt;
const glow = createSafeRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, radius * 4.5);
if (glow) {
glow.addColorStop(0, p.color + 'aa');
glow.addColorStop(0.4, p.color + '22');
glow.addColorStop(1, 'transparent');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(pt.x, pt.y, radius * 4.5, 0, Math.PI * 2); ctx.fill();
}
ctx.fillStyle = p.color;
ctx.beginPath(); ctx.arc(pt.x, pt.y, radius, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = p.color + '99'; ctx.lineWidth = 0.8; ctx.stroke();
if (labelPlanets) {
ctx.fillStyle = p.color;
ctx.font = 'bold 10px Rajdhani,sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
ctx.fillText(p.name, pt.x + radius + 4, pt.y);
}
}
}
// ── Favoriten-Kometen ────────────────────────────────────────────────────
if (showComets) drawComets(date);
ctx.restore(); // Ende Clip
// Horizontring
const rim = createSafeRadialGradient(cx, cy, R * 0.95, cx, cy, R * 1.05);
if (rim) {
rim.addColorStop(0, 'transparent');
rim.addColorStop(0.5, CFG.horizon.color);
rim.addColorStop(1, 'transparent');
}
ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.strokeStyle = rim || CFG.horizon.color; ctx.lineWidth = CFG.horizon.width; ctx.stroke();
// Himmelsrichtungen
const CC = CFG.compass;
for (const [lbl, az, maj] of [
['N',0,true],['NO',45,false],['O',90,true],['SO',135,false],
['S',180,true],['SW',225,false],['W',270,true],['NW',315,false]]) {
const azR = az * Math.PI / 180;
const dist = R + (maj ? 20 : 15);
ctx.fillStyle = maj ? CC.colorMajor : CC.colorMinor;
ctx.font = maj ? CC.fontMajor : CC.fontMinor;
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(lbl, cx - dist * Math.sin(azR), cy - dist * Math.cos(azR));
}
// Zenith
ctx.fillStyle = CC.colorMajor + '88';
ctx.beginPath(); ctx.arc(cx, cy, 2.5, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = CC.colorMajor + '55';
ctx.font = '8px Share Tech Mono,monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('ZEN', cx, cy - 5);
}
// ═══════════════════════════════════════════════════════════════════════════════
// INFOZEILE
// ═══════════════════════════════════════════════════════════════════════════════
function updateInfo() {
const d = getTime();
document.getElementById('lblTime').textContent = d.toUTCString().slice(17, 25);
// Lokale Sternzeit via Astronomy Engine (Greenwich Sternzeit in Stunden)
const gst = Astronomy.SiderealTime(Astronomy.MakeTime(d));
const lst = ((gst + LON_DEG / 15.0) % 24 + 24) % 24;
const lH = Math.floor(lst);
const lM = Math.floor((lst - lH) * 60);
document.getElementById('lblLST').textContent =
String(lH).padStart(2,'0') + 'h' + String(lM).padStart(2,'0') + 'm';
}
// ═══════════════════════════════════════════════════════════════════════════════
// DATETIME-PICKER
// ═══════════════════════════════════════════════════════════════════════════════
function syncPicker() {
const d = liveMode ? new Date() : simTime;
const loc = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
document.getElementById('dtPicker').value = loc.toISOString().slice(0, 16);
}
syncPicker();
document.getElementById('dtPicker').addEventListener('change', function() {
if (!this.value) return;
simTime = new Date(this.value);
liveMode = false;
document.getElementById('btnNow').classList.remove('live');
updateInfo(); render();
});
document.getElementById('btnNow').addEventListener('click', function() {
liveMode = true;
this.classList.add('live');
syncPicker(); updateInfo(); render();
});
// Live-Tick alle 10 Sekunden
setInterval(() => {
if (!liveMode) return;
syncPicker(); updateInfo(); render();
}, 10000);
updateInfo();
// ═══════════════════════════════════════════════════════════════════════════════
// ZEITSPRUNG-BUTTONS (★ = Sterntag = 86164.0916 s)
// ═══════════════════════════════════════════════════════════════════════════════
document.querySelectorAll('.step-btn').forEach(btn => {
btn.addEventListener('click', function() {
const sec = parseFloat(this.dataset.sec);
// Wenn Live-Modus: aktuelle Zeit als Basis nehmen und einfrieren
if (liveMode) {
simTime = new Date();
liveMode = false;
document.getElementById('btnNow').classList.remove('live');
}
simTime = new Date(simTime.getTime() + sec * 1000);
syncPicker();
updateInfo();
render();
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// URL-PARAMETER – ?img=true&lat=…&lon=…&elev=…&date=…
// ═══════════════════════════════════════════════════════════════════════════════
const URL_PARAMS = new URLSearchParams(window.location.search);
const AUTO_IMG = URL_PARAMS.get('img') === 'true';
function applyUrlParams() {
// Datum/Zeit (ISO 8601 Lokalzeit: 2026-03-12T21:00 oder …Z für UTC)
const pDate = URL_PARAMS.get('date');
if (pDate) {
const d = new Date(pDate);
if (isFinite(d)) {
simTime = d;
liveMode = false;
syncPicker();
}
}
}
function autoExportPng() {
const size = 1000;
const expCanvas = document.createElement('canvas');
expCanvas.width = size;
expCanvas.height = size;
const _ctx = ctx, _W = W, _H = H, _R = R, _cx = cx, _cy = cy;
ctx = expCanvas.getContext('2d');
W = size; H = size; R = size * 0.455; cx = size / 2; cy = size / 2;
render();
ctx = _ctx; W = _W; H = _H; R = _R; cx = _cx; cy = _cy;
const d = getTime();
const pad = n => String(n).padStart(2, '0');
const stamp = d.getFullYear() + pad(d.getMonth()+1) + pad(d.getDate())
+ '_' + pad(d.getHours()) + pad(d.getMinutes());
const fname = 'planetarium_sonneberg_' + stamp + '.png';
expCanvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
// Neuer Tab: Browser zeigt das PNG direkt an → Rechtsklick/Strg+S zum Speichern
const win = window.open(url, '_blank');
// Falls Popup blockiert: Fallback-Link ins Dokument einblenden
if (!win) {
const bar = document.createElement('div');
bar.className = 'star-popup-fallback';
const label = document.createElement('span');
label.className = 'star-popup-fallback-label';
label.textContent = 'Popup blockiert -';
const link = document.createElement('a');
link.className = 'star-popup-fallback-link';
link.href = url;
link.download = fname;
link.textContent = 'PNG speichern';
const close = document.createElement('button');
close.type = 'button';
close.className = 'star-popup-fallback-close';
close.setAttribute('aria-label', 'Hinweis schliessen');
close.textContent = 'x';
close.addEventListener('click', () => bar.remove());
bar.append(label, link, close);
document.body.appendChild(bar);
}
}, 'image/png');
}
// ═══════════════════════════════════════════════════════════════════════════════
const btnLocation = document.getElementById('btnLocation');
const locationPanel = document.getElementById('locationPanel');
const btnLocSet = document.getElementById('btnLocSet');
const locationSelect = document.getElementById('locationSelect');
if (locationSelect) {
locationSelect.addEventListener('change', function() {
applyLocationOption(this.value);
});
}
if (btnLocation && locationPanel) btnLocation.addEventListener('click', function(e) {
e.stopPropagation();
const open = locationPanel.classList.toggle('open');
btnLocation.classList.toggle('open', open);
// Koordinatenfelder mit aktuellem Standort befüllen
if (open) {
document.getElementById('locLat').value = LAT_DEG.toFixed(4);
document.getElementById('locLon').value = LON_DEG.toFixed(4);
document.getElementById('locElev').value = ELEV_M;
}
});
document.addEventListener('click', function(e) {
if (btnLocation && locationPanel && !locationPanel.contains(e.target) && e.target !== btnLocation) {
locationPanel.classList.remove('open');
btnLocation.classList.remove('open');
}
});
// Preset-Buttons
document.querySelectorAll('.loc-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
setLocation(
parseFloat(this.dataset.lat),
parseFloat(this.dataset.lon),
parseFloat(this.dataset.elev) || 0
);
// Koordinatenfelder aktualisieren
document.getElementById('locLat').value = parseFloat(this.dataset.lat).toFixed(4);
document.getElementById('locLon').value = parseFloat(this.dataset.lon).toFixed(4);
document.getElementById('locElev').value = this.dataset.elev || 0;
});
});
// Manuelle Koordinaten übernehmen
if (btnLocSet) btnLocSet.addEventListener('click', function() {
const lat = parseFloat(document.getElementById('locLat').value);
const lon = parseFloat(document.getElementById('locLon').value);
const elev = parseFloat(document.getElementById('locElev').value) || 0;
if (!isFinite(lat) || !isFinite(lon)) return;
// Keinen Preset als aktiv markieren
document.querySelectorAll('.loc-btn').forEach(b => b.classList.remove('active'));
setLocation(lat, lon, elev);
});
// ═══════════════════════════════════════════════════════════════════════════════
// EINSTELLUNGEN PANEL
// ═══════════════════════════════════════════════════════════════════════════════
const btnSettings = document.getElementById('btnSettings');
const settingsPanel = document.getElementById('settingsPanel');
btnSettings.addEventListener('click', function(e) {
e.stopPropagation();
const open = settingsPanel.classList.toggle('open');
btnSettings.classList.toggle('open', open);
});
document.addEventListener('click', function(e) {
if (!settingsPanel.contains(e.target) && e.target !== btnSettings) {
settingsPanel.classList.remove('open');
btnSettings.classList.remove('open');
}
});
function spBind(id, getter, setter) {
const el = document.getElementById(id);
if (!el) return;
el.checked = getter();
el.addEventListener('change', function() { setter(this.checked); render(); });
}
// Gitter-Auswahl (Radio)
document.querySelectorAll('input[name="gridMode"]').forEach(radio => {
radio.addEventListener('change', function() {
showEq = this.value === 'eq';
showAltaz = this.value === 'altaz';
render();
});
});
spBind('cfgEqLabels', () => labelEq, v => { labelEq = v; labelAltaz = v; });
spBind('cfgConstShow', () => showConst, v => showConst = v);
spBind('cfgPlanetsShow', () => showPlanets, v => showPlanets = v);
spBind('cfgCometsShow', () => showComets, v => showComets = v);
spBind('cfgCometsLabels', () => labelComets, v => labelComets = v);
spBind('cfgEclipShow', () => showEclip, v => showEclip = v);
spBind('cfgMessierShow', () => showMessier, v => showMessier = v);
spBind('cfgSunMoonShow', () => showSunMoon, v => showSunMoon = v);
spBind('cfgConstLabels', () => labelConst, v => labelConst = v);
spBind('cfgStarsShow', () => showStars, v => showStars = v);
spBind('cfgStarsLabels', () => labelStars, v => labelStars = v);
spBind('cfgStarsAutoMag', () => autoStarMagByZoom, v => {
autoStarMagByZoom = v;
if (autoStarMagByZoom) applyAutoMagLimitFromZoom();
else syncMagLimitUi();
});
spBind('cfgMessierAutoMag', () => autoMessierMagByZoom, v => {
autoMessierMagByZoom = v;
if (autoMessierMagByZoom) applyAutoMessierMagLimitFromZoom();
else syncMessierMagLimitUi();
});
spBind('cfgPlanetsLabels', () => labelPlanets, v => labelPlanets = v);
spBind('cfgSunMoonLabels', () => labelSunMoon, v => labelSunMoon = v);
spBind('cfgMessierLabels', () => labelMessier, v => labelMessier = v);
// ═══════════════════════════════════════════════════════════════════════════════
// BILD-EXPORT
// ═══════════════════════════════════════════════════════════════════════════════
document.getElementById('btnExport').addEventListener('click', function() {
const size = 1000;
const expCanvas = document.createElement('canvas');
expCanvas.width = size;
expCanvas.height = size;
const _ctx = ctx, _W = W, _H = H, _R = R, _cx = cx, _cy = cy;
ctx = expCanvas.getContext('2d');
W = size; H = size;
R = size * 0.455;
cx = size / 2; cy = size / 2;
render();
ctx = _ctx; W = _W; H = _H; R = _R; cx = _cx; cy = _cy;
const d = getTime();
const pad = n => String(n).padStart(2, '0');
const stamp = d.getFullYear() + pad(d.getMonth()+1) + pad(d.getDate())
+ '_' + pad(d.getHours()) + pad(d.getMinutes());
expCanvas.toBlob(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'planetarium_sonneberg_' + stamp + '.png';
a.click();
URL.revokeObjectURL(a.href);
}, 'image/png');
});
// ═══════════════════════════════════════════════════════════════════════════════
// MAGNITUDE-REGLER
// ═══════════════════════════════════════════════════════════════════════════════
document.getElementById('magLimit').addEventListener('input', function() {
if (autoStarMagByZoom) return;
magLimit = parseFloat(this.value);
syncMagLimitUi();
if (stars.length > 0) render();
});
document.getElementById('magLimitMessier').addEventListener('input', function() {
if (autoMessierMagByZoom) return;
messierMagLimit = parseFloat(this.value);
syncMessierMagLimitUi();
render();
});
// ═══════════════════════════════════════════════════════════════════════════════
// KATALOG LADEN – automatisch per fetch, Fallback auf Datei-Dialog
// ═══════════════════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════════════════
// RESSOURCEN LADEN – Sterne aus DB
// Auto-Export erst wenn alles fertig ist
// ═══════════════════════════════════════════════════════════════════════════════
if (EMBEDDED_CONSTELLATION_ERROR) {
console.warn('Sternbilddaten aus der Datenbank nicht geladen:', EMBEDDED_CONSTELLATION_ERROR);
}
if (EMBEDDED_STAR_ERROR) {
console.warn('Sterndaten aus der Datenbank nicht geladen:', EMBEDDED_STAR_ERROR);
}
if (EMBEDDED_MESSIER_ERROR) {
console.warn('Messier-Daten aus der Datenbank nicht geladen:', EMBEDDED_MESSIER_ERROR);
}
function updateStarLoadingUi(done, total) {
if (!starLoadingOverlay || !starLoadingFill || !starLoadingStatus || !starLoadingNote) return;
const safeTotal = Math.max(1, total);
const percent = Math.max(0, Math.min(100, Math.round((done / safeTotal) * 100)));
starLoadingFill.style.width = percent + '%';
starLoadingStatus.textContent = percent + '%';
starLoadingNote.textContent = done >= total
? 'Sternkatalog geladen'
: done.toLocaleString('de') + ' / ' + total.toLocaleString('de') + ' Sterne vorbereitet';
}
function hideStarLoadingUi() {
if (!starLoadingOverlay) return;
starLoadingOverlay.classList.add('hidden');
}
function finalizeStarLoad() {
starsLoaded = true;
document.getElementById('lblCount').textContent = stars.length.toLocaleString('de');
applyAutoMagLimitFromZoom();
syncMagLimitUi();
applyAutoMessierMagLimitFromZoom();
syncMessierMagLimitUi();
render();
window.setTimeout(hideStarLoadingUi, 180);
console.info('Sterne aus DB geladen:', stars.length);
if (AUTO_IMG) autoExportPng();
}
function processStarBatch(source) {
const total = source.length;
const chunkSize = total > 60000 ? 1800 : 1200;
let index = 0;
stars = [];
hipMap.clear();
document.getElementById('lblCount').textContent = '0';
updateStarLoadingUi(0, total);
if (total === 0) {
finalizeStarLoad();
return;
}
const processChunk = () => {
const end = Math.min(index + chunkSize, total);
for (; index < end; index++) {
const s = source[index];
const star = {
hip: s.hip ?? null,
proper: s.proper || '',
bayer: s.bayer || '',
con: s.con || '',
spect: s.spect || '',
ra: Number(s.ra),
dec: Number(s.dec),
mag: Number(s.mag),
ci: s.ci === null || s.ci === undefined ? NaN : Number(s.ci),
_x: 0,
_y: 0,
_visible: false
};
if (!Number.isFinite(star.ra) || !Number.isFinite(star.dec) || !Number.isFinite(star.mag)) {
continue;
}
stars.push(star);
if (star.hip !== null && Number.isFinite(Number(star.hip))) {
hipMap.set(parseInt(star.hip, 10), star);
}
}
updateStarLoadingUi(index, total);
render();
if (index < total) {
window.setTimeout(processChunk, 0);
return;
}
finalizeStarLoad();
};
window.setTimeout(processChunk, 0);
}
async function loadEmbeddedStarsAsync() {
updateStarLoadingUi(0, 1);
if (starLoadingNote) starLoadingNote.textContent = 'Sternkatalog wird vom Server geladen ...';
const response = await fetch(STAR_DATA_URL, {
headers: { 'Accept': 'application/json' },
cache: 'no-store'
});
if (!response.ok) {
throw new Error('Sternkatalog konnte nicht geladen werden (HTTP ' + response.status + ')');
}
const payload = await response.json();
if (!payload || payload.ok !== true || !Array.isArray(payload.stars)) {
throw new Error(payload && payload.error ? payload.error : 'Ungültige Sternkatalog-Antwort');
}
processStarBatch(payload.stars);
}
// Manueller Fallback über Datei-Dialog
document.getElementById('fileInput').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
console.info('Lade', file.name);
const reader = new FileReader();
reader.onload = ev => {
stars = parseHYG(ev.target.result);
hipMap.clear();
for (const s of stars) { if (s.hip) hipMap.set(parseInt(s.hip), s); }
document.getElementById('lblCount').textContent = stars.length.toLocaleString('de');
hideStarLoadingUi();
render();
};
reader.readAsText(file);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TOOLTIP
// ═══════════════════════════════════════════════════════════════════════════════
let _mouseDownPos = null;
function enhanceMessierTooltip(tip, obj) {
const image = tip.querySelector('.tt-image');
if (!image) return;
image.addEventListener('error', function handleError() {
const media = tip.querySelector('.tt-media');
if (media) media.remove();
}, { once: true });
image.src = 'images/dso/messier/m' + obj.m + '.png';
}
canvas.addEventListener('mousedown', function(e) {
_mouseDownPos = { x: e.clientX, y: e.clientY };
}, true);
canvas.addEventListener('click', function(e) {
// Drag ignorieren (Bewegung > 4px = kein Klick)
if (_mouseDownPos && Math.hypot(e.clientX - _mouseDownPos.x, e.clientY - _mouseDownPos.y) > 4) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const tip = document.getElementById('tooltip');
// Sonne & Mond zuerst
if (showSunMoon) {
if (sunCache && sunCache._xy && Math.hypot(mx - sunCache._xy.x, my - sunCache._xy.y) < 18) {
tip.innerHTML =
'<span class="tt-name tt-sun">Sonne</span>' +
'Alt: ' + sunCache.alt.toFixed(2) + '°<br>' +
'Az: ' + sunCache.az.toFixed(2) + '°<br>' +
'RA: ' + sunCache.ra.toFixed(4) + ' h<br>' +
'Dec: ' + sunCache.dec.toFixed(4) + '°';
tip.className = 'visible'; positionTooltip(e); return;
}
if (moonCache && moonCache._xy && Math.hypot(mx - moonCache._xy.x, my - moonCache._xy.y) < 18) {
const pct = Math.round(moonCache.phase_fraction * 100);
const mp = moonCache.moon_phase;
const phaseName = mp < 45 ? 'Neumond' : mp < 90 ? 'Zunehmende Sichel' :
mp < 135 ? 'Erstes Viertel' : mp < 180 ? 'Zunehmender Gibbous' :
mp < 225 ? 'Vollmond' : mp < 270 ? 'Abnehmender Gibbous' :
mp < 315 ? 'Letztes Viertel' : 'Abnehmende Sichel';
tip.innerHTML =
'<span class="tt-name tt-moon">Mond</span>' +
phaseName + '<br>' +
'Beleuchtung: ' + pct + '%<br>' +
'Phase: ' + mp.toFixed(1) + '°<br>' +
'Alt: ' + moonCache.alt.toFixed(2) + '°<br>' +
'Az: ' + moonCache.az.toFixed(2) + '°<br>' +
'RA: ' + moonCache.ra.toFixed(4) + ' h<br>' +
'Dec: ' + moonCache.dec.toFixed(4) + '°';
tip.className = 'visible'; positionTooltip(e); return;
}
}
// Planeten
let hit = null;
for (const p of planetCache) {
if (!p._xy) continue;
if (Math.hypot(mx - p._xy.x, my - p._xy.y) < 14) { hit = p; break; }
}
if (hit) {
const hor = eqHorizon(hit.ra, hit.dec, getTime());
tip.innerHTML =
'<span class="tt-name tt-planet">' + hit.name + '</span>' +
'Mag: ' + hit.mag.toFixed(2) + '<br>' +
'Alt: ' + hor.altitude.toFixed(2) + '°<br>' +
'Az: ' + hor.azimuth.toFixed(2) + '°<br>' +
'RA: ' + hit.ra.toFixed(5) + ' h<br>' +
'Dec: ' + hit.dec.toFixed(5) + '°';
tip.className = 'visible'; positionTooltip(e); return;
}
// Favoriten-Kometen
if (showComets) {
for (const comet of FAV_COMETS) {
if (!comet._xy) continue;
if (Math.hypot(mx - comet._xy.x, my - comet._xy.y) < 14) {
const pos = comet._pos || {};
const hor = eqHorizon(pos.ra ?? 0, pos.dec ?? 0, getTime());
const magStr = pos.mag !== null && pos.mag !== undefined && isFinite(pos.mag) ? pos.mag.toFixed(1) : '–';
tip.innerHTML =
'<span class="tt-name tt-planet" style="color:#f0d990">☄ ' + comet.name + '</span>' +
'Mag: ' + magStr + '<br>' +
'Alt: ' + hor.altitude.toFixed(2) + '°<br>' +
'Az: ' + hor.azimuth.toFixed(2) + '°<br>' +
'RA: ' + (pos.ra ?? 0).toFixed(4) + ' h<br>' +
'Dec: ' + (pos.dec ?? 0).toFixed(3) + '°';
tip.className = 'visible'; positionTooltip(e); return;
}
}
}
// Messier-Objekte
if (showMessier) {
for (const obj of MESSIER) {
if (!obj._xy) continue;
if (Math.hypot(mx - obj._xy.x, my - obj._xy.y) < 16) {
const typeNames = {Oc:'Offener Haufen',Gc:'Kugelhaufen',Nb:'Emissionsnebel',
Sc:'Haufen+Nebel',Pn:'Planetarischer Nebel',Gx:'Galaxie',As:'Asterismus'};
tip.innerHTML =
'<span class="tt-name ' + ('tt-messier-' + String(obj.type || 'As').toLowerCase()) + '">M' + obj.m +
(obj.name ? ' – ' + obj.name : '') + '</span>' +
'<div class="tt-media"><img class="tt-image" alt="Messier M' + obj.m + '"></div>' +
'Typ: ' + (typeNames[obj.type] || obj.type) + '<br>' +
'Mag: ' + obj.mag.toFixed(1) + '<br>' +
'Größe: '+ obj.size + '′<br>' +
'Alt: ' + obj._alt.toFixed(1) + '°<br>' +
'Az: ' + obj._az.toFixed(1) + '°<br>' +
'RA: ' + obj.ra.toFixed(4) + ' h Dec: ' + obj.dec.toFixed(3) + '°';
enhanceMessierTooltip(tip, obj);
tip.className = 'visible'; positionTooltip(e); return;
}
}
}
// Sterne
let closest = null, minD = 10;
for (const s of stars) {
if (!s._visible) continue;
const d = Math.hypot(mx - s._x, my - s._y);
if (d < minD) { minD = d; closest = s; }
}
if (closest) {
const hor = starHorizon(closest, getTime());
const hasBayer = !!closest.bayer;
const name = closest.proper || closest.bayer || ('HIP ' + closest.hip);
tip.innerHTML =
'<span class="tt-name">' + name + '</span>' +
(hasBayer ? 'Bayer: ' + closest.bayer + ' ' + closest.con + '<br>' : 'HIP: ' + closest.hip + '<br>') +
'Kons: ' + (closest.con || '–') + '<br>' +
'Mag: ' + closest.mag.toFixed(2) + '<br>' +
'SpTyp: ' + (closest.spect.slice(0,6) || '–') + '<br>' +
'B-V: ' + (isNaN(closest.ci) ? '–' : closest.ci.toFixed(2)) + '<br>' +
'Alt: ' + hor.altitude.toFixed(2) + '°<br>' +
'Az: ' + hor.azimuth.toFixed(2) + '°';
tip.className = 'visible'; positionTooltip(e);
} else {
// Klick auf leeren Himmel → Tooltip schließen
tip.className = '';
}
});
function positionTooltip(e) {
const tip = document.getElementById('tooltip');
const tw = 225; // max-width aus CSS
const th = tip.offsetHeight > 0 ? tip.offsetHeight : 180;
const margin = 12;
let tx = e.clientX + 16;
let ty = e.clientY - 10;
if (tx + tw + margin > window.innerWidth) tx = e.clientX - tw - 16;
if (tx < margin) tx = margin;
if (ty + th + margin > window.innerHeight) ty = window.innerHeight - th - margin;
if (ty < margin) ty = margin;
tip.style.left = tx + 'px';
tip.style.top = ty + 'px';
}
// ═══════════════════════════════════════════════════════════════════════════════
// START
// ═══════════════════════════════════════════════════════════════════════════════
applyUrlParams();
applyAutoMagLimitFromZoom();
syncMagLimitUi();
applyAutoMessierMagLimitFromZoom();
syncMessierMagLimitUi();
setLocation(LAT_DEG, LON_DEG, ELEV_M, HEADER_LOCATION.name || 'Standard-Standort', HEADER_LOCATION.id ?? '');
resize();
window.requestAnimationFrame(() => {
window.setTimeout(() => {
loadEmbeddedStarsAsync().catch(err => {
console.warn('Sterndaten aus der Datenbank nicht geladen:', err);
if (starLoadingStatus) starLoadingStatus.textContent = 'Fehler';
if (starLoadingNote) starLoadingNote.textContent = err && err.message ? err.message : 'Sternkatalog konnte nicht geladen werden';
});
}, 40);
});
}); // end window.onload
</script>
<?php require __DIR__ . '/footer.php'; ?>