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

2079 lines
84 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
ini_set('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']),
];
}
}
} 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();
}
$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>
<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;
// 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 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 : [];
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;
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 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([]);
}
// ═══════════════════════════════════════════════════════════════════════════════
// 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 bvToColor(bv) {
if (isNaN(bv) || bv === undefined) return '#ffffff';
if (bv < -0.30) return '#9bb0ff';
if (bv < -0.10) return '#aabfff';
if (bv < 0.00) return '#cad7ff';
if (bv < 0.20) return '#f8f7ff';
if (bv < 0.40) return '#fff4e8';
if (bv < 0.60) return '#ffddb4';
if (bv < 0.80) return '#ffcc6f';
if (bv < 1.20) return '#ffaa44';
return '#ff6622';
}
// ═══════════════════════════════════════════════════════════════════════════════
// 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);
}
}
}
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);
if (s.mag < CFG.stars.glowBelowMag) {
const glow = createSafeRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, radius * 6);
if (glow) {
glow.addColorStop(0, color + 'bb'); glow.addColorStop(0.3, color + '33');
glow.addColorStop(1, 'transparent');
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(pt.x, pt.y, radius * 6, 0, Math.PI * 2); ctx.fill();
}
}
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(pt.x, pt.y, radius, 0, Math.PI * 2); ctx.fill();
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);
}
}
}
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('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;
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;
}
// 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>' +
'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) + '°';
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'; ?>