Files
skyview.astronomiemuseum.de/public/solarsystem.php
T

3271 lines
114 KiB
PHP

<?php
declare(strict_types=1);
ini_set('display_errors', '1');
error_reporting(E_ALL);
session_start();
$loggedIn = isset($_SESSION['user_id']);
$trackedMinorplanetsForJs = [];
$trackedCometsForJs = [];
$voyager1TrackForJs = [];
$voyager2TrackForJs = [];
$newHorizonsTrackForJs = [];
$pdo = null;
$spacecraftOptionsForJs = [];
function h(?string $value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function decodeMpcPackedDateChar(string $value): ?int
{
if ($value >= '0' && $value <= '9') {
return (int) $value;
}
$upper = strtoupper($value);
if ($upper >= 'A' && $upper <= 'V') {
return ord($upper) - ord('A') + 10;
}
return null;
}
function decodeMpcPackedDate(?string $packed): ?string
{
if ($packed === null || strlen($packed) !== 5) {
return null;
}
$centuryMap = [
'I' => 1800,
'J' => 1900,
'K' => 2000,
];
$centuryCode = strtoupper($packed[0]);
if (!isset($centuryMap[$centuryCode])) {
return null;
}
$yearPart = substr($packed, 1, 2);
if (!ctype_digit($yearPart)) {
return null;
}
$month = decodeMpcPackedDateChar($packed[3]);
$day = decodeMpcPackedDateChar($packed[4]);
if ($month === null || $day === null || $month < 1 || $month > 12 || $day < 1 || $day > 31) {
return null;
}
return sprintf('%04d-%02d-%02d', $centuryMap[$centuryCode] + (int) $yearPart, $month, $day);
}
function formatMinorplanetDisplayName(array $minorplanet): string
{
$designation = trim((string) ($minorplanet['designation'] ?? $minorplanet['designation_text'] ?? ''));
$designation = preg_replace('/^\(\d+\)\s*/', '', $designation) ?? $designation;
$number = $minorplanet['number'] ?? $minorplanet['mp_number'] ?? null;
if ($number !== null && $number !== '') {
return '(' . (int) $number . ') ' . $designation;
}
return $designation !== '' ? $designation : (string) ($minorplanet['packedDesignation'] ?? $minorplanet['desig_packed'] ?? '--');
}
function formatCometDisplayName(array $comet): string
{
$designation = trim((string) ($comet['designation_and_name'] ?? $comet['designation'] ?? ''));
return $designation !== '' ? $designation : (string) ($comet['designation_packed'] ?? $comet['packedDesignation'] ?? '--');
}
function buildCometPerihelionIso(array $comet): ?string
{
$year = isset($comet['year_of_perihelion']) ? (int) $comet['year_of_perihelion'] : 0;
$month = isset($comet['month_of_perihelion']) ? (int) $comet['month_of_perihelion'] : 0;
$dayValue = isset($comet['day_of_perihelion']) ? (float) $comet['day_of_perihelion'] : 0.0;
if ($year <= 0 || $month < 1 || $month > 12 || $dayValue <= 0.0) {
return null;
}
$day = (int) floor($dayValue);
if ($day < 1 || $day > 31) {
return null;
}
$fractionalDay = $dayValue - $day;
$seconds = (int) round($fractionalDay * 86400.0);
try {
$date = new DateTimeImmutable(sprintf('%04d-%02d-%02d 00:00:00', $year, $month, $day), new DateTimeZone('UTC'));
} catch (Throwable $e) {
return null;
}
return $date->modify(sprintf('+%d seconds', $seconds))->format('c');
}
function loadSpacecraftTrackCsv(string $csvPath): array
{
if (!is_file($csvPath)) {
return [];
}
$lines = file($csvPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false || count($lines) < 2) {
return [];
}
$header = str_getcsv((string) array_shift($lines), ',', '"', '\\');
if ($header === false || empty($header)) {
return [];
}
$rows = [];
foreach ($lines as $line) {
$values = str_getcsv((string) $line, ',', '"', '\\');
if (!is_array($values) || count($values) !== count($header)) {
continue;
}
$row = array_combine($header, $values);
if (!is_array($row)) {
continue;
}
$timestamp = strtotime((string) ($row['date_tdb'] ?? ''));
$xKm = isset($row['x_km']) ? (float) str_replace(',', '.', (string) $row['x_km']) : null;
$yKm = isset($row['y_km']) ? (float) str_replace(',', '.', (string) $row['y_km']) : null;
$zKm = isset($row['z_km']) ? (float) str_replace(',', '.', (string) $row['z_km']) : null;
$distanceAu = isset($row['distance_au']) ? (float) str_replace(',', '.', (string) $row['distance_au']) : null;
if ($timestamp === false || !is_finite((float) $xKm) || !is_finite((float) $yKm) || !is_finite((float) $zKm) || !is_finite((float) $distanceAu)) {
continue;
}
$rows[] = [
'timestampMs' => $timestamp * 1000,
'dateTdb' => (string) $row['date_tdb'],
'xAu' => $xKm / 149597870.7,
'yAu' => $yKm / 149597870.7,
'zAu' => $zKm / 149597870.7,
'distanceAu' => $distanceAu,
];
}
return $rows;
}
function loadSpacecraftTrackFromDatabase(PDO $pdo, string $missionKey): array
{
$stmt = $pdo->prepare("
SELECT
`date_tdb`,
`x_km`,
`y_km`,
`z_km`,
`distance_au`
FROM `spacecraft_tracks`
WHERE `mission_key` = :mission_key
ORDER BY `date_tdb` ASC
");
$stmt->execute([
':mission_key' => $missionKey,
]);
$rows = [];
foreach ($stmt->fetchAll() as $row) {
$timestamp = strtotime((string) ($row['date_tdb'] ?? ''));
$xKm = isset($row['x_km']) ? (float) $row['x_km'] : null;
$yKm = isset($row['y_km']) ? (float) $row['y_km'] : null;
$zKm = isset($row['z_km']) ? (float) $row['z_km'] : null;
$distanceAu = isset($row['distance_au']) ? (float) $row['distance_au'] : null;
if ($timestamp === false || !is_finite((float) $xKm) || !is_finite((float) $yKm) || !is_finite((float) $zKm) || !is_finite((float) $distanceAu)) {
continue;
}
$rows[] = [
'timestampMs' => $timestamp * 1000,
'dateTdb' => (string) $row['date_tdb'],
'xAu' => $xKm / 149597870.7,
'yAu' => $yKm / 149597870.7,
'zAu' => $zKm / 149597870.7,
'distanceAu' => $distanceAu,
];
}
return $rows;
}
try {
$config = require __DIR__ . '/../config/database.php';
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
$config['host'],
$config['dbname'],
$config['charset'] ?? 'utf8mb4'
);
$pdo = new PDO(
$dsn,
$config['user'],
$config['pass'],
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
} catch (Throwable $e) {
$pdo = null;
}
if ($loggedIn && $pdo instanceof PDO) {
try {
$stmtTrackedMinorplanets = $pdo->prepare("
SELECT
mp.`id`,
mp.`mp_number`,
mp.`designation_text`,
mp.`desig_packed`,
mp.`epoch_packed`,
mp.`mean_anomaly_deg`,
mp.`arg_perihelion_deg`,
mp.`ascending_node_deg`,
mp.`inclination_deg`,
mp.`eccentricity`,
mp.`mean_motion_deg_per_day`,
mp.`semimajor_axis_au`,
mp.`absolute_magnitude_h`,
mp.`slope_g`,
aum.`is_favorite`
FROM `app_user_minorplanets` aum
INNER JOIN `minorplanets_mpc` mp
ON mp.`id` = aum.`minorplanet_id`
WHERE aum.`user_id` = :user_id
ORDER BY aum.`is_favorite` DESC, mp.`mp_number` IS NULL ASC, mp.`mp_number` ASC, mp.`designation_text` ASC
");
$stmtTrackedMinorplanets->execute([
':user_id' => (int) $_SESSION['user_id'],
]);
foreach ($stmtTrackedMinorplanets->fetchAll() as $minorplanet) {
$trackedMinorplanetsForJs[] = [
'id' => (int) $minorplanet['id'],
'number' => $minorplanet['mp_number'] !== null ? (int) $minorplanet['mp_number'] : null,
'designation' => (string) $minorplanet['designation_text'],
'packedDesignation' => (string) $minorplanet['desig_packed'],
'epochPacked' => (string) $minorplanet['epoch_packed'],
'epochDateIso' => decodeMpcPackedDate($minorplanet['epoch_packed']),
'meanAnomalyDeg' => (float) $minorplanet['mean_anomaly_deg'],
'argPerihelionDeg' => (float) $minorplanet['arg_perihelion_deg'],
'ascendingNodeDeg' => (float) $minorplanet['ascending_node_deg'],
'inclinationDeg' => (float) $minorplanet['inclination_deg'],
'eccentricity' => (float) $minorplanet['eccentricity'],
'meanMotionDegPerDay' => (float) $minorplanet['mean_motion_deg_per_day'],
'semimajorAxisAu' => (float) $minorplanet['semimajor_axis_au'],
'absoluteMagnitudeH' => $minorplanet['absolute_magnitude_h'] !== null ? (float) $minorplanet['absolute_magnitude_h'] : null,
'slopeG' => $minorplanet['slope_g'] !== null ? (float) $minorplanet['slope_g'] : null,
'isFavorite' => (bool) $minorplanet['is_favorite'],
];
}
$stmtTrackedComets = $pdo->prepare("
SELECT
c.`id`,
c.`designation_packed`,
c.`designation_and_name`,
c.`orbit_type`,
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`,
auc.`is_favorite`
FROM `app_user_comets` auc
INNER JOIN `comets_mpc` c
ON c.`id` = auc.`comet_id`
WHERE auc.`user_id` = :user_id
ORDER BY auc.`is_favorite` DESC, c.`year_of_perihelion` ASC, c.`month_of_perihelion` ASC, c.`day_of_perihelion` ASC, c.`designation_and_name` ASC
");
$stmtTrackedComets->execute([
':user_id' => (int) $_SESSION['user_id'],
]);
foreach ($stmtTrackedComets->fetchAll() as $comet) {
$trackedCometsForJs[] = [
'id' => (int) $comet['id'],
'designation' => (string) $comet['designation_and_name'],
'packedDesignation' => (string) $comet['designation_packed'],
'orbitType' => (string) $comet['orbit_type'],
'perihelionIso' => buildCometPerihelionIso($comet),
'perihelionDistanceAu' => (float) $comet['perihelion_dist_au'],
'eccentricity' => (float) $comet['eccentricity'],
'argPerihelionDeg' => (float) $comet['arg_perihelion_deg'],
'ascendingNodeDeg' => (float) $comet['ascending_node_deg'],
'inclinationDeg' => (float) $comet['inclination_deg'],
'absoluteMagnitudeH' => $comet['absolute_magnitude_h'] !== null ? (float) $comet['absolute_magnitude_h'] : null,
'photometricIndex' => $comet['slope_parameter_g'] !== null ? (float) $comet['slope_parameter_g'] : null,
'isFavorite' => (bool) $comet['is_favorite'],
];
}
} catch (Throwable $e) {
$trackedMinorplanetsForJs = [];
$trackedCometsForJs = [];
}
}
if ($pdo instanceof PDO) {
try {
$voyager1TrackForJs = loadSpacecraftTrackFromDatabase($pdo, 'voyager1');
$voyager2TrackForJs = loadSpacecraftTrackFromDatabase($pdo, 'voyager2');
$newHorizonsTrackForJs = loadSpacecraftTrackFromDatabase($pdo, 'new_horizons');
} catch (Throwable $e) {
$voyager1TrackForJs = [];
$voyager2TrackForJs = [];
$newHorizonsTrackForJs = [];
}
}
if ($voyager1TrackForJs === []) {
$voyager1TrackForJs = loadSpacecraftTrackCsv(__DIR__ . '/../docs/voyager1_horizons_1977_2046_monthly_sun_centered.csv');
}
if ($voyager2TrackForJs === []) {
$voyager2TrackForJs = loadSpacecraftTrackCsv(__DIR__ . '/../docs/voyager2_horizons_1977_2046_monthly_sun_centered.csv');
}
if ($newHorizonsTrackForJs === []) {
$newHorizonsTrackForJs = loadSpacecraftTrackCsv(__DIR__ . '/../docs/new_horizons_2006_2046_monthly_sun_centered.csv');
}
if ($voyager1TrackForJs !== []) {
$spacecraftOptionsForJs[] = ['key' => 'voyager1', 'label' => 'Voyager 1'];
}
if ($voyager2TrackForJs !== []) {
$spacecraftOptionsForJs[] = ['key' => 'voyager2', 'label' => 'Voyager 2'];
}
if ($newHorizonsTrackForJs !== []) {
$spacecraftOptionsForJs[] = ['key' => 'new_horizons', 'label' => 'New Horizons'];
}
$pageTitle = 'Sonnensystem - AstroTools';
$bodyClass = 'solarsystem-page';
?>
<?php require __DIR__ . '/header.php'; ?>
<script src="js/astronomy.browser.min.js"></script>
<div class="solarsystem-tool">
<div class="solarsystem-layout">
<aside class="card solarsystem-sidebar">
<div class="solarsystem-sidebar-block">
<h2>Sonnensystem</h2>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Datum</div>
<div class="solarsystem-input-row">
<input type="date" id="solarDatePicker">
<button type="button" class="btn btn-secondary" id="solarBtnToday">Heute</button>
</div>
<div class="solarsystem-current-date" id="solarCurrentDate">--</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Wiedergabe</div>
<div class="solarsystem-button-grid">
<button type="button" class="btn btn-primary" id="solarBtnPlay">Play</button>
<button type="button" class="btn btn-secondary" id="solarBtnReset">Jetzt</button>
</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Geschwindigkeit</div>
<input type="range" id="solarSpeedSlider" min="-100" max="100" step="0.1" value="1">
<div class="solarsystem-speed-value" id="solarSpeedLabel">1 d/s</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Ansicht</div>
<div class="solarsystem-button-grid">
<button type="button" class="btn btn-secondary" id="solarBtnTop">Top</button>
<button type="button" class="btn btn-secondary" id="solarBtnSide">Seite</button>
<button type="button" class="btn btn-secondary is-active" id="solarBtnOrbits">Orbits</button>
<button type="button" class="btn btn-secondary" id="solarBtnLabels">Namen</button>
<button type="button" class="btn btn-secondary" id="solarBtnEcliptic">Ekliptik</button>
</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Kleinplanet</div>
<div class="solarsystem-input-row">
<details class="solarsystem-multiselect<?= empty($trackedMinorplanetsForJs) ? ' is-disabled' : '' ?>" id="solarMinorplanetSelectWrap"<?= empty($trackedMinorplanetsForJs) ? ' open' : '' ?>>
<summary id="solarMinorplanetSelectSummary">
<?= empty($trackedMinorplanetsForJs) ? 'Keine Kleinplaneten verfuegbar' : 'Kleinplaneten auswaehlen' ?>
</summary>
<div class="solarsystem-multiselect-panel" id="solarMinorplanetSelectPanel">
<?php if (!empty($trackedMinorplanetsForJs)): ?>
<?php foreach ($trackedMinorplanetsForJs as $minorplanet): ?>
<label class="solarsystem-multiselect-option">
<input
type="checkbox"
class="solar-minorplanet-checkbox"
value="<?= (int) $minorplanet['id'] ?>"
>
<span><?= h(formatMinorplanetDisplayName($minorplanet)) ?></span>
</label>
<?php endforeach; ?>
<?php endif; ?>
</div>
</details>
</div>
<div class="solarsystem-current-date solarsystem-minorplanet-hint" id="solarMinorplanetHint">
<?= empty($trackedMinorplanetsForJs) ? 'Fuege in den Favoriten Kleinplaneten hinzu, um hier ihre Orbits anzuzeigen.' : '' ?>
</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Komet</div>
<div class="solarsystem-input-row">
<details class="solarsystem-multiselect<?= empty($trackedCometsForJs) ? ' is-disabled' : '' ?>" id="solarCometSelectWrap"<?= empty($trackedCometsForJs) ? ' open' : '' ?>>
<summary id="solarCometSelectSummary">
<?= empty($trackedCometsForJs) ? 'Keine Kometen verfuegbar' : 'Kometen auswaehlen' ?>
</summary>
<div class="solarsystem-multiselect-panel" id="solarCometSelectPanel">
<?php if (!empty($trackedCometsForJs)): ?>
<?php foreach ($trackedCometsForJs as $comet): ?>
<label class="solarsystem-multiselect-option">
<input
type="checkbox"
class="solar-comet-checkbox"
value="<?= (int) $comet['id'] ?>"
>
<span><?= h(formatCometDisplayName($comet)) ?></span>
</label>
<?php endforeach; ?>
<?php endif; ?>
</div>
</details>
</div>
<div class="solarsystem-current-date solarsystem-minorplanet-hint" id="solarCometHint">
<?= empty($trackedCometsForJs) ? 'Fuege in den Favoriten Kometen hinzu, um hier ihre Orbits anzuzeigen.' : '' ?>
</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Raumfahrzeuge</div>
<div class="solarsystem-input-row">
<details class="solarsystem-multiselect<?= empty($spacecraftOptionsForJs) ? ' is-disabled' : '' ?>" id="solarSpacecraftSelectWrap"<?= empty($spacecraftOptionsForJs) ? ' open' : '' ?>>
<summary id="solarSpacecraftSelectSummary">
<?= empty($spacecraftOptionsForJs) ? 'Keine Raumfahrzeuge verfuegbar' : 'Raumfahrzeuge auswaehlen' ?>
</summary>
<div class="solarsystem-multiselect-panel" id="solarSpacecraftSelectPanel">
<?php if (!empty($spacecraftOptionsForJs)): ?>
<?php foreach ($spacecraftOptionsForJs as $spacecraft): ?>
<label class="solarsystem-multiselect-option">
<input
type="checkbox"
class="solar-spacecraft-checkbox"
value="<?= h($spacecraft['key']) ?>"
>
<span><?= h($spacecraft['label']) ?></span>
</label>
<?php endforeach; ?>
<?php endif; ?>
</div>
</details>
</div>
<div class="solarsystem-current-date solarsystem-minorplanet-hint" id="solarSpacecraftHint">
<?= empty($spacecraftOptionsForJs) ? 'Keine Raumfahrzeuge mit Bahndaten verfuegbar.' : '' ?>
</div>
</div>
<div class="solarsystem-sidebar-block">
<div class="solarsystem-label">Hinweise</div>
<ul class="solarsystem-notes">
<li>Maus ziehen: Kamera drehen</li>
<li>Mausrad: Zoom</li>
<li>Klick auf Planet: Fokus setzen</li>
<li>Dropdown: mehrere Kleinplaneten per Haken anzeigen</li>
<li>Dropdown: Raumfahrzeuge ein- und ausblenden</li>
<li>Erneuter Klick ins Leere: Fokus loesen</li>
</ul>
</div>
</aside>
<section class="card solarsystem-stage-card">
<div class="solarsystem-stage-wrap" id="solarStageWrap">
<canvas id="solarCanvas"></canvas>
<div id="solarLabels" class="solarsystem-label-layer"></div>
<div class="solarsystem-info-panel" id="solarInfoPanel" hidden>
<h3 id="solarInfoName">--</h3>
<div class="solarsystem-info-row" title="Aktueller Abstand des Objekts zur Sonne in Astronomischen Einheiten.">
<span title="Aktueller Abstand des Objekts zur Sonne in Astronomischen Einheiten.">Akt. Abstand</span>
<strong id="solarInfoDistanceAu" title="Aktueller Abstand des Objekts zur Sonne in Astronomischen Einheiten.">--</strong>
</div>
<div class="solarsystem-info-row" title="Aktueller Abstand des Objekts zur Sonne in Kilometern.">
<span title="Aktueller Abstand des Objekts zur Sonne in Kilometern.">Akt. Abstand km</span>
<strong id="solarInfoDistanceKm" title="Aktueller Abstand des Objekts zur Sonne in Kilometern.">--</strong>
</div>
<div class="solarsystem-info-row" title="Aktueller Abstand zwischen Erde und dem gewaehlten Objekt in Astronomischen Einheiten.">
<span title="Aktueller Abstand zwischen Erde und dem gewaehlten Objekt in Astronomischen Einheiten.">Abstand Erde</span>
<strong id="solarInfoEarthDistanceAu" title="Aktueller Abstand zwischen Erde und dem gewaehlten Objekt in Astronomischen Einheiten.">--</strong>
</div>
<div class="solarsystem-info-row" title="Aktueller Abstand zwischen Erde und dem gewaehlten Objekt in Kilometern.">
<span title="Aktueller Abstand zwischen Erde und dem gewaehlten Objekt in Kilometern.">Erde km</span>
<strong id="solarInfoEarthDistanceKm" title="Aktueller Abstand zwischen Erde und dem gewaehlten Objekt in Kilometern.">--</strong>
</div>
<div class="solarsystem-info-row" title="Scheinbare Helligkeit. Bei Kleinplaneten ist das ein geschaetzter Wert aus H-G-Modell, Sonnenabstand, Erdabstand und Phasenwinkel.">
<span title="Scheinbare Helligkeit. Bei Kleinplaneten ist das ein geschaetzter Wert aus H-G-Modell, Sonnenabstand, Erdabstand und Phasenwinkel.">Helligkeit</span>
<strong id="solarInfoMagnitude" title="Scheinbare Helligkeit. Bei Kleinplaneten ist das ein geschaetzter Wert aus H-G-Modell, Sonnenabstand, Erdabstand und Phasenwinkel.">--</strong>
</div>
<div class="solarsystem-info-row" title="Zeit fuer einen Umlauf um die Sonne.">
<span title="Zeit fuer einen Umlauf um die Sonne.">Umlaufzeit</span>
<strong id="solarInfoPeriod" title="Zeit fuer einen Umlauf um die Sonne.">--</strong>
</div>
<div class="solarsystem-info-row" title="Grosse Halbachse der Bahn, also der mittlere Bahnradius in Astronomischen Einheiten.">
<span title="Grosse Halbachse der Bahn, also der mittlere Bahnradius in Astronomischen Einheiten.">Achse</span>
<strong id="solarInfoAxis" title="Grosse Halbachse der Bahn, also der mittlere Bahnradius in Astronomischen Einheiten.">--</strong>
</div>
<div class="solarsystem-info-row" title="Objekttyp oder Zusatzinformation zum gewaehlten Himmelskoerper.">
<span title="Objekttyp oder Zusatzinformation zum gewaehlten Himmelskoerper.">Typ</span>
<strong id="solarInfoType" title="Objekttyp oder Zusatzinformation zum gewaehlten Himmelskoerper.">--</strong>
</div>
</div>
<div class="solarsystem-overlay" id="solarOverlay">Initialisiere Visualisierung...</div>
</div>
</section>
</div>
</div>
<script type="module">
const AstronomyEngine = window.Astronomy;
const AU_IN_KM = 149597870.7;
const DISPLAY_SCALE = 34;
const USER_MINOR_PLANETS = <?= json_encode($trackedMinorplanetsForJs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const USER_COMETS = <?= json_encode($trackedCometsForJs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const SPACECRAFT_OPTIONS = <?= json_encode($spacecraftOptionsForJs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const VOYAGER1_TRACK = <?= json_encode($voyager1TrackForJs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const VOYAGER2_TRACK = <?= json_encode($voyager2TrackForJs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const NEW_HORIZONS_TRACK = <?= json_encode($newHorizonsTrackForJs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
const JUPITER_MIN_VISIBLE_SIZE = 1.42;
const MOON_DISPLAY_DISTANCE_MULTIPLIER = 2200;
const PLANETS = [
{ key: 'Mercury', label: 'Merkur', color: '#aeb7c4', emissive: '#29303a', size: 0.34, type: 'Gesteinsplanet', roughness: 0.95, metalness: 0.00, glow: false, texture: 'images/planets/merkur_1024.jpg' },
{ key: 'Venus', label: 'Venus', color: '#e8d890', emissive: '#5a4412', size: 0.58, type: 'Gesteinsplanet', roughness: 0.90, metalness: 0.00, glow: true, texture: 'images/planets/venus_1024.jpg' },
{ key: 'Earth', label: 'Erde', color: '#4fb8ff', emissive: '#0a2e50', size: 0.62, type: 'Gesteinsplanet', roughness: 0.88, metalness: 0.00, glow: true, texture: 'images/planets/earth_day_1024.jpg' },
{ key: 'Mars', label: 'Mars', color: '#d45b2e', emissive: '#42160a', size: 0.46, type: 'Gesteinsplanet', roughness: 0.95, metalness: 0.00, glow: false, texture: 'images/planets/mars_1024.jpg' },
{ key: 'Jupiter', label: 'Jupiter',color: '#c89c63', emissive: '#3b2412', size: 1.42, type: 'Gasriese', roughness: 0.92, metalness: 0.00, glow: true, texture: 'images/planets/jupiter_1024.jpg' },
{ key: 'Saturn', label: 'Saturn', color: '#e8d29a', emissive: '#4a3a12', size: 1.18, type: 'Gasriese', roughness: 0.92, metalness: 0.00, glow: true, texture: 'images/planets/saturn_1024.jpg' },
{ key: 'Uranus', label: 'Uranus', color: '#7fe3e7', emissive: '#12353b', size: 0.94, type: 'Eisriese', roughness: 0.92, metalness: 0.00, glow: true, texture: 'images/planets/uranus_1024.jpg' },
{ key: 'Neptune', label: 'Neptun', color: '#4f78f0', emissive: '#101b46', size: 0.92, type: 'Eisriese', roughness: 0.92, metalness: 0.00, glow: true, texture: 'images/planets/neptun_1024.jpg' },
{ key: 'Pluto', label: 'Pluto', color: '#c5b090', emissive: '#3c2f20', size: 0.26, type: 'Zwergplanet', roughness: 0.95, metalness: 0.00, glow: false, texture: 'images/planets/pluto_1024.jpg' }
];
const MOON = {
key: 'Moon',
label: 'Mond',
color: '#d7d7d7',
emissive: '#2d2d30',
size: 0.17,
type: 'Mond',
roughness: 0.96,
metalness: 0.00,
texture: 'images/moons/moon_1024.jpg',
orbitalPeriodDays: 27.321661,
};
const stageWrap = document.getElementById('solarStageWrap');
const canvas = document.getElementById('solarCanvas');
const labelsLayer = document.getElementById('solarLabels');
const overlay = document.getElementById('solarOverlay');
const datePicker = document.getElementById('solarDatePicker');
const currentDateLabel = document.getElementById('solarCurrentDate');
const btnToday = document.getElementById('solarBtnToday');
const btnPlay = document.getElementById('solarBtnPlay');
const btnReset = document.getElementById('solarBtnReset');
const btnTop = document.getElementById('solarBtnTop');
const btnSide = document.getElementById('solarBtnSide');
const btnOrbits = document.getElementById('solarBtnOrbits');
const btnLabels = document.getElementById('solarBtnLabels');
const btnEcliptic = document.getElementById('solarBtnEcliptic');
const speedSlider = document.getElementById('solarSpeedSlider');
const speedLabel = document.getElementById('solarSpeedLabel');
const infoPanel = document.getElementById('solarInfoPanel');
const minorPlanetSelectWrap = document.getElementById('solarMinorplanetSelectWrap');
const minorPlanetSelectSummary = document.getElementById('solarMinorplanetSelectSummary');
const minorPlanetCheckboxes = Array.from(document.querySelectorAll('.solar-minorplanet-checkbox'));
const cometSelectWrap = document.getElementById('solarCometSelectWrap');
const cometSelectSummary = document.getElementById('solarCometSelectSummary');
const cometCheckboxes = Array.from(document.querySelectorAll('.solar-comet-checkbox'));
const spacecraftSelectWrap = document.getElementById('solarSpacecraftSelectWrap');
const spacecraftSelectSummary = document.getElementById('solarSpacecraftSelectSummary');
const spacecraftCheckboxes = Array.from(document.querySelectorAll('.solar-spacecraft-checkbox'));
if (!AstronomyEngine) {
overlay.textContent = 'Astronomy Engine konnte nicht geladen werden.';
throw new Error('Astronomy Engine unavailable');
}
let THREE;
let textureLoader;
let renderer;
let scene;
let camera;
let planetMeshes;
let orbitLines;
let labelElements;
let raycaster;
let pointer;
let eclipticGroup;
let rotationEqjToEcl;
let moonMesh = null;
let moonOrbitLine = null;
let moonLabelElement = null;
let minorPlanetObjects = null;
let cometObjects = null;
let voyager1Objects = null;
let voyager1LabelElement = null;
let voyager2Objects = null;
let voyager2LabelElement = null;
let newHorizonsObjects = null;
let newHorizonsLabelElement = null;
const planetDynamicsCache = new Map();
let showOrbits = true;
let showLabels = false;
let showEcliptic = false;
let playing = false;
let speedDaysPerSecond = 1;
let focusedPlanetKey = null;
let selectedMinorPlanetIds = [];
let focusedMinorPlanetId = null;
let selectedCometIds = [];
let focusedCometId = null;
let selectedSpacecraftKeys = spacecraftCheckboxes
.filter((input) => input.checked)
.map((input) => String(input.value));
let focusedVoyager1 = false;
let focusedVoyager2 = false;
let focusedNewHorizons = false;
let simDate = new Date();
let lastAnimationTime = performance.now();
let orbitRefreshCursor = 0;
let cameraTarget;
let camTheta = 0.45;
let camPhi = 1.05;
let camRadius = 190;
let camRadiusTarget = 190;
let isDragging = false;
let pointerDownMoved = false;
let lastPointerX = 0;
let lastPointerY = 0;
function formatDateInput(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatDisplayDate(date) {
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
function normalizedSpeedText(daysPerSecond) {
if (Math.abs(daysPerSecond) < 0.05) {
return '0 d/s';
}
if (Math.abs(daysPerSecond) >= 1) {
return `${daysPerSecond.toFixed(1)} d/s`;
}
return `${(daysPerSecond * 24).toFixed(1)} h/s`;
}
function updateSpeedFromSlider() {
speedDaysPerSecond = Number(speedSlider.value);
speedLabel.textContent = normalizedSpeedText(speedDaysPerSecond);
}
function adjustSpeedSliderFromWheel(event) {
if (!speedSlider) {
return;
}
event.preventDefault();
const min = Number(speedSlider.min || -100);
const max = Number(speedSlider.max || 100);
const step = Number(speedSlider.step || 0.1);
const direction = event.deltaY < 0 ? 1 : -1;
const multiplier = event.shiftKey ? 10 : 1;
const nextValue = Number(speedSlider.value) + (direction * step * multiplier);
const clampedValue = Math.max(min, Math.min(max, nextValue));
speedSlider.value = String(clampedValue);
updateSpeedFromSlider();
}
function getPlanetMeta(key) {
return PLANETS.find((planet) => planet.key === key) || null;
}
function getPlanetBodyKey(key) {
return AstronomyEngine.Body && AstronomyEngine.Body[key] ? AstronomyEngine.Body[key] : key;
}
function getPlanetOrbitalPeriodDays(key) {
try {
return AstronomyEngine.PlanetOrbitalPeriod(getPlanetBodyKey(key));
} catch (error) {
return null;
}
}
function calculatePlanetSemimajorAxisAu(key, periodDays) {
const referenceDate = new Date('2000-01-01T12:00:00Z');
const steps = Math.max(240, Math.min(960, Math.round(periodDays / 20)));
let minRadius = Number.POSITIVE_INFINITY;
let maxRadius = 0;
for (let step = 0; step <= steps; step += 1) {
const sampleDate = new Date(referenceDate.getTime() + ((periodDays * step) / steps) * 86400000);
const radius = getHelioPosition(key, sampleDate).r;
if (radius < minRadius) {
minRadius = radius;
}
if (radius > maxRadius) {
maxRadius = radius;
}
}
if (!Number.isFinite(minRadius) || !Number.isFinite(maxRadius)) {
return null;
}
return (minRadius + maxRadius) / 2;
}
function getPlanetDynamics(key) {
if (planetDynamicsCache.has(key)) {
return planetDynamicsCache.get(key);
}
const periodDays = getPlanetOrbitalPeriodDays(key);
const axisAu = periodDays !== null ? calculatePlanetSemimajorAxisAu(key, periodDays) : null;
const dynamics = { periodDays, axisAu };
planetDynamicsCache.set(key, dynamics);
return dynamics;
}
function getMinorPlanetMeta(id) {
return USER_MINOR_PLANETS.find((minorPlanet) => minorPlanet.id === Number(id)) || null;
}
function getSelectedMinorPlanetSet() {
return new Set(selectedMinorPlanetIds);
}
function getCometMeta(id) {
return USER_COMETS.find((comet) => comet.id === Number(id)) || null;
}
function getSelectedCometSet() {
return new Set(selectedCometIds);
}
function getSelectedSpacecraftSet() {
return new Set(selectedSpacecraftKeys);
}
function getVoyager1Position(date) {
if (!Array.isArray(VOYAGER1_TRACK) || VOYAGER1_TRACK.length === 0) {
return null;
}
const targetMs = date.getTime();
if (targetMs <= Number(VOYAGER1_TRACK[0].timestampMs)) {
return VOYAGER1_TRACK[0];
}
const lastIndex = VOYAGER1_TRACK.length - 1;
if (targetMs >= Number(VOYAGER1_TRACK[lastIndex].timestampMs)) {
return VOYAGER1_TRACK[lastIndex];
}
for (let index = 0; index < lastIndex; index += 1) {
const left = VOYAGER1_TRACK[index];
const right = VOYAGER1_TRACK[index + 1];
const leftMs = Number(left.timestampMs);
const rightMs = Number(right.timestampMs);
if (targetMs < leftMs || targetMs > rightMs) {
continue;
}
const span = Math.max(rightMs - leftMs, 1);
const factor = (targetMs - leftMs) / span;
const xAu = Number(left.xAu) + ((Number(right.xAu) - Number(left.xAu)) * factor);
const yAu = Number(left.yAu) + ((Number(right.yAu) - Number(left.yAu)) * factor);
const zAu = Number(left.zAu) + ((Number(right.zAu) - Number(left.zAu)) * factor);
const distanceAu = Math.hypot(xAu, yAu, zAu);
return {
timestampMs: targetMs,
dateTdb: String(left.dateTdb || ''),
xAu,
yAu,
zAu,
distanceAu,
};
}
return null;
}
function getVoyager2Position(date) {
if (!Array.isArray(VOYAGER2_TRACK) || VOYAGER2_TRACK.length === 0) {
return null;
}
const targetMs = date.getTime();
if (targetMs <= Number(VOYAGER2_TRACK[0].timestampMs)) {
return VOYAGER2_TRACK[0];
}
const lastIndex = VOYAGER2_TRACK.length - 1;
if (targetMs >= Number(VOYAGER2_TRACK[lastIndex].timestampMs)) {
return VOYAGER2_TRACK[lastIndex];
}
for (let index = 0; index < lastIndex; index += 1) {
const left = VOYAGER2_TRACK[index];
const right = VOYAGER2_TRACK[index + 1];
const leftMs = Number(left.timestampMs);
const rightMs = Number(right.timestampMs);
if (targetMs < leftMs || targetMs > rightMs) {
continue;
}
const span = Math.max(rightMs - leftMs, 1);
const factor = (targetMs - leftMs) / span;
const xAu = Number(left.xAu) + ((Number(right.xAu) - Number(left.xAu)) * factor);
const yAu = Number(left.yAu) + ((Number(right.yAu) - Number(left.yAu)) * factor);
const zAu = Number(left.zAu) + ((Number(right.zAu) - Number(left.zAu)) * factor);
const distanceAu = Math.hypot(xAu, yAu, zAu);
return {
timestampMs: targetMs,
dateTdb: String(left.dateTdb || ''),
xAu,
yAu,
zAu,
distanceAu,
};
}
return null;
}
function getNewHorizonsPosition(date) {
if (!Array.isArray(NEW_HORIZONS_TRACK) || NEW_HORIZONS_TRACK.length === 0) {
return null;
}
const targetMs = date.getTime();
if (targetMs <= Number(NEW_HORIZONS_TRACK[0].timestampMs)) {
return NEW_HORIZONS_TRACK[0];
}
const lastIndex = NEW_HORIZONS_TRACK.length - 1;
if (targetMs >= Number(NEW_HORIZONS_TRACK[lastIndex].timestampMs)) {
return NEW_HORIZONS_TRACK[lastIndex];
}
for (let index = 0; index < lastIndex; index += 1) {
const left = NEW_HORIZONS_TRACK[index];
const right = NEW_HORIZONS_TRACK[index + 1];
const leftMs = Number(left.timestampMs);
const rightMs = Number(right.timestampMs);
if (targetMs < leftMs || targetMs > rightMs) {
continue;
}
const span = Math.max(rightMs - leftMs, 1);
const factor = (targetMs - leftMs) / span;
const xAu = Number(left.xAu) + ((Number(right.xAu) - Number(left.xAu)) * factor);
const yAu = Number(left.yAu) + ((Number(right.yAu) - Number(left.yAu)) * factor);
const zAu = Number(left.zAu) + ((Number(right.zAu) - Number(left.zAu)) * factor);
const distanceAu = Math.hypot(xAu, yAu, zAu);
return {
timestampMs: targetMs,
dateTdb: String(left.dateTdb || ''),
xAu,
yAu,
zAu,
distanceAu,
};
}
return null;
}
const MINOR_PLANET_COLOR_PALETTE = [
{ line: '#ff6b6b', body: '#ff5a5a', emissive: '#d63b3b' },
{ line: '#ff5252', body: '#ff4747', emissive: '#c62828' },
{ line: '#ff7a5c', body: '#ff6b4a', emissive: '#d9482c' },
{ line: '#ff4d6d', body: '#ff3f5f', emissive: '#c81f45' },
{ line: '#ff8a65', body: '#ff7043', emissive: '#d4512c' },
{ line: '#f0625d', body: '#eb554f', emissive: '#bf3c36' },
];
function getMinorPlanetColor(id) {
const safeId = Math.abs(Number(id) || 0);
return MINOR_PLANET_COLOR_PALETTE[safeId % MINOR_PLANET_COLOR_PALETTE.length];
}
const COMET_COLOR_PALETTE = [
{ line: '#63e6be', body: '#46d9a5', emissive: '#1d7f63' },
{ line: '#7ad8ff', body: '#58c8f4', emissive: '#1f6e8d' },
{ line: '#8ce99a', body: '#69db7c', emissive: '#2b8a3e' },
{ line: '#b2f2bb', body: '#8ce99a', emissive: '#2f9e44' },
{ line: '#66d9e8', body: '#3bc9db', emissive: '#0b7285' },
{ line: '#95d5b2', body: '#74c69d', emissive: '#2d6a4f' },
];
function getCometColor(id) {
const safeId = Math.abs(Number(id) || 0);
return COMET_COLOR_PALETTE[safeId % COMET_COLOR_PALETTE.length];
}
function updateMinorPlanetSummary() {
if (!minorPlanetSelectSummary) {
return;
}
if (minorPlanetCheckboxes.length === 0) {
minorPlanetSelectSummary.textContent = 'Keine Kleinplaneten verfuegbar';
return;
}
if (selectedMinorPlanetIds.length === 0) {
minorPlanetSelectSummary.textContent = 'Kleinplaneten auswaehlen';
return;
}
const selectedNames = selectedMinorPlanetIds
.map((id) => formatMinorPlanetName(getMinorPlanetMeta(id)))
.filter((name) => name && name !== '--');
minorPlanetSelectSummary.textContent = selectedNames.length <= 2
? selectedNames.join(', ')
: `${selectedNames.length} Kleinplaneten ausgewaehlt`;
}
function updateCometSummary() {
if (!cometSelectSummary) {
return;
}
if (cometCheckboxes.length === 0) {
cometSelectSummary.textContent = 'Keine Kometen verfuegbar';
return;
}
if (selectedCometIds.length === 0) {
cometSelectSummary.textContent = 'Kometen auswaehlen';
return;
}
const selectedNames = selectedCometIds
.map((id) => formatCometName(getCometMeta(id)))
.filter((name) => name && name !== '--');
cometSelectSummary.textContent = selectedNames.length <= 2
? selectedNames.join(', ')
: `${selectedNames.length} Kometen ausgewaehlt`;
}
function updateSpacecraftSummary() {
if (!spacecraftSelectSummary) {
return;
}
if (spacecraftCheckboxes.length === 0) {
spacecraftSelectSummary.textContent = 'Keine Raumfahrzeuge verfuegbar';
return;
}
if (selectedSpacecraftKeys.length === 0) {
spacecraftSelectSummary.textContent = 'Raumfahrzeuge auswaehlen';
return;
}
const selectedLabels = SPACECRAFT_OPTIONS
.filter((spacecraft) => selectedSpacecraftKeys.includes(String(spacecraft.key)))
.map((spacecraft) => String(spacecraft.label || '').trim())
.filter((label) => label !== '');
spacecraftSelectSummary.textContent = selectedLabels.length <= 2
? selectedLabels.join(', ')
: `${selectedLabels.length} Raumfahrzeuge ausgewaehlt`;
}
function isSpacecraftSelected(spacecraftKey) {
return getSelectedSpacecraftSet().has(String(spacecraftKey));
}
function formatMinorPlanetName(minorPlanet) {
if (!minorPlanet) {
return '--';
}
const designation = String(minorPlanet.designation || '').trim();
const designationWithoutLeadingNumber = designation.replace(/^\(\d+\)\s*/, '');
return minorPlanet.number !== null
? `(${minorPlanet.number}) ${designationWithoutLeadingNumber}`
: designation;
}
function formatCometName(comet) {
if (!comet) {
return '--';
}
return String(comet.designation || comet.packedDesignation || '--').trim();
}
function getHelioPosition(key, date) {
const body = getPlanetBodyKey(key);
const vectorEqj = AstronomyEngine.HelioVector(body, date);
const vectorEcl = rotationEqjToEcl
? AstronomyEngine.RotateVector(rotationEqjToEcl, vectorEqj)
: vectorEqj;
const radiusAu = Math.hypot(vectorEcl.x, vectorEcl.y, vectorEcl.z);
return { x: vectorEcl.x, y: vectorEcl.y, z: vectorEcl.z, r: radiusAu };
}
function getPlanetApparentMagnitude(key, date) {
try {
const body = AstronomyEngine.Body && AstronomyEngine.Body[key] ? AstronomyEngine.Body[key] : key;
const illumination = AstronomyEngine.Illumination(body, date);
return illumination && Number.isFinite(illumination.mag) ? illumination.mag : null;
} catch (error) {
return null;
}
}
function toDisplayPosition(position) {
const radiusAu = Math.max(position.r, 1e-9);
const displayRadius = Math.pow(radiusAu, 0.56) * DISPLAY_SCALE;
const scale = displayRadius / radiusAu;
return new THREE.Vector3(
position.x * scale,
position.z * scale,
-position.y * scale
);
}
function toDisplayDirection(vector) {
return new THREE.Vector3(
vector.x,
vector.z,
-vector.y
);
}
function getPlanetRotationState(key, date) {
try {
const axisInfo = AstronomyEngine.RotationAxis(getPlanetBodyKey(key), date);
if (!axisInfo || !axisInfo.north) {
return null;
}
const northEqj = axisInfo.north;
const northEcl = rotationEqjToEcl
? AstronomyEngine.RotateVector(rotationEqjToEcl, northEqj)
: northEqj;
const northDisplay = toDisplayDirection(northEcl).normalize();
if (!Number.isFinite(northDisplay.lengthSq()) || northDisplay.lengthSq() < 1e-12) {
return null;
}
return {
northDisplay,
spinRad: THREE.MathUtils.degToRad(Number(axisInfo.spin) || 0),
};
} catch (error) {
return null;
}
}
function getMoonGeoPosition(date) {
const astroTime = AstronomyEngine.MakeTime(date);
const vectorEqj = AstronomyEngine.GeoVector('Moon', astroTime, true);
const vectorEcl = rotationEqjToEcl
? AstronomyEngine.RotateVector(rotationEqjToEcl, vectorEqj)
: vectorEqj;
const radiusAu = Math.hypot(vectorEcl.x, vectorEcl.y, vectorEcl.z);
return { x: vectorEcl.x, y: vectorEcl.y, z: vectorEcl.z, r: radiusAu };
}
function getMoonHelioPosition(date) {
const earthPosition = getHelioPosition('Earth', date);
const moonGeoPosition = getMoonGeoPosition(date);
return {
x: earthPosition.x + moonGeoPosition.x,
y: earthPosition.y + moonGeoPosition.y,
z: earthPosition.z + moonGeoPosition.z,
r: Math.hypot(
earthPosition.x + moonGeoPosition.x,
earthPosition.y + moonGeoPosition.y,
earthPosition.z + moonGeoPosition.z
),
};
}
function getMoonDisplayPosition(date) {
const earthHelioPosition = getHelioPosition('Earth', date);
const earthDisplayPosition = toDisplayPosition(earthHelioPosition);
const moonGeoPosition = getMoonGeoPosition(date);
const moonGeoDisplayOffset = toDisplayDirection(moonGeoPosition)
.multiplyScalar(MOON_DISPLAY_DISTANCE_MULTIPLIER);
return earthDisplayPosition.clone().add(moonGeoDisplayOffset);
}
function refreshCurrentDateUi() {
currentDateLabel.textContent = formatDisplayDate(simDate);
datePicker.value = formatDateInput(simDate);
}
function resizeRenderer() {
const width = stageWrap.clientWidth;
const height = Math.max(520, stageWrap.clientHeight);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
function updateCamera() {
const sinPhi = Math.sin(camPhi);
const x = camRadius * sinPhi * Math.sin(camTheta);
const y = camRadius * Math.cos(camPhi);
const z = camRadius * sinPhi * Math.cos(camTheta);
camera.position.set(
cameraTarget.x + x,
cameraTarget.y + y,
cameraTarget.z + z
);
camera.lookAt(cameraTarget);
}
function clearFocus() {
focusedPlanetKey = null;
focusedMinorPlanetId = null;
focusedCometId = null;
focusedVoyager1 = false;
focusedVoyager2 = false;
focusedNewHorizons = false;
cameraTarget.set(0, 0, 0);
camRadiusTarget = camRadius;
document.querySelectorAll('.solarsystem-planet-chip').forEach((button) => {
button.classList.remove('is-focused');
});
infoPanel.hidden = true;
updateCamera();
}
function updatePlanetInfoPanel(key) {
const meta = getPlanetMeta(key);
if (!meta) {
infoPanel.hidden = true;
return;
}
const position = getHelioPosition(key, simDate);
const earthPosition = getHelioPosition('Earth', simDate);
const earthDistanceAu = Math.hypot(
position.x - earthPosition.x,
position.y - earthPosition.y,
position.z - earthPosition.z
);
const dynamics = getPlanetDynamics(key);
infoPanel.hidden = false;
document.getElementById('solarInfoName').textContent = meta.label;
document.getElementById('solarInfoName').style.color = meta.color;
document.getElementById('solarInfoDistanceAu').textContent = `${position.r.toFixed(4)} AU`;
document.getElementById('solarInfoDistanceKm').textContent = `${(position.r * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoEarthDistanceAu').textContent = `${earthDistanceAu.toFixed(4)} AU`;
document.getElementById('solarInfoEarthDistanceKm').textContent = `${(earthDistanceAu * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
const apparentMagnitude = getPlanetApparentMagnitude(key, simDate);
document.getElementById('solarInfoMagnitude').textContent = apparentMagnitude === null ? '--' : `${apparentMagnitude.toFixed(1)} mag`;
document.getElementById('solarInfoPeriod').textContent = dynamics.periodDays === null
? '--'
: dynamics.periodDays >= 365
? `${(dynamics.periodDays / 365.25).toFixed(2)} Jahre`
: `${dynamics.periodDays.toFixed(2)} Tage`;
document.getElementById('solarInfoAxis').textContent = dynamics.axisAu === null ? '--' : `${dynamics.axisAu.toFixed(3)} AU`;
document.getElementById('solarInfoType').textContent = meta.type;
}
function updateMinorPlanetInfoPanel(id) {
const minorPlanet = getMinorPlanetMeta(id);
if (!minorPlanet) {
infoPanel.hidden = true;
return;
}
const position = getMinorPlanetHelioPosition(minorPlanet, simDate);
if (!position) {
infoPanel.hidden = true;
return;
}
const earthPosition = getHelioPosition('Earth', simDate);
const earthDistanceAu = Math.hypot(
position.x - earthPosition.x,
position.y - earthPosition.y,
position.z - earthPosition.z
);
const apparentMagnitude = estimateMinorPlanetMagnitude(minorPlanet, position, earthPosition, earthDistanceAu);
const periodDays = minorPlanet.meanMotionDegPerDay > 0 ? 360 / minorPlanet.meanMotionDegPerDay : null;
const color = getMinorPlanetColor(id);
infoPanel.hidden = false;
document.getElementById('solarInfoName').textContent = formatMinorPlanetName(minorPlanet);
document.getElementById('solarInfoName').style.color = color.line;
document.getElementById('solarInfoDistanceAu').textContent = `${position.r.toFixed(4)} AU`;
document.getElementById('solarInfoDistanceKm').textContent = `${(position.r * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoEarthDistanceAu').textContent = `${earthDistanceAu.toFixed(4)} AU`;
document.getElementById('solarInfoEarthDistanceKm').textContent = `${(earthDistanceAu * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoMagnitude').textContent = apparentMagnitude === null ? '--' : `${apparentMagnitude.toFixed(1)} mag (geschaetzt)`;
document.getElementById('solarInfoPeriod').textContent = periodDays === null
? '--'
: periodDays >= 365
? `${(periodDays / 365.25).toFixed(2)} Jahre`
: `${periodDays.toFixed(2)} Tage`;
document.getElementById('solarInfoAxis').textContent = `${minorPlanet.semimajorAxisAu.toFixed(3)} AU`;
document.getElementById('solarInfoType').textContent = `Kleinplanet${minorPlanet.absoluteMagnitudeH !== null ? ` | H ${minorPlanet.absoluteMagnitudeH.toFixed(1)}` : ''}`;
}
function updateCometInfoPanel(id) {
const comet = getCometMeta(id);
if (!comet) {
infoPanel.hidden = true;
return;
}
const position = getCometHelioPosition(comet, simDate);
if (!position) {
infoPanel.hidden = true;
return;
}
const earthPosition = getHelioPosition('Earth', simDate);
const earthDistanceAu = Math.hypot(
position.x - earthPosition.x,
position.y - earthPosition.y,
position.z - earthPosition.z
);
const apparentMagnitude = estimateCometMagnitude(comet, position, earthDistanceAu);
const color = getCometColor(id);
const semimajorAxisAu = comet.eccentricity < 1 ? comet.perihelionDistanceAu / (1 - comet.eccentricity) : null;
const periodDays = semimajorAxisAu !== null ? 365.25 * Math.sqrt(semimajorAxisAu ** 3) : null;
infoPanel.hidden = false;
document.getElementById('solarInfoName').textContent = formatCometName(comet);
document.getElementById('solarInfoName').style.color = color.line;
document.getElementById('solarInfoDistanceAu').textContent = `${position.r.toFixed(4)} AU`;
document.getElementById('solarInfoDistanceKm').textContent = `${(position.r * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoEarthDistanceAu').textContent = `${earthDistanceAu.toFixed(4)} AU`;
document.getElementById('solarInfoEarthDistanceKm').textContent = `${(earthDistanceAu * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoMagnitude').textContent = apparentMagnitude === null ? '--' : `${apparentMagnitude.toFixed(1)} mag (geschaetzt)`;
document.getElementById('solarInfoPeriod').textContent = periodDays === null
? '--'
: periodDays >= 365
? `${(periodDays / 365.25).toFixed(2)} Jahre`
: `${periodDays.toFixed(2)} Tage`;
document.getElementById('solarInfoAxis').textContent = semimajorAxisAu === null ? '--' : `${semimajorAxisAu.toFixed(3)} AU`;
document.getElementById('solarInfoType').textContent = `Komet${comet.absoluteMagnitudeH !== null ? ` | H ${comet.absoluteMagnitudeH.toFixed(1)}` : ''}${comet.photometricIndex !== null ? ` | n ${comet.photometricIndex.toFixed(1)}` : ''}`;
}
function updateVoyager1InfoPanel() {
const position = getVoyager1Position(simDate);
if (!position) {
infoPanel.hidden = true;
return;
}
const earthPosition = getHelioPosition('Earth', simDate);
const earthDistanceAu = Math.hypot(
position.xAu - earthPosition.x,
position.yAu - earthPosition.y,
position.zAu - earthPosition.z
);
infoPanel.hidden = false;
document.getElementById('solarInfoName').textContent = 'Voyager 1';
document.getElementById('solarInfoName').style.color = '#8fe3ff';
document.getElementById('solarInfoDistanceAu').textContent = `${Number(position.distanceAu).toFixed(3)} AU`;
document.getElementById('solarInfoDistanceKm').textContent = `${(Number(position.distanceAu) * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoEarthDistanceAu').textContent = `${earthDistanceAu.toFixed(3)} AU`;
document.getElementById('solarInfoEarthDistanceKm').textContent = `${(earthDistanceAu * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoMagnitude').textContent = '--';
document.getElementById('solarInfoPeriod').textContent = '--';
document.getElementById('solarInfoAxis').textContent = '--';
document.getElementById('solarInfoType').textContent = 'Raumsonde | JPL Horizons CSV';
}
function updateVoyager2InfoPanel() {
const position = getVoyager2Position(simDate);
if (!position) {
infoPanel.hidden = true;
return;
}
const earthPosition = getHelioPosition('Earth', simDate);
const earthDistanceAu = Math.hypot(
position.xAu - earthPosition.x,
position.yAu - earthPosition.y,
position.zAu - earthPosition.z
);
infoPanel.hidden = false;
document.getElementById('solarInfoName').textContent = 'Voyager 2';
document.getElementById('solarInfoName').style.color = '#ffd36f';
document.getElementById('solarInfoDistanceAu').textContent = `${Number(position.distanceAu).toFixed(3)} AU`;
document.getElementById('solarInfoDistanceKm').textContent = `${(Number(position.distanceAu) * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoEarthDistanceAu').textContent = `${earthDistanceAu.toFixed(3)} AU`;
document.getElementById('solarInfoEarthDistanceKm').textContent = `${(earthDistanceAu * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoMagnitude').textContent = '--';
document.getElementById('solarInfoPeriod').textContent = '--';
document.getElementById('solarInfoAxis').textContent = '--';
document.getElementById('solarInfoType').textContent = 'Raumsonde | JPL Horizons CSV';
}
function updateNewHorizonsInfoPanel() {
const position = getNewHorizonsPosition(simDate);
if (!position) {
infoPanel.hidden = true;
return;
}
const earthPosition = getHelioPosition('Earth', simDate);
const earthDistanceAu = Math.hypot(
position.xAu - earthPosition.x,
position.yAu - earthPosition.y,
position.zAu - earthPosition.z
);
infoPanel.hidden = false;
document.getElementById('solarInfoName').textContent = 'New Horizons';
document.getElementById('solarInfoName').style.color = '#ff9fd6';
document.getElementById('solarInfoDistanceAu').textContent = `${Number(position.distanceAu).toFixed(3)} AU`;
document.getElementById('solarInfoDistanceKm').textContent = `${(Number(position.distanceAu) * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoEarthDistanceAu').textContent = `${earthDistanceAu.toFixed(3)} AU`;
document.getElementById('solarInfoEarthDistanceKm').textContent = `${(earthDistanceAu * AU_IN_KM).toFixed(0).replace(/\B(?=(\d{3})+(?!\d))/g, '.')} km`;
document.getElementById('solarInfoMagnitude').textContent = '--';
document.getElementById('solarInfoPeriod').textContent = '--';
document.getElementById('solarInfoAxis').textContent = '--';
document.getElementById('solarInfoType').textContent = 'Raumsonde | JPL Horizons CSV';
}
function focusPlanet(key) {
focusedPlanetKey = key;
focusedMinorPlanetId = null;
focusedCometId = null;
focusedVoyager1 = false;
focusedVoyager2 = false;
focusedNewHorizons = false;
document.querySelectorAll('.solarsystem-planet-chip').forEach((button) => {
button.classList.toggle('is-focused', button.dataset.planet === key);
});
updatePlanetInfoPanel(key);
}
function buildLabels() {
PLANETS.forEach((planet) => {
const label = document.createElement('div');
label.className = 'solarsystem-planet-label';
label.textContent = planet.label;
label.style.color = planet.color;
label.hidden = true;
labelsLayer.appendChild(label);
labelElements.set(planet.key, label);
});
moonLabelElement = document.createElement('div');
moonLabelElement.className = 'solarsystem-planet-label';
moonLabelElement.textContent = MOON.label;
moonLabelElement.style.color = MOON.color;
moonLabelElement.hidden = true;
labelsLayer.appendChild(moonLabelElement);
voyager1LabelElement = document.createElement('div');
voyager1LabelElement.className = 'solarsystem-planet-label';
voyager1LabelElement.textContent = 'Voyager 1';
voyager1LabelElement.style.color = '#8fe3ff';
voyager1LabelElement.hidden = true;
labelsLayer.appendChild(voyager1LabelElement);
voyager2LabelElement = document.createElement('div');
voyager2LabelElement.className = 'solarsystem-planet-label';
voyager2LabelElement.textContent = 'Voyager 2';
voyager2LabelElement.style.color = '#ffd36f';
voyager2LabelElement.hidden = true;
labelsLayer.appendChild(voyager2LabelElement);
newHorizonsLabelElement = document.createElement('div');
newHorizonsLabelElement.className = 'solarsystem-planet-label';
newHorizonsLabelElement.textContent = 'New Horizons';
newHorizonsLabelElement.style.color = '#ff9fd6';
newHorizonsLabelElement.hidden = true;
labelsLayer.appendChild(newHorizonsLabelElement);
}
function ensureVoyager1Objects() {
if (voyager1Objects || !Array.isArray(VOYAGER1_TRACK) || VOYAGER1_TRACK.length === 0) {
return;
}
const orbitLine = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({
color: new THREE.Color('#8fe3ff'),
transparent: true,
opacity: 0.8,
})
);
orbitLine.visible = showOrbits;
scene.add(orbitLine);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(0.28, 20, 20),
new THREE.MeshStandardMaterial({
color: new THREE.Color('#b7f0ff'),
emissive: new THREE.Color('#4db5d4'),
emissiveIntensity: 0.45,
roughness: 0.45,
metalness: 0.7,
})
);
mesh.userData.baseScale = 1;
mesh.userData.minScaleMultiplier = 1.8;
scene.add(mesh);
voyager1Objects = { orbitLine, mesh, labelElement: voyager1LabelElement };
}
function rebuildVoyager1Track() {
ensureVoyager1Objects();
if (!voyager1Objects) {
return;
}
const positions = [];
VOYAGER1_TRACK.forEach((entry) => {
const display = toDisplayPosition({
x: Number(entry.xAu),
y: Number(entry.yAu),
z: Number(entry.zAu),
r: Math.max(Number(entry.distanceAu), 1e-9),
});
positions.push(display.x, display.y, display.z);
});
voyager1Objects.orbitLine.geometry.dispose();
voyager1Objects.orbitLine.geometry = new THREE.BufferGeometry();
voyager1Objects.orbitLine.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
voyager1Objects.orbitLine.visible = showOrbits;
}
function updateVoyager1Visual() {
ensureVoyager1Objects();
if (!voyager1Objects) {
return;
}
if (!isSpacecraftSelected('voyager1')) {
voyager1Objects.mesh.visible = false;
voyager1Objects.orbitLine.visible = false;
if (voyager1Objects.labelElement) {
voyager1Objects.labelElement.hidden = true;
}
return;
}
const position = getVoyager1Position(simDate);
if (!position) {
voyager1Objects.mesh.visible = false;
voyager1Objects.orbitLine.visible = false;
if (voyager1Objects.labelElement) {
voyager1Objects.labelElement.hidden = true;
}
return;
}
voyager1Objects.mesh.visible = true;
voyager1Objects.mesh.position.copy(toDisplayPosition({
x: Number(position.xAu),
y: Number(position.yAu),
z: Number(position.zAu),
r: Math.max(Number(position.distanceAu), 1e-9),
}));
voyager1Objects.mesh.rotation.y = (simDate.getTime() / 1000) * 0.0004;
voyager1Objects.orbitLine.visible = showOrbits;
if (focusedVoyager1) {
updateVoyager1InfoPanel();
}
}
function ensureVoyager2Objects() {
if (voyager2Objects || !Array.isArray(VOYAGER2_TRACK) || VOYAGER2_TRACK.length === 0) {
return;
}
const orbitLine = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({
color: new THREE.Color('#ffd36f'),
transparent: true,
opacity: 0.8,
})
);
orbitLine.visible = showOrbits;
scene.add(orbitLine);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(0.28, 20, 20),
new THREE.MeshStandardMaterial({
color: new THREE.Color('#ffe4a6'),
emissive: new THREE.Color('#d4972e'),
emissiveIntensity: 0.45,
roughness: 0.45,
metalness: 0.7,
})
);
mesh.userData.baseScale = 1;
mesh.userData.minScaleMultiplier = 1.8;
scene.add(mesh);
voyager2Objects = { orbitLine, mesh, labelElement: voyager2LabelElement };
}
function rebuildVoyager2Track() {
ensureVoyager2Objects();
if (!voyager2Objects) {
return;
}
const positions = [];
VOYAGER2_TRACK.forEach((entry) => {
const display = toDisplayPosition({
x: Number(entry.xAu),
y: Number(entry.yAu),
z: Number(entry.zAu),
r: Math.max(Number(entry.distanceAu), 1e-9),
});
positions.push(display.x, display.y, display.z);
});
voyager2Objects.orbitLine.geometry.dispose();
voyager2Objects.orbitLine.geometry = new THREE.BufferGeometry();
voyager2Objects.orbitLine.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
voyager2Objects.orbitLine.visible = showOrbits;
}
function updateVoyager2Visual() {
ensureVoyager2Objects();
if (!voyager2Objects) {
return;
}
if (!isSpacecraftSelected('voyager2')) {
voyager2Objects.mesh.visible = false;
voyager2Objects.orbitLine.visible = false;
if (voyager2Objects.labelElement) {
voyager2Objects.labelElement.hidden = true;
}
return;
}
const position = getVoyager2Position(simDate);
if (!position) {
voyager2Objects.mesh.visible = false;
voyager2Objects.orbitLine.visible = false;
if (voyager2Objects.labelElement) {
voyager2Objects.labelElement.hidden = true;
}
return;
}
voyager2Objects.mesh.visible = true;
voyager2Objects.mesh.position.copy(toDisplayPosition({
x: Number(position.xAu),
y: Number(position.yAu),
z: Number(position.zAu),
r: Math.max(Number(position.distanceAu), 1e-9),
}));
voyager2Objects.mesh.rotation.y = (simDate.getTime() / 1000) * 0.0004;
voyager2Objects.orbitLine.visible = showOrbits;
if (focusedVoyager2) {
updateVoyager2InfoPanel();
}
}
function ensureNewHorizonsObjects() {
if (newHorizonsObjects || !Array.isArray(NEW_HORIZONS_TRACK) || NEW_HORIZONS_TRACK.length === 0) {
return;
}
const orbitLine = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({
color: new THREE.Color('#ff9fd6'),
transparent: true,
opacity: 0.8,
})
);
orbitLine.visible = showOrbits;
scene.add(orbitLine);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(0.28, 20, 20),
new THREE.MeshStandardMaterial({
color: new THREE.Color('#ffd4ec'),
emissive: new THREE.Color('#cc4da0'),
emissiveIntensity: 0.45,
roughness: 0.45,
metalness: 0.7,
})
);
mesh.userData.baseScale = 1;
mesh.userData.minScaleMultiplier = 1.8;
scene.add(mesh);
newHorizonsObjects = { orbitLine, mesh, labelElement: newHorizonsLabelElement };
}
function rebuildNewHorizonsTrack() {
ensureNewHorizonsObjects();
if (!newHorizonsObjects) {
return;
}
const positions = [];
NEW_HORIZONS_TRACK.forEach((entry) => {
const display = toDisplayPosition({
x: Number(entry.xAu),
y: Number(entry.yAu),
z: Number(entry.zAu),
r: Math.max(Number(entry.distanceAu), 1e-9),
});
positions.push(display.x, display.y, display.z);
});
newHorizonsObjects.orbitLine.geometry.dispose();
newHorizonsObjects.orbitLine.geometry = new THREE.BufferGeometry();
newHorizonsObjects.orbitLine.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
newHorizonsObjects.orbitLine.visible = showOrbits;
}
function updateNewHorizonsVisual() {
ensureNewHorizonsObjects();
if (!newHorizonsObjects) {
return;
}
if (!isSpacecraftSelected('new_horizons')) {
newHorizonsObjects.mesh.visible = false;
newHorizonsObjects.orbitLine.visible = false;
if (newHorizonsObjects.labelElement) {
newHorizonsObjects.labelElement.hidden = true;
}
return;
}
const position = getNewHorizonsPosition(simDate);
if (!position) {
newHorizonsObjects.mesh.visible = false;
newHorizonsObjects.orbitLine.visible = false;
if (newHorizonsObjects.labelElement) {
newHorizonsObjects.labelElement.hidden = true;
}
return;
}
newHorizonsObjects.mesh.visible = true;
newHorizonsObjects.mesh.position.copy(toDisplayPosition({
x: Number(position.xAu),
y: Number(position.yAu),
z: Number(position.zAu),
r: Math.max(Number(position.distanceAu), 1e-9),
}));
newHorizonsObjects.mesh.rotation.y = (simDate.getTime() / 1000) * 0.0004;
newHorizonsObjects.orbitLine.visible = showOrbits;
if (focusedNewHorizons) {
updateNewHorizonsInfoPanel();
}
}
function syncSpacecraftObjects() {
const selectedSpacecraftSet = getSelectedSpacecraftSet();
if (voyager1Objects) {
const isSelected = selectedSpacecraftSet.has('voyager1');
voyager1Objects.mesh.visible = isSelected && voyager1Objects.mesh.visible;
voyager1Objects.orbitLine.visible = isSelected && showOrbits && voyager1Objects.orbitLine.visible;
if (!isSelected && voyager1Objects.labelElement) {
voyager1Objects.labelElement.hidden = true;
}
}
if (voyager2Objects) {
const isSelected = selectedSpacecraftSet.has('voyager2');
voyager2Objects.mesh.visible = isSelected && voyager2Objects.mesh.visible;
voyager2Objects.orbitLine.visible = isSelected && showOrbits && voyager2Objects.orbitLine.visible;
if (!isSelected && voyager2Objects.labelElement) {
voyager2Objects.labelElement.hidden = true;
}
}
if (newHorizonsObjects) {
const isSelected = selectedSpacecraftSet.has('new_horizons');
newHorizonsObjects.mesh.visible = isSelected && newHorizonsObjects.mesh.visible;
newHorizonsObjects.orbitLine.visible = isSelected && showOrbits && newHorizonsObjects.orbitLine.visible;
if (!isSelected && newHorizonsObjects.labelElement) {
newHorizonsObjects.labelElement.hidden = true;
}
}
if (focusedVoyager1 && !selectedSpacecraftSet.has('voyager1')) {
clearFocus();
}
if (focusedVoyager2 && !selectedSpacecraftSet.has('voyager2')) {
clearFocus();
}
if (focusedNewHorizons && !selectedSpacecraftSet.has('new_horizons')) {
clearFocus();
}
}
function ensureMoonObjects() {
if (moonMesh && moonOrbitLine) {
return;
}
const moonTex = textureLoader.load(MOON.texture);
moonTex.colorSpace = THREE.SRGBColorSpace;
moonMesh = new THREE.Mesh(
new THREE.SphereGeometry(MOON.size, 28, 28),
new THREE.MeshStandardMaterial({
color: 0xffffff,
map: moonTex,
emissive: new THREE.Color(MOON.emissive),
emissiveIntensity: 0.05,
roughness: MOON.roughness,
metalness: MOON.metalness,
})
);
moonMesh.castShadow = true;
moonMesh.receiveShadow = true;
moonMesh.userData.baseScale = 1;
moonMesh.userData.minScaleMultiplier = 1;
scene.add(moonMesh);
moonOrbitLine = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({
color: new THREE.Color(MOON.color),
transparent: true,
opacity: 0.55,
})
);
moonOrbitLine.visible = showOrbits;
scene.add(moonOrbitLine);
}
function rebuildMoonOrbitLine() {
ensureMoonObjects();
const positions = [];
const steps = 180;
for (let step = 0; step <= steps; step += 1) {
const offsetDays = (step / steps) * MOON.orbitalPeriodDays;
const date = new Date(simDate.getTime() + offsetDays * 86400000);
const display = getMoonDisplayPosition(date);
positions.push(display.x, display.y, display.z);
}
moonOrbitLine.geometry.dispose();
moonOrbitLine.geometry = new THREE.BufferGeometry();
moonOrbitLine.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
moonOrbitLine.visible = showOrbits;
}
function updateMoonVisual() {
ensureMoonObjects();
const display = getMoonDisplayPosition(simDate);
moonMesh.position.copy(display);
const rotationState = getPlanetRotationState('Moon', simDate);
if (rotationState) {
moonMesh.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), rotationState.northDisplay);
moonMesh.rotateY(rotationState.spinRad);
}
moonOrbitLine.visible = showOrbits;
}
function ensureMinorPlanetObjects() {
if (!minorPlanetObjects) {
minorPlanetObjects = new Map();
}
}
function createMinorPlanetObjects(id) {
ensureMinorPlanetObjects();
if (minorPlanetObjects.has(id)) {
return minorPlanetObjects.get(id);
}
const color = getMinorPlanetColor(id);
const lineMaterial = new THREE.LineBasicMaterial({
color: new THREE.Color(color.line),
transparent: true,
opacity: 0.85
});
const orbitLine = new THREE.Line(new THREE.BufferGeometry(), lineMaterial);
orbitLine.visible = false;
scene.add(orbitLine);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(0.58, 28, 28),
new THREE.MeshStandardMaterial({
color: new THREE.Color(color.body),
emissive: new THREE.Color(color.emissive),
emissiveIntensity: 0.06,
roughness: 0.65,
metalness: 0.08,
})
);
mesh.visible = false;
mesh.userData.baseScale = 1;
mesh.userData.minScaleMultiplier = 1;
mesh.userData.minorPlanetId = id;
scene.add(mesh);
const labelElement = document.createElement('div');
labelElement.className = 'solarsystem-planet-label';
labelElement.style.color = color.line;
labelElement.hidden = true;
labelsLayer.appendChild(labelElement);
const objects = { orbitLine, mesh, labelElement };
minorPlanetObjects.set(id, objects);
return objects;
}
function removeMinorPlanetObjects(id) {
if (!minorPlanetObjects || !minorPlanetObjects.has(id)) {
return;
}
const { orbitLine, mesh, labelElement } = minorPlanetObjects.get(id);
orbitLine.geometry.dispose();
orbitLine.material.dispose();
scene.remove(orbitLine);
mesh.geometry.dispose();
mesh.material.dispose();
scene.remove(mesh);
labelElement.remove();
minorPlanetObjects.delete(id);
}
function syncMinorPlanetObjects() {
ensureMinorPlanetObjects();
const selectedSet = getSelectedMinorPlanetSet();
selectedMinorPlanetIds.forEach((id) => {
createMinorPlanetObjects(id);
});
Array.from(minorPlanetObjects.keys()).forEach((id) => {
if (!selectedSet.has(id)) {
removeMinorPlanetObjects(id);
}
});
}
function normalizeRadians(value) {
const tau = Math.PI * 2;
return ((value % tau) + tau) % tau;
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function estimateMinorPlanetMagnitude(minorPlanet, helioPosition, earthHelioPosition, earthDistanceAu) {
if (!minorPlanet || minorPlanet.absoluteMagnitudeH === null) {
return null;
}
const r = Math.max(helioPosition.r, 1e-9);
const delta = Math.max(earthDistanceAu, 1e-9);
const earthSunDistance = Math.max(earthHelioPosition.r, 1e-9);
const cosPhase = clamp(
(r * r + delta * delta - earthSunDistance * earthSunDistance) / (2 * r * delta),
-1,
1
);
const phaseAngle = Math.acos(cosPhase);
const tanHalfPhase = Math.tan(phaseAngle / 2);
const phi1 = Math.exp(-3.33 * Math.pow(Math.max(tanHalfPhase, 0), 0.63));
const phi2 = Math.exp(-1.87 * Math.pow(Math.max(tanHalfPhase, 0), 1.22));
const g = minorPlanet.slopeG ?? 0.15;
const phaseTerm = ((1 - g) * phi1) + (g * phi2);
if (!Number.isFinite(phaseTerm) || phaseTerm <= 0) {
return null;
}
return minorPlanet.absoluteMagnitudeH + (5 * Math.log10(r * delta)) - (2.5 * Math.log10(phaseTerm));
}
function solveKeplerEquation(meanAnomaly, eccentricity) {
let eccentricAnomaly = eccentricity < 0.8 ? meanAnomaly : Math.PI;
for (let i = 0; i < 12; i += 1) {
const delta = (eccentricAnomaly - eccentricity * Math.sin(eccentricAnomaly) - meanAnomaly)
/ (1 - eccentricity * Math.cos(eccentricAnomaly));
eccentricAnomaly -= delta;
if (Math.abs(delta) < 1e-7) {
break;
}
}
return eccentricAnomaly;
}
function getMinorPlanetHelioPosition(minorPlanet, date) {
if (!minorPlanet || !minorPlanet.epochDateIso) {
return null;
}
const epochMs = Date.parse(`${minorPlanet.epochDateIso}T00:00:00Z`);
if (Number.isNaN(epochMs)) {
return null;
}
const daysSinceEpoch = (date.getTime() - epochMs) / 86400000;
const meanAnomaly = normalizeRadians((minorPlanet.meanAnomalyDeg + (minorPlanet.meanMotionDegPerDay * daysSinceEpoch)) * Math.PI / 180);
const eccentricity = Math.max(0, Math.min(0.999999, minorPlanet.eccentricity));
const eccentricAnomaly = solveKeplerEquation(meanAnomaly, eccentricity);
const trueAnomaly = 2 * Math.atan2(
Math.sqrt(1 + eccentricity) * Math.sin(eccentricAnomaly / 2),
Math.sqrt(1 - eccentricity) * Math.cos(eccentricAnomaly / 2)
);
const radiusAu = minorPlanet.semimajorAxisAu * (1 - eccentricity * Math.cos(eccentricAnomaly));
const inclination = minorPlanet.inclinationDeg * Math.PI / 180;
const ascendingNode = minorPlanet.ascendingNodeDeg * Math.PI / 180;
const argPerihelion = minorPlanet.argPerihelionDeg * Math.PI / 180;
const argumentOfLatitude = trueAnomaly + argPerihelion;
return {
x: radiusAu * ((Math.cos(ascendingNode) * Math.cos(argumentOfLatitude)) - (Math.sin(ascendingNode) * Math.sin(argumentOfLatitude) * Math.cos(inclination))),
y: radiusAu * ((Math.sin(ascendingNode) * Math.cos(argumentOfLatitude)) + (Math.cos(ascendingNode) * Math.sin(argumentOfLatitude) * Math.cos(inclination))),
z: radiusAu * (Math.sin(argumentOfLatitude) * Math.sin(inclination)),
r: radiusAu
};
}
function ensureCometObjects() {
if (!cometObjects) {
cometObjects = new Map();
}
}
function createCometObjects(id) {
ensureCometObjects();
if (cometObjects.has(id)) {
return cometObjects.get(id);
}
const color = getCometColor(id);
const orbitLine = new THREE.Line(
new THREE.BufferGeometry(),
new THREE.LineBasicMaterial({
color: new THREE.Color(color.line),
transparent: true,
opacity: 0.85
})
);
orbitLine.visible = false;
scene.add(orbitLine);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 24, 24),
new THREE.MeshStandardMaterial({
color: new THREE.Color(color.body),
emissive: new THREE.Color(color.emissive),
emissiveIntensity: 0.08,
roughness: 0.5,
metalness: 0.03,
})
);
mesh.visible = false;
mesh.userData.baseScale = 1;
mesh.userData.minScaleMultiplier = 1;
mesh.userData.cometId = id;
scene.add(mesh);
const labelElement = document.createElement('div');
labelElement.className = 'solarsystem-planet-label';
labelElement.style.color = color.line;
labelElement.hidden = true;
labelsLayer.appendChild(labelElement);
const objects = { orbitLine, mesh, labelElement };
cometObjects.set(id, objects);
return objects;
}
function removeCometObjects(id) {
if (!cometObjects || !cometObjects.has(id)) {
return;
}
const { orbitLine, mesh, labelElement } = cometObjects.get(id);
orbitLine.geometry.dispose();
orbitLine.material.dispose();
scene.remove(orbitLine);
mesh.geometry.dispose();
mesh.material.dispose();
scene.remove(mesh);
labelElement.remove();
cometObjects.delete(id);
}
function syncCometObjects() {
ensureCometObjects();
const selectedSet = getSelectedCometSet();
selectedCometIds.forEach((id) => {
createCometObjects(id);
});
Array.from(cometObjects.keys()).forEach((id) => {
if (!selectedSet.has(id)) {
removeCometObjects(id);
}
});
}
const GAUSSIAN_GRAVITATIONAL_CONSTANT = 0.01720209895;
const PARABOLIC_ECCENTRICITY_TOLERANCE = 1e-6;
function solveHyperbolicAnomaly(meanAnomaly, eccentricity) {
let anomaly = meanAnomaly === 0 ? 0 : Math.asinh(meanAnomaly / eccentricity);
for (let i = 0; i < 40; i += 1) {
const sinhValue = Math.sinh(anomaly);
const coshValue = Math.cosh(anomaly);
const delta = (eccentricity * sinhValue - anomaly - meanAnomaly) / (eccentricity * coshValue - 1);
anomaly -= delta;
if (Math.abs(delta) < 1e-12) {
break;
}
}
return anomaly;
}
function solveParabolicParameter(deltaDays, perihelionDistanceAu) {
const scale = GAUSSIAN_GRAVITATIONAL_CONSTANT * deltaDays / Math.sqrt(2 * perihelionDistanceAu ** 3);
let parameter = scale;
for (let i = 0; i < 40; i += 1) {
const delta = (parameter + (parameter ** 3) / 3 - scale) / (1 + parameter ** 2);
parameter -= delta;
if (Math.abs(delta) < 1e-12) {
break;
}
}
return parameter;
}
function getCometTrueAnomalyAndRadius(deltaDays, perihelionDistanceAu, eccentricity) {
if (eccentricity < 1 - PARABOLIC_ECCENTRICITY_TOLERANCE) {
const semimajorAxis = perihelionDistanceAu / (1 - eccentricity);
const meanMotion = GAUSSIAN_GRAVITATIONAL_CONSTANT / (semimajorAxis ** 1.5);
const meanAnomaly = normalizeRadians(meanMotion * deltaDays);
const eccentricAnomaly = solveKeplerEquation(meanAnomaly, eccentricity);
const radiusAu = semimajorAxis * (1 - eccentricity * Math.cos(eccentricAnomaly));
const trueAnomaly = 2 * Math.atan2(
Math.sqrt(1 + eccentricity) * Math.sin(eccentricAnomaly / 2),
Math.sqrt(1 - eccentricity) * Math.cos(eccentricAnomaly / 2)
);
return { trueAnomaly, radiusAu };
}
if (eccentricity > 1 + PARABOLIC_ECCENTRICITY_TOLERANCE) {
const semimajorAxisAbs = perihelionDistanceAu / (eccentricity - 1);
const meanAnomaly = GAUSSIAN_GRAVITATIONAL_CONSTANT * deltaDays / (semimajorAxisAbs ** 1.5);
const hyperbolicAnomaly = solveHyperbolicAnomaly(meanAnomaly, eccentricity);
const radiusAu = semimajorAxisAbs * (eccentricity * Math.cosh(hyperbolicAnomaly) - 1);
const trueAnomaly = 2 * Math.atan2(
Math.sqrt(eccentricity + 1) * Math.sinh(hyperbolicAnomaly / 2),
Math.sqrt(eccentricity - 1) * Math.cosh(hyperbolicAnomaly / 2)
);
return { trueAnomaly, radiusAu };
}
const parameter = solveParabolicParameter(deltaDays, perihelionDistanceAu);
return {
trueAnomaly: 2 * Math.atan(parameter),
radiusAu: perihelionDistanceAu * (1 + parameter ** 2),
};
}
function estimateCometMagnitude(comet, helioPosition, earthDistanceAu) {
if (!comet || comet.absoluteMagnitudeH === null || comet.photometricIndex === null) {
return null;
}
const r = Math.max(helioPosition.r, 1e-9);
const delta = Math.max(earthDistanceAu, 1e-9);
return comet.absoluteMagnitudeH + (5 * Math.log10(delta)) + (2.5 * comet.photometricIndex * Math.log10(r));
}
function getCometHelioPosition(comet, date) {
if (!comet || !comet.perihelionIso) {
return null;
}
const perihelionMs = Date.parse(comet.perihelionIso);
if (Number.isNaN(perihelionMs)) {
return null;
}
const deltaDays = (date.getTime() - perihelionMs) / 86400000;
const orbit = getCometTrueAnomalyAndRadius(deltaDays, comet.perihelionDistanceAu, comet.eccentricity);
const inclination = comet.inclinationDeg * Math.PI / 180;
const ascendingNode = comet.ascendingNodeDeg * Math.PI / 180;
const argPerihelion = comet.argPerihelionDeg * Math.PI / 180;
const argumentOfLatitude = orbit.trueAnomaly + argPerihelion;
return {
x: orbit.radiusAu * ((Math.cos(ascendingNode) * Math.cos(argumentOfLatitude)) - (Math.sin(ascendingNode) * Math.sin(argumentOfLatitude) * Math.cos(inclination))),
y: orbit.radiusAu * ((Math.sin(ascendingNode) * Math.cos(argumentOfLatitude)) + (Math.cos(ascendingNode) * Math.sin(argumentOfLatitude) * Math.cos(inclination))),
z: orbit.radiusAu * (Math.sin(argumentOfLatitude) * Math.sin(inclination)),
r: orbit.radiusAu,
};
}
function rebuildMinorPlanetOrbit(id) {
const minorPlanet = getMinorPlanetMeta(id);
const objects = createMinorPlanetObjects(id);
if (!minorPlanet || !objects) {
return;
}
const periodDays = minorPlanet.meanMotionDegPerDay > 0 ? 360 / minorPlanet.meanMotionDegPerDay : 365;
const steps = Math.max(180, Math.min(520, Math.round(periodDays / 8)));
const positions = [];
for (let step = 0; step <= steps; step += 1) {
const orbitDate = new Date(Date.parse(`${minorPlanet.epochDateIso}T00:00:00Z`) + ((periodDays * step) / steps) * 86400000);
const heliocentric = getMinorPlanetHelioPosition(minorPlanet, orbitDate);
if (!heliocentric) {
continue;
}
const display = toDisplayPosition(heliocentric);
positions.push(display.x, display.y, display.z);
}
objects.orbitLine.geometry.dispose();
objects.orbitLine.geometry = new THREE.BufferGeometry();
objects.orbitLine.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
objects.orbitLine.visible = showOrbits;
}
function updateMinorPlanetVisual() {
syncMinorPlanetObjects();
if (!minorPlanetObjects || minorPlanetObjects.size === 0) {
if (!focusedPlanetKey) {
infoPanel.hidden = true;
}
return;
}
const selectedSet = getSelectedMinorPlanetSet();
minorPlanetObjects.forEach((objects, id) => {
const minorPlanet = getMinorPlanetMeta(id);
const isSelected = selectedSet.has(id);
if (!minorPlanet || !isSelected) {
objects.orbitLine.visible = false;
objects.mesh.visible = false;
objects.labelElement.hidden = true;
return;
}
const heliocentric = getMinorPlanetHelioPosition(minorPlanet, simDate);
if (!heliocentric) {
objects.orbitLine.visible = false;
objects.mesh.visible = false;
objects.labelElement.hidden = true;
return;
}
const display = toDisplayPosition(heliocentric);
objects.mesh.visible = true;
objects.mesh.position.copy(display);
objects.mesh.rotation.y = (simDate.getTime() / 1000) * 0.0002;
objects.orbitLine.visible = showOrbits;
if (focusedMinorPlanetId === id) {
updateMinorPlanetInfoPanel(id);
}
});
if (focusedMinorPlanetId !== null && !selectedSet.has(focusedMinorPlanetId)) {
focusedMinorPlanetId = null;
if (!focusedPlanetKey) {
infoPanel.hidden = true;
}
}
}
function rebuildCometOrbit(id) {
const comet = getCometMeta(id);
const objects = createCometObjects(id);
if (!comet || !objects) {
return;
}
const positions = [];
const steps = 720;
const beforeDays = comet.eccentricity < 1 ? 3650 : 1200;
const afterDays = comet.eccentricity < 1 ? 3650 : 1200;
for (let step = 0; step <= steps; step += 1) {
const fraction = step / steps;
const centered = (fraction * 2) - 1;
const curved = Math.sign(centered) * Math.pow(Math.abs(centered), 2.2);
const offsetDays = curved < 0
? curved * beforeDays
: curved * afterDays;
const orbitDate = new Date(simDate.getTime() + offsetDays * 86400000);
const heliocentric = getCometHelioPosition(comet, orbitDate);
if (!heliocentric) {
continue;
}
const display = toDisplayPosition(heliocentric);
positions.push(display.x, display.y, display.z);
}
objects.orbitLine.geometry.dispose();
objects.orbitLine.geometry = new THREE.BufferGeometry();
objects.orbitLine.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
objects.orbitLine.visible = showOrbits;
}
function updateCometVisual() {
syncCometObjects();
if (!cometObjects || cometObjects.size === 0) {
if (!focusedPlanetKey && focusedMinorPlanetId === null) {
infoPanel.hidden = true;
}
return;
}
const selectedSet = getSelectedCometSet();
cometObjects.forEach((objects, id) => {
const comet = getCometMeta(id);
const isSelected = selectedSet.has(id);
if (!comet || !isSelected) {
objects.orbitLine.visible = false;
objects.mesh.visible = false;
objects.labelElement.hidden = true;
return;
}
const heliocentric = getCometHelioPosition(comet, simDate);
if (!heliocentric) {
objects.orbitLine.visible = false;
objects.mesh.visible = false;
objects.labelElement.hidden = true;
return;
}
const display = toDisplayPosition(heliocentric);
objects.mesh.visible = true;
objects.mesh.position.copy(display);
objects.mesh.rotation.y = (simDate.getTime() / 1000) * 0.0002;
objects.orbitLine.visible = showOrbits;
if (focusedCometId === id) {
updateCometInfoPanel(id);
}
});
if (focusedCometId !== null && !selectedSet.has(focusedCometId)) {
focusedCometId = null;
if (!focusedPlanetKey && focusedMinorPlanetId === null) {
infoPanel.hidden = true;
}
}
}
function createOrbitLine(planet) {
const periodDays = getPlanetDynamics(planet.key).periodDays ?? 365;
const steps = planet.key === 'Pluto' ? 300 : 220;
const positions = [];
for (let step = 0; step <= steps; step += 1) {
const offsetDays = (step / steps) * periodDays;
const date = new Date(simDate.getTime() + offsetDays * 86400000);
const heliocentric = getHelioPosition(planet.key, date);
const display = toDisplayPosition(heliocentric);
positions.push(display.x, display.y, display.z);
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(planet.color).multiplyScalar(0.9),
transparent: true,
opacity: 0.55,
});
const line = new THREE.Line(geometry, material);
line.visible = showOrbits;
orbitLines.set(planet.key, line);
scene.add(line);
}
function rebuildOrbitLine(planet) {
const line = orbitLines.get(planet.key);
if (!line) {
return;
}
const periodDays = getPlanetDynamics(planet.key).periodDays ?? 365;
const steps = planet.key === 'Pluto' ? 300 : 220;
const positions = [];
for (let step = 0; step <= steps; step += 1) {
const offsetDays = (step / steps) * periodDays;
const date = new Date(simDate.getTime() + offsetDays * 86400000);
const heliocentric = getHelioPosition(planet.key, date);
const display = toDisplayPosition(heliocentric);
positions.push(display.x, display.y, display.z);
}
line.geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
line.geometry.attributes.position.needsUpdate = true;
}
function createPlanetMesh(planet) {
const group = new THREE.Group();
const axisGroup = new THREE.Group();
const spinGroup = new THREE.Group();
const matParams = {
emissive: new THREE.Color(planet.emissive),
emissiveIntensity: 0.12,
roughness: planet.roughness ?? 0.7,
metalness: planet.metalness ?? 0.05,
};
matParams.color = new THREE.Color(planet.texture ? 0xffffff : planet.color);
if (planet.texture && textureLoader) {
const tex = textureLoader.load(
planet.texture,
(loaded) => { loaded.colorSpace = THREE.SRGBColorSpace; },
undefined,
() => { mesh.material.map = null; mesh.material.color.set(planet.color); mesh.material.needsUpdate = true; }
);
tex.colorSpace = THREE.SRGBColorSpace;
matParams.map = tex;
}
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(planet.size, 48, 48),
new THREE.MeshStandardMaterial(matParams)
);
mesh.castShadow = true;
mesh.receiveShadow = true;
spinGroup.add(mesh);
axisGroup.add(spinGroup);
if (planet.key === 'Saturn') {
const ringInner = planet.size * 1.3;
const ringOuter = planet.size * 2.38;
const ringGeo = new THREE.RingGeometry(ringInner, ringOuter, 128, 8);
// Fix UV mapping: U = radial (0=inner, 1=outer)
const pos = ringGeo.attributes.position;
const uv = ringGeo.attributes.uv;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const y = pos.getY(i);
const r = Math.sqrt(x * x + y * y);
uv.setXY(i, (r - ringInner) / (ringOuter - ringInner), 0.5);
}
uv.needsUpdate = true;
const ringTex = textureLoader.load('images/planets/saturn_ring_8192.png');
ringTex.colorSpace = THREE.SRGBColorSpace;
const ring = new THREE.Mesh(
ringGeo,
new THREE.MeshStandardMaterial({
map: ringTex,
transparent: true,
alphaTest: 0.05,
side: THREE.DoubleSide,
depthWrite: true,
roughness: 1.0,
metalness: 0.0,
emissive: new THREE.Color(0x000000),
})
);
ring.castShadow = true;
ring.receiveShadow = true;
ring.rotation.x = Math.PI / 2;
axisGroup.add(ring);
}
group.add(axisGroup);
group.userData.planetKey = planet.key;
group.userData.baseScale = 1;
group.userData.minScaleMultiplier = Math.max(JUPITER_MIN_VISIBLE_SIZE / Math.max(planet.size, 0.001), 1);
group.userData.axisGroup = axisGroup;
group.userData.spinGroup = spinGroup;
group.userData.surfaceMesh = mesh;
scene.add(group);
planetMeshes.set(planet.key, group);
createOrbitLine(planet);
}
function createEclipticPlane() {
eclipticGroup = new THREE.Group();
const grid = new THREE.GridHelper(340, 34, 0x5aa8d8, 0x2c4d66);
grid.material.transparent = true;
grid.material.opacity = 0.28;
eclipticGroup.add(grid);
const axisGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, -170, 0),
new THREE.Vector3(0, 170, 0)
]);
const axisMaterial = new THREE.LineBasicMaterial({
color: 0xffdd55,
transparent: true,
opacity: 0.95
});
const verticalAxis = new THREE.Line(axisGeometry, axisMaterial);
eclipticGroup.add(verticalAxis);
eclipticGroup.visible = showEcliptic;
scene.add(eclipticGroup);
}
function updatePlanetPositions() {
const upAxis = new THREE.Vector3(0, 1, 0);
PLANETS.forEach((planet) => {
const meshGroup = planetMeshes.get(planet.key);
if (!meshGroup) {
return;
}
const heliocentric = getHelioPosition(planet.key, simDate);
const display = toDisplayPosition(heliocentric);
meshGroup.position.copy(display);
const axisGroup = meshGroup.userData.axisGroup;
const spinGroup = meshGroup.userData.spinGroup;
const rotationState = getPlanetRotationState(planet.key, simDate);
if (axisGroup && spinGroup && rotationState) {
axisGroup.quaternion.setFromUnitVectors(upAxis, rotationState.northDisplay);
spinGroup.rotation.y = rotationState.spinRad;
}
});
}
function applyMinimumDisplayScale(object3D, minVisibleDistance, maxScaleMultiplier) {
if (!object3D || !camera) {
return;
}
const baseScale = Number(object3D.userData.baseScale || 1);
const minScaleMultiplier = Number(object3D.userData.minScaleMultiplier || 1);
const distanceToCamera = camera.position.distanceTo(object3D.position);
const scaleMultiplier = Math.min(
Math.max(distanceToCamera / Math.max(minVisibleDistance, 1), minScaleMultiplier),
maxScaleMultiplier
);
object3D.scale.setScalar(baseScale * scaleMultiplier);
}
function updateBodyDisplayScales() {
planetMeshes.forEach((group) => {
applyMinimumDisplayScale(group, 180, 6);
});
if (moonMesh) {
applyMinimumDisplayScale(moonMesh, 220, 10);
}
if (minorPlanetObjects) {
minorPlanetObjects.forEach(({ mesh }) => {
if (mesh.visible) {
applyMinimumDisplayScale(mesh, 220, 10);
}
});
}
if (cometObjects) {
cometObjects.forEach(({ mesh }) => {
if (mesh.visible) {
applyMinimumDisplayScale(mesh, 220, 10);
}
});
}
if (voyager1Objects && voyager1Objects.mesh && voyager1Objects.mesh.visible) {
applyMinimumDisplayScale(voyager1Objects.mesh, 260, 10);
}
if (voyager2Objects && voyager2Objects.mesh && voyager2Objects.mesh.visible) {
applyMinimumDisplayScale(voyager2Objects.mesh, 260, 10);
}
if (newHorizonsObjects && newHorizonsObjects.mesh && newHorizonsObjects.mesh.visible) {
applyMinimumDisplayScale(newHorizonsObjects.mesh, 260, 10);
}
}
function updateLabels() {
labelElements.forEach((label, key) => {
label.hidden = !showLabels;
if (!showLabels) {
return;
}
const meshGroup = planetMeshes.get(key);
if (!meshGroup) {
label.hidden = true;
return;
}
const projected = meshGroup.position.clone().project(camera);
if (projected.z > 1) {
label.style.opacity = '0';
return;
}
label.style.opacity = '1';
label.style.left = `${(projected.x * 0.5 + 0.5) * stageWrap.clientWidth}px`;
label.style.top = `${(-projected.y * 0.5 + 0.5) * stageWrap.clientHeight}px`;
});
if (moonLabelElement && moonMesh) {
moonLabelElement.hidden = !showLabels;
if (!moonLabelElement.hidden) {
const projectedMoon = moonMesh.position.clone().project(camera);
if (projectedMoon.z > 1) {
moonLabelElement.style.opacity = '0';
} else {
moonLabelElement.style.opacity = '1';
moonLabelElement.style.left = `${(projectedMoon.x * 0.5 + 0.5) * stageWrap.clientWidth}px`;
moonLabelElement.style.top = `${(-projectedMoon.y * 0.5 + 0.5) * stageWrap.clientHeight}px`;
}
}
}
if (!minorPlanetObjects) {
} else {
minorPlanetObjects.forEach(({ mesh, labelElement }, id) => {
const minorPlanet = getMinorPlanetMeta(id);
labelElement.hidden = !showLabels || !minorPlanet || !mesh.visible;
if (labelElement.hidden) {
return;
}
const projected = mesh.position.clone().project(camera);
if (projected.z > 1) {
labelElement.style.opacity = '0';
return;
}
labelElement.textContent = formatMinorPlanetName(minorPlanet);
labelElement.style.opacity = '1';
labelElement.style.left = `${(projected.x * 0.5 + 0.5) * stageWrap.clientWidth}px`;
labelElement.style.top = `${(-projected.y * 0.5 + 0.5) * stageWrap.clientHeight}px`;
});
}
if (!cometObjects) {
} else {
cometObjects.forEach(({ mesh, labelElement }, id) => {
const comet = getCometMeta(id);
labelElement.hidden = !showLabels || !comet || !mesh.visible;
if (labelElement.hidden) {
return;
}
const projected = mesh.position.clone().project(camera);
if (projected.z > 1) {
labelElement.style.opacity = '0';
return;
}
labelElement.textContent = formatCometName(comet);
labelElement.style.opacity = '1';
labelElement.style.left = `${(projected.x * 0.5 + 0.5) * stageWrap.clientWidth}px`;
labelElement.style.top = `${(-projected.y * 0.5 + 0.5) * stageWrap.clientHeight}px`;
});
}
if (voyager1Objects && voyager1LabelElement) {
voyager1LabelElement.hidden = !showLabels || !voyager1Objects.mesh.visible;
if (!voyager1LabelElement.hidden) {
const projectedVoyager = voyager1Objects.mesh.position.clone().project(camera);
if (projectedVoyager.z > 1) {
voyager1LabelElement.style.opacity = '0';
} else {
voyager1LabelElement.style.opacity = '1';
voyager1LabelElement.style.left = `${(projectedVoyager.x * 0.5 + 0.5) * stageWrap.clientWidth}px`;
voyager1LabelElement.style.top = `${(-projectedVoyager.y * 0.5 + 0.5) * stageWrap.clientHeight}px`;
}
}
}
if (voyager2Objects && voyager2LabelElement) {
voyager2LabelElement.hidden = !showLabels || !voyager2Objects.mesh.visible;
if (!voyager2LabelElement.hidden) {
const projectedVoyager2 = voyager2Objects.mesh.position.clone().project(camera);
if (projectedVoyager2.z > 1) {
voyager2LabelElement.style.opacity = '0';
} else {
voyager2LabelElement.style.opacity = '1';
voyager2LabelElement.style.left = `${(projectedVoyager2.x * 0.5 + 0.5) * stageWrap.clientWidth}px`;
voyager2LabelElement.style.top = `${(-projectedVoyager2.y * 0.5 + 0.5) * stageWrap.clientHeight}px`;
}
}
}
if (newHorizonsObjects && newHorizonsLabelElement) {
newHorizonsLabelElement.hidden = !showLabels || !newHorizonsObjects.mesh.visible;
if (!newHorizonsLabelElement.hidden) {
const projectedNewHorizons = newHorizonsObjects.mesh.position.clone().project(camera);
if (projectedNewHorizons.z > 1) {
newHorizonsLabelElement.style.opacity = '0';
} else {
newHorizonsLabelElement.style.opacity = '1';
newHorizonsLabelElement.style.left = `${(projectedNewHorizons.x * 0.5 + 0.5) * stageWrap.clientWidth}px`;
newHorizonsLabelElement.style.top = `${(-projectedNewHorizons.y * 0.5 + 0.5) * stageWrap.clientHeight}px`;
}
}
}
}
function pickPlanet(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -(((clientY - rect.top) / rect.height) * 2 - 1);
raycaster.setFromCamera(pointer, camera);
const meshes = [];
planetMeshes.forEach((group, key) => {
const surfaceMesh = group.userData.surfaceMesh;
if (surfaceMesh) {
meshes.push({ key, mesh: surfaceMesh });
}
});
const hits = raycaster.intersectObjects(meshes.map((item) => item.mesh), false);
if (minorPlanetObjects) {
const visibleMinorPlanetMeshes = Array.from(minorPlanetObjects.entries())
.filter(([, objects]) => objects.mesh.visible)
.map(([id, objects]) => ({ id, mesh: objects.mesh }));
const minorPlanetHits = raycaster.intersectObjects(visibleMinorPlanetMeshes.map((item) => item.mesh), false);
if (minorPlanetHits.length > 0) {
const hitItem = visibleMinorPlanetMeshes.find((item) => item.mesh === minorPlanetHits[0].object);
if (hitItem) {
focusedPlanetKey = null;
focusedMinorPlanetId = hitItem.id;
focusedCometId = null;
focusedVoyager1 = false;
focusedVoyager2 = false;
focusedNewHorizons = false;
document.querySelectorAll('.solarsystem-planet-chip').forEach((button) => {
button.classList.remove('is-focused');
});
updateMinorPlanetInfoPanel(hitItem.id);
return;
}
}
}
if (cometObjects) {
const visibleCometMeshes = Array.from(cometObjects.entries())
.filter(([, objects]) => objects.mesh.visible)
.map(([id, objects]) => ({ id, mesh: objects.mesh }));
const cometHits = raycaster.intersectObjects(visibleCometMeshes.map((item) => item.mesh), false);
if (cometHits.length > 0) {
const hitItem = visibleCometMeshes.find((item) => item.mesh === cometHits[0].object);
if (hitItem) {
focusedPlanetKey = null;
focusedMinorPlanetId = null;
focusedCometId = hitItem.id;
focusedVoyager1 = false;
focusedVoyager2 = false;
focusedNewHorizons = false;
document.querySelectorAll('.solarsystem-planet-chip').forEach((button) => {
button.classList.remove('is-focused');
});
updateCometInfoPanel(hitItem.id);
return;
}
}
}
if (voyager1Objects && voyager1Objects.mesh.visible) {
const voyagerHits = raycaster.intersectObject(voyager1Objects.mesh, false);
if (voyagerHits.length > 0) {
focusedPlanetKey = null;
focusedMinorPlanetId = null;
focusedCometId = null;
focusedVoyager1 = true;
focusedVoyager2 = false;
focusedNewHorizons = false;
document.querySelectorAll('.solarsystem-planet-chip').forEach((button) => {
button.classList.remove('is-focused');
});
updateVoyager1InfoPanel();
return;
}
}
if (voyager2Objects && voyager2Objects.mesh.visible) {
const voyager2Hits = raycaster.intersectObject(voyager2Objects.mesh, false);
if (voyager2Hits.length > 0) {
focusedPlanetKey = null;
focusedMinorPlanetId = null;
focusedCometId = null;
focusedVoyager1 = false;
focusedVoyager2 = true;
focusedNewHorizons = false;
document.querySelectorAll('.solarsystem-planet-chip').forEach((button) => {
button.classList.remove('is-focused');
});
updateVoyager2InfoPanel();
return;
}
}
if (newHorizonsObjects && newHorizonsObjects.mesh.visible) {
const newHorizonsHits = raycaster.intersectObject(newHorizonsObjects.mesh, false);
if (newHorizonsHits.length > 0) {
focusedPlanetKey = null;
focusedMinorPlanetId = null;
focusedCometId = null;
focusedVoyager1 = false;
focusedVoyager2 = false;
focusedNewHorizons = true;
document.querySelectorAll('.solarsystem-planet-chip').forEach((button) => {
button.classList.remove('is-focused');
});
updateNewHorizonsInfoPanel();
return;
}
}
if (hits.length === 0) {
clearFocus();
return;
}
const hit = meshes.find((item) => item.mesh === hits[0].object);
if (hit) {
focusedCometId = null;
focusedMinorPlanetId = null;
focusPlanet(hit.key);
}
}
function attachPointerControls() {
canvas.addEventListener('pointerdown', (event) => {
isDragging = true;
pointerDownMoved = false;
lastPointerX = event.clientX;
lastPointerY = event.clientY;
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener('pointermove', (event) => {
if (!isDragging) {
return;
}
const dx = event.clientX - lastPointerX;
const dy = event.clientY - lastPointerY;
pointerDownMoved = pointerDownMoved || Math.abs(dx) > 3 || Math.abs(dy) > 3;
camTheta -= dx * 0.005;
camPhi = Math.max(0.14, Math.min(Math.PI - 0.14, camPhi + dy * 0.005));
lastPointerX = event.clientX;
lastPointerY = event.clientY;
if (!focusedPlanetKey) {
updateCamera();
}
});
canvas.addEventListener('pointerup', (event) => {
if (!pointerDownMoved) {
pickPlanet(event.clientX, event.clientY);
}
isDragging = false;
canvas.releasePointerCapture(event.pointerId);
});
canvas.addEventListener('wheel', (event) => {
event.preventDefault();
camRadiusTarget = Math.max(12, Math.min(900, camRadiusTarget * (1 + event.deltaY * 0.001)));
camRadius = camRadiusTarget;
updateCamera();
}, { passive: false });
}
function attachUiHandlers() {
btnToday.addEventListener('click', () => {
simDate = new Date();
refreshCurrentDateUi();
});
btnReset.addEventListener('click', () => {
simDate = new Date();
clearFocus();
refreshCurrentDateUi();
});
btnPlay.addEventListener('click', () => {
playing = !playing;
btnPlay.textContent = playing ? 'Pause' : 'Play';
btnPlay.classList.toggle('is-active', playing);
});
btnTop.addEventListener('click', () => {
camPhi = 0.18;
camTheta = 0;
camRadius = 230;
camRadiusTarget = 230;
clearFocus();
updateCamera();
});
btnSide.addEventListener('click', () => {
camPhi = Math.PI / 2;
camTheta = 0;
camRadius = 220;
camRadiusTarget = 220;
clearFocus();
updateCamera();
});
btnOrbits.addEventListener('click', () => {
showOrbits = !showOrbits;
btnOrbits.classList.toggle('is-active', showOrbits);
orbitLines.forEach((line) => {
line.visible = showOrbits;
});
if (minorPlanetObjects) {
minorPlanetObjects.forEach(({ orbitLine }) => {
orbitLine.visible = showOrbits;
});
}
if (cometObjects) {
cometObjects.forEach(({ orbitLine }) => {
orbitLine.visible = showOrbits;
});
}
if (voyager1Objects) {
voyager1Objects.orbitLine.visible = showOrbits;
}
if (voyager2Objects) {
voyager2Objects.orbitLine.visible = showOrbits;
}
if (newHorizonsObjects) {
newHorizonsObjects.orbitLine.visible = showOrbits;
}
if (moonOrbitLine) {
moonOrbitLine.visible = showOrbits;
}
});
btnLabels.addEventListener('click', () => {
showLabels = !showLabels;
btnLabels.classList.toggle('is-active', showLabels);
labelElements.forEach((element) => {
element.hidden = !showLabels;
});
});
btnEcliptic.addEventListener('click', () => {
showEcliptic = !showEcliptic;
btnEcliptic.classList.toggle('is-active', showEcliptic);
if (eclipticGroup) {
eclipticGroup.visible = showEcliptic;
}
});
speedSlider.addEventListener('input', updateSpeedFromSlider);
speedSlider.addEventListener('wheel', adjustSpeedSliderFromWheel, { passive: false });
datePicker.addEventListener('change', () => {
if (!datePicker.value) {
return;
}
const [year, month, day] = datePicker.value.split('-').map(Number);
const current = new Date(simDate);
simDate = new Date(year, month - 1, day, current.getHours(), current.getMinutes(), current.getSeconds());
refreshCurrentDateUi();
});
minorPlanetCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('change', () => {
selectedMinorPlanetIds = minorPlanetCheckboxes
.filter((input) => input.checked)
.map((input) => Number(input.value));
if (focusedMinorPlanetId !== null && !selectedMinorPlanetIds.includes(focusedMinorPlanetId)) {
focusedMinorPlanetId = null;
}
syncMinorPlanetObjects();
selectedMinorPlanetIds.forEach((id) => {
rebuildMinorPlanetOrbit(id);
});
updateMinorPlanetSummary();
if (selectedMinorPlanetIds.length === 0 && !focusedPlanetKey && focusedMinorPlanetId === null && focusedCometId === null && !focusedVoyager1 && !focusedVoyager2 && !focusedNewHorizons) {
infoPanel.hidden = true;
}
});
});
cometCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('change', () => {
selectedCometIds = cometCheckboxes
.filter((input) => input.checked)
.map((input) => Number(input.value));
if (focusedCometId !== null && !selectedCometIds.includes(focusedCometId)) {
focusedCometId = null;
}
syncCometObjects();
selectedCometIds.forEach((id) => {
rebuildCometOrbit(id);
});
updateCometSummary();
if (selectedCometIds.length === 0 && !focusedPlanetKey && focusedMinorPlanetId === null && focusedCometId === null) {
if (!focusedVoyager1 && !focusedVoyager2 && !focusedNewHorizons) {
infoPanel.hidden = true;
}
}
});
});
spacecraftCheckboxes.forEach((checkbox) => {
checkbox.addEventListener('change', () => {
selectedSpacecraftKeys = spacecraftCheckboxes
.filter((input) => input.checked)
.map((input) => String(input.value));
syncSpacecraftObjects();
updateSpacecraftSummary();
if (!focusedPlanetKey && focusedMinorPlanetId === null && focusedCometId === null && !focusedVoyager1 && !focusedVoyager2 && !focusedNewHorizons) {
infoPanel.hidden = true;
}
});
});
}
function animate() {
requestAnimationFrame(animate);
const now = performance.now();
const deltaSeconds = (now - lastAnimationTime) / 1000;
lastAnimationTime = now;
if (playing) {
simDate = new Date(simDate.getTime() + speedDaysPerSecond * deltaSeconds * 86400000);
}
updatePlanetPositions();
updateMoonVisual();
updateMinorPlanetVisual();
updateCometVisual();
updateVoyager1Visual();
updateVoyager2Visual();
updateNewHorizonsVisual();
// Aim shadow light from sun toward Saturn
const saturnMesh = planetMeshes.get('Saturn');
const sl = scene.userData.shadowLight;
if (saturnMesh && sl) {
const sp = saturnMesh.position;
const dist = sp.length();
sl.position.set(-sp.x / dist * 5, -sp.y / dist * 5, -sp.z / dist * 5);
sl.target.position.copy(sp);
sl.target.updateMatrixWorld();
}
refreshCurrentDateUi();
if (focusedPlanetKey) {
const focusedMesh = planetMeshes.get(focusedPlanetKey);
if (focusedMesh) {
cameraTarget.lerp(focusedMesh.position, 0.06);
updatePlanetInfoPanel(focusedPlanetKey);
}
}
if (focusedMinorPlanetId && minorPlanetObjects && minorPlanetObjects.has(focusedMinorPlanetId)) {
const focusedMinorPlanetObjects = minorPlanetObjects.get(focusedMinorPlanetId);
if (focusedMinorPlanetObjects.mesh.visible) {
cameraTarget.lerp(focusedMinorPlanetObjects.mesh.position, 0.06);
updateMinorPlanetInfoPanel(focusedMinorPlanetId);
}
}
if (focusedCometId && cometObjects && cometObjects.has(focusedCometId)) {
const focusedCometObjects = cometObjects.get(focusedCometId);
if (focusedCometObjects.mesh.visible) {
cameraTarget.lerp(focusedCometObjects.mesh.position, 0.06);
updateCometInfoPanel(focusedCometId);
}
}
if (focusedVoyager1 && voyager1Objects && voyager1Objects.mesh.visible) {
cameraTarget.lerp(voyager1Objects.mesh.position, 0.06);
updateVoyager1InfoPanel();
}
if (focusedVoyager2 && voyager2Objects && voyager2Objects.mesh.visible) {
cameraTarget.lerp(voyager2Objects.mesh.position, 0.06);
updateVoyager2InfoPanel();
}
if (focusedNewHorizons && newHorizonsObjects && newHorizonsObjects.mesh.visible) {
cameraTarget.lerp(newHorizonsObjects.mesh.position, 0.06);
updateNewHorizonsInfoPanel();
}
camRadius += (camRadiusTarget - camRadius) * 0.18;
updateCamera();
updateBodyDisplayScales();
updateLabels();
orbitRefreshCursor += 1;
if (playing && orbitRefreshCursor % 90 === 0) {
const planet = PLANETS[(orbitRefreshCursor / 90) % PLANETS.length | 0];
rebuildOrbitLine(planet);
}
if (playing && orbitRefreshCursor % 45 === 0) {
rebuildMoonOrbitLine();
}
if (playing && orbitRefreshCursor % 60 === 0) {
selectedCometIds.forEach((id) => {
rebuildCometOrbit(id);
});
}
renderer.render(scene, camera);
}
async function initSolarSystem() {
try {
THREE = await import('./js/three.module.js');
textureLoader = new THREE.TextureLoader();
renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setClearColor(0x000000, 0);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(45, 1, 0.1, 2000);
planetMeshes = new Map();
orbitLines = new Map();
labelElements = new Map();
raycaster = new THREE.Raycaster();
pointer = new THREE.Vector2();
cameraTarget = new THREE.Vector3(0, 0, 0);
rotationEqjToEcl = AstronomyEngine.Rotation_EQJ_ECL();
updateMinorPlanetSummary();
updateCometSummary();
updateSpacecraftSummary();
const ambientLight = new THREE.AmbientLight(0x334466, 0.8);
scene.add(ambientLight);
const hemiLight = new THREE.HemisphereLight(0x445577, 0x111a2e, 0.6);
scene.add(hemiLight);
const sunLight = new THREE.PointLight(0xfff5d0, 8.0, 0, 0);
// Dedicated directional light for shadow casting only (PointLight shadows are unreliable)
const shadowLight = new THREE.DirectionalLight(0x000000, 0);
shadowLight.castShadow = true;
shadowLight.shadow.mapSize.set(2048, 2048);
shadowLight.shadow.camera.near = 0.5;
shadowLight.shadow.camera.far = 400;
shadowLight.shadow.camera.left = -80;
shadowLight.shadow.camera.right = 80;
shadowLight.shadow.camera.top = 80;
shadowLight.shadow.camera.bottom = -80;
shadowLight.shadow.bias = -0.002;
scene.add(shadowLight);
scene.add(shadowLight.target);
scene.userData.shadowLight = shadowLight;
scene.add(sunLight);
// Starfield background
(function createStarfield() {
const starCount = 2200;
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const starColors = [
[1.0, 0.95, 0.85], [0.85, 0.92, 1.0], [1.0, 1.0, 1.0],
[1.0, 0.88, 0.72], [0.78, 0.88, 1.0],
];
for (let i = 0; i < starCount; i++) {
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 860 + Math.random() * 80;
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
positions[i * 3 + 1] = r * Math.sin(phi) * Math.sin(theta);
positions[i * 3 + 2] = r * Math.cos(phi);
const c = starColors[Math.floor(Math.random() * starColors.length)];
colors[i * 3] = c[0]; colors[i * 3 + 1] = c[1]; colors[i * 3 + 2] = c[2];
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
scene.add(new THREE.Points(geo, new THREE.PointsMaterial({
vertexColors: true,
size: 1.1,
sizeAttenuation: false,
transparent: true,
opacity: 0.88,
})));
})();
const sunGroup = new THREE.Group();
const sunTex = textureLoader.load('images/planets/sun_1024.jpg');
sunTex.colorSpace = THREE.SRGBColorSpace;
const sunCore = new THREE.Mesh(
new THREE.SphereGeometry(2.8, 40, 40),
new THREE.MeshBasicMaterial({ map: sunTex })
);
sunGroup.add(sunCore);
[
{ r: 3.7, opacity: 0.24, color: 0xffcc44 },
{ r: 5.5, opacity: 0.10, color: 0xff9900 },
{ r: 9.0, opacity: 0.042, color: 0xff6600 },
{ r: 15.0, opacity: 0.016, color: 0xff4400 },
].forEach(({ r, opacity, color }) => {
sunGroup.add(new THREE.Mesh(
new THREE.SphereGeometry(r, 24, 24),
new THREE.MeshBasicMaterial({
color,
transparent: true,
opacity,
side: THREE.BackSide,
depthWrite: false,
blending: THREE.AdditiveBlending,
})
));
});
scene.add(sunGroup);
createEclipticPlane();
buildLabels();
PLANETS.forEach(createPlanetMesh);
ensureMoonObjects();
ensureMinorPlanetObjects();
ensureVoyager1Objects();
rebuildVoyager1Track();
ensureVoyager2Objects();
rebuildVoyager2Track();
ensureNewHorizonsObjects();
rebuildNewHorizonsTrack();
syncSpacecraftObjects();
attachPointerControls();
attachUiHandlers();
updateSpeedFromSlider();
refreshCurrentDateUi();
resizeRenderer();
updatePlanetPositions();
rebuildMoonOrbitLine();
updateMoonVisual();
rebuildMinorPlanetOrbit(null);
updateCamera();
overlay.hidden = true;
animate();
if (typeof ResizeObserver === 'function') {
const resizeObserver = new ResizeObserver(() => {
resizeRenderer();
});
resizeObserver.observe(stageWrap);
}
window.addEventListener('resize', resizeRenderer);
} catch (error) {
console.error('Sonnensystem konnte nicht initialisiert werden.', error);
overlay.hidden = false;
overlay.textContent = 'Fehler beim Laden der Visualisierung. Bitte Browser-Konsole pruefen.';
}
}
initSolarSystem();
</script>
<?php require __DIR__ . '/footer.php'; ?>