2605 lines
102 KiB
PHP
2605 lines
102 KiB
PHP
<?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/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'; ?>
|
||
|
||
|